From c25283eff90fc8d4fbbe186aca386218a9f3c903 Mon Sep 17 00:00:00 2001 From: Alex Williams Date: Sat, 11 Apr 2026 13:56:24 +0100 Subject: [PATCH 1/4] Adding to the coriolis 'core' plugin, the ability to add an API key for cmdr.coriolis.io and when added, the ability to send material, ship and module data to cmdr.coriolis.io with the users consent. --- plugins/coriolis.py | 420 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 418 insertions(+), 2 deletions(-) diff --git a/plugins/coriolis.py b/plugins/coriolis.py index 007a8472..4d08fabc 100644 --- a/plugins/coriolis.py +++ b/plugins/coriolis.py @@ -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 json +import threading 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,287 @@ 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 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: + loadout = _build_loadout(state) + if loadout: + 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) + + # --- Module events: send the journal entry + current loadout --- + elif event_name in MODULE_EVENTS: + loadout = _build_loadout(state) + payload = { + '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) + + # --- Engineering events: send the journal entry + updated loadout --- + elif event_name in ENGINEERING_EVENTS: + loadout = _build_loadout(state) + payload = { + '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) + + # --- 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 From f4245a06696a34f2b332a6e92fbe3eff22c9fd6c Mon Sep 17 00:00:00 2001 From: Michael K Williams Date: Thu, 30 Apr 2026 15:33:30 +0100 Subject: [PATCH 2/4] Synthesize Loadout event for plugins after catch-up replay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After EDMC restarts and replays the journal during catch-up, state['Modules'] is populated from any Loadout entries in the journal. However, plugins never see these historical events — only the synthesized StartUp event is forwarded. This means plugins that depend on receiving a Loadout event (e.g. Inara's shipswap handler, Coriolis CMDR) miss the current ship loadout after an EDMC restart. Fix: after synthesizing the StartUp event, also synthesize a Loadout event via self.ship() if state['Modules'] is populated. This gives every plugin the current ship loadout on startup without requiring any plugin-side changes. --- monitor.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/monitor.py b/monitor.py index b28adb44..67df7174 100644 --- a/monitor.py +++ b/monitor.py @@ -409,6 +409,17 @@ class EDLogs(FileSystemEventHandler): self.event_queue.put(json.dumps(entry, separators=(', ', ':'))) + # If state['Modules'] is populated from catch-up replay (e.g. a + # Loadout event was in the journal), synthesize a Loadout event so + # plugins see the current ship loadout on startup. Without this, + # plugins that wait for a Loadout event (like Inara's shipswap + # handler) will never receive one after an EDMC restart. + if self.state['Modules']: + loadout_entry = self.ship(timestamped=True) + if loadout_entry: + logger.info("Synthesizing Loadout event for plugins (state has modules from catch-up)") + self.event_queue.put(json.dumps(loadout_entry, separators=(', ', ':'))) + else: # Generate null event to update the display (with possibly out-of-date info) self.event_queue.put(None) From 64e5dcf0cbd5dfa16e6c63d9ae7263b86cf46342 Mon Sep 17 00:00:00 2001 From: Michael K Williams Date: Thu, 30 Apr 2026 15:43:56 +0100 Subject: [PATCH 3/4] Revert "Synthesize Loadout event for plugins after catch-up replay" This reverts commit f4245a06696a34f2b332a6e92fbe3eff22c9fd6c. --- monitor.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/monitor.py b/monitor.py index 67df7174..b28adb44 100644 --- a/monitor.py +++ b/monitor.py @@ -409,17 +409,6 @@ class EDLogs(FileSystemEventHandler): self.event_queue.put(json.dumps(entry, separators=(', ', ':'))) - # If state['Modules'] is populated from catch-up replay (e.g. a - # Loadout event was in the journal), synthesize a Loadout event so - # plugins see the current ship loadout on startup. Without this, - # plugins that wait for a Loadout event (like Inara's shipswap - # handler) will never receive one after an EDMC restart. - if self.state['Modules']: - loadout_entry = self.ship(timestamped=True) - if loadout_entry: - logger.info("Synthesizing Loadout event for plugins (state has modules from catch-up)") - self.event_queue.put(json.dumps(loadout_entry, separators=(', ', ':'))) - else: # Generate null event to update the display (with possibly out-of-date info) self.event_queue.put(None) From e9d288daca4488775985f47964874ad19d185f9b Mon Sep 17 00:00:00 2001 From: Michael K Williams Date: Thu, 30 Apr 2026 16:24:20 +0100 Subject: [PATCH 4/4] Add CAPI sync hook for Coriolis CMDR ship detection Add cmdr_data() hook and _build_loadout_from_capi() to the coriolis plugin. This sends the current ship loadout to the Coriolis CMDR API whenever EDMC fetches fresh data from Frontier's CAPI (on docking and manual sync button press). This is the primary mechanism for detecting ship changes after a ShipyardSwap, because the Loadout journal event may not be forwarded to plugins by EDMC after a catch-up replay. Also: - Remove unused json import - Extract _handle_ship_event and _handle_module_event helpers to reduce journal_entry complexity below flake8 threshold (C901) - Add datetime import for CAPI timestamp generation --- plugins/coriolis.py | 242 ++++++++++++++++++++++++++++++++++---------- 1 file changed, 189 insertions(+), 53 deletions(-) diff --git a/plugins/coriolis.py b/plugins/coriolis.py index 4d08fabc..bb98fd5d 100644 --- a/plugins/coriolis.py +++ b/plugins/coriolis.py @@ -22,8 +22,8 @@ referenced in this file (or only in any other core plugin), and if so... # pylint: disable=import-error from __future__ import annotations -import json import threading +from datetime import datetime, timezone from typing import Any from collections.abc import Mapping import tkinter as tk @@ -480,6 +480,190 @@ def _send_to_cmdr_api(cmdr: str, api_key: str, payload: dict[str, Any]) -> None: 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, @@ -525,59 +709,11 @@ def journal_entry( # --- Ship events: send full loadout --- if event_name in SHIP_EVENTS: - loadout = _build_loadout(state) - if loadout: - 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', '') + _handle_ship_event(cmdr, api_key, event_name, entry, state) - _send_to_cmdr_api(cmdr, api_key, payload) - - # --- Module events: send the journal entry + current loadout --- - elif event_name in MODULE_EVENTS: - loadout = _build_loadout(state) - payload = { - '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) - - # --- Engineering events: send the journal entry + updated loadout --- - elif event_name in ENGINEERING_EVENTS: - loadout = _build_loadout(state) - payload = { - '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) + # --- 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: