diff --git a/plugins/coriolis.py b/plugins/coriolis.py index 007a8472..bb98fd5d 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 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