diff --git a/Contributing.md b/Contributing.md index c74953a1..bdbf4634 100644 --- a/Contributing.md +++ b/Contributing.md @@ -247,10 +247,10 @@ from edmc_data import DEBUG_WEBSERVER_HOST, DEBUG_WEBSERVER_PORT 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' + TARGET_URL = f'http://{DEBUG_WEBSERVER_HOST}:{DEBUG_WEBSERVER_PORT}/edsm' ... - r = this.session.post(TARGET_URL, data=data, timeout=_TIMEOUT) +r = this.requests_session.post(TARGET_URL, data=data, timeout=_TIMEOUT) ``` Be sure to set a URL path in the `TARGET_URL` that denotes where the data diff --git a/EDMC.py b/EDMC.py index 26dc6d7b..6c7163d7 100755 --- a/EDMC.py +++ b/EDMC.py @@ -6,6 +6,7 @@ import argparse import json import locale import os +import queue import re import sys from os.path import getmtime, join @@ -270,8 +271,38 @@ sys.path: {sys.path}''' companion.session.login(monitor.cmdr, monitor.is_beta) + ################################################################### + # Initiate CAPI queries querytime = int(time()) - data = companion.session.station() + companion.session.station(query_time=querytime) + + # Wait for the response + _capi_request_timeout = 60 + try: + capi_response = companion.session.capi_response_queue.get( + block=True, timeout=_capi_request_timeout + ) + + except queue.Empty: + logger.error(f'CAPI requests timed out after {_capi_request_timeout} seconds') + sys.exit(EXIT_SERVER) + + ################################################################### + + # noinspection DuplicatedCode + if isinstance(capi_response, companion.EDMCCAPIFailedRequest): + logger.trace_if('capi.worker', f'Failed Request: {capi_response.message}') + if capi_response.exception: + raise capi_response.exception + + else: + raise ValueError(capi_response.message) + + logger.trace_if('capi.worker', 'Answer is not a Failure') + if not isinstance(capi_response, companion.EDMCCAPIResponse): + raise ValueError(f"Response was neither CAPIFailedRequest nor EDMCAPIResponse: {type(capi_response)}") + + data = capi_response.capi_data config.set('querytime', querytime) # Validation @@ -279,10 +310,10 @@ sys.path: {sys.path}''' logger.error("No data['command']['name'] from CAPI") sys.exit(EXIT_SERVER) - elif not deep_get(data, 'lastSystem', 'name') or \ - data['commander'].get('docked') and not \ - deep_get(data, 'lastStarport', 'name'): # Only care if docked - + elif ( + not deep_get(data, 'lastSystem', 'name') or + data['commander'].get('docked') and not deep_get(data, 'lastStarport', 'name') + ): # Only care if docked logger.error("No data['lastSystem']['name'] from CAPI") sys.exit(EXIT_SERVER) @@ -294,21 +325,20 @@ sys.path: {sys.path}''' pass # Skip further validation elif data['commander']['name'] != monitor.cmdr: - logger.error(f'Commander "{data["commander"]["name"]}" from CAPI doesn\'t match "{monitor.cmdr}" from Journal') # noqa: E501 - sys.exit(EXIT_CREDENTIALS) + raise companion.CmdrError() - elif data['lastSystem']['name'] != monitor.system or \ - ((data['commander']['docked'] and data['lastStarport']['name'] or None) != monitor.station) or \ - data['ship']['id'] != monitor.state['ShipID'] or \ - data['ship']['name'].lower() != monitor.state['ShipType']: - - logger.error('Mismatch(es) between CAPI and Journal for at least one of: StarSystem, Last Star Port, Ship ID or Ship Name/Type') # noqa: E501 - sys.exit(EXIT_LAGGING) + elif ( + data['lastSystem']['name'] != monitor.system or + ((data['commander']['docked'] and data['lastStarport']['name'] or None) != monitor.station) or + data['ship']['id'] != monitor.state['ShipID'] or + data['ship']['name'].lower() != monitor.state['ShipType'] + ): + raise companion.ServerLagging() # stuff we can do when not docked if args.d: logger.debug(f'Writing raw JSON data to "{args.d}"') - out = json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True, separators=(',', ': ')) + out = json.dumps(dict(data), ensure_ascii=False, indent=2, sort_keys=True, separators=(',', ': ')) with open(args.d, 'wb') as f: f.write(out.encode("utf-8")) @@ -408,21 +438,44 @@ sys.path: {sys.path}''' try: eddn_sender = eddn.EDDN(None) logger.debug('Sending Market, Outfitting and Shipyard data to EDDN...') - eddn_sender.export_commodities(data, monitor.is_beta) - eddn_sender.export_outfitting(data, monitor.is_beta) - eddn_sender.export_shipyard(data, monitor.is_beta) + eddn_sender.export_commodities(data, monitor.is_beta, monitor.state['Odyssey']) + eddn_sender.export_outfitting(data, monitor.is_beta, monitor.state['Odyssey']) + eddn_sender.export_shipyard(data, monitor.is_beta, monitor.state['Odyssey']) except Exception: logger.exception('Failed to send data to EDDN') except companion.ServerError: - logger.error('Frontier CAPI Server returned an error') + logger.exception('Frontier CAPI Server returned an error') + sys.exit(EXIT_SERVER) + + except companion.ServerConnectionError: + logger.exception('Exception while contacting server') sys.exit(EXIT_SERVER) except companion.CredentialsError: logger.error('Frontier CAPI Server: Invalid Credentials') sys.exit(EXIT_CREDENTIALS) + # Companion API problem + except companion.ServerLagging: + logger.error( + 'Mismatch(es) between CAPI and Journal for at least one of: ' + 'StarSystem, Last Star Port, Ship ID or Ship Name/Type' + ) + sys.exit(EXIT_SERVER) + + except companion.CmdrError: # Companion API return doesn't match Journal + logger.error( + f'Commander "{data["commander"]["name"]}" from CAPI doesn\'t match ' + f'"{monitor.cmdr}" from Journal' + ) + sys.exit(EXIT_SERVER) + + except Exception: + logger.exception('"other" exception') + sys.exit(EXIT_SERVER) + if __name__ == '__main__': main() diff --git a/EDMarketConnector.py b/EDMarketConnector.py index ab893b50..77b07bfe 100755 --- a/EDMarketConnector.py +++ b/EDMarketConnector.py @@ -4,9 +4,9 @@ import argparse import html -import json import locale import pathlib +import queue import re import sys # import threading @@ -16,7 +16,7 @@ from os import chdir, environ from os.path import dirname, join from sys import platform from time import localtime, strftime, time -from typing import TYPE_CHECKING, Optional, Tuple +from typing import TYPE_CHECKING, Optional, Tuple, Union # Have this as early as possible for people running EDMarketConnector.exe # from cmd.exe or a bat file or similar. Else they might not be in the correct @@ -374,6 +374,7 @@ SHIPYARD_HTML_TEMPLATE = """ class AppWindow(object): """Define the main application window.""" + _CAPI_RESPONSE_TK_EVENT_NAME = '<>' # Tkinter Event types EVENT_KEYPRESS = 2 EVENT_BUTTON = 4 @@ -383,13 +384,16 @@ class AppWindow(object): def __init__(self, master: tk.Tk): # noqa: C901, CCR001 # TODO - can possibly factor something out - self.holdofftime = config.get_int('querytime', default=0) + companion.holdoff + self.capi_query_holdoff_time = config.get_int('querytime', default=0) + companion.capi_query_cooldown self.w = master self.w.title(applongname) self.w.rowconfigure(0, weight=1) self.w.columnconfigure(0, weight=1) + # companion needs to be able to send <> events + companion.session.set_tk_master(self.w) + self.prefsdialog = None # if platform == 'win32': @@ -485,8 +489,8 @@ class AppWindow(object): theme.register_alternate((self.button, self.theme_button, self.theme_button), {'row': ui_row, 'columnspan': 2, 'sticky': tk.NSEW}) self.status.grid(columnspan=2, sticky=tk.EW) - self.button.bind('', self.getandsend) - theme.button_bind(self.theme_button, self.getandsend) + self.button.bind('', self.capi_request_data) + theme.button_bind(self.theme_button, self.capi_request_data) for child in frame.winfo_children(): child.grid_configure(padx=self.PADX, pady=(platform != 'win32' or isinstance(child, tk.Frame)) and 2 or 0) @@ -646,9 +650,10 @@ class AppWindow(object): self.w.bind('', self.onenter) # Special handling for transparency self.w.bind('', self.onleave) # Special handling for transparency self.w.bind('', self.onleave) # Special handling for transparency - self.w.bind('', self.getandsend) - self.w.bind('', self.getandsend) - self.w.bind_all('<>', self.getandsend) # Hotkey monitoring + self.w.bind('', self.capi_request_data) + self.w.bind('', self.capi_request_data) + self.w.bind_all('<>', self.capi_request_data) # Ask for CAPI queries to be performed + self.w.bind_all(self._CAPI_RESPONSE_TK_EVENT_NAME, self.capi_handle_response) self.w.bind_all('<>', self.journal_event) # Journal monitoring self.w.bind_all('<>', self.dashboard_event) # Dashboard monitoring self.w.bind_all('<>', self.plugin_error) # Statusbar @@ -886,32 +891,58 @@ class AppWindow(object): return True - def getandsend(self, event=None, retrying: bool = False): # noqa: C901, CCR001 + def capi_request_data(self, event=None) -> None: """ Perform CAPI data retrieval and associated actions. This can be triggered by hitting the main UI 'Update' button, automatically on docking, or due to a retry. + + :param event: Tk generated event details. """ + logger.trace_if('capi.worker', 'Begin') auto_update = not event play_sound = (auto_update or int(event.type) == self.EVENT_VIRTUAL) and not config.get_int('hotkey_mute') - play_bad = False - err: Optional[str] = None - if ( - not monitor.cmdr or not monitor.mode or monitor.state['Captain'] - or not monitor.system or monitor.mode == 'CQC' - ): - return # In CQC or on crew - do nothing + if not monitor.cmdr: + logger.trace_if('capi.worker', 'Aborting Query: Cmdr unknown') + # LANG: CAPI queries aborted because Cmdr name is unknown + self.status['text'] = _('CAPI query aborted: Cmdr name unknown') + return + + if not monitor.mode: + logger.trace_if('capi.worker', 'Aborting Query: Game Mode unknown') + # LANG: CAPI queries aborted because game mode unknown + self.status['text'] = _('CAPI query aborted: Game mode unknown') + return + + if not monitor.system: + logger.trace_if('capi.worker', 'Aborting Query: Current star system unknown') + # LANG: CAPI queries aborted because current star system name unknown + self.status['text'] = _('CAPI query aborted: Current system unknown') + return + + if monitor.state['Captain']: + logger.trace_if('capi.worker', 'Aborting Query: In multi-crew') + # LANG: CAPI queries aborted because player is in multi-crew on other Cmdr's ship + self.status['text'] = _('CAPI query aborted: In other-ship multi-crew') + return + + if monitor.mode == 'CQC': + logger.trace_if('capi.worker', 'Aborting Query: In CQC') + # LANG: CAPI queries aborted because player is in CQC (Arena) + self.status['text'] = _('CAPI query aborted: CQC (Arena) detected') + return if companion.session.state == companion.Session.STATE_AUTH: + logger.trace_if('capi.worker', 'Auth in progress? Aborting query') # Attempt another Auth self.login() return - if not retrying: - if time() < self.holdofftime: # Was invoked by key while in cooldown - if play_sound and (self.holdofftime - time()) < companion.holdoff * 0.75: + if not companion.session.retrying: + if time() < self.capi_query_holdoff_time: # Was invoked by key while in cooldown + if play_sound and (self.capi_query_holdoff_time - time()) < companion.capi_query_cooldown * 0.75: self.status['text'] = '' hotkeymgr.play_bad() # Don't play sound in first few seconds to prevent repeats @@ -925,56 +956,91 @@ class AppWindow(object): self.button['state'] = self.theme_button['state'] = tk.DISABLED self.w.update_idletasks() + query_time = int(time()) + logger.trace_if('capi.worker', 'Requesting full station data') + config.set('querytime', query_time) + logger.trace_if('capi.worker', 'Calling companion.session.station') + companion.session.station( + query_time=query_time, tk_response_event=self._CAPI_RESPONSE_TK_EVENT_NAME, + play_sound=play_sound + ) + + def capi_handle_response(self, event=None): # noqa: C901, CCR001 + """Handle the resulting data from a CAPI query.""" + logger.trace_if('capi.worker', 'Handling response') + play_bad: bool = False + err: Optional[str] = None + + capi_response: Union[companion.EDMCCAPIFailedRequest, companion.EDMCCAPIResponse] try: - querytime = int(time()) - data = companion.session.station() - config.set('querytime', querytime) + logger.trace_if('capi.worker', 'Pulling answer off queue') + capi_response = companion.session.capi_response_queue.get(block=False) + if isinstance(capi_response, companion.EDMCCAPIFailedRequest): + logger.trace_if('capi.worker', f'Failed Request: {capi_response.message}') + if capi_response.exception: + raise capi_response.exception + + else: + raise ValueError(capi_response.message) + + logger.trace_if('capi.worker', 'Answer is not a Failure') + if not isinstance(capi_response, companion.EDMCCAPIResponse): + msg = f'Response was neither CAPIFailedRequest nor EDMCAPIResponse: {type(capi_response)}' + logger.error(msg) + raise ValueError(msg) # Validation - if 'commander' not in data: + if 'commander' not in capi_response.capi_data: # This can happen with EGS Auth if no commander created yet # LANG: No data was returned for the commander from the Frontier CAPI err = self.status['text'] = _('CAPI: No commander data returned') - elif not data.get('commander', {}).get('name'): + elif not capi_response.capi_data.get('commander', {}).get('name'): # LANG: We didn't have the commander name when we should have err = self.status['text'] = _("Who are you?!") # Shouldn't happen - elif (not data.get('lastSystem', {}).get('name') - or (data['commander'].get('docked') - and not data.get('lastStarport', {}).get('name'))): + elif (not capi_response.capi_data.get('lastSystem', {}).get('name') + or (capi_response.capi_data['commander'].get('docked') + and not capi_response.capi_data.get('lastStarport', {}).get('name'))): # LANG: We don't know where the commander is, when we should err = self.status['text'] = _("Where are you?!") # Shouldn't happen - elif not data.get('ship', {}).get('name') or not data.get('ship', {}).get('modules'): + elif ( + not capi_response.capi_data.get('ship', {}).get('name') + or not capi_response.capi_data.get('ship', {}).get('modules') + ): # LANG: We don't know what ship the commander is in, when we should err = self.status['text'] = _("What are you flying?!") # Shouldn't happen - elif monitor.cmdr and data['commander']['name'] != monitor.cmdr: + elif monitor.cmdr and capi_response.capi_data['commander']['name'] != monitor.cmdr: # Companion API Commander doesn't match Journal + logger.trace_if('capi.worker', 'Raising CmdrError()') raise companion.CmdrError() - elif auto_update and not monitor.state['OnFoot'] and not data['commander'].get('docked'): + elif ( + capi_response.auto_update and not monitor.state['OnFoot'] + and not capi_response.capi_data['commander'].get('docked') + ): # auto update is only when just docked - logger.warning(f"{auto_update!r} and not {monitor.state['OnFoot']!r} and " - f"not {data['commander'].get('docked')!r}") + logger.warning(f"{capi_response.auto_update!r} and not {monitor.state['OnFoot']!r} and " + f"not {capi_response.capi_data['commander'].get('docked')!r}") raise companion.ServerLagging() - elif data['lastSystem']['name'] != monitor.system: + elif capi_response.capi_data['lastSystem']['name'] != monitor.system: # CAPI system must match last journal one - logger.warning(f"{data['lastSystem']['name']!r} != {monitor.system!r}") + logger.warning(f"{capi_response.capi_data['lastSystem']['name']!r} != {monitor.system!r}") raise companion.ServerLagging() - elif data['lastStarport']['name'] != monitor.station: + elif capi_response.capi_data['lastStarport']['name'] != monitor.station: if monitor.state['OnFoot'] and monitor.station: - logger.warning(f"({data['lastStarport']['name']!r} != {monitor.station!r}) AND " + logger.warning(f"({capi_response.capi_data['lastStarport']['name']!r} != {monitor.station!r}) AND " f"{monitor.state['OnFoot']!r} and {monitor.station!r}") raise companion.ServerLagging() else: last_station = None - if data['commander']['docked']: - last_station = data['lastStarport']['name'] + if capi_response.capi_data['commander']['docked']: + last_station = capi_response.capi_data['lastStarport']['name'] if monitor.station is None: # Likely (re-)Embarked on ship docked at an EDO settlement. @@ -986,32 +1052,40 @@ class AppWindow(object): if last_station != monitor.station: # CAPI lastStarport must match - logger.warning(f"({data['lastStarport']['name']!r} != {monitor.station!r}) AND " - f"{last_station!r} != {monitor.station!r}") + logger.warning(f"({capi_response.capi_data['lastStarport']['name']!r} != {monitor.station!r})" + f" AND {last_station!r} != {monitor.station!r}") raise companion.ServerLagging() - self.holdofftime = querytime + companion.holdoff + self.capi_query_holdoff_time = capi_response.query_time + companion.capi_query_cooldown - elif not monitor.state['OnFoot'] and data['ship']['id'] != monitor.state['ShipID']: + elif not monitor.state['OnFoot'] and capi_response.capi_data['ship']['id'] != monitor.state['ShipID']: # CAPI ship must match logger.warning(f"not {monitor.state['OnFoot']!r} and " - f"{data['ship']['id']!r} != {monitor.state['ShipID']!r}") + f"{capi_response.capi_data['ship']['id']!r} != {monitor.state['ShipID']!r}") raise companion.ServerLagging() - elif not monitor.state['OnFoot'] and data['ship']['name'].lower() != monitor.state['ShipType']: + elif ( + not monitor.state['OnFoot'] + and capi_response.capi_data['ship']['name'].lower() != monitor.state['ShipType'] + ): # CAPI ship type must match logger.warning(f"not {monitor.state['OnFoot']!r} and " - f"{data['ship']['name'].lower()!r} != {monitor.state['ShipType']!r}") + f"{capi_response.capi_data['ship']['name'].lower()!r} != " + f"{monitor.state['ShipType']!r}") raise companion.ServerLagging() else: + # TODO: Change to depend on its own CL arg if __debug__: # Recording - companion.session.dump_capi_data(data) + companion.session.dump_capi_data(capi_response.capi_data) if not monitor.state['ShipType']: # Started game in SRV or fighter - self.ship['text'] = ship_name_map.get(data['ship']['name'].lower(), data['ship']['name']) - monitor.state['ShipID'] = data['ship']['id'] - monitor.state['ShipType'] = data['ship']['name'].lower() + self.ship['text'] = ship_name_map.get( + capi_response.capi_data['ship']['name'].lower(), + capi_response.capi_data['ship']['name'] + ) + monitor.state['ShipID'] = capi_response.capi_data['ship']['id'] + monitor.state['ShipType'] = capi_response.capi_data['ship']['name'].lower() if not monitor.state['Modules']: self.ship.configure(state=tk.DISABLED) @@ -1021,45 +1095,62 @@ class AppWindow(object): self.ship.configure(state=True) if monitor.state.get('SuitCurrent') is not None: - if (loadout := data.get('loadout')) is not None: + if (loadout := capi_response.capi_data.get('loadout')) is not None: if (suit := loadout.get('suit')) is not None: if (suitname := suit.get('edmcName')) is not None: # We've been paranoid about loadout->suit->suitname, now just assume loadouts is there loadout_name = index_possibly_sparse_list( - data['loadouts'], loadout['loadoutSlotId'] + capi_response.capi_data['loadouts'], loadout['loadoutSlotId'] )['name'] self.suit['text'] = f'{suitname} ({loadout_name})' self.suit_show_if_set() + # Update Odyssey Suit data + companion.session.suit_update(capi_response.capi_data) - if data['commander'].get('credits') is not None: - monitor.state['Credits'] = data['commander']['credits'] - monitor.state['Loan'] = data['commander'].get('debt', 0) + if capi_response.capi_data['commander'].get('credits') is not None: + monitor.state['Credits'] = capi_response.capi_data['commander']['credits'] + monitor.state['Loan'] = capi_response.capi_data['commander'].get('debt', 0) # stuff we can do when not docked - err = plug.notify_newdata(data, monitor.is_beta) + err = plug.notify_newdata(capi_response.capi_data, monitor.is_beta) self.status['text'] = err and err or '' if err: play_bad = True # Export market data - if not self.export_market_data(data): + if not self.export_market_data(capi_response.capi_data): err = 'Error: Exporting Market data' play_bad = True - self.holdofftime = querytime + companion.holdoff + self.capi_query_holdoff_time = capi_response.query_time + companion.capi_query_cooldown + + except queue.Empty: + logger.error('There was no response in the queue!') + # TODO: Set status text + return + + except companion.ServerConnectionError: + self.status['text'] = _('Frontier CAPI server error') + + except companion.CredentialsError: + companion.session.retrying = False + companion.session.invalidate() + companion.session.login() + return # We need to give Auth time to complete, so can't set a timed retry # Companion API problem except companion.ServerLagging as e: err = str(e) - if retrying: + if companion.session.retrying: self.status['text'] = err play_bad = True else: # Retry once if Companion server is unresponsive - self.w.after(int(SERVER_RETRY * 1000), lambda: self.getandsend(event, True)) + companion.session.retrying = True + self.w.after(int(SERVER_RETRY * 1000), lambda: self.capi_request_data(event)) return # early exit to avoid starting cooldown count except companion.CmdrError as e: # Companion API return doesn't match Journal @@ -1080,14 +1171,16 @@ class AppWindow(object): if not err: # not self.status['text']: # no errors # LANG: Time when we last obtained Frontier CAPI data - self.status['text'] = strftime(_('Last updated at %H:%M:%S'), localtime(querytime)) + self.status['text'] = strftime(_('Last updated at %H:%M:%S'), localtime(capi_response.query_time)) - if play_sound and play_bad: + if capi_response.play_sound and play_bad: hotkeymgr.play_bad() + logger.trace_if('capi.worker', 'Updating suit and cooldown...') self.update_suit_text() self.suit_show_if_set() self.cooldown() + logger.trace_if('capi.worker', '...done') def journal_event(self, event): # noqa: C901, CCR001 # Currently not easily broken up. """ @@ -1222,7 +1315,7 @@ class AppWindow(object): logger.info('Monitor: Disable WinSparkle automatic update checks') # Can't start dashboard monitoring - if not dashboard.start(self.w, monitor.started): + if not dashboard.start_frontier_auth(self.w, monitor.started): logger.info("Can't start Status monitoring") # Export loadout @@ -1264,7 +1357,7 @@ class AppWindow(object): auto_update = True if auto_update: - self.w.after(int(SERVER_RETRY * 1000), self.getandsend) + self.w.after(int(SERVER_RETRY * 1000), self.capi_request_data) if entry['event'] == 'ShutDown': # Enable WinSparkle automatic update checks @@ -1363,10 +1456,12 @@ class AppWindow(object): def cooldown(self) -> None: """Display and update the cooldown timer for 'Update' button.""" - if time() < self.holdofftime: + if time() < self.capi_query_holdoff_time: # Update button in main window self.button['text'] = self.theme_button['text'] \ - = _('cooldown {SS}s').format(SS=int(self.holdofftime - time())) # LANG: Cooldown on 'Update' button + = _('cooldown {SS}s').format( # LANG: Cooldown on 'Update' button + SS=int(self.capi_query_holdoff_time - time()) + ) self.w.after(1000, self.cooldown) else: @@ -1494,47 +1589,32 @@ class AppWindow(object): self.destroy() self.__class__.showing = False - def save_raw(self) -> None: # noqa: CCR001 # Not easily broken up. - """Save newly acquired CAPI data in the configured file.""" - # LANG: Status - Attempting to retrieve data from Frontier CAPI to save to file - self.status['text'] = _('Fetching data...') - self.w.update_idletasks() + def save_raw(self) -> None: + """ + Save any CAPI data already acquired to a file. - try: - data: CAPIData = companion.session.station() - self.status['text'] = '' - default_extension: str = '' + This specifically does *not* cause new queries to be performed, as the + purpose is to aid in diagnosing any issues that occurred during 'normal' + queries. + """ + default_extension: str = '' - if platform == 'darwin': - default_extension = '.json' + if platform == 'darwin': + default_extension = '.json' - last_system: str = data.get("lastSystem", {}).get("name", "Unknown") - last_starport: str = '' + timestamp: str = strftime('%Y-%m-%dT%H.%M.%S', localtime()) + f = tkinter.filedialog.asksaveasfilename( + parent=self.w, + defaultextension=default_extension, + filetypes=[('JSON', '.json'), ('All Files', '*')], + initialdir=config.get_str('outdir'), + initialfile=f'{monitor.system}{monitor.station}.{timestamp}' + ) + if not f: + return - if data['commander'].get('docked'): - last_starport = '.' + data.get('lastStarport', {}).get('name', 'Unknown') - - timestamp: str = strftime('%Y-%m-%dT%H.%M.%S', localtime()) - f = tkinter.filedialog.asksaveasfilename( - parent=self.w, - defaultextension=default_extension, - filetypes=[('JSON', '.json'), ('All Files', '*')], - initialdir=config.get_str('outdir'), - initialfile=f'{last_system}{last_starport}.{timestamp}' - ) - if f: - with open(f, 'wb') as h: - h.write(json.dumps(dict(data), - ensure_ascii=False, - indent=2, - sort_keys=True, - separators=(',', ': ')).encode('utf-8')) - except companion.ServerError as e: - self.status['text'] = str(e) - - except Exception as e: - logger.debug('"other" exception', exc_info=e) - self.status['text'] = str(e) + with open(f, 'wb') as h: + h.write(str(companion.session.capi_raw_data).encode(encoding='utf-8')) # def exit_tray(self, systray: 'SysTrayIcon') -> None: # """Tray icon is shutting down.""" @@ -1577,6 +1657,10 @@ class AppWindow(object): logger.info('Unregistering hotkey manager...') hotkeymgr.unregister() + # Now the CAPI query thread + logger.info('Closing CAPI query thread...') + companion.session.capi_query_close_worker() + # Now the main programmatic input methods logger.info('Closing dashboard...') dashboard.close() @@ -1617,7 +1701,7 @@ class AppWindow(object): def oniconify(self, event=None) -> None: """Handle minimization of the application.""" - self.w.overrideredirect(0) # Can't iconize while overrideredirect + self.w.overrideredirect(False) # Can't iconize while overrideredirect self.w.iconify() self.w.update_idletasks() # Size and windows styles get recalculated here self.w.wait_visibility() # Need main window to be re-created before returning diff --git a/L10n/en.template b/L10n/en.template index f62c5428..2221e523 100644 --- a/L10n/en.template +++ b/L10n/en.template @@ -1,172 +1,175 @@ /* Language name */ "!Language" = "English"; -/* companion.py: Frontier CAPI didn't respond; In files: companion.py:171; */ +/* companion.py: Frontier CAPI didn't respond; In files: companion.py:212; */ "Error: Frontier CAPI didn't respond" = "Error: Frontier CAPI didn't respond"; -/* companion.py: Frontier CAPI data doesn't agree with latest Journal game location; In files: companion.py:190; */ +/* companion.py: Frontier CAPI data doesn't agree with latest Journal game location; In files: companion.py:231; */ "Error: Frontier server is lagging" = "Error: Frontier server is lagging"; -/* companion.py: Commander is docked at an EDO settlement, got out and back in, we forgot the station; In files: companion.py:205; */ +/* companion.py: Commander is docked at an EDO settlement, got out and back in, we forgot the station; In files: companion.py:247; */ "Docked but unknown station: EDO Settlement?" = "Docked but unknown station: EDO Settlement?"; -/* companion.py: Generic "something went wrong with Frontier Auth" error; In files: companion.py:214; */ +/* companion.py: Generic "something went wrong with Frontier Auth" error; In files: companion.py:257; */ "Error: Invalid Credentials" = "Error: Invalid Credentials"; -/* companion.py: Frontier CAPI authorisation not for currently game-active commander; In files: companion.py:230; */ +/* companion.py: Frontier CAPI authorisation not for currently game-active commander; In files: companion.py:273; */ "Error: Wrong Cmdr" = "Error: Wrong Cmdr"; -/* companion.py: Generic error prefix - following text is from Frontier auth service; In files: companion.py:341; companion.py:422; */ +/* companion.py: Generic error prefix - following text is from Frontier auth service; In files: companion.py:397; companion.py:482; */ "Error" = "Error"; -/* companion.py: Frontier auth, no 'usr' section in returned data; companion.py: Frontier auth, no 'customer_id' in 'usr' section in returned data; In files: companion.py:380; companion.py:385; */ +/* companion.py: Frontier auth, no 'usr' section in returned data; companion.py: Frontier auth, no 'customer_id' in 'usr' section in returned data; In files: companion.py:440; companion.py:445; */ "Error: Couldn't check token customer_id" = "Error: Couldn't check token customer_id"; -/* companion.py: Frontier auth customer_id doesn't match game session FID; In files: companion.py:391; */ +/* companion.py: Frontier auth customer_id doesn't match game session FID; In files: companion.py:451; */ "Error: customer_id doesn't match!" = "Error: customer_id doesn't match!"; -/* companion.py: Failed to get Access Token from Frontier Auth service; In files: companion.py:413; */ +/* companion.py: Failed to get Access Token from Frontier Auth service; In files: companion.py:473; */ "Error: unable to get token" = "Error: unable to get token"; -/* companion.py: Frontier CAPI data retrieval failed; In files: companion.py:575; */ +/* companion.py: Frontier CAPI data retrieval failed; In files: companion.py:774; */ "Frontier CAPI query failure" = "Frontier CAPI query failure"; -/* companion.py: Frontier CAPI data retrieval failed with 5XX code; In files: companion.py:591; */ +/* companion.py: Frontier CAPI data retrieval failed with 5XX code; In files: companion.py:786; */ "Frontier CAPI server error" = "Frontier CAPI server error"; -/* EDMarketConnector.py: Update button in main window; In files: EDMarketConnector.py:424; EDMarketConnector.py:718; EDMarketConnector.py:1302; */ +/* EDMarketConnector.py: Update button in main window; In files: EDMarketConnector.py:485; EDMarketConnector.py:780; EDMarketConnector.py:1440; */ "Update" = "Update"; -/* EDMarketConnector.py: Appearance - Label for checkbox to select if application always on top; prefs.py: Appearance - Label for checkbox to select if application always on top; In files: EDMarketConnector.py:507; prefs.py:843; */ +/* EDMarketConnector.py: Appearance - Label for checkbox to select if application always on top; prefs.py: Appearance - Label for checkbox to select if application always on top; In files: EDMarketConnector.py:568; prefs.py:866; */ "Always on top" = "Always on top"; -/* EDMarketConnector.py: Unknown suit; In files: EDMarketConnector.py:636; */ +/* EDMarketConnector.py: Unknown suit; In files: EDMarketConnector.py:698; */ "Unknown" = "Unknown"; -/* EDMarketConnector.py: ED Journal file location appears to be in error; In files: EDMarketConnector.py:705; */ +/* EDMarketConnector.py: ED Journal file location appears to be in error; In files: EDMarketConnector.py:767; */ "Error: Check E:D journal file location" = "Error: Check E:D journal file location"; -/* EDMarketConnector.py: Label for commander name in main window; edsm.py: Game Commander name label in EDSM settings; stats.py: Cmdr stats; theme.py: Label for commander name in main window; In files: EDMarketConnector.py:712; edsm.py:219; stats.py:50; theme.py:227; */ +/* EDMarketConnector.py: Label for commander name in main window; edsm.py: Game Commander name label in EDSM settings; stats.py: Cmdr stats; theme.py: Label for commander name in main window; In files: EDMarketConnector.py:774; edsm.py:257; stats.py:50; theme.py:227; */ "Cmdr" = "Cmdr"; -/* EDMarketConnector.py: 'Ship' or multi-crew role label in main window, as applicable; EDMarketConnector.py: Multicrew role label in main window; In files: EDMarketConnector.py:714; EDMarketConnector.py:1072; */ +/* EDMarketConnector.py: 'Ship' or multi-crew role label in main window, as applicable; EDMarketConnector.py: Multicrew role label in main window; In files: EDMarketConnector.py:776; EDMarketConnector.py:1199; */ "Role" = "Role"; -/* EDMarketConnector.py: 'Ship' or multi-crew role label in main window, as applicable; EDMarketConnector.py: 'Ship' label in main UI; stats.py: Status dialog subtitle; In files: EDMarketConnector.py:714; EDMarketConnector.py:1082; EDMarketConnector.py:1105; stats.py:363; */ +/* EDMarketConnector.py: 'Ship' or multi-crew role label in main window, as applicable; EDMarketConnector.py: 'Ship' label in main UI; stats.py: Status dialog subtitle; In files: EDMarketConnector.py:776; EDMarketConnector.py:1209; EDMarketConnector.py:1232; stats.py:365; */ "Ship" = "Ship"; -/* EDMarketConnector.py: Label for 'Suit' line in main UI; In files: EDMarketConnector.py:715; */ +/* EDMarketConnector.py: Label for 'Suit' line in main UI; In files: EDMarketConnector.py:777; */ "Suit" = "Suit"; -/* EDMarketConnector.py: Label for 'System' line in main UI; prefs.py: Configuration - Label for selection of 'System' provider website; stats.py: Main window; In files: EDMarketConnector.py:716; prefs.py:605; stats.py:365; */ +/* EDMarketConnector.py: Label for 'System' line in main UI; prefs.py: Configuration - Label for selection of 'System' provider website; stats.py: Main window; In files: EDMarketConnector.py:778; prefs.py:606; stats.py:367; */ "System" = "System"; -/* EDMarketConnector.py: Label for 'Station' line in main UI; prefs.py: Configuration - Label for selection of 'Station' provider website; prefs.py: Appearance - Example 'Normal' text; stats.py: Status dialog subtitle; In files: EDMarketConnector.py:717; prefs.py:623; prefs.py:738; stats.py:366; */ +/* EDMarketConnector.py: Label for 'Station' line in main UI; prefs.py: Configuration - Label for selection of 'Station' provider website; prefs.py: Appearance - Example 'Normal' text; stats.py: Status dialog subtitle; In files: EDMarketConnector.py:779; prefs.py:624; prefs.py:761; stats.py:368; */ "Station" = "Station"; -/* EDMarketConnector.py: 'File' menu title on OSX; EDMarketConnector.py: 'File' menu title; EDMarketConnector.py: 'File' menu; In files: EDMarketConnector.py:720; EDMarketConnector.py:735; EDMarketConnector.py:738; EDMarketConnector.py:1791; */ +/* EDMarketConnector.py: 'File' menu title on OSX; EDMarketConnector.py: 'File' menu title; EDMarketConnector.py: 'File' menu; In files: EDMarketConnector.py:782; EDMarketConnector.py:797; EDMarketConnector.py:800; EDMarketConnector.py:1917; */ "File" = "File"; -/* EDMarketConnector.py: 'Edit' menu title on OSX; EDMarketConnector.py: 'Edit' menu title; In files: EDMarketConnector.py:721; EDMarketConnector.py:736; EDMarketConnector.py:739; */ +/* EDMarketConnector.py: 'Edit' menu title on OSX; EDMarketConnector.py: 'Edit' menu title; In files: EDMarketConnector.py:783; EDMarketConnector.py:798; EDMarketConnector.py:801; */ "Edit" = "Edit"; -/* EDMarketConnector.py: 'View' menu title on OSX; In files: EDMarketConnector.py:722; */ +/* EDMarketConnector.py: 'View' menu title on OSX; In files: EDMarketConnector.py:784; */ "View" = "View"; -/* EDMarketConnector.py: 'Window' menu title on OSX; In files: EDMarketConnector.py:723; */ +/* EDMarketConnector.py: 'Window' menu title on OSX; In files: EDMarketConnector.py:785; */ "Window" = "Window"; -/* EDMarketConnector.py: Help' menu title on OSX; EDMarketConnector.py: 'Help' menu title; In files: EDMarketConnector.py:724; EDMarketConnector.py:737; EDMarketConnector.py:740; */ +/* EDMarketConnector.py: Help' menu title on OSX; EDMarketConnector.py: 'Help' menu title; In files: EDMarketConnector.py:786; EDMarketConnector.py:799; EDMarketConnector.py:802; */ "Help" = "Help"; -/* EDMarketConnector.py: App menu entry on OSX; EDMarketConnector.py: Help > About App; In files: EDMarketConnector.py:727; EDMarketConnector.py:753; EDMarketConnector.py:1347; */ +/* EDMarketConnector.py: App menu entry on OSX; EDMarketConnector.py: Help > About App; In files: EDMarketConnector.py:789; EDMarketConnector.py:815; EDMarketConnector.py:1486; */ "About {APP}" = "About {APP}"; -/* EDMarketConnector.py: Help > Check for Updates...; In files: EDMarketConnector.py:729; EDMarketConnector.py:752; */ +/* EDMarketConnector.py: Help > Check for Updates...; In files: EDMarketConnector.py:791; EDMarketConnector.py:814; */ "Check for Updates..." = "Check for Updates..."; -/* EDMarketConnector.py: File > Save Raw Data...; In files: EDMarketConnector.py:730; EDMarketConnector.py:744; */ +/* EDMarketConnector.py: File > Save Raw Data...; In files: EDMarketConnector.py:792; EDMarketConnector.py:806; */ "Save Raw Data..." = "Save Raw Data..."; -/* EDMarketConnector.py: File > Status; stats.py: Status dialog title; In files: EDMarketConnector.py:731; EDMarketConnector.py:743; stats.py:360; */ +/* EDMarketConnector.py: File > Status; stats.py: Status dialog title; In files: EDMarketConnector.py:793; EDMarketConnector.py:805; stats.py:362; */ "Status" = "Status"; -/* EDMarketConnector.py: Help > Privacy Policy; In files: EDMarketConnector.py:732; EDMarketConnector.py:750; */ +/* EDMarketConnector.py: Help > Privacy Policy; In files: EDMarketConnector.py:794; EDMarketConnector.py:812; */ "Privacy Policy" = "Privacy Policy"; -/* EDMarketConnector.py: Help > Release Notes; In files: EDMarketConnector.py:733; EDMarketConnector.py:751; EDMarketConnector.py:1381; */ +/* EDMarketConnector.py: Help > Release Notes; In files: EDMarketConnector.py:795; EDMarketConnector.py:813; EDMarketConnector.py:1520; */ "Release Notes" = "Release Notes"; -/* EDMarketConnector.py: File > Settings; prefs.py: File > Settings (macOS); In files: EDMarketConnector.py:745; EDMarketConnector.py:1792; prefs.py:254; */ +/* EDMarketConnector.py: File > Settings; prefs.py: File > Settings (macOS); In files: EDMarketConnector.py:807; EDMarketConnector.py:1918; prefs.py:254; */ "Settings" = "Settings"; -/* EDMarketConnector.py: File > Exit; In files: EDMarketConnector.py:746; */ +/* EDMarketConnector.py: File > Exit; In files: EDMarketConnector.py:808; */ "Exit" = "Exit"; -/* EDMarketConnector.py: Help > Documentation; In files: EDMarketConnector.py:749; */ +/* EDMarketConnector.py: Help > Documentation; In files: EDMarketConnector.py:811; */ "Documentation" = "Documentation"; -/* EDMarketConnector.py: Label for 'Copy' as in 'Copy and Paste'; ttkHyperlinkLabel.py: Label for 'Copy' as in 'Copy and Paste'; In files: EDMarketConnector.py:756; ttkHyperlinkLabel.py:42; */ +/* EDMarketConnector.py: Label for 'Copy' as in 'Copy and Paste'; ttkHyperlinkLabel.py: Label for 'Copy' as in 'Copy and Paste'; In files: EDMarketConnector.py:818; ttkHyperlinkLabel.py:42; */ "Copy" = "Copy"; -/* EDMarketConnector.py: Status - Attempting to get a Frontier Auth Access Token; In files: EDMarketConnector.py:762; */ +/* EDMarketConnector.py: Status - Attempting to get a Frontier Auth Access Token; In files: EDMarketConnector.py:824; */ "Logging in..." = "Logging in..."; -/* EDMarketConnector.py: Successfully authenticated with the Frontier website; In files: EDMarketConnector.py:778; EDMarketConnector.py:1215; */ +/* EDMarketConnector.py: Successfully authenticated with the Frontier website; In files: EDMarketConnector.py:840; EDMarketConnector.py:1351; */ "Authentication successful" = "Authentication successful"; -/* EDMarketConnector.py: Player is not docked at a station, when we expect them to be; In files: EDMarketConnector.py:809; */ +/* EDMarketConnector.py: Player is not docked at a station, when we expect them to be; In files: EDMarketConnector.py:871; */ "You're not docked at a station!" = "You're not docked at a station!"; -/* EDMarketConnector.py: Status - Either no market or no modules data for station from Frontier CAPI; In files: EDMarketConnector.py:817; */ +/* EDMarketConnector.py: Status - Either no market or no modules data for station from Frontier CAPI; In files: EDMarketConnector.py:879; */ "Station doesn't have anything!" = "Station doesn't have anything!"; -/* EDMarketConnector.py: Status - No station market data from Frontier CAPI; In files: EDMarketConnector.py:822; */ +/* EDMarketConnector.py: Status - No station market data from Frontier CAPI; In files: EDMarketConnector.py:884; */ "Station doesn't have a market!" = "Station doesn't have a market!"; -/* EDMarketConnector.py: Status - Attempting to retrieve data from Frontier CAPI; EDMarketConnector.py: Status - Attempting to retrieve data from Frontier CAPI to save to file; stats.py: Fetching data from Frontier CAPI in order to display on File > Status; In files: EDMarketConnector.py:867; EDMarketConnector.py:1428; stats.py:279; */ +/* EDMarketConnector.py: CAPI queries aborted because player is in CQC (Arena); In files: EDMarketConnector.py:913; */ +"CQC detected, aborting CAPI query" = "CQC detected, aborting CAPI query"; + +/* EDMarketConnector.py: Status - Attempting to retrieve data from Frontier CAPI; stats.py: Fetching data from Frontier CAPI in order to display on File > Status; In files: EDMarketConnector.py:934; stats.py:280; */ "Fetching data..." = "Fetching data..."; -/* EDMarketConnector.py: No data was returned for the commander from the Frontier CAPI; In files: EDMarketConnector.py:880; */ +/* EDMarketConnector.py: No data was returned for the commander from the Frontier CAPI; In files: EDMarketConnector.py:973; */ "CAPI: No commander data returned" = "CAPI: No commander data returned"; -/* EDMarketConnector.py: We didn't have the commander name when we should have; stats.py: Unknown commander; In files: EDMarketConnector.py:884; stats.py:297; */ +/* EDMarketConnector.py: We didn't have the commander name when we should have; stats.py: Unknown commander; In files: EDMarketConnector.py:977; stats.py:299; */ "Who are you?!" = "Who are you?!"; -/* EDMarketConnector.py: We don't know where the commander is, when we should; stats.py: Unknown location; In files: EDMarketConnector.py:890; stats.py:307; */ +/* EDMarketConnector.py: We don't know where the commander is, when we should; stats.py: Unknown location; In files: EDMarketConnector.py:983; stats.py:309; */ "Where are you?!" = "Where are you?!"; -/* EDMarketConnector.py: We don't know what ship the commander is in, when we should; stats.py: Unknown ship; In files: EDMarketConnector.py:894; stats.py:312; */ +/* EDMarketConnector.py: We don't know what ship the commander is in, when we should; stats.py: Unknown ship; In files: EDMarketConnector.py:990; stats.py:314; */ "What are you flying?!" = "What are you flying?!"; -/* EDMarketConnector.py: Time when we last obtained Frontier CAPI data; In files: EDMarketConnector.py:1026; */ +/* EDMarketConnector.py: Time when we last obtained Frontier CAPI data; In files: EDMarketConnector.py:1145; */ "Last updated at %H:%M:%S" = "Last updated at %H:%M:%S"; -/* EDMarketConnector.py: Multicrew role; In files: EDMarketConnector.py:1052; */ +/* EDMarketConnector.py: Multicrew role; In files: EDMarketConnector.py:1174; */ "Fighter" = "Fighter"; -/* EDMarketConnector.py: Multicrew role; In files: EDMarketConnector.py:1053; */ +/* EDMarketConnector.py: Multicrew role; In files: EDMarketConnector.py:1175; */ "Gunner" = "Gunner"; -/* EDMarketConnector.py: Multicrew role; In files: EDMarketConnector.py:1054; */ +/* EDMarketConnector.py: Multicrew role; In files: EDMarketConnector.py:1176; */ "Helm" = "Helm"; -/* EDMarketConnector.py: Cooldown on 'Update' button; In files: EDMarketConnector.py:1298; */ +/* EDMarketConnector.py: Cooldown on 'Update' button; In files: EDMarketConnector.py:1434; */ "cooldown {SS}s" = "cooldown {SS}s"; -/* EDMarketConnector.py: Generic 'OK' button label; prefs.py: 'OK' button on Settings/Preferences window; In files: EDMarketConnector.py:1407; prefs.py:304; */ +/* EDMarketConnector.py: Generic 'OK' button label; prefs.py: 'OK' button on Settings/Preferences window; In files: EDMarketConnector.py:1546; prefs.py:305; */ "OK" = "OK"; -/* EDMarketConnector.py: The application is shutting down; In files: EDMarketConnector.py:1489; */ +/* EDMarketConnector.py: The application is shutting down; In files: EDMarketConnector.py:1611; */ "Shutting down..." = "Shutting down..."; -/* EDMarketConnector.py: Popup-text about 'active' plugins without Python 3.x support; In files: EDMarketConnector.py:1780:1786; */ +/* EDMarketConnector.py: Popup-text about 'active' plugins without Python 3.x support; In files: EDMarketConnector.py:1906:1912; */ "One or more of your enabled plugins do not yet have support for Python 3.x. Please see the list on the '{PLUGINS}' tab of '{FILE}' > '{SETTINGS}'. You should check if there is an updated version available, else alert the developer that they need to update the code for Python 3.x.\r\n\r\nYou can disable a plugin by renaming its folder to have '{DISABLED}' on the end of the name." = "One or more of your enabled plugins do not yet have support for Python 3.x. Please see the list on the '{PLUGINS}' tab of '{FILE}' > '{SETTINGS}'. You should check if there is an updated version available, else alert the developer that they need to update the code for Python 3.x.\r\n\r\nYou can disable a plugin by renaming its folder to have '{DISABLED}' on the end of the name."; -/* EDMarketConnector.py: Settings > Plugins tab; prefs.py: Label on Settings > Plugins tab; In files: EDMarketConnector.py:1790; prefs.py:953; */ +/* EDMarketConnector.py: Settings > Plugins tab; prefs.py: Label on Settings > Plugins tab; In files: EDMarketConnector.py:1916; prefs.py:976; */ "Plugins" = "Plugins"; -/* EDMarketConnector.py: Popup window title for list of 'enabled' plugins that don't work with Python 3.x; In files: EDMarketConnector.py:1801; */ +/* EDMarketConnector.py: Popup window title for list of 'enabled' plugins that don't work with Python 3.x; In files: EDMarketConnector.py:1927; */ "EDMC: Plugins Without Python 3.x Support" = "EDMC: Plugins Without Python 3.x Support"; /* journal_lock.py: Title text on popup when Journal directory already locked; In files: journal_lock.py:206; */ @@ -181,7 +184,7 @@ /* journal_lock.py: Generic 'Ignore' button label; In files: journal_lock.py:232; */ "Ignore" = "Ignore"; -/* l10n.py: The system default language choice in Settings > Appearance; prefs.py: Settings > Configuration - Label on 'reset journal files location to default' button; prefs.py: The system default language choice in Settings > Appearance; prefs.py: Label for 'Default' theme radio button; In files: l10n.py:194; prefs.py:466; prefs.py:678; prefs.py:711; */ +/* l10n.py: The system default language choice in Settings > Appearance; prefs.py: Settings > Configuration - Label on 'reset journal files location to default' button; prefs.py: The system default language choice in Settings > Appearance; prefs.py: Label for 'Default' theme radio button; In files: l10n.py:194; prefs.py:467; prefs.py:701; prefs.py:734; */ "Default" = "Default"; /* coriolis.py: 'Auto' label for Coriolis site override selection; coriolis.py: Coriolis normal/beta selection - auto; In files: coriolis.py:52; coriolis.py:55; coriolis.py:101; coriolis.py:117; coriolis.py:123; */ @@ -214,193 +217,205 @@ /* eddb.py: Journal Processing disabled due to an active killswitch; In files: eddb.py:100; */ "EDDB Journal processing disabled. See Log." = "EDDB Journal processing disabled. See Log."; -/* eddn.py: Status text shown while attempting to send data; In files: eddn.py:215; eddn.py:619; eddn.py:969; */ +/* eddn.py: Status text shown while attempting to send data; In files: eddn.py:225; eddn.py:630; eddn.py:982; */ "Sending data to EDDN..." = "Sending data to EDDN..."; -/* eddn.py: Error while trying to send data to EDDN; In files: eddn.py:264; eddn.py:907; eddn.py:942; eddn.py:981; */ +/* eddn.py: Error while trying to send data to EDDN; In files: eddn.py:274; eddn.py:920; eddn.py:955; eddn.py:994; */ "Error: Can't connect to EDDN" = "Error: Can't connect to EDDN"; -/* eddn.py: EDDN has banned this version of our client; In files: eddn.py:282; */ +/* eddn.py: EDDN has banned this version of our client; In files: eddn.py:292; */ "EDDN Error: EDMC is too old for EDDN. Please update." = "EDDN Error: EDMC is too old for EDDN. Please update."; -/* eddn.py: EDDN returned an error that indicates something about what we sent it was wrong; In files: eddn.py:288; */ +/* eddn.py: EDDN returned an error that indicates something about what we sent it was wrong; In files: eddn.py:298; */ "EDDN Error: Validation Failed (EDMC Too Old?). See Log" = "EDDN Error: Validation Failed (EDMC Too Old?). See Log"; -/* eddn.py: EDDN returned some sort of HTTP error, one we didn't expect. {STATUS} contains a number; In files: eddn.py:293; */ +/* eddn.py: EDDN returned some sort of HTTP error, one we didn't expect. {STATUS} contains a number; In files: eddn.py:303; */ "EDDN Error: Returned {STATUS} status code" = "EDDN Error: Returned {STATUS} status code"; -/* eddn.py: Enable EDDN support for station data checkbox label; In files: eddn.py:699; */ +/* eddn.py: Enable EDDN support for station data checkbox label; In files: eddn.py:710; */ "Send station data to the Elite Dangerous Data Network" = "Send station data to the Elite Dangerous Data Network"; -/* eddn.py: Enable EDDN support for system and other scan data checkbox label; In files: eddn.py:710; */ +/* eddn.py: Enable EDDN support for system and other scan data checkbox label; In files: eddn.py:721; */ "Send system and scan data to the Elite Dangerous Data Network" = "Send system and scan data to the Elite Dangerous Data Network"; -/* eddn.py: EDDN delay sending until docked option is on, this message notes that a send was skipped due to this; In files: eddn.py:721; */ +/* eddn.py: EDDN delay sending until docked option is on, this message notes that a send was skipped due to this; In files: eddn.py:732; */ "Delay sending until docked" = "Delay sending until docked"; -/* eddn.py: Killswitch disabled EDDN; In files: eddn.py:785; */ +/* eddn.py: Killswitch disabled EDDN; In files: eddn.py:796; */ "EDDN journal handler disabled. See Log." = "EDDN journal handler disabled. See Log."; -/* edsm.py: Settings>EDSM - Label on checkbox for 'send data'; In files: edsm.py:198; */ +/* edsm.py: Settings>EDSM - Label on checkbox for 'send data'; In files: edsm.py:236; */ "Send flight log and Cmdr status to EDSM" = "Send flight log and Cmdr status to EDSM"; -/* edsm.py: Settings>EDSM - Label on header/URL to EDSM API key page; In files: edsm.py:208; */ +/* edsm.py: Settings>EDSM - Label on header/URL to EDSM API key page; In files: edsm.py:246; */ "Elite Dangerous Star Map credentials" = "Elite Dangerous Star Map credentials"; -/* edsm.py: EDSM Commander name label in EDSM settings; In files: edsm.py:227; */ +/* edsm.py: EDSM Commander name label in EDSM settings; In files: edsm.py:265; */ "Commander Name" = "Commander Name"; -/* edsm.py: EDSM API key label; inara.py: Inara API key label; In files: edsm.py:235; inara.py:237; */ +/* edsm.py: EDSM API key label; inara.py: Inara API key label; In files: edsm.py:273; inara.py:243; */ "API Key" = "API Key"; -/* edsm.py: We have no data on the current commander; prefs.py: No hotkey/shortcut set; stats.py: No rank; In files: edsm.py:262; prefs.py:518; prefs.py:1156; prefs.py:1189; stats.py:117; stats.py:136; stats.py:155; stats.py:172; */ +/* edsm.py: We have no data on the current commander; prefs.py: No hotkey/shortcut set; stats.py: No rank; In files: edsm.py:300; prefs.py:519; prefs.py:1173; prefs.py:1206; stats.py:117; stats.py:136; stats.py:155; stats.py:172; */ "None" = "None"; -/* edsm.py: EDSM plugin - Journal handling disabled by killswitch; In files: edsm.py:358; */ +/* edsm.py: EDSM plugin - Journal handling disabled by killswitch; In files: edsm.py:401; */ "EDSM Handler disabled. See Log." = "EDSM Handler disabled. See Log."; -/* edsm.py: EDSM Plugin - Error message from EDSM API; In files: edsm.py:640; edsm.py:745; */ +/* edsm.py: EDSM Plugin - Error message from EDSM API; In files: edsm.py:746; edsm.py:874; */ "Error: EDSM {MSG}" = "Error: EDSM {MSG}"; -/* edsm.py: EDSM Plugin - Error connecting to EDSM API; In files: edsm.py:677; edsm.py:740; */ +/* edsm.py: EDSM Plugin - Error connecting to EDSM API; In files: edsm.py:783; edsm.py:869; */ "Error: Can't connect to EDSM" = "Error: Can't connect to EDSM"; -/* inara.py: Checkbox to enable INARA API Usage; In files: inara.py:216; */ +/* inara.py: Checkbox to enable INARA API Usage; In files: inara.py:222; */ "Send flight log and Cmdr status to Inara" = "Send flight log and Cmdr status to Inara"; -/* inara.py: Text for INARA API keys link ( goes to https://inara.cz/settings-api ); In files: inara.py:228; */ +/* inara.py: Text for INARA API keys link ( goes to https://inara.cz/settings-api ); In files: inara.py:234; */ "Inara credentials" = "Inara credentials"; -/* inara.py: INARA support disabled via killswitch; In files: inara.py:335; */ +/* inara.py: INARA support disabled via killswitch; In files: inara.py:341; */ "Inara disabled. See Log." = "Inara disabled. See Log."; -/* inara.py: INARA API returned some kind of error (error message will be contained in {MSG}); In files: inara.py:1549; inara.py:1562; */ +/* inara.py: INARA API returned some kind of error (error message will be contained in {MSG}); In files: inara.py:1580; inara.py:1593; */ "Error: Inara {MSG}" = "Error: Inara {MSG}"; /* prefs.py: File > Preferences menu entry for macOS; In files: prefs.py:250; */ "Preferences" = "Preferences"; -/* prefs.py: Settings > Output - choosing what data to save to files; In files: prefs.py:346; */ +/* prefs.py: Settings > Output - choosing what data to save to files; In files: prefs.py:347; */ "Please choose what data to save" = "Please choose what data to save"; -/* prefs.py: Settings > Output option; In files: prefs.py:352; */ +/* prefs.py: Settings > Output option; In files: prefs.py:353; */ "Market data in CSV format file" = "Market data in CSV format file"; -/* prefs.py: Settings > Output option; In files: prefs.py:361; */ +/* prefs.py: Settings > Output option; In files: prefs.py:362; */ "Market data in Trade Dangerous format file" = "Market data in Trade Dangerous format file"; -/* prefs.py: Settings > Output option; In files: prefs.py:371; */ +/* prefs.py: Settings > Output option; In files: prefs.py:372; */ "Ship loadout" = "Ship loadout"; -/* prefs.py: Settings > Output option; In files: prefs.py:381; */ +/* prefs.py: Settings > Output option; In files: prefs.py:382; */ "Automatically update on docking" = "Automatically update on docking"; -/* prefs.py: Settings > Output - Label for "where files are located"; In files: prefs.py:390; prefs.py:409; */ +/* prefs.py: Settings > Output - Label for "where files are located"; In files: prefs.py:391; prefs.py:410; */ "File location" = "File location"; -/* prefs.py: macOS Preferences - files location selection button; In files: prefs.py:398; prefs.py:448; */ +/* prefs.py: macOS Preferences - files location selection button; In files: prefs.py:399; prefs.py:449; */ "Change..." = "Change..."; -/* prefs.py: NOT-macOS Settings - files location selection button; prefs.py: NOT-macOS Setting - files location selection button; In files: prefs.py:401; prefs.py:451; */ +/* prefs.py: NOT-macOS Settings - files location selection button; prefs.py: NOT-macOS Setting - files location selection button; In files: prefs.py:402; prefs.py:452; */ "Browse..." = "Browse..."; -/* prefs.py: Label for 'Output' Settings/Preferences tab; In files: prefs.py:416; */ +/* prefs.py: Label for 'Output' Settings/Preferences tab; In files: prefs.py:417; */ "Output" = "Output"; -/* prefs.py: Settings > Configuration - Label for Journal files location; In files: prefs.py:442; prefs.py:457; */ +/* prefs.py: Settings > Configuration - Label for Journal files location; In files: prefs.py:443; prefs.py:458; */ "E:D journal file location" = "E:D journal file location"; -/* prefs.py: Hotkey/Shortcut settings prompt on OSX; In files: prefs.py:482; */ +/* prefs.py: Hotkey/Shortcut settings prompt on OSX; In files: prefs.py:483; */ "Keyboard shortcut" = "Keyboard shortcut"; -/* prefs.py: Hotkey/Shortcut settings prompt on Windows; In files: prefs.py:484; */ +/* prefs.py: Hotkey/Shortcut settings prompt on Windows; In files: prefs.py:485; */ "Hotkey" = "Hotkey"; -/* prefs.py: macOS Preferences > Configuration - restart the app message; In files: prefs.py:493; */ +/* prefs.py: macOS Preferences > Configuration - restart the app message; In files: prefs.py:494; */ "Re-start {APP} to use shortcuts" = "Re-start {APP} to use shortcuts"; -/* prefs.py: macOS - Configuration - need to grant the app permission for keyboard shortcuts; In files: prefs.py:502; */ +/* prefs.py: macOS - Configuration - need to grant the app permission for keyboard shortcuts; In files: prefs.py:503; */ "{APP} needs permission to use shortcuts" = "{APP} needs permission to use shortcuts"; -/* prefs.py: Shortcut settings button on OSX; In files: prefs.py:507; */ +/* prefs.py: Shortcut settings button on OSX; In files: prefs.py:508; */ "Open System Preferences" = "Open System Preferences"; -/* prefs.py: Configuration - Act on hotkey only when ED is in foreground; In files: prefs.py:529; */ +/* prefs.py: Configuration - Act on hotkey only when ED is in foreground; In files: prefs.py:530; */ "Only when Elite: Dangerous is the active app" = "Only when Elite: Dangerous is the active app"; -/* prefs.py: Configuration - play sound when hotkey used; In files: prefs.py:540; */ +/* prefs.py: Configuration - play sound when hotkey used; In files: prefs.py:541; */ "Play sound" = "Play sound"; -/* prefs.py: Configuration - disable checks for app updates when in-game; In files: prefs.py:555; */ +/* prefs.py: Configuration - disable checks for app updates when in-game; In files: prefs.py:556; */ "Disable Automatic Application Updates Check when in-game" = "Disable Automatic Application Updates Check when in-game"; -/* prefs.py: Label for preferred shipyard, system and station 'providers'; In files: prefs.py:568; */ +/* prefs.py: Label for preferred shipyard, system and station 'providers'; In files: prefs.py:569; */ "Preferred websites" = "Preferred websites"; -/* prefs.py: Label for Shipyard provider selection; In files: prefs.py:579; */ +/* prefs.py: Label for Shipyard provider selection; In files: prefs.py:580; */ "Shipyard" = "Shipyard"; -/* prefs.py: Label for checkbox to utilise alternative Coriolis URL method; In files: prefs.py:591; */ +/* prefs.py: Label for checkbox to utilise alternative Coriolis URL method; In files: prefs.py:592; */ "Use alternate URL method" = "Use alternate URL method"; -/* prefs.py: Configuration - Label for selection of Log Level; In files: prefs.py:644; */ +/* prefs.py: Configuration - Label for selection of Log Level; In files: prefs.py:645; */ "Log Level" = "Log Level"; -/* prefs.py: Label for 'Configuration' tab in Settings; In files: prefs.py:672; */ +/* prefs.py: Label for 'Configuration' tab in Settings; In files: prefs.py:673; */ "Configuration" = "Configuration"; -/* prefs.py: Label for Settings > Appeareance > selection of 'normal' text colour; In files: prefs.py:685; */ +/* prefs.py: UI elements privacy section header in privacy tab of preferences; In files: prefs.py:682; */ +"Main UI privacy options" = "Main UI privacy options"; + +/* prefs.py: Hide private group owner name from UI checkbox; In files: prefs.py:687; */ +"Hide private group name in UI" = "Hide private group name in UI"; + +/* prefs.py: Hide multicrew captain name from main UI checkbox; In files: prefs.py:691; */ +"Hide multi-crew captain name" = "Hide multi-crew captain name"; + +/* prefs.py: Preferences privacy tab title; In files: prefs.py:695; */ +"Privacy" = "Privacy"; + +/* prefs.py: Label for Settings > Appeareance > selection of 'normal' text colour; In files: prefs.py:708; */ "Normal text" = "Normal text"; -/* prefs.py: Label for Settings > Appeareance > selection of 'highlightes' text colour; In files: prefs.py:687; */ +/* prefs.py: Label for Settings > Appeareance > selection of 'highlightes' text colour; In files: prefs.py:710; */ "Highlighted text" = "Highlighted text"; -/* prefs.py: Appearance - Label for selection of application display language; In files: prefs.py:696; */ +/* prefs.py: Appearance - Label for selection of application display language; In files: prefs.py:719; */ "Language" = "Language"; -/* prefs.py: Label for Settings > Appearance > Theme selection; In files: prefs.py:706; */ +/* prefs.py: Label for Settings > Appearance > Theme selection; In files: prefs.py:729; */ "Theme" = "Theme"; -/* prefs.py: Label for 'Dark' theme radio button; In files: prefs.py:717; */ +/* prefs.py: Label for 'Dark' theme radio button; In files: prefs.py:740; */ "Dark" = "Dark"; -/* prefs.py: Label for 'Transparent' theme radio button; In files: prefs.py:724; */ +/* prefs.py: Label for 'Transparent' theme radio button; In files: prefs.py:747; */ "Transparent" = "Transparent"; -/* prefs.py: Appearance - Label for selection of UI scaling; In files: prefs.py:770; */ +/* prefs.py: Appearance - Label for selection of UI scaling; In files: prefs.py:793; */ "UI Scale Percentage" = "UI Scale Percentage"; -/* prefs.py: Appearance - Help/hint text for UI scaling selection; In files: prefs.py:791; */ +/* prefs.py: Appearance - Help/hint text for UI scaling selection; In files: prefs.py:814; */ "100 means Default{CR}Restart Required for{CR}changes to take effect!" = "100 means Default{CR}Restart Required for{CR}changes to take effect!"; -/* prefs.py: Appearance - Label for selection of main window transparency; In files: prefs.py:801; */ +/* prefs.py: Appearance - Label for selection of main window transparency; In files: prefs.py:824; */ "Main window transparency" = "Main window transparency"; -/* prefs.py: Appearance - Help/hint text for Main window transparency selection; In files: prefs.py:821:824; */ +/* prefs.py: Appearance - Help/hint text for Main window transparency selection; In files: prefs.py:844:847; */ "100 means fully opaque.{CR}Window is updated in real time" = "100 means fully opaque.{CR}Window is updated in real time"; -/* prefs.py: Label for Settings > Appearance tab; In files: prefs.py:860; */ +/* prefs.py: Label for Settings > Appearance tab; In files: prefs.py:883; */ "Appearance" = "Appearance"; -/* prefs.py: Label for location of third-party plugins folder; In files: prefs.py:875; */ +/* prefs.py: Label for location of third-party plugins folder; In files: prefs.py:898; */ "Plugins folder" = "Plugins folder"; -/* prefs.py: Label on button used to open a filesystem folder; In files: prefs.py:882; */ +/* prefs.py: Label on button used to open a filesystem folder; In files: prefs.py:905; */ "Open" = "Open"; -/* prefs.py: Tip/label about how to disable plugins; In files: prefs.py:890; */ +/* prefs.py: Tip/label about how to disable plugins; In files: prefs.py:913; */ "Tip: You can disable a plugin by{CR}adding '{EXT}' to its folder name" = "Tip: You can disable a plugin by{CR}adding '{EXT}' to its folder name"; -/* prefs.py: Label on list of enabled plugins; In files: prefs.py:901; */ +/* prefs.py: Label on list of enabled plugins; In files: prefs.py:924; */ "Enabled Plugins" = "Enabled Plugins"; -/* prefs.py: Plugins - Label for list of 'enabled' plugins that don't work with Python 3.x; In files: prefs.py:921; */ +/* prefs.py: Plugins - Label for list of 'enabled' plugins that don't work with Python 3.x; In files: prefs.py:944; */ "Plugins Without Python 3.x Support:" = "Plugins Without Python 3.x Support:"; -/* prefs.py: Plugins - Label on URL to documentation about migrating plugins from Python 2.7; In files: prefs.py:929; */ +/* prefs.py: Plugins - Label on URL to documentation about migrating plugins from Python 2.7; In files: prefs.py:952; */ "Information on migrating plugins" = "Information on migrating plugins"; -/* prefs.py: Lable on list of user-disabled plugins; In files: prefs.py:944; */ +/* prefs.py: Lable on list of user-disabled plugins; In files: prefs.py:967; */ "Disabled Plugins" = "Disabled Plugins"; /* stats.py: Cmdr stats; In files: stats.py:51; */ @@ -625,20 +640,8 @@ /* stats.py: Power rank; In files: stats.py:160; */ "Rating 5" = "Rating 5"; -/* stats.py: Status dialog subtitle - CR value of ship; In files: stats.py:367; */ +/* stats.py: Status dialog subtitle - CR value of ship; In files: stats.py:369; */ "Value" = "Value"; -/* stats.py: Status dialog title; In files: stats.py:376; */ +/* stats.py: Status dialog title; In files: stats.py:378; */ "Ships" = "Ships"; - -/* prefs.py: UI elements privacy section header in privacy tab of preferences; In files: prefs.py:682; */ -"Main UI privacy options" = "Main UI privacy options"; - -/* prefs.py: Hide private group owner name from UI checkbox; In files: prefs.py:687; */ -"Hide private group name in UI" = "Hide private group name in UI"; - -/* prefs.py: Hide multicrew captain name from main UI checkbox; In files: prefs.py:691; */ -"Hide multi-crew captain name" = "Hide multi-crew captain name"; - -/* prefs.py: Preferences privacy tab title; In files: prefs.py:695; */ -"Privacy" = "Privacy"; diff --git a/companion.py b/companion.py index 08c92b7d..e61b39a7 100644 --- a/companion.py +++ b/companion.py @@ -9,17 +9,21 @@ protocol used for the callback. import base64 import collections import csv +import datetime import hashlib import json import numbers import os import random +import threading import time +import tkinter as tk import urllib.parse import webbrowser from builtins import object, range, str from email.utils import parsedate from os.path import join +from queue import Queue from typing import TYPE_CHECKING, Any, Dict, List, Mapping, Optional, OrderedDict, TypeVar, Union import requests @@ -44,26 +48,16 @@ else: # Define custom type for the dicts that hold CAPI data # CAPIData = NewType('CAPIData', Dict) -holdoff = 60 # be nice -timeout = 10 # requests timeout +capi_query_cooldown = 60 # Minimum time between (sets of) CAPI queries +capi_default_requests_timeout = 10 auth_timeout = 30 # timeout for initial auth -# Currently the "Elite Dangerous Market Connector (EDCD/Athanasius)" one in -# Athanasius' Frontier account -# Obtain from https://auth.frontierstore.net/client/signup -CLIENT_ID = os.getenv('CLIENT_ID') or 'fb88d428-9110-475f-a3d2-dc151c2b9c7a' -SERVER_AUTH = 'https://auth.frontierstore.net' -URL_AUTH = '/auth' -URL_TOKEN = '/token' -URL_DECODE = '/decode' - +# Used by both class Auth and Session +FRONTIER_AUTH_SERVER = 'https://auth.frontierstore.net' USER_AGENT = f'EDCD-{appname}-{appversion()}' SERVER_LIVE = 'https://companion.orerve.net' SERVER_BETA = 'https://pts-companion.orerve.net' -URL_QUERY = '/profile' -URL_MARKET = '/market' -URL_SHIPYARD = '/shipyard' commodity_map: Dict = {} @@ -71,7 +65,10 @@ commodity_map: Dict = {} class CAPIData(UserDict): """CAPI Response.""" - def __init__(self, data: Union[str, Dict[str, Any], 'CAPIData', None] = None, source_endpoint: str = None) -> None: + def __init__( + self, + data: Union[str, Dict[str, Any], 'CAPIData', None] = None, source_endpoint: str = None + ) -> None: if data is None: super().__init__() elif isinstance(data, str): @@ -86,7 +83,7 @@ class CAPIData(UserDict): if source_endpoint is None: return - if source_endpoint == URL_SHIPYARD and self.data.get('lastStarport'): + if source_endpoint == Session.FRONTIER_CAPI_PATH_SHIPYARD and self.data.get('lastStarport'): # All the other endpoints may or may not have a lastStarport, but definitely wont have valid data # for this check, which means it'll just make noise for no reason while we're working on other things self.check_modules_ships() @@ -128,6 +125,57 @@ class CAPIData(UserDict): self.data['lastStarport']['ships'] = {'shipyard_list': {}, 'unavailable_list': []} +class CAPIDataEncoder(json.JSONEncoder): + """Allow for json dumping via specified encoder.""" + + def default(self, o): + """Tell JSON encoder that we're actually just a dict.""" + return o.__dict__ + + +class CAPIDataRawEndpoint: + """Last received CAPI response for a specific endpoint.""" + + def __init__(self, raw_data: str, query_time: datetime.datetime): + self.query_time = query_time + self.raw_data = raw_data + # TODO: Maybe requests.response status ? + + +class CAPIDataRaw: + """The last obtained raw CAPI response for each endpoint.""" + + raw_data: Dict[str, CAPIDataRawEndpoint] = {} + + def record_endpoint( + self, endpoint: str, + raw_data: str, + query_time: datetime.datetime + ): + """Record the latest raw data for the given endpoint.""" + self.raw_data[endpoint] = CAPIDataRawEndpoint(raw_data, query_time) + + def __str__(self): + """Return a more readable string form of the data.""" + capi_data_str = '{' + for k, v in self.raw_data.items(): + capi_data_str += f'"{k}":\n{{\n\t"query_time": "{v.query_time}",\n\t' \ + f'"raw_data": {v.raw_data}\n}},\n\n' + + capi_data_str = capi_data_str.removesuffix(',\n\n') + capi_data_str += '\n\n}' + + return capi_data_str + + def __iter__(self): + """Make this iterable on its raw_data dict.""" + yield from self.raw_data + + def __getitem__(self, item): + """Make the raw_data dict's items get'able.""" + return self.raw_data.__getitem__(item) + + def listify(thing: Union[List, Dict]) -> List: """ Convert actual JSON array or int-indexed dict into a Python list. @@ -236,17 +284,26 @@ class CmdrError(Exception): class Auth(object): """Handles authentication with the Frontier CAPI service via oAuth2.""" + # Currently the "Elite Dangerous Market Connector (EDCD/Athanasius)" one in + # Athanasius' Frontier account + # Obtain from https://auth.frontierstore.net/client/signup + CLIENT_ID = os.getenv('CLIENT_ID') or 'fb88d428-9110-475f-a3d2-dc151c2b9c7a' + + FRONTIER_AUTH_PATH_AUTH = '/auth' + FRONTIER_AUTH_PATH_TOKEN = '/token' + FRONTIER_AUTH_PATH_DECODE = '/decode' + def __init__(self, cmdr: str) -> None: self.cmdr: str = cmdr - self.session = requests.Session() - self.session.headers['User-Agent'] = USER_AGENT + self.requests_session = requests.Session() + self.requests_session.headers['User-Agent'] = USER_AGENT self.verifier: Union[bytes, None] = None self.state: Union[str, None] = None def __del__(self) -> None: """Ensure our Session is closed if we're being deleted.""" - if self.session: - self.session.close() + if self.requests_session: + self.requests_session.close() def refresh(self) -> Optional[str]: """ @@ -271,13 +328,17 @@ class Auth(object): logger.debug('We have a refresh token for that idx') data = { 'grant_type': 'refresh_token', - 'client_id': CLIENT_ID, + 'client_id': self.CLIENT_ID, 'refresh_token': tokens[idx], } logger.debug('Attempting refresh with Frontier...') try: - r = self.session.post(SERVER_AUTH + URL_TOKEN, data=data, timeout=auth_timeout) + r = self.requests_session.post( + FRONTIER_AUTH_SERVER + self.FRONTIER_AUTH_PATH_TOKEN, + data=data, + timeout=auth_timeout + ) if r.status_code == requests.codes.ok: data = r.json() tokens[idx] = data.get('refresh_token', '') @@ -307,10 +368,10 @@ class Auth(object): logger.info(f'Trying auth from scratch for Commander "{self.cmdr}"') challenge = self.base64_url_encode(hashlib.sha256(self.verifier).digest()) webbrowser.open( - f'{SERVER_AUTH}{URL_AUTH}?response_type=code' + f'{FRONTIER_AUTH_SERVER}{self.FRONTIER_AUTH_PATH_AUTH}?response_type=code' f'&audience=frontier,steam,epic' f'&scope=auth capi' - f'&client_id={CLIENT_ID}' + f'&client_id={self.CLIENT_ID}' f'&code_challenge={challenge}' f'&code_challenge_method=S256' f'&state={self.state}' @@ -348,7 +409,7 @@ class Auth(object): logger.debug('Got code, posting it back...') request_data = { 'grant_type': 'authorization_code', - 'client_id': CLIENT_ID, + 'client_id': self.CLIENT_ID, 'code_verifier': self.verifier, 'code': data['code'][0], 'redirect_uri': protocolhandler.redirect, @@ -361,12 +422,16 @@ class Auth(object): # requests_log.setLevel(logging.DEBUG) # requests_log.propagate = True - r = self.session.post(SERVER_AUTH + URL_TOKEN, data=request_data, timeout=auth_timeout) + r = self.requests_session.post( + FRONTIER_AUTH_SERVER + self.FRONTIER_AUTH_PATH_TOKEN, + data=request_data, + timeout=auth_timeout + ) data_token = r.json() if r.status_code == requests.codes.ok: # Now we need to /decode the token to check the customer_id against FID - r = self.session.get( - SERVER_AUTH + URL_DECODE, + r = self.requests_session.get( + FRONTIER_AUTH_SERVER + self.FRONTIER_AUTH_PATH_DECODE, headers={ 'Authorization': f'Bearer {data_token.get("access_token", "")}', 'Content-Type': 'application/json', @@ -463,30 +528,127 @@ class Auth(object): return base64.urlsafe_b64encode(text).decode().replace('=', '') +class EDMCCAPIReturn: + """Base class for Request, Failure or Response.""" + + def __init__( + self, query_time: int, tk_response_event: Optional[str] = None, + play_sound: bool = False, auto_update: bool = False + ): + self.tk_response_event = tk_response_event # Name of tk event to generate when response queued. + self.query_time: int = query_time # When this query is considered to have started (time_t). + self.play_sound: bool = play_sound # Whether to play good/bad sounds for success/failure. + self.auto_update: bool = auto_update # Whether this was automatically triggered. + + +class EDMCCAPIRequest(EDMCCAPIReturn): + """Encapsulates a request for CAPI data.""" + + REQUEST_WORKER_SHUTDOWN = '__EDMC_WORKER_SHUTDOWN' + + def __init__( + self, endpoint: str, query_time: int, + tk_response_event: Optional[str] = None, + play_sound: bool = False, auto_update: bool = False + ): + super().__init__( + query_time=query_time, tk_response_event=tk_response_event, + play_sound=play_sound, auto_update=auto_update + ) + self.endpoint: str = endpoint # The CAPI query to perform. + + +class EDMCCAPIResponse(EDMCCAPIReturn): + """Encapsulates a response from CAPI quer(y|ies).""" + + def __init__( + self, capi_data: CAPIData, + query_time: int, play_sound: bool = False, auto_update: bool = False + ): + super().__init__(query_time=query_time, play_sound=play_sound, auto_update=auto_update) + self.capi_data: CAPIData = capi_data # Frontier CAPI response, possibly augmented (station query) + + +class EDMCCAPIFailedRequest(EDMCCAPIReturn): + """CAPI failed query error class.""" + + def __init__( + self, message: str, + query_time: int, play_sound: bool = False, auto_update: bool = False, + exception=None + ): + super().__init__(query_time=query_time, play_sound=play_sound, auto_update=auto_update) + self.message: str = message # User-friendly reason for failure. + self.exception: int = exception # Exception that recipient should raise. + + class Session(object): - """Methods for handling an oAuth2 session.""" + """Methods for handling Frontier Auth and CAPI queries.""" STATE_INIT, STATE_AUTH, STATE_OK = list(range(3)) + FRONTIER_CAPI_PATH_PROFILE = '/profile' + FRONTIER_CAPI_PATH_MARKET = '/market' + FRONTIER_CAPI_PATH_SHIPYARD = '/shipyard' + # This is a dummy value, to signal to Session.capi_query_worker that we + # the 'station' triplet of queries. + _CAPI_PATH_STATION = '_edmc_station' + def __init__(self) -> None: self.state = Session.STATE_INIT self.server: Optional[str] = None self.credentials: Optional[Dict[str, Any]] = None - self.session: Optional[requests.Session] = None + self.requests_session: Optional[requests.Session] = None self.auth: Optional[Auth] = None self.retrying = False # Avoid infinite loop when successful auth / unsuccessful query + self.tk_master: Optional[tk.Tk] = None - def login(self, cmdr: str = None, is_beta: Union[None, bool] = None) -> bool: + self.capi_raw_data = CAPIDataRaw() # Cache of raw replies from CAPI service + # Queue that holds requests for CAPI queries, the items should always + # be EDMCCAPIRequest objects. + self.capi_request_queue: Queue[EDMCCAPIRequest] = Queue() + # This queue is used to pass the result, possibly a failure, of CAPI + # queries back to the requesting code (technically anything checking + # this queue, but it should be either EDMarketConnector.AppWindow or + # EDMC.py). Items may be EDMCCAPIResponse or EDMCCAPIFailedRequest. + self.capi_response_queue: Queue[Union[EDMCCAPIResponse, EDMCCAPIFailedRequest]] = Queue() + logger.debug('Starting CAPI queries thread...') + self.capi_query_thread = threading.Thread( + target=self.capi_query_worker, + daemon=True, + name='CAPI worker' + ) + self.capi_query_thread.start() + logger.debug('Done') + + def set_tk_master(self, master: tk.Tk) -> None: + """Set a reference to main UI Tk root window.""" + self.tk_master = master + + ###################################################################### + # Frontier Authorization + ###################################################################### + def start_frontier_auth(self, access_token: str) -> None: + """Start an oAuth2 session.""" + logger.debug('Starting session') + self.requests_session = requests.Session() + self.requests_session.headers['Authorization'] = f'Bearer {access_token}' + self.requests_session.headers['User-Agent'] = USER_AGENT + self.state = Session.STATE_OK + + def login(self, cmdr: str = None, is_beta: Optional[bool] = None) -> bool: """ Attempt oAuth2 login. :return: True if login succeeded, False if re-authorization initiated. """ - if not CLIENT_ID: - logger.error('CLIENT_ID is None') + if not Auth.CLIENT_ID: + logger.error('self.CLIENT_ID is None') raise CredentialsError('cannot login without a valid Client ID') # TODO: WTF is the intent behind this logic ? + # Perhaps to do with not even trying to auth if we're not sure if + # it's beta, but that's moot for *auth* since oAuth2. if not cmdr or is_beta is None: # Use existing credentials if not self.credentials: @@ -516,7 +678,7 @@ class Session(object): if access_token: logger.debug('We have an access_token') self.auth = None - self.start(access_token) + self.start_frontier_auth(access_token) return True else: @@ -536,7 +698,7 @@ class Session(object): try: logger.debug('Trying authorize with payload from handler') - self.start(self.auth.authorize(protocolhandler.lastpayload)) # type: ignore + self.start_frontier_auth(self.auth.authorize(protocolhandler.lastpayload)) # type: ignore self.auth = None except Exception: @@ -545,176 +707,263 @@ class Session(object): self.auth = None raise # Bad thing happened - def start(self, access_token: str) -> None: - """Start an oAuth2 session.""" - logger.debug('Starting session') - self.session = requests.Session() - self.session.headers['Authorization'] = f'Bearer {access_token}' - self.session.headers['User-Agent'] = USER_AGENT - self.state = Session.STATE_OK + def close(self) -> None: + """Close Frontier authorization session.""" + self.state = Session.STATE_INIT + if self.requests_session: + try: + self.requests_session.close() - def query(self, endpoint: str) -> CAPIData: # noqa: CCR001 - """Perform a query against the specified CAPI endpoint.""" - logger.trace_if('capi.query', f'Performing query for endpoint "{endpoint}"') - if self.state == Session.STATE_INIT: - if self.login(): - return self.query(endpoint) + except Exception as e: + logger.debug('Frontier Auth: closing', exc_info=e) - elif self.state == Session.STATE_AUTH: - logger.error('cannot make a query when unauthorized') - raise CredentialsError('cannot make a query when unauthorized') + self.requests_session = None - logger.trace_if('capi.query', 'Trying...') - if conf_module.capi_pretend_down: - raise ServerConnectionError(f'Pretending CAPI down: {endpoint}') + def invalidate(self) -> None: + """Invalidate Frontier authorization credentials.""" + logger.debug('Forcing a full re-authentication') + # Force a full re-authentication + self.close() + Auth.invalidate(self.credentials['cmdr']) # type: ignore + ###################################################################### - try: - r = self.session.get(self.server + endpoint, timeout=timeout) # type: ignore + ###################################################################### + # CAPI queries + ###################################################################### + def capi_query_worker(self): # noqa: C901, CCR001 + """Worker thread that performs actual CAPI queries.""" + logger.debug('CAPI worker thread starting') - except requests.ConnectionError as e: - logger.warning(f'Unable to resolve name for CAPI: {e} (for request: {endpoint})') - raise ServerConnectionError(f'Unable to connect to endpoint {endpoint}') from e + def capi_single_query( # noqa: CCR001 + capi_endpoint: str, timeout: int = capi_default_requests_timeout + ) -> CAPIData: + """ + Perform a *single* CAPI endpoint query within the thread worker. - except Exception as e: - logger.debug('Attempting GET', exc_info=e) - # LANG: Frontier CAPI data retrieval failed - raise ServerError(f'{_("Frontier CAPI query failure")}: {endpoint}') from e + :param capi_endpoint: An actual Frontier CAPI endpoint to query. + :param timeout: requests query timeout to use. + :return: The resulting CAPI data, of type CAPIData. + """ + capi_data: CAPIData + try: + logger.trace_if('capi.worker', 'Sending HTTP request...') + if conf_module.capi_pretend_down: + raise ServerConnectionError(f'Pretending CAPI down: {capi_endpoint}') - if r.url.startswith(SERVER_AUTH): - logger.info('Redirected back to Auth Server') - # Redirected back to Auth server - force full re-authentication - self.dump(r) - self.invalidate() - self.retrying = False - self.login() - raise CredentialsError() + r = self.requests_session.get(self.server + capi_endpoint, timeout=timeout) # type: ignore + logger.trace_if('capi.worker', '... got result...') + r.raise_for_status() # Typically 403 "Forbidden" on token expiry + # May also fail here if token expired since response is empty + # r.status_code = 401 + # raise requests.HTTPError + capi_json = r.json() + capi_data = CAPIData(capi_json, capi_endpoint) + self.capi_raw_data.record_endpoint( + capi_endpoint, r.content.decode(encoding='utf-8'), + datetime.datetime.utcnow() + ) - elif 500 <= r.status_code < 600: - # Server error. Typically 500 "Internal Server Error" if server is down - logger.debug('500 status back from CAPI') - self.dump(r) - # LANG: Frontier CAPI data retrieval failed with 5XX code - raise ServerError(f'{_("Frontier CAPI server error")}: {r.status_code}') + except requests.ConnectionError as e: + logger.warning(f'Request {capi_endpoint}: {e}') + raise ServerConnectionError(f'Unable to connect to endpoint: {capi_endpoint}') from e - try: - r.raise_for_status() # Typically 403 "Forbidden" on token expiry - data = CAPIData(r.json(), endpoint) # May also fail here if token expired since response is empty + except requests.HTTPError as e: # In response to raise_for_status() + logger.exception(f'Frontier CAPI Auth: GET {capi_endpoint}') + self.dump(r) - except (requests.HTTPError, ValueError) as e: - logger.exception('Frontier CAPI Auth: GET ') - self.dump(r) - self.close() + if r.status_code == 401: # CAPI doesn't think we're Auth'd + # No need for translation, we'll go straight into trying new Auth + # and thus any message would be overwritten. + raise CredentialsError('Frontier CAPI said Auth required') from e - if self.retrying: # Refresh just succeeded but this query failed! Force full re-authentication - logger.error('Frontier CAPI Auth: query failed after refresh') - self.invalidate() - self.retrying = False - self.login() - raise CredentialsError('query failed after refresh') from e + if r.status_code == 418: # "I'm a teapot" - used to signal maintenance + # LANG: Frontier CAPI returned 418, meaning down for maintenance + raise ServerError(_("Frontier CAPI down for maintenance")) from e - elif self.login(): # Maybe our token expired. Re-authorize in any case - logger.debug('Initial query failed, but login() just worked, trying again...') - self.retrying = True - return self.query(endpoint) + logger.exception('Frontier CAPI: Misc. Error') + raise ServerError('Frontier CAPI: Misc. Error') from e + + except ValueError as e: + logger.exception(f'decoding CAPI response content:\n{r.content.decode(encoding="utf-8")}\n') + raise ServerError("Frontier CAPI response: couldn't decode JSON") from e + + except Exception as e: + logger.debug('Attempting GET', exc_info=e) + # LANG: Frontier CAPI data retrieval failed + raise ServerError(f'{_("Frontier CAPI query failure")}: {capi_endpoint}') from e + + if capi_endpoint == self.FRONTIER_CAPI_PATH_PROFILE and 'commander' not in capi_data: + logger.error('No commander in returned data') + + if 'timestamp' not in capi_data: + capi_data['timestamp'] = time.strftime( + '%Y-%m-%dT%H:%M:%SZ', parsedate(r.headers['Date']) # type: ignore + ) + + return capi_data + + def capi_station_queries(timeout: int = capi_default_requests_timeout) -> CAPIData: # noqa: CCR001 + """ + Perform all 'station' queries for the caller. + + A /profile query is performed to check that we are docked (or on foot) + and the station name and marketid match the prior Docked event. + If they do match, and the services list says they're present, also + retrieve CAPI market and/or shipyard/outfitting data and merge into + the /profile data. + + :param timeout: requests timeout to use. + :return: CAPIData instance with what we retrieved. + """ + station_data = capi_single_query(self.FRONTIER_CAPI_PATH_PROFILE, timeout=timeout) + + if not station_data['commander'].get('docked') and not monitor.state['OnFoot']: + return station_data + + # Sanity checks in case data isn't as we expect, and maybe 'docked' flag + # is also lagging. + if (last_starport := station_data.get('lastStarport')) is None: + logger.error("No lastStarport in data!") + return station_data + + if ( + (last_starport_name := last_starport.get('name')) is None + or last_starport_name == '' + ): + # This could well be valid if you've been out exploring for a long + # time. + logger.warning("No lastStarport name!") + return station_data + + # WORKAROUND: n/a | 06-08-2021: Issue 1198 and https://issues.frontierstore.net/issue-detail/40706 + # -- strip "+" chars off star port names returned by the CAPI + last_starport_name = last_starport["name"] = last_starport_name.rstrip(" +") + + services = last_starport.get('services', {}) + if not isinstance(services, dict): + # Odyssey Alpha Phase 3 4.0.0.20 has been observed having + # this be an empty list when you've jumped to another system + # and not yet docked. As opposed to no services key at all + # or an empty dict. + logger.error(f'services is "{type(services)}", not dict !') + # TODO: Change this to be dependent on its own CL arg + if __debug__: + self.dump_capi_data(station_data) + + # Set an empty dict so as to not have to retest below. + services = {} + + last_starport_id = int(last_starport.get('id')) + + if services.get('commodities'): + market_data = capi_single_query(self.FRONTIER_CAPI_PATH_MARKET, timeout=timeout) + if last_starport_id != int(market_data['id']): + logger.warning(f"{last_starport_id!r} != {int(market_data['id'])!r}") + raise ServerLagging() + + else: + market_data['name'] = last_starport_name + station_data['lastStarport'].update(market_data) + + if services.get('outfitting') or services.get('shipyard'): + shipyard_data = capi_single_query(self.FRONTIER_CAPI_PATH_SHIPYARD, timeout=timeout) + if last_starport_id != int(shipyard_data['id']): + logger.warning(f"{last_starport_id!r} != {int(shipyard_data['id'])!r}") + raise ServerLagging() + + else: + shipyard_data['name'] = last_starport_name + station_data['lastStarport'].update(shipyard_data) + # WORKAROUND END + + return station_data + + while True: + query = self.capi_request_queue.get() + logger.trace_if('capi.worker', 'De-queued request') + if not isinstance(query, EDMCCAPIRequest): + logger.error("Item from queue wasn't an EDMCCAPIRequest") + break + + if query.endpoint == query.REQUEST_WORKER_SHUTDOWN: + logger.info(f'endpoint {query.REQUEST_WORKER_SHUTDOWN}, exiting...') + break + + logger.trace_if('capi.worker', f'Processing query: {query.endpoint}') + capi_data: CAPIData + try: + if query.endpoint == self._CAPI_PATH_STATION: + capi_data = capi_station_queries() + + else: + capi_data = capi_single_query(self.FRONTIER_CAPI_PATH_PROFILE) + + except Exception as e: + self.capi_response_queue.put( + EDMCCAPIFailedRequest( + message=str(e.args), + exception=e, + query_time=query.query_time, + play_sound=query.play_sound, + auto_update=query.auto_update + ) + ) else: - self.retrying = False - logger.error('Frontier CAPI Auth: HTTP error or invalid JSON') - raise CredentialsError('HTTP error or invalid JSON') from e + self.capi_response_queue.put( + EDMCCAPIResponse( + capi_data=capi_data, + query_time=query.query_time, + play_sound=query.play_sound, + auto_update=query.auto_update + ) + ) - self.retrying = False - if 'timestamp' not in data: - data['timestamp'] = time.strftime('%Y-%m-%dT%H:%M:%SZ', parsedate(r.headers['Date'])) # type: ignore + # If the query came from EDMC.(py|exe) there's no tk to send an + # event too, so assume it will be polling there response queue. + if query.tk_response_event is not None: + logger.trace_if('capi.worker', 'Sending <>') + self.tk_master.event_generate('<>') - # Update Odyssey Suit data - if endpoint == URL_QUERY: - self.suit_update(data) + logger.info('CAPI worker thread DONE') - return data + def capi_query_close_worker(self) -> None: + """Ask the CAPI query thread to finish.""" + self.capi_request_queue.put( + EDMCCAPIRequest( + endpoint=EDMCCAPIRequest.REQUEST_WORKER_SHUTDOWN, + query_time=int(time.time()) + ) + ) - def profile(self) -> CAPIData: - """Perform general CAPI /profile endpoint query.""" - data = self.query(URL_QUERY) - if 'commander' not in data: - logger.error('No commander in returned data') - - return data - - def station(self) -> CAPIData: # noqa: CCR001 + def station( + self, query_time: int, tk_response_event: Optional[str] = None, + play_sound: bool = False, auto_update: bool = False + ) -> None: """ Perform CAPI quer(y|ies) for station data. - A /profile query is performed to check that we are docked (or on foot) - and the station name and marketid match the prior Docked event. - If they do match, and the services list says they're present, also - retrieve CAPI market and/or shipyard/outfitting data and merge into - the /profile data. - - :return: Possibly augmented CAPI data. + :param query_time: When this query was initiated. + :param tk_response_event: Name of tk event to generate when response queued. + :param play_sound: Whether the app should play a sound on error. + :param auto_update: Whether this request was triggered automatically. """ - data = self.query(URL_QUERY) - if 'commander' not in data: - logger.error('No commander in returned data') - return data - - if not data['commander'].get('docked') and not monitor.state['OnFoot']: - return data - - # Sanity checks in case data isn't as we expect, and maybe 'docked' flag - # is also lagging. - if (last_starport := data.get('lastStarport')) is None: - logger.error("No lastStarport in data!") - return data - - if ((last_starport_name := last_starport.get('name')) is None - or last_starport_name == ''): - # This could well be valid if you've been out exploring for a long - # time. - logger.warning("No lastStarport name!") - return data - - # WORKAROUND: n/a | 06-08-2021: Issue 1198 and https://issues.frontierstore.net/issue-detail/40706 - # -- strip "+" chars off star port names returned by the CAPI - last_starport_name = last_starport["name"] = last_starport_name.rstrip(" +") - - services = last_starport.get('services', {}) - if not isinstance(services, dict): - # Odyssey Alpha Phase 3 4.0.0.20 has been observed having - # this be an empty list when you've jumped to another system - # and not yet docked. As opposed to no services key at all - # or an empty dict. - logger.error(f'services is "{type(services)}", not dict !') - if __debug__: - self.dump_capi_data(data) - - # Set an empty dict so as to not have to retest below. - services = {} - - last_starport_id = int(last_starport.get('id')) - - if services.get('commodities'): - marketdata = self.query(URL_MARKET) - if last_starport_id != int(marketdata['id']): - logger.warning(f"{last_starport_id!r} != {int(marketdata['id'])!r}") - raise ServerLagging() - - else: - marketdata['name'] = last_starport_name - data['lastStarport'].update(marketdata) - - if services.get('outfitting') or services.get('shipyard'): - shipdata = self.query(URL_SHIPYARD) - if last_starport_id != int(shipdata['id']): - logger.warning(f"{last_starport_id!r} != {int(shipdata['id'])!r}") - raise ServerLagging() - - else: - shipdata['name'] = last_starport_name - data['lastStarport'].update(shipdata) -# WORKAROUND END - - return data + # Ask the thread worker to perform all three queries + logger.trace_if('capi.worker', 'Enqueueing request') + self.capi_request_queue.put( + EDMCCAPIRequest( + endpoint=self._CAPI_PATH_STATION, + tk_response_event=tk_response_event, + query_time=query_time, + play_sound=play_sound, + auto_update=auto_update + ) + ) + ###################################################################### + ###################################################################### + # Utility functions + ###################################################################### def suit_update(self, data: CAPIData) -> None: """ Update monitor.state suit data. @@ -752,25 +1001,6 @@ class Session(object): else: monitor.state['SuitLoadouts'] = suit_loadouts - def close(self) -> None: - """Close CAPI authorization session.""" - self.state = Session.STATE_INIT - if self.session: - try: - self.session.close() - - except Exception as e: - logger.debug('Frontier CAPI Auth: closing', exc_info=e) - - self.session = None - - def invalidate(self) -> None: - """Invalidate oAuth2 credentials.""" - logger.debug('Forcing a full re-authentication') - # Force a full re-authentication - self.close() - Auth.invalidate(self.credentials['cmdr']) # type: ignore - # noinspection PyMethodMayBeStatic def dump(self, r: requests.Response) -> None: """Log, as error, status of requests.Response from CAPI request.""" @@ -797,13 +1027,17 @@ class Session(object): timestamp = time.strftime('%Y-%m-%dT%H.%M.%S', time.localtime()) with open(f'dump/{system}{station}.{timestamp}.json', 'wb') as h: - h.write(json.dumps(dict(data), + h.write(json.dumps(data, cls=CAPIDataEncoder, ensure_ascii=False, indent=2, sort_keys=True, separators=(',', ': ')).encode('utf-8')) + ###################################################################### +###################################################################### +# Non-class utility functions +###################################################################### def fixup(data: CAPIData) -> CAPIData: # noqa: C901, CCR001 # Can't be usefully simplified """ Fix up commodity names to English & miscellaneous anomalies fixes. @@ -940,6 +1174,7 @@ def index_possibly_sparse_list(data: Union[Mapping[str, V], List[V]], key: int) else: raise ValueError(f'Unexpected data type {type(data)}') +###################################################################### # singleton diff --git a/loadout.py b/loadout.py index 92c9ff3d..dabce54b 100644 --- a/loadout.py +++ b/loadout.py @@ -13,7 +13,10 @@ import util_ships def export(data, filename=None): - string = json.dumps(companion.ship(data), ensure_ascii=False, indent=2, sort_keys=True, separators=(',', ': ')) # pretty print + string = json.dumps( + companion.ship(data), cls=companion.CAPIDataEncoder, + ensure_ascii=False, indent=2, sort_keys=True, separators=(',', ': ') + ) # pretty print if filename: with open(filename, 'wt') as h: diff --git a/plugins/eddn.py b/plugins/eddn.py index 3d0307fa..36a4cb94 100644 --- a/plugins/eddn.py +++ b/plugins/eddn.py @@ -310,6 +310,7 @@ Msg:\n{msg}''' :param data: a dict containing the starport data :param is_beta: whether or not we're currently in beta mode + :param is_odyssey: whether the account shows as having Odyssey expansion. """ commodities: List[OrderedDictT[str, Any]] = [] for commodity in data['lastStarport'].get('commodities') or []: @@ -790,7 +791,6 @@ def journal_entry( # noqa: C901, CCR001 :param state: `dict` - Current `monitor.state` data. :return: `str` - Error message, or `None` if no errors. """ - should_return, new_data = killswitch.check_killswitch('plugins.eddn.journal', entry) if should_return: plug.show_error(_('EDDN journal handler disabled. See Log.')) # LANG: Killswitch disabled EDDN diff --git a/stats.py b/stats.py index b64a3261..89e79d9a 100644 --- a/stats.py +++ b/stats.py @@ -1,5 +1,6 @@ """CMDR Status information.""" import csv +import json import tkinter import tkinter as tk from sys import platform @@ -275,45 +276,41 @@ class StatsDialog(): if not monitor.cmdr: return - # LANG: Fetching data from Frontier CAPI in order to display on File > Status - self.status['text'] = _('Fetching data...') - self.parent.update_idletasks() - - try: - data = companion.session.profile() - - except companion.ServerError as e: - self.status['text'] = str(e) + # TODO: This needs to use cached data + if companion.session.FRONTIER_CAPI_PATH_PROFILE not in companion.session.capi_raw_data: + logger.info('No cached data, aborting...') return - except Exception as e: - logger.exception("error while attempting to show status") - self.status['text'] = str(e) - return + capi_data = json.loads( + companion.session.capi_raw_data[companion.session.FRONTIER_CAPI_PATH_PROFILE].raw_data + ) - if not data.get('commander') or not data['commander'].get('name', '').strip(): + if not capi_data.get('commander') or not capi_data['commander'].get('name', '').strip(): # Shouldn't happen # LANG: Unknown commander self.status['text'] = _("Who are you?!") elif ( - not data.get('lastSystem') - or not data['lastSystem'].get('name', '').strip() - or not data.get('lastStarport') - or not data['lastStarport'].get('name', '').strip() + not capi_data.get('lastSystem') + or not capi_data['lastSystem'].get('name', '').strip() + or not capi_data.get('lastStarport') + or not capi_data['lastStarport'].get('name', '').strip() ): # Shouldn't happen # LANG: Unknown location self.status['text'] = _("Where are you?!") - elif not data.get('ship') or not data['ship'].get('modules') or not data['ship'].get('name', '').strip(): + elif ( + not capi_data.get('ship') or not capi_data['ship'].get('modules') + or not capi_data['ship'].get('name', '').strip() + ): # Shouldn't happen # LANG: Unknown ship self.status['text'] = _("What are you flying?!") else: self.status['text'] = '' - StatsResults(self.parent, data) + StatsResults(self.parent, capi_data) class StatsResults(tk.Toplevel):