"""System display and EDSM lookup.""" # TODO: # 1) Re-factor EDSM API calls out of journal_entry() into own function. # 2) Fix how StartJump already changes things, but only partially. # 3) Possibly this and other two 'provider' plugins could do with being # based on a single class that they extend. There's a lot of duplicated # logic. # 4) Ensure the EDSM API call(back) for setting the image at end of system # text is always fired. i.e. CAPI cmdr_data() processing. # ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# # ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# # # This is an EDMC 'core' plugin. # # All EDMC plugins are *dynamically* loaded at run-time. # # We build for Windows using `py2exe`. # # `py2exe` can't possibly know about anything in the dynamically loaded # core plugins. # # Thus you **MUST** check if any imports you add in this file are only # referenced in this file (or only in any other core plugin), and if so... # # YOU MUST ENSURE THAT PERTINENT ADJUSTMENTS ARE MADE IN `setup.py` # SO AS TO ENSURE THE FILES ARE ACTUALLY PRESENT IN AN END-USER # INSTALLATION ON WINDOWS. # # # ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# # ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# import json import threading import tkinter as tk from datetime import datetime, timedelta, timezone from queue import Queue from threading import Thread from time import sleep from typing import TYPE_CHECKING, Any, List, Literal, Mapping, MutableMapping, Optional, Set, Tuple, Union, cast import requests import killswitch import monitor import myNotebook as nb # noqa: N813 import plug from companion import CAPIData from config import applongname, appversion, config, debug_senders, user_agent from edmc_data import DEBUG_WEBSERVER_HOST, DEBUG_WEBSERVER_PORT from EDMCLogging import get_main_logger from ttkHyperlinkLabel import HyperlinkLabel if TYPE_CHECKING: def _(x: str) -> str: return x logger = get_main_logger() EDSM_POLL = 0.1 _TIMEOUT = 20 DISCARDED_EVENTS_SLEEP = 10 # trace-if events CMDR_EVENTS = 'plugin.edsm.cmdr-events' CMDR_CREDS = 'plugin.edsm.cmdr-credentials' class This: """Holds module globals.""" def __init__(self): self.shutting_down = False # Plugin is shutting down. self.game_version = "" self.game_build = "" # Handle only sending Live galaxy data self.legacy_galaxy_last_notified: Optional[datetime] = None self.session: requests.Session = requests.Session() self.session.headers['User-Agent'] = user_agent self.queue: Queue = Queue() # Items to be sent to EDSM by worker thread self.discarded_events: Set[str] = [] # List discarded events from EDSM self.lastlookup: requests.Response # Result of last system lookup # Game state self.multicrew: bool = False # don't send captain's ship info to EDSM while on a crew self.coordinates: Optional[Tuple[int, int, int]] = None self.newgame: bool = False # starting up - batch initial burst of events self.newgame_docked: bool = False # starting up while docked self.navbeaconscan: int = 0 # batch up burst of Scan events after NavBeaconScan self.system_link: tk.Widget = None self.system: tk.Tk = None self.system_address: Optional[int] = None # Frontier SystemAddress self.system_population: Optional[int] = None self.station_link: tk.Widget = None self.station: Optional[str] = None self.station_marketid: Optional[int] = None # Frontier MarketID self.on_foot = False self._IMG_KNOWN = None self._IMG_UNKNOWN = None self._IMG_NEW = None self._IMG_ERROR = None self.thread: Optional[threading.Thread] = None self.log = None self.log_button = None self.label = None self.cmdr_label = None self.cmdr_text = None self.user_label = None self.user = None self.apikey_label = None self.apikey = None this = This() STATION_UNDOCKED: str = '×' # "Station" name to display when not docked = U+00D7 __cleanup = str.maketrans({' ': None, '\n': None}) IMG_KNOWN_B64 = """ R0lGODlhEAAQAMIEAFWjVVWkVWS/ZGfFZ////////////////yH5BAEKAAQALAAAAAAQABAAAAMvSLrc/lAFIUIkYOgNXt5g14Dk0AQlaC1CuglM6w7wgs7r MpvNV4q932VSuRiPjQQAOw== """.translate(__cleanup) IMG_UNKNOWN_B64 = """ R0lGODlhEAAQAKEDAGVLJ+ddWO5fW////yH5BAEKAAMALAAAAAAQABAAAAItnI+pywYRQBtA2CtVvTwjDgrJFlreEJRXgKSqwB5keQ6vOKq1E+7IE5kIh4kC ADs= """.translate(__cleanup) IMG_NEW_B64 = """ R0lGODlhEAAQAMZwANKVHtWcIteiHuiqLPCuHOS1MN22ZeW7ROG6Zuu9MOy+K/i8Kf/DAuvCVf/FAP3BNf/JCf/KAPHHSv7ESObHdv/MBv/GRv/LGP/QBPXO PvjPQfjQSvbRSP/UGPLSae7Sfv/YNvLXgPbZhP7dU//iI//mAP/jH//kFv7fU//fV//ebv/iTf/iUv/kTf/iZ/vgiP/hc/vgjv/jbfriiPriiv7ka//if//j d//sJP/oT//tHv/mZv/sLf/rRP/oYv/rUv/paP/mhv/sS//oc//lkf/mif/sUf/uPv/qcv/uTv/uUv/vUP/qhP/xP//pm//ua//sf//ubf/wXv/thv/tif/s lv/tjf/smf/yYP/ulf/2R//2Sv/xkP/2av/0gP/ylf/2df/0i//0j//0lP/5cP/7a//1p//5gf/7ev/3o//2sf/5mP/6kv/2vP/3y//+jP////////////// /////////////////////////////////////////////////yH5BAEKAH8ALAAAAAAQABAAAAePgH+Cg4SFhoJKPIeHYT+LhVppUTiPg2hrUkKPXWdlb2xH Jk9jXoNJQDk9TVtkYCUkOy4wNjdGfy1UXGJYOksnPiwgFwwYg0NubWpmX1ArHREOFYUyWVNIVkxXQSoQhyMoNVUpRU5EixkcMzQaGy8xhwsKHiEfBQkSIg+G BAcUCIIBBDSYYGiAAUMALFR6FAgAOw== """.translate(__cleanup) IMG_ERR_B64 = """ R0lGODlhEAAQAKEBAAAAAP///////////yH5BAEKAAIALAAAAAAQABAAAAIwlBWpeR0AIwwNPRmZuVNJinyWuClhBlZjpm5fqnIAHJPtOd3Hou9mL6NVgj2L plEAADs= """.translate(__cleanup) # Main window clicks def system_url(system_name: str) -> str: """Get a URL for the current system.""" if this.system_address: return requests.utils.requote_uri(f'https://www.edsm.net/en/system?systemID64={this.system_address}') if system_name: return requests.utils.requote_uri(f'https://www.edsm.net/en/system?systemName={system_name}') return '' def station_url(system_name: str, station_name: str) -> str: """Get a URL for the current station.""" if system_name and station_name: return requests.utils.requote_uri( f'https://www.edsm.net/en/system?systemName={system_name}&stationName={station_name}' ) # monitor state might think these are gone, but we don't yet if this.system and this.station: return requests.utils.requote_uri( f'https://www.edsm.net/en/system?systemName={this.system}&stationName={this.station}' ) if system_name: return requests.utils.requote_uri( f'https://www.edsm.net/en/system?systemName={system_name}&stationName=ALL' ) return '' def plugin_start3(plugin_dir: str) -> str: """Plugin setup hook.""" # Can't be earlier since can only call PhotoImage after window is created this._IMG_KNOWN = tk.PhotoImage(data=IMG_KNOWN_B64) # green circle this._IMG_UNKNOWN = tk.PhotoImage(data=IMG_UNKNOWN_B64) # red circle this._IMG_NEW = tk.PhotoImage(data=IMG_NEW_B64) this._IMG_ERROR = tk.PhotoImage(data=IMG_ERR_B64) # BBC Mode 5 '?' # Migrate old settings if not config.get_list('edsm_cmdrs'): if isinstance(config.get_list('cmdrs'), list) and \ config.get_list('edsm_usernames') and config.get_list('edsm_apikeys'): # Migrate <= 2.34 settings config.set('edsm_cmdrs', config.get_list('cmdrs')) elif config.get_list('edsm_cmdrname'): # Migrate <= 2.25 settings. edsm_cmdrs is unknown at this time config.set('edsm_usernames', [config.get_str('edsm_cmdrname', default='')]) config.set('edsm_apikeys', [config.get_str('edsm_apikey', default='')]) config.delete('edsm_cmdrname', suppress=True) config.delete('edsm_apikey', suppress=True) if config.get_int('output') & 256: # Migrate <= 2.34 setting config.set('edsm_out', 1) config.delete('edsm_autoopen', suppress=True) config.delete('edsm_historical', suppress=True) logger.debug('Starting worker thread...') this.thread = Thread(target=worker, name='EDSM worker') this.thread.daemon = True this.thread.start() logger.debug('Done.') return 'EDSM' def plugin_app(parent: tk.Tk) -> None: """Plugin UI setup.""" this.system_link = parent.children['system'] # system label in main window this.system_link.bind_all('<>', update_status) this.station_link = parent.children['station'] # station label in main window def plugin_stop() -> None: """Stop this plugin.""" logger.debug('Signalling queue to close...') # Signal thread to close and wait for it this.shutting_down = True this.queue.put(None) # Still necessary to get `this.queue.get()` to unblock this.thread.join() # type: ignore this.thread = None this.session.close() # Suppress 'Exception ignored in: ' errors # TODO: this is bad. this._IMG_KNOWN = this._IMG_UNKNOWN = this._IMG_NEW = this._IMG_ERROR = None logger.debug('Done.') def plugin_prefs(parent: tk.Tk, cmdr: str, is_beta: bool) -> tk.Frame: """Plugin preferences setup hook.""" PADX = 10 # noqa: N806 BUTTONX = 12 # indent Checkbuttons and Radiobuttons # noqa: N806 PADY = 2 # close spacing # noqa: N806 frame = nb.Frame(parent) frame.columnconfigure(1, weight=1) HyperlinkLabel( frame, text='Elite Dangerous Star Map', background=nb.Label().cget('background'), url='https://www.edsm.net/', underline=True ).grid(columnspan=2, padx=PADX, sticky=tk.W) # Don't translate this.log = tk.IntVar(value=config.get_int('edsm_out') and 1) this.log_button = nb.Checkbutton( # LANG: Settings>EDSM - Label on checkbox for 'send data' frame, text=_('Send flight log and Cmdr status to EDSM'), variable=this.log, command=prefsvarchanged ) this.log_button.grid(columnspan=2, padx=BUTTONX, pady=(5, 0), sticky=tk.W) nb.Label(frame).grid(sticky=tk.W) # big spacer # Section heading in settings this.label = HyperlinkLabel( frame, # LANG: Settings>EDSM - Label on header/URL to EDSM API key page text=_('Elite Dangerous Star Map credentials'), background=nb.Label().cget('background'), url='https://www.edsm.net/settings/api', underline=True ) cur_row = 10 this.label.grid(columnspan=2, padx=PADX, sticky=tk.W) # LANG: Game Commander name label in EDSM settings this.cmdr_label = nb.Label(frame, text=_('Cmdr')) # Main window this.cmdr_label.grid(row=cur_row, padx=PADX, sticky=tk.W) this.cmdr_text = nb.Label(frame) this.cmdr_text.grid(row=cur_row, column=1, padx=PADX, pady=PADY, sticky=tk.W) cur_row += 1 # LANG: EDSM Commander name label in EDSM settings this.user_label = nb.Label(frame, text=_('Commander Name')) # EDSM setting this.user_label.grid(row=cur_row, padx=PADX, sticky=tk.W) this.user = nb.Entry(frame) this.user.grid(row=cur_row, column=1, padx=PADX, pady=PADY, sticky=tk.EW) cur_row += 1 # LANG: EDSM API key label this.apikey_label = nb.Label(frame, text=_('API Key')) # EDSM setting this.apikey_label.grid(row=cur_row, padx=PADX, sticky=tk.W) this.apikey = nb.Entry(frame) this.apikey.grid(row=cur_row, column=1, padx=PADX, pady=PADY, sticky=tk.EW) prefs_cmdr_changed(cmdr, is_beta) return frame def prefs_cmdr_changed(cmdr: str, is_beta: bool) -> None: """Commanders changed hook.""" this.log_button['state'] = tk.NORMAL if cmdr and not is_beta else tk.DISABLED this.user['state'] = tk.NORMAL this.user.delete(0, tk.END) this.apikey['state'] = tk.NORMAL this.apikey.delete(0, tk.END) if cmdr: this.cmdr_text['text'] = f'{cmdr}{" [Beta]" if is_beta else ""}' cred = credentials(cmdr) if cred: this.user.insert(0, cred[0]) this.apikey.insert(0, cred[1]) else: # LANG: We have no data on the current commander this.cmdr_text['text'] = _('None') to_set: Union[Literal['normal'], Literal['disabled']] = tk.DISABLED if cmdr and not is_beta and this.log.get(): to_set = tk.NORMAL set_prefs_ui_states(to_set) def prefsvarchanged() -> None: """Preferences screen closed hook.""" to_set = tk.DISABLED if this.log.get(): to_set = this.log_button['state'] set_prefs_ui_states(to_set) def set_prefs_ui_states(state: str) -> None: """ Set the state of various config UI entries. :param state: the state to set each entry to """ this.label['state'] = state this.cmdr_label['state'] = state this.cmdr_text['state'] = state this.user_label['state'] = state this.user['state'] = state this.apikey_label['state'] = state this.apikey['state'] = state def prefs_changed(cmdr: str, is_beta: bool) -> None: """Preferences changed hook.""" config.set('edsm_out', this.log.get()) if cmdr and not is_beta: # TODO: remove this when config is rewritten. cmdrs: List[str] = config.get_list('edsm_cmdrs', default=[]) usernames: List[str] = config.get_list('edsm_usernames', default=[]) apikeys: List[str] = config.get_list('edsm_apikeys', default=[]) if cmdr in cmdrs: idx = cmdrs.index(cmdr) usernames.extend([''] * (1 + idx - len(usernames))) usernames[idx] = this.user.get().strip() apikeys.extend([''] * (1 + idx - len(apikeys))) apikeys[idx] = this.apikey.get().strip() else: config.set('edsm_cmdrs', cmdrs + [cmdr]) usernames.append(this.user.get().strip()) apikeys.append(this.apikey.get().strip()) config.set('edsm_usernames', usernames) config.set('edsm_apikeys', apikeys) def credentials(cmdr: str) -> Optional[Tuple[str, str]]: """ Get credentials for the given commander, if they exist. :param cmdr: The commander to get credentials for :return: The credentials, or None """ logger.trace_if(CMDR_CREDS, f'{cmdr=}') # Credentials for cmdr if not cmdr: return None cmdrs = config.get_list('edsm_cmdrs') if not cmdrs: # Migrate from <= 2.25 cmdrs = [cmdr] config.set('edsm_cmdrs', cmdrs) if (cmdr in cmdrs and (edsm_usernames := config.get_list('edsm_usernames')) and (edsm_apikeys := config.get_list('edsm_apikeys'))): idx = cmdrs.index(cmdr) # The EDSM cmdr and apikey might not exist yet! if idx >= len(edsm_usernames) or idx >= len(edsm_apikeys): return None logger.trace_if(CMDR_CREDS, f'{cmdr=}: returning ({edsm_usernames[idx]=}, {edsm_apikeys[idx]=})') return (edsm_usernames[idx], edsm_apikeys[idx]) else: logger.trace_if(CMDR_CREDS, f'{cmdr=}: returning None') return None def journal_entry( # noqa: C901, CCR001 cmdr: str, is_beta: bool, system: str, station: str, entry: MutableMapping[str, Any], state: Mapping[str, Any] ) -> str: """ Process a Journal event. :param cmdr: :param is_beta: :param system: :param station: :param entry: :param state: :return: str - empty if no error, else error string. """ should_return, new_entry = killswitch.check_killswitch('plugins.edsm.journal', entry, logger) if should_return: # LANG: EDSM plugin - Journal handling disabled by killswitch plug.show_error(_('EDSM Handler disabled. See Log.')) return '' should_return, new_entry = killswitch.check_killswitch( f'plugins.edsm.journal.event.{entry["event"]}', data=new_entry, log=logger ) if should_return: return '' this.game_version = state['GameVersion'] this.game_build = state['GameBuild'] entry = new_entry this.on_foot = state['OnFoot'] if entry['event'] in ('CarrierJump', 'FSDJump', 'Location', 'Docked'): logger.trace_if( 'journal.locations', f'''{entry["event"]} Commander: {cmdr} System: {system} Station: {station} state: {state!r} entry: {entry!r}''' ) # Always update our system address even if we're not currently the provider for system or station, but dont update # on events that contain "future" data, such as FSDTarget if entry['event'] in ('Location', 'Docked', 'CarrierJump', 'FSDJump'): this.system_address = entry.get('SystemAddress', this.system_address) this.system = entry.get('StarSystem', this.system) # We need pop == 0 to set the value so as to clear 'x' in systems with # no stations. pop = entry.get('Population') if pop is not None: this.system_population = pop this.station = entry.get('StationName', this.station) # on_foot station detection if entry['event'] == 'Location' and entry['BodyType'] == 'Station': this.station = entry['Body'] this.station_marketid = entry.get('MarketID', this.station_marketid) # We might pick up StationName in DockingRequested, make sure we clear it if leaving if entry['event'] in ('Undocked', 'FSDJump', 'SupercruiseEntry'): this.station = None this.station_marketid = None if entry['event'] == 'Embark' and not entry.get('OnStation'): # If we're embarking OnStation to a Taxi/Dropship we'll also get an # Undocked event. this.station = None this.station_marketid = None if config.get_str('station_provider') == 'EDSM': to_set = this.station if not this.station: if this.system_population and this.system_population > 0: to_set = STATION_UNDOCKED else: to_set = '' this.station_link['text'] = to_set this.station_link['url'] = station_url(str(this.system), str(this.station)) this.station_link.update_idletasks() # Update display of 'EDSM Status' image if this.system_link['text'] != system: this.system_link['text'] = system if system else '' this.system_link['image'] = '' this.system_link.update_idletasks() this.multicrew = bool(state['Role']) if 'StarPos' in entry: this.coordinates = entry['StarPos'] elif entry['event'] == 'LoadGame': this.coordinates = None if entry['event'] in ('LoadGame', 'Commander', 'NewCommander'): this.newgame = True this.newgame_docked = False this.navbeaconscan = 0 elif entry['event'] == 'StartUp': this.newgame = False this.newgame_docked = False this.navbeaconscan = 0 elif entry['event'] == 'Location': this.newgame = True this.newgame_docked = entry.get('Docked', False) this.navbeaconscan = 0 elif entry['event'] == 'NavBeaconScan': this.navbeaconscan = entry['NumBodies'] elif entry['event'] == 'BackPack': # Use the stored file contents, not the empty journal event if state['BackpackJSON']: entry = state['BackpackJSON'] # Queue all events to send to EDSM. worker() will take care of dropping EDSM discarded events if config.get_int('edsm_out') and not is_beta and not this.multicrew and credentials(cmdr): if not monitor.monitor.is_live_galaxy(): logger.info("EDSM only accepts Live galaxy data") # Since Update 14 on 2022-11-29 Inara only accepts Live data. if ( this.legacy_galaxy_last_notified is None or (datetime.now(timezone.utc) - this.legacy_galaxy_last_notified) > timedelta(seconds=300) ): # LANG: The Inara API only accepts Live galaxy data, not Legacy galaxy data logger.info("EDSM only accepts Live galaxy data") # this.parent.children['status']['text'] = this.legacy_galaxy_last_notified = datetime.now(timezone.utc) return _("EDSM only accepts Live galaxy data") return '' # Introduce transient states into the event transient = { '_systemName': system, '_systemCoordinates': this.coordinates, '_stationName': station, '_shipId': state['ShipID'], } entry.update(transient) if entry['event'] == 'LoadGame': # Synthesise Materials events on LoadGame since we will have missed it materials = { 'timestamp': entry['timestamp'], 'event': 'Materials', 'Raw': [{'Name': k, 'Count': v} for k, v in state['Raw'].items()], 'Manufactured': [{'Name': k, 'Count': v} for k, v in state['Manufactured'].items()], 'Encoded': [{'Name': k, 'Count': v} for k, v in state['Encoded'].items()], } materials.update(transient) logger.trace_if(CMDR_EVENTS, f'"LoadGame" event, queueing Materials: {cmdr=}') this.queue.put((cmdr, this.game_version, this.game_build, materials)) if entry['event'] in ('CarrierJump', 'FSDJump', 'Location', 'Docked'): logger.trace_if( 'journal.locations', f'''{entry["event"]} Queueing: {entry!r}''' ) logger.trace_if(CMDR_EVENTS, f'"{entry["event"]=}" event, queueing: {cmdr=}') this.queue.put((cmdr, this.game_version, this.game_build, entry)) return '' # Update system data def cmdr_data(data: CAPIData, is_beta: bool) -> None: """CAPI Entry Hook.""" system = data['lastSystem']['name'] # Always store initially, even if we're not the *current* system provider. if not this.station_marketid and data['commander']['docked']: this.station_marketid = data['lastStarport']['id'] # Only trust CAPI if these aren't yet set if not this.system: this.system = data['lastSystem']['name'] if not this.station and data['commander']['docked']: this.station = data['lastStarport']['name'] # TODO: Fire off the EDSM API call to trigger the callback for the icons if config.get_str('system_provider') == 'EDSM': this.system_link['text'] = this.system # Do *NOT* set 'url' here, as it's set to a function that will call # through correctly. We don't want a static string. this.system_link.update_idletasks() if config.get_str('station_provider') == 'EDSM': if data['commander']['docked'] or this.on_foot and this.station: this.station_link['text'] = this.station elif data['lastStarport']['name'] and data['lastStarport']['name'] != "": this.station_link['text'] = STATION_UNDOCKED else: this.station_link['text'] = '' # Do *NOT* set 'url' here, as it's set to a function that will call # through correctly. We don't want a static string. this.station_link.update_idletasks() if not this.system_link['text']: this.system_link['text'] = system this.system_link['image'] = '' this.system_link.update_idletasks() TARGET_URL = 'https://www.edsm.net/api-journal-v1' if 'edsm' in debug_senders: TARGET_URL = f'http://{DEBUG_WEBSERVER_HOST}:{DEBUG_WEBSERVER_PORT}/edsm' def get_discarded_events_list() -> None: """Retrieve the list of to-discard events from EDSM.""" try: r = this.session.get('https://www.edsm.net/api-journal-v1/discard', timeout=_TIMEOUT) r.raise_for_status() this.discarded_events = set(r.json()) this.discarded_events.discard('Docked') # should_send() assumes that we send 'Docked' events if not this.discarded_events: logger.warning( 'Unexpected empty discarded events list from EDSM: ' f'{type(this.discarded_events)} -- {this.discarded_events}' ) except Exception as e: logger.warning('Exception whilst trying to set this.discarded_events:', exc_info=e) def worker() -> None: # noqa: CCR001 C901 # Cant be broken up currently """ Handle uploading events to EDSM API. Target function of a thread. Processes `this.queue` until the queued item is None. """ logger.debug('Starting...') pending: List[Mapping[str, Any]] = [] # Unsent events closing = False cmdr: str = "" last_game_version = "" last_game_build = "" entry: Mapping[str, Any] = {} while not this.discarded_events: if this.shutting_down: logger.debug(f'returning from discarded_events loop due to {this.shutting_down=}') return get_discarded_events_list() if this.discarded_events: break sleep(DISCARDED_EVENTS_SLEEP) logger.debug('Got "events to discard" list, commencing queue consumption...') while True: if this.shutting_down: logger.debug(f'{this.shutting_down=}, so setting closing = True') closing = True item: Optional[Tuple[str, str, str, Mapping[str, Any]]] = this.queue.get() if item: (cmdr, game_version, game_build, entry) = item logger.trace_if(CMDR_EVENTS, f'De-queued ({cmdr=}, {game_version=}, {game_build=}, {entry["event"]=})') else: logger.debug('Empty queue message, setting closing = True') closing = True # Try to send any unsent events before we close entry = {'event': 'ShutDown'} # Dummy to allow for `uentry['event']` belowt retrying = 0 while retrying < 3: should_skip, new_item = killswitch.check_killswitch( 'plugins.edsm.worker', item if item is not None else cast(Tuple[str, Mapping[str, Any]], ("", {})), logger ) if should_skip: break if item is not None: item = new_item try: if item and entry['event'] not in this.discarded_events: logger.trace_if( CMDR_EVENTS, f'({cmdr=}, {entry["event"]=}): not in discarded_events, appending to pending') # Discard the pending list if it's a new Journal file OR # if the gameversion has changed. We claim a single # gameversion for an entire batch of events so can't mix # them. # The specific gameversion check caters for scenarios where # we took some time in the last POST, had new events queued # in the meantime *and* the game client crashed *and* was # changed to a different gameversion. if ( entry['event'].lower() == 'fileheader' or last_game_version != game_version or last_game_build != game_build ): pending = [] pending.append(entry) # drop events if required by killswitch new_pending = [] for e in pending: skip, new = killswitch.check_killswitch(f'plugin.edsm.worker.{e["event"]}', e, logger) if skip: continue new_pending.append(new) pending = new_pending if pending and should_send(pending, entry['event']): logger.trace_if(CMDR_EVENTS, f'({cmdr=}, {entry["event"]=}): should_send() said True') logger.trace_if(CMDR_EVENTS, f'pending contains:\n{chr(0x0A).join(str(p) for p in pending)}') if any(p for p in pending if p['event'] in ('CarrierJump', 'FSDJump', 'Location', 'Docked')): logger.trace_if('journal.locations', "pending has at least one of " "('CarrierJump', 'FSDJump', 'Location', 'Docked')" " and it passed should_send()") for p in pending: if p['event'] in ('Location'): logger.trace_if( 'journal.locations', f'"Location" event in pending passed should_send(),timestamp: {p["timestamp"]}' ) creds = credentials(cmdr) if creds is None: raise ValueError("Unexpected lack of credentials") username, apikey = creds logger.trace_if(CMDR_EVENTS, f'({cmdr=}, {entry["event"]=}): Using {username=} from credentials()') data = { 'commanderName': username.encode('utf-8'), 'apiKey': apikey, 'fromSoftware': applongname, 'fromSoftwareVersion': str(appversion()), 'fromGameVersion': game_version, 'fromGameBuild': game_build, 'message': json.dumps(pending, ensure_ascii=False).encode('utf-8'), } if any(p for p in pending if p['event'] in ('CarrierJump', 'FSDJump', 'Location', 'Docked')): data_elided = data.copy() data_elided['apiKey'] = '' data_elided['message'] = data_elided['message'].decode('utf-8') data_elided['commanderName'] = data_elided['commanderName'].decode('utf-8') logger.trace_if( 'journal.locations', "pending has at least one of ('CarrierJump', 'FSDJump', 'Location', 'Docked')" " Attempting API call with the following events:" ) for p in pending: logger.trace_if('journal.locations', f"Event: {p!r}") if p['event'] in ('Location'): logger.trace_if( 'journal.locations', f'Attempting API call for "Location" event with timestamp: {p["timestamp"]}' ) logger.trace_if( 'journal.locations', f'Overall POST data (elided) is:\n{json.dumps(data_elided, indent=2)}' ) r = this.session.post(TARGET_URL, data=data, timeout=_TIMEOUT) logger.trace_if('plugin.edsm.api', f'API response content: {r.content!r}') r.raise_for_status() reply = r.json() msg_num = reply['msgnum'] msg = reply['msg'] # 1xx = OK # 2xx = fatal error # 3&4xx not generated at top-level # 5xx = error but events saved for later processing if msg_num // 100 == 2: logger.warning(f'EDSM\t{msg_num} {msg}\t{json.dumps(pending, separators=(",", ": "))}') # LANG: EDSM Plugin - Error message from EDSM API plug.show_error(_('Error: EDSM {MSG}').format(MSG=msg)) else: if msg_num // 100 == 1: logger.trace_if('plugin.edsm.api', 'Overall OK') pass elif msg_num // 100 == 5: logger.trace_if('plugin.edsm.api', 'Event(s) not currently processed, but saved for later') pass else: logger.warning(f'EDSM API call status not 1XX, 2XX or 5XX: {msg.num}') for e, r in zip(pending, reply['events']): if not closing and e['event'] in ('StartUp', 'Location', 'FSDJump', 'CarrierJump'): # Update main window's system status this.lastlookup = r # calls update_status in main thread if not config.shutting_down: this.system_link.event_generate('<>', when="tail") if r['msgnum'] // 100 != 1: # type: ignore logger.warning(f'EDSM event with not-1xx status:\n{r["msgnum"]}\n' # type: ignore f'{r["msg"]}\n{json.dumps(e, separators = (",", ": "))}') pending = [] break # No exception, so assume success except Exception as e: logger.debug(f'Attempt to send API events: retrying == {retrying}', exc_info=e) retrying += 1 else: # LANG: EDSM Plugin - Error connecting to EDSM API plug.show_error(_("Error: Can't connect to EDSM")) if entry['event'].lower() in ('shutdown', 'commander', 'fileheader'): # Game shutdown or new login, so we MUST not hang on to pending pending = [] logger.trace_if(CMDR_EVENTS, f'Blanked pending because of event: {entry["event"]}') if closing: logger.debug('closing, so returning.') return last_game_version = game_version last_game_build = game_build logger.debug('Done.') def should_send(entries: List[Mapping[str, Any]], event: str) -> bool: # noqa: CCR001 """ Whether or not any of the given entries should be sent to EDSM. :param entries: The entries to check :param event: The latest event being processed :return: bool indicating whether or not to send said entries """ # We MUST flush pending on logout, in case new login is a different Commander if event.lower() in ('shutdown', 'fileheader'): logger.trace_if(CMDR_EVENTS, f'True because {event=}') return True # batch up burst of Scan events after NavBeaconScan if this.navbeaconscan: if entries and entries[-1]['event'] == 'Scan': this.navbeaconscan -= 1 if this.navbeaconscan: logger.trace_if(CMDR_EVENTS, f'False because {this.navbeaconscan=}') return False else: logger.error( 'Invalid state NavBeaconScan exists, but passed entries either ' "doesn't exist or doesn't have the expected content" ) this.navbeaconscan = 0 for entry in entries: if (entry['event'] == 'Cargo' and not this.newgame_docked) or entry['event'] == 'Docked': # Cargo is the last event on startup, unless starting when docked in which case Docked is the last event this.newgame = False this.newgame_docked = False logger.trace_if(CMDR_EVENTS, f'True because {entry["event"]=}') return True elif this.newgame: pass elif entry['event'] not in ( 'CommunityGoal', # Spammed periodically 'ModuleBuy', 'ModuleSell', 'ModuleSwap', # will be shortly followed by "Loadout" 'ShipyardBuy', 'ShipyardNew', 'ShipyardSwap'): # " logger.trace_if(CMDR_EVENTS, f'True because {entry["event"]=}') return True else: logger.trace_if(CMDR_EVENTS, f'{entry["event"]=}, {this.newgame_docked=}') logger.trace_if(CMDR_EVENTS, f'False as default: {this.newgame_docked=}') return False def update_status(event=None) -> None: """Update listening plugins with our response to StartUp, Location, FSDJump, or CarrierJump.""" for plugin in plug.provides('edsm_notify_system'): plug.invoke(plugin, None, 'edsm_notify_system', this.lastlookup) # Called with EDSM's response to a 'StartUp', 'Location', 'FSDJump' or 'CarrierJump' event. # https://www.edsm.net/en/api-journal-v1 # msgnum: 1xx = OK, 2xx = fatal error, 3xx = error, 4xx = ignorable errors. def edsm_notify_system(reply: Mapping[str, Any]) -> None: """Update the image next to the system link.""" if not reply: this.system_link['image'] = this._IMG_ERROR # LANG: EDSM Plugin - Error connecting to EDSM API plug.show_error(_("Error: Can't connect to EDSM")) elif reply['msgnum'] // 100 not in (1, 4): this.system_link['image'] = this._IMG_ERROR # LANG: EDSM Plugin - Error message from EDSM API plug.show_error(_('Error: EDSM {MSG}').format(MSG=reply['msg'])) elif reply.get('systemCreated'): this.system_link['image'] = this._IMG_NEW else: this.system_link['image'] = this._IMG_KNOWN