Merge branch 'develop' of https://github.com/EDCD/EDMarketConnector into develop

This commit is contained in:
David Sangrey committed 2026-05-28 21:25:07 -04:00
commit 1167030b07
1 file changed
+554 -2
+554 -2
View File
@@ -22,16 +22,47 @@ referenced in this file (or only in any other core plugin), and if so...
# pylint: disable=import-error
from __future__ import annotations
import threading
from datetime import datetime, timezone
from typing import Any
from collections.abc import Mapping
import tkinter as tk
from tkinter import ttk
import requests
import myNotebook as nb # noqa: N813 # its not my fault.
from EDMCLogging import get_main_logger
from plug import show_error
from config import config
from config import appname, appversion, config
from monitor import monitor
from l10n import translations as tr
from plugins.common_coreutils import PADX, PADY, BOXY, shipyard_url_common
from plugins.common_coreutils import PADX, PADY, BOXY, BUTTONX, shipyard_url_common
# Default URL for the Coriolis CMDR API
DEFAULT_CMDR_API_URL = 'https://cmdr.coriolis.io/api/edmc/'
# Journal events we care about for ship / module / material tracking
SHIP_EVENTS = {
'Loadout', 'ShipyardNew', 'ShipyardBuy', 'ShipyardSell',
'SellShipOnRebuy', 'ShipyardSwap', 'ShipyardTransfer',
'SetUserShipName', 'StartUp',
}
MODULE_EVENTS = {
'ModuleBuy', 'ModuleSell', 'ModuleStore', 'ModuleRetrieve',
'ModuleSwap', 'MassModuleStore',
}
ENGINEERING_EVENTS = {
'EngineerCraft',
}
MATERIAL_EVENTS = {
'Materials', 'MaterialCollected', 'MaterialDiscarded',
'MaterialTrade', 'Synthesis', 'ScientificResearch',
'TechnologyBroker', 'StartUp',
}
STORED_MODULE_EVENTS = {
'StoredModules',
}
TRACKED_EVENTS = SHIP_EVENTS | MODULE_EVENTS | ENGINEERING_EVENTS | MATERIAL_EVENTS | STORED_MODULE_EVENTS
class CoriolisConfig:
@@ -47,8 +78,19 @@ class CoriolisConfig:
self.normal_textvar = tk.StringVar()
self.beta_textvar = tk.StringVar()
self.auto_send_textvar = tk.BooleanVar()
self.override_textvar = tk.StringVar()
# CMDR sync state
self.cmdr_sync = tk.IntVar(value=0)
self.cmdr: str | None = None
self.apikey: nb.EntryMenu | None = None
self.apikey_label: nb.Label | None = None
self.cmdr_sync_button: nb.Checkbutton | None = None
# Track previous material totals to detect changes
self.last_materials: list[dict[str, Any]] | None = None
def initialize_urls(self) -> None:
"""Initialize Coriolis URLs and override mode from configuration."""
self.normal_url = config.get_str('coriolis_normal_url', default=DEFAULT_NORMAL_URL)
@@ -73,10 +115,29 @@ DEFAULT_NORMAL_URL = 'https://coriolis.io/import?data='
DEFAULT_BETA_URL = 'https://beta.coriolis.io/import?data='
DEFAULT_OVERRIDE_MODE = 'auto'
CMDR_API_TIMEOUT = 15 # HTTP request timeout in seconds
def _cmdr_api_key(cmdr: str | None) -> str | None:
"""Look up the Coriolis CMDR API key for the given commander name."""
if not cmdr:
return None
cmdrs = config.get_list('coriolis_cmdr_cmdrs', default=[])
apikeys = config.get_list('coriolis_cmdr_apikeys', default=[])
if cmdr in cmdrs:
idx = cmdrs.index(cmdr)
if idx < len(apikeys) and apikeys[idx]:
return apikeys[idx]
return None
def plugin_start3(path: str) -> str:
"""Set up URLs."""
coriolis_config.initialize_urls()
coriolis_config.cmdr_sync.set(config.get_int('coriolis_cmdr_sync'))
return 'Coriolis'
@@ -135,9 +196,61 @@ def plugin_prefs(parent: ttk.Notebook, cmdr: str | None, is_beta: bool) -> nb.Fr
).grid(sticky=tk.W, row=cur_row, column=1, padx=PADX, pady=BOXY)
cur_row += 1
# --- Coriolis CMDR real-time sync ---
ttk.Separator(conf_frame, orient=tk.HORIZONTAL).grid(
columnspan=3, padx=PADX, pady=PADY, sticky=tk.EW, row=cur_row
)
cur_row += 1
# LANG: Settings>Coriolis: checkbox to enable sending data to Coriolis CMDR
coriolis_config.cmdr_sync_button = nb.Checkbutton(
conf_frame,
text=tr.tl('Send ship, module and material data to Coriolis CMDR'),
variable=coriolis_config.cmdr_sync,
command=_prefs_cmdr_sync_changed,
)
coriolis_config.cmdr_sync_button.grid(
row=cur_row, columnspan=3, padx=BUTTONX, pady=PADY, sticky=tk.W
)
cur_row += 1
# LANG: Settings>Coriolis: API key label
coriolis_config.apikey_label = nb.Label(conf_frame, text=tr.tl('Coriolis CMDR API Key'))
coriolis_config.apikey_label.grid(row=cur_row, padx=PADX, pady=PADY, sticky=tk.W)
coriolis_config.apikey = nb.EntryMenu(conf_frame, width=50)
coriolis_config.apikey.grid(row=cur_row, column=1, padx=PADX, pady=BOXY, sticky=tk.EW)
cur_row += 1
# Populate the API key for the current CMDR
prefs_cmdr_changed(cmdr, is_beta)
return conf_frame
def _prefs_cmdr_sync_changed() -> None:
"""Update widget states when the sync checkbox changes."""
state = tk.NORMAL if coriolis_config.cmdr_sync.get() else tk.DISABLED
if coriolis_config.apikey_label:
coriolis_config.apikey_label['state'] = state
if coriolis_config.apikey:
coriolis_config.apikey['state'] = state
def prefs_cmdr_changed(cmdr: str | None, is_beta: bool) -> None:
"""Plugin commander change hook — populate the API key field."""
if coriolis_config.apikey is None:
return
coriolis_config.apikey['state'] = tk.NORMAL
coriolis_config.apikey.delete(0, tk.END)
if cmdr:
cred = _cmdr_api_key(cmdr)
if cred:
coriolis_config.apikey.insert(0, cred)
_prefs_cmdr_sync_changed()
def prefs_changed(cmdr: str | None, is_beta: bool) -> None:
"""
Update URLs and override mode based on user preferences.
@@ -185,6 +298,25 @@ def prefs_changed(cmdr: str | None, is_beta: bool) -> None:
config.set('coriolis_beta_url', coriolis_config.beta_url)
config.set('coriolis_overide_url_selection', coriolis_config.override_mode)
# Save CMDR sync settings
config.set('coriolis_cmdr_sync', coriolis_config.cmdr_sync.get())
if cmdr and coriolis_config.apikey is not None:
cmdrs = config.get_list('coriolis_cmdr_cmdrs', default=[])
apikeys = config.get_list('coriolis_cmdr_apikeys', default=[])
new_key = coriolis_config.apikey.get().strip()
if cmdr in cmdrs:
idx = cmdrs.index(cmdr)
apikeys.extend([''] * (1 + idx - len(apikeys)))
apikeys[idx] = new_key
else:
cmdrs.append(cmdr)
apikeys.append(new_key)
config.set('coriolis_cmdr_cmdrs', cmdrs)
config.set('coriolis_cmdr_apikeys', apikeys)
def _get_target_url(is_beta: bool) -> str:
if coriolis_config.override_mode not in ('auto', 'normal', 'beta'):
@@ -214,3 +346,423 @@ def shipyard_url(loadout: Mapping[str, Any], is_beta: bool) -> bool | str:
"""
encoded_data = shipyard_url_common(loadout)
return _get_target_url(is_beta) + encoded_data if encoded_data else False
# ---------------------------------------------------------------------------
# Coriolis CMDR – real-time data sync via journal events
# ---------------------------------------------------------------------------
def _build_loadout(state: dict[str, Any]) -> dict[str, Any] | None:
"""
Build a loadout dict from EDMC state, similar to Inara's make_loadout.
Returns None if the state has no module information yet.
"""
if not state.get('Modules'):
return None
modules = []
for m in state['Modules'].values():
module: dict[str, Any] = {
'slot': m['Slot'],
'item': m['Item'],
'on': m['On'],
'priority': m['Priority'],
}
if m.get('Health') is not None:
module['health'] = m['Health']
if m.get('Value') is not None:
module['value'] = m['Value']
if 'Engineering' in m:
eng = m['Engineering']
engineering: dict[str, Any] = {
'blueprintName': eng.get('BlueprintName', ''),
'level': eng.get('Level', 0),
'quality': eng.get('Quality', 0),
}
if 'ExperimentalEffect' in eng:
engineering['experimentalEffect'] = eng['ExperimentalEffect']
if 'Modifiers' in eng:
mods = []
for mod in eng['Modifiers']:
modifier: dict[str, Any] = {'label': mod['Label']}
if 'OriginalValue' in mod:
modifier['value'] = mod['Value']
modifier['originalValue'] = mod['OriginalValue']
modifier['lessIsGood'] = mod.get('LessIsGood', 0)
elif 'ValueStr' in mod:
modifier['valueStr'] = mod['ValueStr']
mods.append(modifier)
engineering['modifiers'] = mods
module['engineering'] = engineering
modules.append(module)
return {
'shipType': state.get('ShipType', ''),
'shipID': state.get('ShipID'),
'shipName': state.get('ShipName', ''),
'shipIdent': state.get('ShipIdent', ''),
'modules': modules,
'hullValue': state.get('HullValue'),
'modulesValue': state.get('ModulesValue'),
'rebuy': state.get('Rebuy'),
}
def _build_materials(state: dict[str, Any]) -> list[dict[str, Any]]:
"""Build a sorted material inventory from EDMC state."""
materials = []
for category in ('Raw', 'Manufactured', 'Encoded'):
for name in sorted(state.get(category, {})):
materials.append({
'category': category.lower(),
'name': name,
'count': state[category][name],
})
return materials
def _build_stored_modules(entry: dict[str, Any]) -> list[dict[str, Any]]:
"""Build a stored-modules list from a StoredModules journal entry."""
items = entry.get('Items', [])
modules = []
for item in items:
mod: dict[str, Any] = {
'storageSlot': item.get('StorageSlot', 0),
'name': item.get('Name', ''),
'nameLocalised': item.get('Name_Localised', ''),
'buyPrice': item.get('BuyPrice', 0),
'hot': item.get('Hot', False),
}
# Location — absent when module is in transit
if 'StarSystem' in item:
mod['starSystem'] = item['StarSystem']
if 'MarketID' in item:
mod['marketID'] = item['MarketID']
# Engineering
if 'EngineerModifications' in item:
mod['engineerModification'] = item['EngineerModifications']
if 'Level' in item:
mod['engineerLevel'] = item['Level']
if 'Quality' in item:
mod['engineerQuality'] = item['Quality']
modules.append(mod)
return modules
def _send_to_cmdr_api(cmdr: str, api_key: str, payload: dict[str, Any]) -> None:
"""POST a payload to the Coriolis CMDR API in a background thread."""
def _do_send():
try:
masked = f'{api_key[:4]}...{api_key[-4:]}' if len(api_key) >= 8 else '***'
logger.warning(
f'Coriolis CMDR API: POST {DEFAULT_CMDR_API_URL} '
f'event={payload.get("event", "?")} key={masked} len={len(api_key)}'
)
resp = requests.post(
DEFAULT_CMDR_API_URL,
json=payload,
headers={
'Authorization': f'Bearer {api_key}',
'X-Api-Key': api_key,
'User-Agent': f'{appname}/{appversion()}',
'Content-Type': 'application/json',
},
timeout=CMDR_API_TIMEOUT,
)
if not resp.ok:
logger.warning(f'Coriolis CMDR API returned {resp.status_code}: {resp.text[:2000]}')
except requests.RequestException as e:
logger.warning(f'Coriolis CMDR API request failed: {e}')
threading.Thread(target=_do_send, name='CoriolisCMDR sender', daemon=True).start()
def _build_loadout_from_capi(ship: dict[str, Any]) -> dict[str, Any] | None:
"""
Build a loadout dict from CAPI data['ship'].
CAPI module structure per slot::
{ 'module': {'id': ..., 'name': 'Int_Engine', ...},
'on': True, 'priority': 1, 'health': 10000,
'value': {'base': ..., 'current': ...},
'modifications': {...} }
"""
capi_modules = ship.get('modules')
if not capi_modules:
return None
modules = []
for slot, m in capi_modules.items():
if not isinstance(m, dict):
continue
mod_info = m.get('module', {})
if not isinstance(mod_info, dict):
continue
item_name = mod_info.get('name', '')
if not item_name:
continue
module: dict[str, Any] = {
'slot': slot,
'item': item_name,
'on': m.get('on', True),
'priority': m.get('priority', 1),
}
health = m.get('health')
if health is not None:
module['health'] = health / 10000.0 # CAPI uses 0-10000 scale
value = m.get('value', {})
if isinstance(value, dict) and value.get('base'):
module['value'] = value['base']
# Engineering: CAPI uses 'modifications' key
mods_raw = m.get('modifications') or m.get('WorkInProgress_modifications') or {}
if mods_raw and isinstance(mods_raw, dict):
engineering: dict[str, Any] = {
'blueprintName': mod_info.get('engineering', {}).get('recipeName', ''),
'level': mod_info.get('engineering', {}).get('recipeLevel', 0),
'quality': mod_info.get('engineering', {}).get('recipeQuality', 0),
}
mods = []
for label, mod in mods_raw.items():
if not isinstance(mod, dict):
continue
modifier: dict[str, Any] = {'label': label}
if 'value' in mod and 'originalValue' in mod:
modifier['value'] = mod['value']
modifier['originalValue'] = mod['originalValue']
modifier['lessIsGood'] = mod.get('lessIsGood', 0)
elif 'valueStr' in mod:
modifier['valueStr'] = mod['valueStr']
mods.append(modifier)
if mods:
engineering['modifiers'] = mods
module['engineering'] = engineering
modules.append(module)
if not modules:
return None
return {
'shipType': ship.get('name', ''),
'shipID': ship.get('id'),
'shipName': ship.get('shipName') or '',
'shipIdent': ship.get('shipIdent') or '',
'modules': modules,
'hullValue': ship.get('hullValue'),
'modulesValue': ship.get('modulesValue'),
'rebuy': ship.get('rebuy'),
}
def cmdr_data(data: Any, is_beta: bool) -> str | None:
"""
CAPI data hook -- called by EDMC after a successful Frontier API query.
Fires automatically on docking and when the user presses the EDMC sync
button. ``data['ship']`` contains the full current ship loadout from
Frontier's servers -- the most authoritative source available.
This is the primary mechanism for detecting ship changes after a
ShipyardSwap, because the game's Loadout journal event may not be
forwarded to plugins by EDMC after a catch-up replay.
"""
if not coriolis_config.cmdr_sync.get():
return None
if is_beta:
return None
if not monitor.is_live_galaxy():
return None
cmdr = getattr(monitor, 'cmdr', None)
if not cmdr:
return None
api_key = _cmdr_api_key(cmdr)
if not api_key:
return None
ship = data.get('ship') if hasattr(data, 'get') else None
if not ship:
return None
loadout = _build_loadout_from_capi(ship)
if not loadout:
return None
logger.info(f'cmdr_data: sending loadout from CAPI for {cmdr!r}')
payload: dict[str, Any] = {
'event': 'Loadout',
'timestamp': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
'commander': cmdr,
'ship': loadout,
}
_send_to_cmdr_api(cmdr, api_key, payload)
return None
def _handle_ship_event(
cmdr: str, api_key: str, event_name: str,
entry: dict[str, Any], state: dict[str, Any],
) -> None:
"""Build and send a ship loadout payload for a ship event."""
loadout = _build_loadout(state)
if not loadout:
return
payload: dict[str, Any] = {
'event': event_name,
'timestamp': entry.get('timestamp', ''),
'commander': cmdr,
'ship': loadout,
}
# Include extra context from the journal entry itself
if event_name == 'ShipyardBuy':
payload['storeShipID'] = entry.get('StoreShipID')
payload['sellShipID'] = entry.get('SellShipID')
payload['newShipType'] = entry.get('ShipType', '')
elif event_name in ('ShipyardSell', 'SellShipOnRebuy'):
payload['soldShipType'] = entry.get('ShipType', '')
payload['soldShipID'] = entry.get('SellShipID') or entry.get('ShipID')
elif event_name == 'ShipyardSwap':
payload['storeShipID'] = entry.get('StoreOldShip')
payload['storeShipType'] = entry.get('ShipType', '')
_send_to_cmdr_api(cmdr, api_key, payload)
def _handle_module_event(
cmdr: str, api_key: str, event_name: str,
entry: dict[str, Any], state: dict[str, Any],
) -> None:
"""Build and send a module/engineering event payload."""
loadout = _build_loadout(state)
payload: dict[str, Any] = {
'event': event_name,
'timestamp': entry.get('timestamp', ''),
'commander': cmdr,
'journalEntry': {
k: v for k, v in entry.items()
if k not in ('event', 'timestamp')
},
}
if loadout:
payload['ship'] = loadout
_send_to_cmdr_api(cmdr, api_key, payload)
def journal_entry(
cmdr: str,
is_beta: bool,
system: str,
station: str,
entry: dict[str, Any],
state: dict[str, Any],
) -> str | None:
"""
Journal entry hook – send relevant events to Coriolis CMDR.
:param cmdr: Commander name.
:param is_beta: Whether the game is in beta.
:param system: Current system name.
:param station: Current station name.
:param entry: The journal entry dict.
:param state: The cumulative game state maintained by EDMC's monitor.
:return: Error string or None.
"""
# Guard: sync must be enabled, we need a CMDR name and an API key,
# and we only send data from the live galaxy.
if not coriolis_config.cmdr_sync.get():
return None
if is_beta:
return None
if not monitor.is_live_galaxy():
return None
api_key = _cmdr_api_key(cmdr)
if not api_key:
return None
event_name = entry.get('event', '')
if event_name not in TRACKED_EVENTS:
# Even for non-tracked events, check if materials changed (like Inara does).
# Some events modify materials without being in our explicit list.
_check_material_changes(cmdr, api_key, entry, state)
return None
coriolis_config.cmdr = cmdr
# --- Ship events: send full loadout ---
if event_name in SHIP_EVENTS:
_handle_ship_event(cmdr, api_key, event_name, entry, state)
# --- Module / Engineering events: send journal entry + current loadout ---
elif event_name in MODULE_EVENTS or event_name in ENGINEERING_EVENTS:
_handle_module_event(cmdr, api_key, event_name, entry, state)
# --- Material events: send the full material inventory ---
if event_name in MATERIAL_EVENTS:
materials = _build_materials(state)
payload = {
'event': event_name,
'timestamp': entry.get('timestamp', ''),
'commander': cmdr,
'materials': materials,
}
coriolis_config.last_materials = materials
_send_to_cmdr_api(cmdr, api_key, payload)
# --- Stored modules: send full list from the journal entry ---
elif event_name in STORED_MODULE_EVENTS:
stored = _build_stored_modules(entry)
payload = {
'event': event_name,
'timestamp': entry.get('timestamp', ''),
'commander': cmdr,
'storedModules': stored,
}
_send_to_cmdr_api(cmdr, api_key, payload)
else:
# For ship/module/engineering events, still check if materials changed
# (e.g. EngineerCraft consumes materials)
_check_material_changes(cmdr, api_key, entry, state)
return None
def _check_material_changes(
cmdr: str, api_key: str, entry: dict[str, Any], state: dict[str, Any]
) -> None:
"""
Detect material inventory changes and send an update if they differ.
This catches events that modify materials without being in MATERIAL_EVENTS
(e.g. EngineerCraft modifies materials as a side-effect).
"""
current = _build_materials(state)
if coriolis_config.last_materials is not None and current != coriolis_config.last_materials:
payload = {
'event': 'MaterialsUpdated',
'timestamp': entry.get('timestamp', ''),
'commander': cmdr,
'materials': current,
}
_send_to_cmdr_api(cmdr, api_key, payload)
coriolis_config.last_materials = current