Plugins allow you to customise and extend the behavior of EDMarketConnector.
See Plugins on the wiki.
Check Releasing.md to be sure of the current version of Python that we've tested against.
Plugins are loaded when EDMarketConnector 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) then you'll need to be using an appropriate version of Python. The current version is listed in the Environment section of Releasing.md. 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).
Please be sure to read the Avoiding potential pitfalls section, else you might inadvertently cause issues for the core EDMarketConnector code including whole application crashes.
It is highly advisable to ensure you are aware of all EDMarketConnector releases, including the pre-releases. The -beta and -rc changelogs will contain valuable information about any forthcoming changes that affect plugins. The easiest way is:
- Login to GitHub.
- Navigate to EDMarketConnector.
- Click the 'Watch' (or 'Unwatch' if you previously set up any watches on us). It's currently (2021-05-13) the left-most button of 3 near the top-right of the page.
- Click 'Custom'.
- Ensure 'Releases' is selected.
- Click 'Apply'.
And, of course, either ensure you check your GitHub messages regularly, or have it set up to email you such notifications.
You should also keep an eye on our GitHub Discussions in case there are any proposed changes to EDMC plugin functionality. You can do this by ensuring 'Discussions' is also ticked when following the steps above to set up a 'Custom' watch on this repository.
We have some example plugins available in the docs/examples directory. See the readme in each folder for more info.
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. Intended use of config classes
and functions is only for managing a plugin's own configuration.
Explicitly you can expect to use:
config.set()
- to store a plugin configuration value.config.get_list()
,config.get_str()
,config.get_bool()
,config.get_int()
- To retrieve a plugin configuration value.config.delete()
- To remove a plugin configuration value.config.shutting_down
(NB: a property, not a function!) to detect if the application is currently shutting down.
Anything else from import config
is not part of the stable plugin API and
liable to change without notice.
from prefs import prefsVersion
- to allow for versioned preferences.
from companion import CAPIData, SERVER_LIVE, SERVER_LEGACY, SERVER_BETA
-
CAPIData
is the actual type of data
as passed into cmdr_data()
,
cmdr_data_legacy()
and capi_fleetcarrier()
.
See Commander Data from Frontier CAPI)
for further information.
import edmc_data
(or specific 'from' imports) - This contains various static
data that used to be in other files. You should not now import anything
from the original files unless specified as allowed in this section.
import plug
- For using plug.show_error()
only.
Use monitor.game_running()
as follows in case a plugin needs to know if we
think the game is running. NB: This is a function, and should be called as
such. Using the bare word game_running
will always be True
.
from monitor import monitor
...
if monitor.game_running():
...
Use monitor.is_live_galaxy()
to determine if the player is playing in the
Live galaxy. Note the implementation details of this. At time of writing it
performs a semantic_version
>= check.
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. This also ensures your requests use the central
"User-Agent" header value. If you do have reason to make a request otherwise
please ensure you use the config.user_agent
value as the User-Agent (you can
append a string to call out your plugin if you wish).
from ttkHyperlinkLabel import HyperlinkLabel
and import myNotebook as nb
-
For creating UI elements.
In addition to the above we also explicitly package the following python modules for plugin use:
- shutil
- sqlite3
- zipfile
Unfortunately we cannot promise to include every part of the Python Standard Library due to issues with correctly detecting all the modules, and if they're single file or a package, and perhaps have sub-modules. For now, if you find something is missing that you need for your plugin, ask us to add it in, and we'll do so on a 'best efforts' basis.
See #1327 - ModuleNotFound when creating a new plugin. for some discussion.
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
for the location of this log file.
EDMarketConnector 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()
):
import logging
import os
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 EDMarketConnector 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 EDMarketConnector 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 EDMarketConnector 4.1.0
or newer then you can remove the if
clause.
Replace all print(...)
statements with one of the following:
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 to include variables, and even the returns of functions, in the output.
logger.debug(f"Couldn't frob the {thing} with the {wotsit()}")
If you have code that needs to act differently under different versions of
this application then you can check utilise config.appversion
.
Prior to version 5.0.0 this was a simple string. From 5.0.0 onwards it is,
instead, a function which returns an instance of semantic_version.Version
.
import semantic_version
from config import appversion
...
# Up until 5.0.0-beta1 config.appversion is a string
if isinstance(appversion, str):
core_version = semantic_version.Version(appversion)
elif callable(appversion):
# From 5.0.0-beta1 it's a function, returning semantic_version.Version
core_version = appversion()
# Yes, just blow up if config.appverison is neither str or callable
logger.info(f'Core EDMarketConnector version: {core_version}')
# And then compare like this
if core_version < semantic_version.Version('5.0.0-beta1'):
logger.info('EDMarketConnector core version is before 5.0.0-beta1')
else:
logger.info('EDMarketConnector core version is at least 5.0.0-beta1')
EDMarketConnector will import the load.py
file as a module and then call the
plugin_start3()
function.
def plugin_start3(plugin_dir: str) -> str:
"""
Load this plugin into EDMarketConnector
"""
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 |
There are a number of things that your code should either do or avoiding doing so as to play nicely with the core EDMarketConnector code and not risk causing application crashes or hangs.
You might want your plugin directory name to be usable in import statements. See the section on packaging extra modules.
By default, your plugin code will be running in the main thread. So, if you perform some operation that takes significant time (more than a second) you will be blocking both the core code from continuing and any other plugins from running their main-thread code.
This includes any connections to remote services, such as a website or remote database. So please place such code within its own thread.
See the EDSM plugin code for an example of using a thread worker, along with a queue to send data, and telling the sub-thread to stop during shutdown.
The only tkinter calls that should ever be made from a sub-thread are
event_generate()
calls to send data back to the main thread.
Any attempt to manipulate tkinter UI elements directly from a sub-thread will most likely crash the whole program.
See the EDSM plugin
code for an example of using event_generate()
to cause the plugin main
thread code to update a UI element. Start from the plugin_app()
implementation.
However, you must not make any tkinter event_generate()
call whilst
the application is shutting down.
The application shutdown sequence is itself triggered from the <<Quit>>
event
handler, and generating another event from any code in, or called from,
there causes the application to hang somewhere in the tk libraries.
You can detect if the application is shutting down with the boolean
config.shutting_down
. Note that although this is technically a function
its implementation is of a property on config.AbstractConfig
and thus you
should treat it as a variable.
Do NOT use:
from config import config
if config.shutting_down():
# During shutdown
as this will cause the 'During shutdown' branch to always be taken, as in this context you're testing if the function exists, and that is always True.
So instead use:
from config import config
if config.shutting_down:
# During shutdown
We use requests
in lots of core code, so it will always be available. An
advantage to using it, instead of the core urllib
, is that it brings in
certifi
with its own set of trusted root certificates.
We've seen issues where a plugin was using urllib
, which uses the system
certificate store, and a user's system didn't yet have a new root certificate
that was necessary for the operation of a URL the plugin was acessing.
We keep requests
, and thus certifi
up to date via GitHub's dependabot. If
there is ever a certificate update that we don't have in a release then
please open a
bug report.
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 EDMarketConnector's settings dialog.
The tab title will be the value that you returned from plugin_start3
. Use
widgets from EDMarketConnector'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()
and get_$type()
(where type is one of: int
, bool
,
str
, list
) from EDMarketConnector's config.config
object to retrieve
your plugin's settings in a platform-independent way. Previously this was done
with a single set and two get methods, the new methods provide better type
safety.
If you want to maintain compatibility with pre-5.0.0 versions of this application (please encourage plugin users to update!) then you'll need to include this code in at least once in your plugin (no harm in putting it in all modules/files):
from config import config
# For compatibility with pre-5.0.0
if not hasattr(config, 'get_int'):
config.get_int = config.getint
if not hasattr(config, 'get_str'):
config.get_str = config.get
if not hasattr(config, 'get_bool'):
config.get_bool = lambda key: bool(config.getint(key))
if not hasattr(config, 'get_list'):
config.get_list = config.get
Be sure to use a unique prefix for any settings you save so as not to clash with core EDMarketConnector or other plugins.
Use number_from_string()
from EDMarketConnector's l10n.Locale
object to
parse input numbers in a locale-independent way. NB: the old CamelCase
versions of number_from_string
and string_from_number
do still exist, but
are deprecated. They will continue to work, but will throw warnings.
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.
import tkinter as tk
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 EDMarketConnector settings dialog.
"""
global my_setting
my_setting = tk.IntVar(value=config.get_int("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:
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 |
You can also have your plugin add an item to the EDMarketConnector 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 string_from_number()
from EDMarketConnector's l10n.Locale
object to format numbers in your widgets in a locale-independent way.
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 EDMarketConnector 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 EDMarketConnector 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.
from typing import Optional
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 EDMarketConnector 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
Remember, you must NOT manipulate any tkinter elements from a sub-thread! See Avoiding potential pitfalls.
Once you have created your plugin and EDMarketConnector has loaded it there
are five other functions you can define to be notified by EDMarketConnector
when something happens: journal_entry()
, journal_entry_cqc()
,
dashboard_entry()
, cmdr_data()
and capi_fleetcarrier()
.
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()
widget method. See the EDSM plugin
for an example of these techniques.
def journal_entry(
cmdr: str, is_beta: bool, system: str, station: str, entry: Dict[str, Any], state: Dict[str, Any]
) -> Optional[str]:
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 EDMarketConnector 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 |
---|---|---|
GameLanguage |
Optional[str] |
language value from Fileheader event. |
GameVersion |
Optional[str] |
version value from Fileheader event. |
GameBuild |
Optional[str] |
build value from Fileheader event. |
Captain [3] |
Optional[str] |
Name of the commander who's crew you're on, if any |
Cargo |
dict |
Current cargo. Note that this will be totals, and any mission specific duplicates will be counted together |
CargoJSON |
dict |
content of cargo.json as of last read. |
Credits |
int |
Current credits balance |
FID |
str |
Frontier commander ID |
Horizons |
bool |
From LoadGame event. |
Odyssey |
bool |
From LoadGame event. False if not present, else the event value. |
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 |
Component |
dict |
Current component 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 |
NavRoute |
dict |
Last plotted multi-hop route[1] |
ModuleInfo |
dict |
Last loaded ModulesInfo.json data |
IsDocked |
bool |
Whether the Cmdr is currently docked in their own ship. |
OnFoot [3] |
bool |
Whether the Cmdr is on foot |
Component |
dict |
'Component' MicroResources in Odyssey, int count each. |
Item |
dict |
'Item' MicroResources in Odyssey, int count each. |
Consumable |
dict |
'Consumable' MicroResources in Odyssey, int count each. |
Data |
dict |
'Data' MicroResources in Odyssey, int count each. |
BackPack |
dict |
dict of Odyssey MicroResources in backpack. |
BackpackJSON |
dict |
Content of Backpack.json as of last read. |
ShipLockerJSON |
dict |
Content of ShipLocker.json as of last read. |
SuitCurrent |
dict |
CAPI-returned data of currently worn suit. NB: May be None if no data. |
Suits |
dict [2] |
CAPI-returned data of owned suits. NB: May be None if no data. |
SuitLoadoutCurrent |
dict |
CAPI-returned data of current Suit Loadout. NB: May be None if no data. |
SuitLoadouts |
dict [2] |
CAPI-returned data of all Suit Loadouts. NB: May be None if no data. |
Taxi |
Optional[bool] |
Whether or not we're currently in a taxi. NB: This is best effort with what the journals provide. |
Dropship |
Optional[bool] |
Whether or not the above taxi is a Dropship |
SystemAddress [3] |
Optional[int] |
Unique ID64 of the star system we're currently in |
SystemName [3] |
Optional[str] |
Name of the star system we're currently in |
SystemPopulation [3] |
Optional[int] |
Population of the star system we're currently in |
StarPos [3] |
Optional[tuple[float]] |
Galaxy co-ordinates of the system we're currently in |
Body [3][4] |
Optional[str] |
Name of the body we're currently on / in the SOI of |
BodyID [3][4] |
Optional[int] |
ID of the body we're currently on / in the SOI of |
BodyType [3][4] |
Optional[str] |
The type of body that Body refers to |
StationName [3] |
Optional[str] |
Name of the station we're docked at, if applicable |
MarketID [3] |
Optional[str] |
MarketID of the station we're docked at, if applicable |
StationType [3] |
Optional[str] |
Type of the station we're docked at, if applicable |
[1] - Contents of NavRoute
not changed if a NavRouteClear
event is seen,
but plugins will see the NavRouteClear
event.
If EDMarketConnector is restarted whilst the game is running then
NavRoute
will be populated with current 'NavRoute.json' contents (assuming
that the file exists). Thus NavRoute
will have the data when the
synthetic StartUp
event is sent to plugins. NB: If the contents of the file
indicate a NavRouteClear
then that's what will be passed.
If the game is restarted then Fileheader
in the new Journal file will
cause state['NavRoute'] = None
, but if you open the galaxy map in-game and
cause an automatic re-plot of last route, then a new NavRoute
event will
also be generated and passed to plugins.
[2] - Some data from the CAPI is sometimes returned as a list
(when all
members are present) and other times as an integer-keyed dict
(when at
least one member is missing, so the indices are not contiguous). We choose to
always convert to the integer-keyed dict
form so that code utilising the data
is simpler.
[3] - Forced to None
if the player joins another player's ship in remote
multi-crew.
[4] - There are some caveats with the Body data. Firstly the name and ID can be for the orbital station or fleet carrier the player is docked at. Check 'BodyType' before using the values.
Secondly there is an issue with close-orbiting binary bodies. If the player:
- Enters Orbital Cruise around a Body an 'ApproachBody' event is emitted and the tracking will update to reflect this.
- If the player then flies in Orbital Cruise without entering Supercruise proper to the close-orbiting binary partner of the Body then there is no new 'ApproachBody' event to indicate the new Body's details. Thus this tracking will incorrectly indicate the first Body still.
So, before making use of any of this Body state a plugin should:
- Have a
dashboard_entry()
method and track the Body name present in its data. - Cross-check that Body name with
state['Body']
before making use of any ofstate'
s Body data.
See plugins/eddn.py
for an example of this in export_journal_codexentry()
.
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
NB: Because this is only the data loaded from the Cargo.json
file, and that
is not written at Commander login (instead the in-Journal Cargo
event
contains all the data), this will not be populated at login.
New in version 5.0.0:
NavRoute
contains the json.load()
of NavRoute.json
as indicated by a
journal NavRoute
event.
ModuleInfo
contains the json.load()
of ModulesInfo.json
as indicated by a
Journal ModuleInfo
event.
OnFoot
is an indication as to if the player is on-foot, rather than in a
vehicle.
Component
, Item
, Consumable
& Data
are dict
s tracking your
Odyssey MicroResources in your Ship Locker. BacKPack
contains dict
s for
the same when you're on-foot.
SuitCurrent
, Suits
, SuitLoadoutCurrent
& SuitLoadouts
hold CAPI data
relating to suits and their loadouts.
New in version 5.0.1:
Odyssey
boolean based on the presence of such a flag in the LoadGame
event. Defaults to False
, i.e. if no such key in the event.
The previously undocumented Horizons
boolean is similarly from LoadGame
,
but blindly retrieves the value rather than having a strict default. There'd
be an exception if it wasn't there, and the value would be None
. Note that
this is NOT the same as the return from
plugins/eddn.py:capi_is_horizons(). That function is
necessary because CAPI data doesn't have a simple indication of Horizons-ness.
New in version 5.0.3:
The Suits
members have an additional key:value pair edmcName
which is our
preferred name for display on the UI, for the in-use game language.
The "language", "gameversion" and "build" values from the "Fileheader" event
are all now stored in state[]
fields; "GameLanguage", "GameVersion" and
"GameBuild".
New in version 5.1.0:
state
entries added for Taxi, Dropship, Body and BodyType.
New in version 5.1.1:
state
now has a ShipLockerJSON
member containing the un-changed, loaded,
JSON from the ShipLockerJSON.json
file.
New in version 5.4.2+:
We now handle the 'Update 13' NavRouteClear
event by detecting if that's what
is in the NavRoute.json
file. If this is the case then we log that, but
do NOT clear state['NavRoute']
. Plugins will get sent the Journal
NavRouteClear
event anyway, and there might be some value to them retaining
access to the prior plotted route.
NB: It is possible, if a player is quick enough, to plot and clear a route before we load it, in which case we'd be retaining the previous plotted route.
New in version 5.6.0:
IsDocked
boolean added to state
. This is set True for a Location
event
having "Docked":true"
, or the Docked
event. It is set back to False (its
default value) for an Undocked
event. Being on-foot in a station at login
time does not count as docked for this.
In general on-foot, including being in a taxi, might not set this 100% correctly. Its main use in core code is to detect being docked so as to send any stored EDDN messages due to "Delay sending until docked" option.
New in version 5.7.0:
state['NavRoute']
will be populated from the file, if present, if you
re-start EDMarketConnector. That will be present when plugins are invoked
with the synthetic StartUp
event. NB: Might just be a NavRouteClear
event
if that's what was in the file.
New in version 5.8.0:
StarPos
, SystemAddress
, SystemName
and SystemPopulation
have been
added to the state
dictionary. Best efforts data pertaining to the star
system the player is in.
BodyID
and BodyType
have been added to the state
dictionary. These
now track in the same manner as prior core EDDN plugin code. Check the
documentation above for some caveats. Do not just blindly use this data, or
the 'Body' name value.
StationName
, MarketID
, and StationType
added to the state
dictionary.
A special "StartUp" entry is sent if EDMarketConnector 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.
NB: Any of the values in this might be None
if the Cmdr has loaded into
Arena (CQC) from the Main Menu.
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.
NB: Any of these events are passing to journal_entry_cqc
rather than to
journal_entry
if player has loaded into Arena (CQC).
This event is not sent when EDMarketConnector is running on a different machine so you should not rely on receiving this event.
In some cases we augment the events, as seen in the Journal, with extra data. Examples of this are:
-
Every
Cargo
event passed to plugins contains the data fromCargo.json
(but see above for caveats). -
Every
NavRoute
event contains the fullRoute
array as loaded fromNavRoute.json
.NB: There is no indication available when a player cancels a route. The game itself does not provide any such, not in a Journal event, not in a
Status.json
flag.The Journal documentation v28 is incorrect about the event and file being
Route(.json)
the word isNavRoute
. Also the format of the data is, e.g.{ "timestamp":"2021-03-10T11:31:37Z", "event":"NavRoute", "Route": [ { "StarSystem": "Esuvit", "SystemAddress": 2869709317505, "StarPos": [-13.18750,-1.15625,-92.68750], "StarClass": "M" }, { "StarSystem": "Ndozins", "SystemAddress": 3446451210595, "StarPos": [-14.31250,-10.68750,-60.56250], "StarClass": "M" }, { "StarSystem": "Tascheter Sector MN-T b3-6", "SystemAddress": 13864825529753, "StarPos": [-11.87500,-21.96875,-29.03125], "StarClass": "M" }, { "StarSystem": "LP 823-4", "SystemAddress": 9466778953129, "StarPos": [-8.62500,-27.84375,3.93750], "StarClass": "M" } ] }
-
Every
ModuleInfo
event contains the full data as loaded from theModulesInfo.json
file. Note that we use the singular form here to stay consistent with the Journal event name.
New in version 5.2.0
def journal_entry_cqc(cmdr: str, is_beta: bool, entry: Dict[str, Any], state: Dict[str, Any]) -> Optional[str]:
if entry['event'] == 'Location':
# We loaded to CQC match, lets detect map!
cqc_maps = { # dict to map systems names to CQC maps, ref: https://forums.frontier.co.uk/threads/cqc-systems.234394/
'Bleae Aewsy GA-Y d1-14': 'Asteria Point',
'Eta Cephei': 'Cluster Compound',
'Theta Ursae Majoris': 'Elevate',
'Boepp SU-E d12-818': 'Ice Field',
}
cqc_map = cqc_maps.get(entry['StarSystem'])
logger.info(f'Loaded to CQC map {cqc_map}')
This is called for new journal entries, instead of journal_entry()
, when the
player is in Arena (CQC).
Parameter | Type | Description |
---|---|---|
cmdr |
str |
Current commander name |
is_beta |
bool |
Is the game currently in beta |
entry |
Dict[str, Any] |
The journal event |
state |
Dict[str, Any] |
More info about the commander, their ship, and their cargo (see below) |
The content of state
will be the same as for journal_entry
,
so check there for documentation.
This gets called when the user closes the program:
def plugin_stop() -> None:
"""
EDMarketConnector 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.
def dashboard_entry(cmdr: str, is_beta: bool, entry: Dict[str, Any]):
is_deployed = entry['Flags'] & edmc_data.FlagsHardpointsDeployed
sys.stderr.write("Hardpoints {}\n".format(is_deployed and "deployed" or "stowed"))
dashboard_entry()
is called with the latest data from the Status.json
file when an update to that file is detected.
This will be 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.
That includes the available entry
properties and the list of "Flags"
.
Refer to edmc_data.py for the list of available
constants.
If a plugin has a cmdr_data()
function it gets called when the application has just fetched fresh CMDR, station and shipyard data from Frontier's CAPI servers, but not for the Legacy galaxy. See cmdr_data_legacy()
below for Legacy data handling.
from companion import CAPIData, SERVER_LIVE, SERVER_LEGACY, SERVER_BETA
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'])
# Determining source galaxy for the data
if data.source_host == SERVER_LIVE:
...
elif data.source_host == SERVER_BETA:
...
elif data.source_host == SERVER_LEGACY:
...
Parameter | Type | Description |
---|---|---|
data |
CAPIData |
/profile API response, with /market and /shipyard added under the keys marketdata and shipdata |
is_beta |
bool |
If the game is currently in beta |
If a plugin has a capi_fleetcarrier()
function it gets called when the application has just fetched fresh Fleetcarrier data from Frontier's CAPI servers. This is done when CarrierBuy
or CarrierStats
events are detected in the Player Journal. To avoid flooding Frontier's CAPI server, a throttle is applied to ensure a significant interval between requests (currently 15 mins). Also be aware that calls to the /fleetcarrier
CAPI endpoint have been reported to take a very long time to return, potentially up to 20 minutes. Delays in responses from this endpoint could delay other CAPI queries.
from companion import CAPIData, SERVER_LIVE, SERVER_LEGACY, SERVER_BETA
def capi_fleetcarrier(data):
"""
We have new data on our Fleet Carrier
"""
if data.get('name') is None or data['name'].get('callsign') is None:
raise ValueError("this isn't possible")
logger.info(data['name']['callsign'])
# Determining source galaxy for the data
if data.source_host == SERVER_LIVE:
...
elif data.source_host == SERVER_BETA:
...
elif data.source_host == SERVER_LEGACY:
...
Parameter | Type | Description |
---|---|---|
data |
CAPIData |
/fleetcarrier API response |
CAPIData
is a class, which you can from companion import CAPIDATA
, and is based on UserDict
. The actual data from CAPI queries is thus accessible via python's normal data['key']
syntax. However, being a class, it can also have extra properties, such as source_host
, as shown in the code example above.
Plugin authors are free to use the following properties of CAPIData
, but MUST NOT rely on any other extra properties, they are for internal use only.
Property | Type | Description |
---|---|---|
data |
Dict |
The data returned by the CAPI query. For the cmdr_data() callback, if the player is docked at a station, and the relevant services are available then the lastStarport key's value will have been augmented with /market and/or /shipyard data. Do not assume this will always be the case. |
source_host |
str |
SERVER_LIVE | SERVER_BETA | SERVER_LEGACY the current galaxy mode. |
request_cmdr |
str |
The name of the active CMDR at the point the request was made. In the case of a CAPI request taking a long time to return, the user may have switched CMDR during the request, so this may be different to the current CMDR. |
See this documentation for details of the expected content structure and data for CAPI queries.
If there is a killswitch in effect for some of the CAPI endpoints, then the data passed to this function might not be as complete as you expect. Code defensively.
When CAPI data has been retrieved from the separate CAPI host for the Legacy galaxy, because the Journal gameversion indicated the player is playing / last played in that galaxy, a different function will be called, cmdr_data_legacy()
. Note that there is no legacy equivalent to capi_fleetcarrier()
, so always use the source_host
property to determine the user's galaxy.
def cmdr_data_legacy(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'])
IF AND ONLY IF your code definitely handles the Live/Legacy split itself then you may simply:
from companion import SERVER_BETA, SERVER_LEGACY, SERVER_LIVE
def cmdr_data_legacy(data, is_beta):
return cmdr_data(data, is_beta)
def cmdr_data(data, is_beta):
if data.source_host == SERVER_LEGACY:
...
elif data.source_host == SERVER_LIVE:
...
elif data.source_host == SERVER_BETA:
# Would also be indicated by `is_beta == True`
...
else:
# Unknown source galaxy !
...
The core 'eddn' plugin might contain some useful hints about how to handle the
split but do not rely on any extra properties on data
unless they are
documented in Available imports in this document.
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 |
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 |
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 |
You can display an error in EDMarketConnector's status area by returning a
string from your journal_entry()
, dashboard_entry()
, cmdr_data()
or capi_fleetcarrier()
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 EDMarketConnector 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.
You can localise your plugin to one of the languages that EDMarketConnector itself supports. Add the following boilerplate near the top of each source file that contains strings that needs translating:
import l10n
import functools
_ = functools.partial(l10n.Translations.translate, context=__file__)
Wrap each string that needs translating with the _()
function, e.g.:
somewidget["text"] = _("Happy!")
If you display localized strings in EDMarketConnector'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 EDMarketConnector's own L10n
folder for the list of supported language codes and for example translation
files.
A Package Plugin is both a standard Python package (i.e. contains an
__init__.py
file) and an EDMarketConnector 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.
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 → 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.
EDMarketConnector's Windows installs only package a minimal set of modules.
Any modules the core application code uses will naturally be packaged, and we explicitly include a small number of additional modules for the use of plugins.
Whilst we would like to make all of the stdlib
of Python available it is
not automatically packaged into our releases by py2exe. We hope to address
this in the 5.3 release series. In the meantime, if there's anything
missing that you'd like to use, please ask. Yes, this very much means you
need to test your plugins against a Windows installation of the application
to be sure it will work.
See Plugins:Available imports for a list.
As such if your plugin requires additional modules you will need to package them with your plugin. There is no general Python interpreter in which to rely on pip to install them.
It will be easier of you are using a Python virtual environment for actually testing the plugin. This is so that you can be sure it is working because you have copied all the correct Python modules inside your plugin, and not because they are installed within the Python site-packages in some applicable location (system level or user level).
So, setup a virtual environment to use when running EDMarketConnector code to test your plugin, and use the 'system' non-virtual Python to install modules in order to have somewhere to copy them from.
NB: If you use PyCharm it's possible to have it do the work of creating a virtual environment for your project.
Technically you could also do this within an additional virtual environment. If they were in your plugin testing virtual environment then you can't be sure you have all the necessary files copied into your plugin so it will work within a vanilla Windows EDMarketConnector install.
We'll use xml_dataclasses
for this example.
pip install xml_dataclasses
-
Assuming it's a 'simple' module with no caveats, now we copy:
-
pip show xml_dataclasses
-Location
is where it was installed. -
If you have a POSIX-compliant command-line environment:
cp -pr <Location> <plugin_dir>
or just use Windows File Explorer, or other GUI means, to copy.
You're going to have to refer to your plugin directory in order to import anything within it. This means it should be compatible with such.
- Do not use hyphens (
-
) as word separators, or full-stops (.
). - You can use underscore (
_
) as a word separator.
So:
EDMC-My-Plugin
BAD.EDMC.My.Plugin
BAD.EDMC_My_Plugin
GOOD.
NB: No, you can't use from . import xml_dataclasses
because the way
EDMarketConnector:plug.py loads 'found' plugins prevents this from working.
Add an import of this module to your plugin code:
from EDMC_My_Plugin import xml_dataclasses
If you're lucky you won't have the "surprise!" of learning your chosen extra module itself requires other modules. If you are gifted such a surprise then you will need to repeat the Copy step for the extra module(s) until it works.
You can debug your http post requests using the builtin debug webserver.
To add support for said debug webserver to your plugin, you need to check
config.debug_senders
(list[str]
) for some indicator string for your
plugin. debug_senders
is generated from args to --debug-sender
on the
invocation command line.
If said string exists, DEBUG_WEBSERVER_HOST
and DEBUG_WEBSERVER_PORT
in
edmc_data
will contain the host and port for the currently running local
webserver. Simply redirect your requests there, and your requests will be
logged to disk. For organisation, rewrite your request path to simply be
/pluginname
.
Logs exist in $TEMP/EDMarketConnector/http_debug/$path.log
. If somehow you
manage to cause a directory traversal, your data will not be saved to disk at
all. You will see this in EDMarketConnectors log.
The simplest way to go about adding support is:
from edmc_data import DEBUG_WEBSERVER_HOST, DEBUG_WEBSERVER_PORT
from config import debug_senders
TARGET_URL = "https://host.tld/path/to/api/magic"
if 'my_plugin' in debug_senders:
TARGET_URL = f'http://{DEBUG_WEBSERVER_HOST}:{DEBUG_WEBSERVER_PORT}/my_plugin'
# Code that uses TARGET_URL to post info from your plugin below.
For returned data, you can modify debug_webserver.DEFAULT_RESPONSES
(dict[str, Union[Callable[[str], str]], str])
with either a function that
accepts a single string (the raw post data) and returns a single string (the
response to send), or with a string if your required response is simple.
EDMarketConnector 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
Starting with pre-release 3.5 EDMarketConnector used Python 3.7. The first full release under Python 3.7 was 4.0.0.0. The 4.2.x series was the last to use Python 3.7, with releases moving on to the latest Python 3.9.x after that.
This is a brief outline of the steps required to migrate a plugin from earlier versions of EDMarketConnector:
-
Rename the function
plugin_start
toplugin_start3(plugin_dir)
. Plugins without aplugin_start3
function are listed as disabled on EDMarketConnector'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 theSettings
>Plugins
tab. -
Check that callback functions
plugin_prefs
,prefs_changed
,journal_entry
,dashboard_entry
,cmdr_data
andcapi_fleetcarrier
, 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.9+. The 2to3 tool can automate much of this work.
We advise against making any attempt to have a plugin's code work under both Python 2.7 and 3.x. We no longer maintain the Python 2.7-based versions of this application, and you shouldn't support use of them with your plugin.