# Developer Plugin Documentation

Plugins allow you to customise and extend the behavior of EDMC.

## Installing a Plugin

See [Plugins](https://github.com/EDCD/EDMarketConnector/wiki/Plugins) on the
wiki.

## Writing a Plugin

Plugins are loaded when EDMC starts up.

Each plugin has it's own folder in the `plugins` directory:

- Windows: `%LOCALAPPDATA%\EDMarketConnector\plugins`
- Mac: `~/Library/Application Support/EDMarketConnector/plugins`
- Linux: `$XDG_DATA_HOME/EDMarketConnector/plugins`, or `~/.local/share/EDMarketConnector/plugins` if `$XDG_DATA_HOME` is unset.

Plugins are python files. The plugin folder must have a file named `load.py`
that must provide one module level function and optionally provide a few
others.

If you're running from source (which allows for debugging with e.g. [PyCharm](https://www.jetbrains.com/pycharm/features/))
then you'll need to be using an appropriate version of Python.  The current
version is listed in the [Environment section of Releasing.md](https://github.com/EDCD/EDMarketConnector/blob/main/docs/Releasing.md#environment).
If you're developing your plugin simply against an install of EDMarketConnector
then you'll be relying on the bundled version of Python (it's baked 
into the .exe via the py2exe build process).

---

### Examples

We have some example plugins available in the docs/examples directory. See the readme in each folder for more info.

---

### Available imports

**`import`ing anything from the core EDMarketConnector code that is not
explicitly mentioned here is unsupported and may lead to your plugin
breaking with future code changes.**

`import L10n` - for plugin localisation support.

`from theme import theme` - So plugins can theme their own UI elements to
 match the main UI.

`from config import appname, applongname, appcmdname, appversion
, copyright, config` - to access config.

`from prefs import prefsVersion` - to allow for versioned preferences.

`from companion import category_map` - Or any of the other static date
contained therein.   NB: There's a plan to move such to a `data` module.

`import plug` - Mostly for using `plug.show_error()`.  Also the flags
for `dashboard_entry()` to be useful (see example below).  Relying on anything
else isn't supported.

`from monitor import gamerunning` - in case a plugin needs to know if we
 think the game is running.

`import timeout_session` - provides a method called `new_session` that creates a requests.session with a default timeout
on all requests. Recommended to reduce noise in HTTP requests

```python
from ttkHyperlinkLabel import HyperlinkLabel
import myNotebook as nb
```

For creating UI elements.

---

### Logging

In the past the only way to provide any logged output from a
plugin was to use `print(...)` statements.  When running the application from
the packaged executeable all output is redirected to a log file.  See
[Reporting a problem](https://github.com/EDCD/EDMarketConnector/wiki/Troubleshooting#reporting-a-problem)
for the location of this log file.

EDMC now implements proper logging using the Python `logging` module.  Plugin
developers should now use the following code instead of simple `print(...)`
statements.

Insert this at the top-level of your load.py file (so not inside `plugin_start3()` ):

```python
import logging

from config import appname

# This could also be returned from plugin_start3()
plugin_name = os.path.basename(os.path.dirname(__file__))

# A Logger is used per 'found' plugin to make it easy to include the plugin's
# folder name in the logging output format.
# NB: plugin_name here *must* be the plugin's folder name as per the preceding
#     code, else the logger won't be properly set up.
logger = logging.getLogger(f'{appname}.{plugin_name}')

# If the Logger has handlers then it was already set up by the core code, else
# it needs setting up here.
if not logger.hasHandlers():
    level = logging.INFO  # So logger.info(...) is equivalent to print()

    logger.setLevel(level)
    logger_channel = logging.StreamHandler()
    logger_formatter = logging.Formatter(f'%(asctime)s - %(name)s - %(levelname)s - %(module)s:%(lineno)d:%(funcName)s: %(message)s')
    logger_formatter.default_time_format = '%Y-%m-%d %H:%M:%S'
    logger_formatter.default_msec_format = '%s.%03d'
    logger_channel.setFormatter(logger_formatter)
    logger.addHandler(logger_channel)
```

Note the admonishment about `plugin_name` being the folder name of your plugin.
It can't be anything else (such as a different string returned from
`plugin_start3()`) because the code in plug.py that sets up the logger uses
exactly the folder name.  Our custom `qualname` and `class` formatters won't
work with a 'bare' logger, and will cause your code to throw exceptions if
you're not using our supplied logger.

If running with 4.1.0-beta1 or later of EDMC the logging setup happens in
the core code and will include the extra logfile destinations.  If your
plugin is run under a pre-4.1.0 version of EDMC then the above will set up
basic logging only to the console (and thus redirected to the log file).

If you're certain your plugin will only be run under EDMC 4.1.0 or newer then
you can remove the `if` clause.

Replace all `print(...)` statements with one of the following:

```python
    logger.info('some info message')  # instead of print(...)

    logger.debug('something only for debug')

    logger.warning('Something needs warning about')

    logger.error('Some error happened')

    logger.critical('Something went wrong in a critical manner')

    try:
        ...
    except Exception:
        # This logs at 'ERROR' level.
        # Also automatically includes exception information.
        logger.exception('An exception occurred')

    try:
        ...
    except Exception as e:
        logger.debug('Exception we only note in debug output', exc_info=e)
```

Remember you can use [fstrings](https://www.python.org/dev/peps/pep-0498/) to include variables, and even the returns of
functions, in the output.

```python
    logger.debug(f"Couldn't frob the {thing} with the {wotsit()}")
```

---

### Startup

EDMC will import the `load.py` file as a module and then call the
`plugin_start3()` function.

```python
def plugin_start3(plugin_dir: str) -> str:
   """
   Load this plugin into EDMC
   """
   print(f"I am loaded! My plugin folder is {plugin_dir}")
   return "Test"
```

The string you return is used as the internal name of the plugin.

Any errors or print statements from your plugin will appear in
`%TMP%\EDMarketConnector.log` on Windows, `$TMPDIR/EDMarketConnector.log` on
Mac, and `$TMP/EDMarketConnector.log` on Linux.

| Parameter    | Type  | Description                                             |
| :----------- | :---: | :------------------------------------------------------ |
| `plugin_dir` | `str` | The directory that your plugin is located in.           |
| `RETURN`     | `str` | The name you want to be used for your plugin internally |

### Shutdown

This gets called when the user closes the program:

```python
def plugin_stop() -> None:
    """
    EDMC is closing
    """
    print("Farewell cruel world!")
```

If your plugin uses one or more threads to handle Events then `stop()` and `join()`
(to wait for their exit -- Recommended, not required) the threads before returning from this function.

## Plugin Hooks

### Configuration

If you want your plugin to be configurable via the GUI you can define a frame
(panel) to be displayed on its own tab in EDMC's settings dialog. The tab
title will be the value that you returned from `plugin_start3`. Use widgets
from EDMC's myNotebook.py for the correct look-and-feel. You can be notified
when the settings dialog is closed so you can save your settings.

You can use `set()`, `get()` and `getint()` from EDMC's `config.config` object
to retrieve your plugin's settings in a platform-independent way.

**Be sure to use a unique prefix for any settings you save so as not to clash
with core EDMC or other plugins.**

Use `numberFromString()` from EDMC's `l10n.Locale` object to parse input
numbers in a locale-independent way.

Note that in the following example the function signature defines that it
returns `Optional[tk.Frame]` only because we need to allow for `None` if
something goes wrong with the creation of the frame (the calling code checks
this).  You absolutely need to return the `nb.Frame()` instance that you get
as in the code below.
```python
import tkinter as tk
from tkinter import ttk
import myNotebook as nb
from config import config
from typing import Optional

my_setting: Optional[tk.IntVar] = None

def plugin_prefs(parent: nb.Notebook, cmdr: str, is_beta: bool) -> Optional[tk.Frame]:
   """
   Return a TK Frame for adding to the EDMC settings dialog.
   """
   global my_setting
   my_setting = tk.IntVar(value=config.getint("MyPluginSetting"))  # Retrieve saved value from config
   frame = nb.Frame(parent)
   nb.Label(frame, text="Hello").grid()
   nb.Label(frame, text="Commander").grid()
   nb.Checkbutton(frame, text="My Setting", variable=my_setting).grid()

   return frame
```

| Parameter |     Type      | Description                                      |
| :-------- | :-----------: | :----------------------------------------------- |
| `parent`  | `nb.Notebook` | Root Notebook object the preferences window uses |
| `cmdr`    |     `str`     | The current commander                            |
| `is_beta` |    `bool`     | If the game is currently a beta version          |

This gets called when the user dismisses the settings dialog:

```python
def prefs_changed(cmdr: str, is_beta: bool) -> None:
   """
   Save settings.
   """
   config.set('MyPluginSetting', my_setting.get())  # Store new value in config
```

| Parameter |  Type  | Description                             |
| :-------- | :----: | :-------------------------------------- |
| `cmdr`    | `str`  | The current commander                   |
| `is_beta` | `bool` | If the game is currently a beta version |

### Display

You can also have your plugin add an item to the EDMC main window and update
from your event hooks. This works in the same way as `plugin_prefs()`. For a
simple one-line item return a `tk.Label` widget or a 2 tuple of widgets.
For a more complicated item create a tk.Frame widget and populate it with other
ttk widgets. Return `None` if you just want to use this as a callback after the
main window and all other plugins are initialised.

You can use `stringFromNumber()` from EDMC's `l10n.Locale` object to format
numbers in your widgets in a locale-independent way.

```python
from typing import Optional, Tuple
import tkinter as tk

status: Optional[tk.Label]


def plugin_app(parent: tk.Frame) -> Tuple[tk.Label, tk.Label]:
    """
    Create a pair of TK widgets for the EDMC main window
    """
    global status
    label = tk.Label(parent, text="Status:")  # By default widgets inherit the current theme's colors
    status = tk.Label(parent, text="", foreground="yellow")  # Override theme's foreground color
    return (label, status)

# later on your event functions can update the contents of these widgets
def some_other_function() -> None:
    global status
    status["text"] = "Happy!"
    status["foreground"] = "green"
```

| Parameter |                      Type                       | Description                                                 |
| :-------- | :---------------------------------------------: | :---------------------------------------------------------- |
| `parent`  |                   `tk.Frame`                    | The root EDMC window                                        |
| `RETURN`  | `Union[tk.Widget, Tuple[tk.Widget, tk.Widget]]` | A widget to add to the main window. See below for more info |

The return from plugin_app can either be any widget (Frame, Label, Notebook, etc.), or a 2 tuple of widgets. In the case of
a 2 tuple, indices 0 and 1 are placed automatically in the outer grid on column indices 0 and 1. Otherwise, the only thing done
to your return widget is it is set to use a columnspan of 2, and placed on the grid.

You can dynamically add and remove widgets on the main window by returning a tk.Frame from `plugin_app()` and later
creating and destroying child widgets of that frame.

```python
from typing import Option
import tkinter as tk

from theme import theme

frame: Optional[tk.Frame] = None

def plugin_app(parent: tk.Frame) -> tk.Frame:
    """
    Create a frame for the EDMC main window
    """
    global frame
    frame = tk.Frame(parent)
    return frame

def some_other_function_called_later() -> None:
# later on your event functions can add or remove widgets
    row = frame.grid_size()[1]
    new_widget_1 = tk.Label(frame, text="Status:")
    new_widget_1.grid(row=row, column=0, sticky=tk.W)
    new_widget_2 = tk.Label(frame, text="Unhappy!", foreground="red")  # Override theme's foreground color
    new_widget_2.grid(row=row, column=1, sticky=tk.W)
    theme.update(this.frame)  # Apply theme colours to the frame and its children, including the new widgets
```

### Events

Once you have created your plugin and EDMC has loaded it there are three other
functions you can define to be notified by EDMC when something happens:
`journal_entry()`, `dashboard_entry()` and `cmdr_data()`.

Your events all get called on the main Tkinter loop so be sure not to block for
very long or the app will appear to freeze. If you have a long running
operation such as sending or receiving data from an external server then you
should do this in a separate worker Thread. You can send work items to the
worker thread over a Queue. Tkinter is not thread-safe so you should not
access any Tkinter resources (including widgets and variables) from worker
threads - doing so may cause the app to crash intermittently. You can signal
back to the main thread using Tkinter's `event_generate()` widget method,
generating a user-defined event that you have previously registered with the
[`bind_all()`](http://effbot.org/tkinterbook/tkinter-events-and-bindings.htm)
widget method. See the [EDSM plugin](https://github.com/Marginal/EDMarketConnector/blob/main/plugins/edsm.py)
for an example of these techniques.

#### Journal Entry

```python
def journal_entry(
    cmdr: str, is_beta: bool, system: str, station: str, entry: Dict[str, Any], state: Dict[str, Any]
) -> None:
    if entry['event'] == 'FSDJump':
        # We arrived at a new system!
        if 'StarPos' in entry:
            logger.info(f'Arrived at {entry["StarSystem"]} {entry["StarPos"')

        else:
            logger.info(f'Arrived at {entry["StarSystem"]}')
```

This gets called when EDMC sees a new entry in the game's journal.

| Parameter |       Type       | Description                                                            |
| :-------- | :--------------: | :--------------------------------------------------------------------- |
| `cmdr`    |      `str`       | Current commander name                                                 |
| `is_beta` |      `bool`      | Is the game currently in beta                                          |
| `system`  | `Optional[str]`  | Current system, if known                                               |
| `station` | `Optional[str]`  | Current station, if any                                                |
| `entry`   | `Dict[str, Any]` | The journal event                                                      |
| `state`   | `Dict[str, Any]` | More info about the commander, their ship, and their cargo (see below) |

Content of `state` (updated to the current journal entry):

| Field          |            Type             | Description                                                                                                     |
| :------------- | :-------------------------: | :-------------------------------------------------------------------------------------------------------------- |
| `Captian`      |       `Optional[str]`       | Name of the commander who's crew you're on, if any                                                              |
| `Cargo`        |           `dict`            | Current cargo                                                                                                   |
| `Credits`      |            `int`            | Current credits balance                                                                                         |
| `FID`          |            `str`            | Frontier commander ID                                                                                           |
| `Loan`         |       `Optional[int]`       | Current loan amount, if any                                                                                     |
| `Raw`          |           `dict`            | Current raw engineering materials                                                                               |
| `Manufactured` |           `dict`            | Current manufactured engineering materials                                                                      |
| `Encoded`      |           `dict`            | Current encoded engineering materials                                                                           |
| `Engineers`    |           `dict`            | Current Raw engineering materials                                                                               |
| `Rank`         | `Dict[str, Tuple[int, int]` | Current ranks, each entry is a tuple of the current rank, and age                                               |
| `Statistics`   |           `dict`            | Contents of a Journal Statistics event, ie, data shown in the stats panel. See the Journal manual for more info |
| `Role`         |       `Optional[str]`       | Current role if in multi-crew, one of `Idle`, `FireCon`, `FighterCon`                                           |
| `Friends`      |            `set`            | Currently online friend                                                                                         |
| `ShipID`       |            `int`            | Frontier ID of current ship                                                                                     |
| `ShipIdent`    |            `str`            | Current user-set ship ID                                                                                        |
| `ShipName`     |            `str`            | Current user-set ship name                                                                                      |
| `ShipType`     |            `str`            | Internal name for the current ship type                                                                         |
| `HullValue`    |            `int`            | Current ship value, excluding modules                                                                           |
| `ModulesValue` |            `int`            | Value of the current ship's modules                                                                             |
| `Rebuy`        |            `int`            | Current ship's rebuy cost                                                                                       |
| `Modules`      |           `dict`            | Currently fitted modules                                                                                        |

A special "StartUp" entry is sent if EDMC is started while the game is already
running. In this case you won't receive initial events such as "LoadGame",
"Rank", "Location", etc. However the `state` dictionary will reflect the
cumulative effect of these missed events.

Similarly, a special "ShutDown" entry is sent when the game stops writing
to the Journal without writing a "Shutdown" event.
This might happen, for example, when the game client crashes.
Note that this is distinct in (letter) case from the "Shutdown" event that
the game itself writes to the Journal when you exit normally.  If you want to
react to either in your plugin code then either compare in a case insensitive
manner or check for both.  The difference in case allows you to differentiate
between the two scenarios.

This event is not sent when EDMC is running on a different
machine so you should not *rely* on receiving this event.

#### Player Dashboard

```python
import plug

def dashboard_entry(cmdr: str, is_beta: bool, entry: Dict[str, Any]):
    is_deployed = entry['Flags'] & plug.FlagsHardpointsDeployed
    sys.stderr.write("Hardpoints {}\n".format(is_deployed and "deployed" or "stowed"))
```

This gets called when something on the player's cockpit display changes -
typically about once a second when in orbital flight.

| Parameter |  Type  | Description                       |
| :-------- | :----: | :-------------------------------- |
| `cmdr`    | `str`  | Current command name              |
| `is_beta` | `bool` | if the game is currently in beta  |
| `entry`   | `dict` | Data from status.json (see below) |

 For more info on `status.json`, See the "Status File" section in the Frontier [Journal documentation](https://forums.frontier.co.uk/showthread.php/401661) for the available `entry` properties and for the list of available `"Flags"`. Refer to the source code of [plug.py](./plug.py) for the list of available  constants.

New in version 4.1.6:

`CargoJSON` contains the raw data from the last read of `cargo.json` passed through json.load.
It contains more information about the cargo contents, such as the mission ID for mission specific cargo

#### Getting Commander Data

```python
def cmdr_data(data, is_beta):
    """
    We have new data on our commander
    """
    if data.get('commander') is None or data['commander'].get('name') is None:
        raise ValueError("this isn't possible")

    logger.info(data['commander']['name'])
```

This gets called when EDMC has just fetched fresh Cmdr and station data from
Frontier's servers.

| Parameter |       Type       | Description                                                                                              |
| :-------- | :--------------: | :------------------------------------------------------------------------------------------------------- |
| `data`    | `Dict[str, Any]` | `/profile` API response, with `/market` and `/shipyard` added under the keys `marketdata` and `shipdata` |
| `is_beta` |      `bool`      | If the game is currently in beta                                                                         |

#### Plugin-specific events

```python
def edsm_notify_system(reply):
    """
    `reply` holds the response from a call to https://www.edsm.net/en/api-journal-v1
    """
    if not reply:
        logger.info("Error: Can't connect to EDSM")

    elif reply['msgnum'] // 100 not in (1,4):
        logger.info(f'Error: EDSM {reply["msg"]}')

    elif reply.get('systemCreated'):
        logger.info('New EDSM system!')

    else:
        logger.info('Known EDSM system')
```

If the player has chosen to "Send flight log and Cmdr status to EDSM" this gets
called when the player starts the game or enters a new system. It is called
some time after the corresponding `journal_entry()` event.

| Parameter |       Type       | Description                                                                                    |
| :-------- | :--------------: | :--------------------------------------------------------------------------------------------- |
| `reply`   | `Dict[str, Any]` | Response to an API call to [EDSM's journal API target](https://www.edsm.net/en/api-journal-v1) |

```python
def inara_notify_location(event_data):
    """
    `event_data` holds the response to one of the "Commander's Flight Log" events https://inara.cz/inara-api-docs/#event-29
    """
    if event_data.get('starsystemInaraID'):
        logging.info(f'Now in Inara system {event_data["starsystemInaraID"]} at {event_data["starsystemInaraURL"]}')
    else:
        logger.info('System not known to Inara')

    if event_data.get('stationInaraID'):
        logger.info(f'Docked at Inara station {event_data["stationInaraID"]} at {event_data["stationInaraURL"]}')

    else:
        logger.info('Undocked or station unknown to Inara')
```

If the player has chosen to "Send flight log and Cmdr status to Inara" this
gets called when the player starts the game, enters a new system, docks or
undocks. It is called some time after the corresponding `journal_entry()`
event.

| Parameter    |       Type       | Description                                                                                                  |
| :----------- | :--------------: | :----------------------------------------------------------------------------------------------------------- |
| `event_data` | `Dict[str, Any]` | Response to an API call to [INARA's `Commander Flight Log` event](https://inara.cz/inara-api-docs/#event-29) |

---

```python
def inara_notify_ship(event_data):
    """
    `event_data` holds the response to an addCommanderShip or setCommanderShip event https://inara.cz/inara-api-docs/#event-11
    """
    if event_data.get('shipInaraID'):
        logger.info(
            f'Now in Inara ship {event_data['shipInaraID'],} at {event_data['shipInaraURL']}
        )
```

If the player has chosen to "Send flight log and Cmdr status to Inara" this
gets called when the player starts the game or switches ship. It is called some
time after the corresponding `journal_entry()` event.

| Parameter    |       Type       | Description                                                                                                                    |
| :----------- | :--------------: | :----------------------------------------------------------------------------------------------------------------------------- |
| `event_data` | `Dict[str, Any]` | Response to an API call to [INARA's `addCommanderShip` or `setCommanderShip` event](https://inara.cz/inara-api-docs/#event-11) |

## Error messages

You can display an error in EDMC's status area by returning a string from your
`journal_entry()`, `dashboard_entry()` or `cmdr_data()` function, or
asynchronously (e.g. from a "worker" thread that is performing a long running
operation) by calling `plug.show_error()`. Either method will cause the "bad"
sound to be played (unless the user has muted sound).

The status area is shared between EDMC itself and all other plugins, so your
message won't be displayed for very long. Create a dedicated widget if you need
to display routine status information.

## Localisation

You can localise your plugin to one of the languages that EDMC itself supports.
Add the following boilerplate near the top of each source file that contains
strings that needs translating:

```python
import l10n
import functools
_ = functools.partial(l10n.Translations.translate, context=__file__)
```

Wrap each string that needs translating with the `_()` function, e.g.:

```python
    status["text"] = _('Happy!')  # Main window status
```

If you display localized strings in EDMC's main window you should refresh them
in your `prefs_changed` function in case the user has changed their preferred
language.

Translation files should reside in folder named `L10n` inside your plugin's
folder. Files must be in macOS/iOS ".strings" format, encoded as UTF-8. You can
generate a starting template file for your translations by invoking `l10n.py`
in your plugin's folder. This extracts all the translatable strings from Python
files in your plugin's folder and places them in a file named `en.template` in
the `L10n` folder. Rename this file as `<language_code>.strings` and edit it.

See EDMC's own [`L10n`](https://github.com/EDCD/EDMarketConnector/tree/master/L10n)
folder for the list of supported language codes and for example translation
files.

## Python Package Plugins

A _Package Plugin_ is both a standard Python package (i.e. contains an
`__init__.py` file) and an EDMC plugin (i.e. contains a `load.py` file
providing at minimum a `plugin_start3()` function). These plugins are loaded
before any non-Package plugins.

Other plugins can access features in a Package Plugin by `import`ing the
package by name in the usual way.

## Distributing a Plugin

To package your plugin for distribution simply create a `.zip` archive of your
plugin's folder:

- Windows: In Explorer right click on your plugin's folder and choose Send to
    &rarr; Compressed (zipped) folder.
- Mac: In Finder right click on your plugin's folder and choose Compress.

If there are any external dependencies then include them in the plugin's
folder.

Optionally, for tidiness delete any `.pyc` and `.pyo` files in the archive, as well as the `__pycache__` directory.

## Disable a plugin

EDMC now lets you disable a plugin without deleting it, simply rename the
plugin folder to append ".disabled". Eg,
"SuperSpaceHelper" -> "SuperSpaceHelper.disabled"

Disabled and enabled plugins are listed on the "Plugins" Settings tab

## Migration

Starting with pre-release 3.5 EDMC uses Python **3.7**.   The first full
release under Python 3.7 was 4.0.0.0.  This is a brief outline of the steps
required to migrate a plugin from earlier versions of EDMC:

- Rename the function `plugin_start` to `plugin_start3(plugin_dir)`.
    Plugins without a `plugin_start3` function are listed as disabled on EDMC's
    "Plugins" tab and a message like "plugin SuperSpaceHelper needs migrating"
    appears in the log. Such plugins are also listed in a section "Plugins Without
    Python 3.x Support:" on the Settings > Plugins tab.

- Check that callback functions `plugin_prefs`, `prefs_changed`,
    `journal_entry`, `dashboard_entry` and `cmdr_data` if used are declared with
    the correct number of arguments.  Older versions of this app were tolerant
    of missing arguments in these function declarations.

- Port the code to Python 3.7. The [2to3](https://docs.python.org/3/library/2to3.html)
    tool can automate much of this work.

Depending on the complexity of the plugin it may be feasible to make it
compatible with both EDMC 3.4 + Python 2.7 and EDMC 3.5 + Python 3.7.

[Here's](https://python-future.org/compatible_idioms.html) a guide on writing
Python 2/3 compatible code and [here's](https://github.com/Marginal/HabZone/commit/3c41cd41d5ad81ef36aab40e967e3baf77b4bd06)
an example of the changes required for a simple plugin.
