diff --git a/EDMarketConnector.py b/EDMarketConnector.py index 5edae5a3..2ce58201 100755 --- a/EDMarketConnector.py +++ b/EDMarketConnector.py @@ -476,6 +476,8 @@ class AppWindow(object): def __init__(self, master: tk.Tk): # noqa: C901, CCR001 # TODO - can possibly factor something out self.capi_query_holdoff_time = config.get_int('querytime', default=0) + companion.capi_query_cooldown + self.capi_fleetcarrier_query_holdoff_time = config.get_int('fleetcarrierquerytime', default=0) \ + + companion.capi_fleetcarrier_query_cooldown self.w = master self.w.title(applongname) @@ -916,6 +918,9 @@ class AppWindow(object): def login(self): """Initiate CAPI/Frontier login and set other necessary state.""" + should_return: bool + new_data: dict[str, Any] + should_return, new_data = killswitch.check_killswitch('capi.auth', {}) if should_return: logger.warning('capi.auth has been disabled via killswitch. Returning.') @@ -1086,6 +1091,54 @@ class AppWindow(object): play_sound=play_sound ) + def capi_request_fleetcarrier_data(self, event=None) -> None: + """ + Perform CAPI fleetcarrier data retrieval and associated actions. + + This is triggered by certain FleetCarrier journal events + + :param event: Tk generated event details. + """ + logger.trace_if('capi.worker', 'Begin') + should_return: bool + new_data: dict[str, Any] + + should_return, new_data = killswitch.check_killswitch('capi.request.fleetcarrier', {}) + if should_return: + logger.warning('capi.fleetcarrier has been disabled via killswitch. Returning.') + self.status['text'] = 'CAPI fleetcarrier disabled by killswitch' + hotkeymgr.play_bad() + return + + 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 monitor.state['GameVersion'] is None: + logger.trace_if('capi.worker', 'Aborting Query: GameVersion unknown') + # LANG: CAPI queries aborted because GameVersion unknown + self.status['text'] = _('CAPI query aborted: GameVersion unknown') + return + + if not companion.session.retrying: + if time() < self.capi_fleetcarrier_query_holdoff_time: # Was invoked while in cooldown + logger.debug('CAPI fleetcarrier query aborted, too soon since last request') + return + + # LANG: Status - Attempting to retrieve data from Frontier CAPI + self.status['text'] = _('Fetching data...') + self.w.update_idletasks() + + query_time = int(time()) + logger.trace_if('capi.worker', 'Requesting fleetcarrier data') + config.set('fleetcarrierquerytime', query_time) + logger.trace_if('capi.worker', 'Calling companion.session.fleetcarrier') + companion.session.fleetcarrier( + query_time=query_time, tk_response_event=self._CAPI_RESPONSE_TK_EVENT_NAME + ) + 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') @@ -1110,8 +1163,32 @@ class AppWindow(object): logger.error(msg) raise ValueError(msg) + if capi_response.capi_data.source_endpoint == companion.session.FRONTIER_CAPI_PATH_FLEETCARRIER: + # Fleetcarrier CAPI response + # Validation + if 'name' not in capi_response.capi_data: + # LANG: No data was returned for the fleetcarrier from the Frontier CAPI + err = self.status['text'] = _('CAPI: No fleetcarrier data returned') + + elif not capi_response.capi_data.get('name', {}).get('callsign'): + # LANG: We didn't have the fleetcarrier callsign when we should have + err = self.status['text'] = _("CAPI: Fleetcarrier data incomplete") # Shouldn't happen + + else: + if __debug__: # Recording + companion.session.dump_capi_data(capi_response.capi_data) + + err = plug.notify_capi_fleetcarrierdata(capi_response.capi_data) + self.status['text'] = err and err or '' + if err: + play_bad = True + + self.capi_fleetcarrier_query_holdoff_time = capi_response.query_time \ + + companion.capi_fleetcarrier_query_cooldown + + # Other CAPI response # Validation - if 'commander' not in capi_response.capi_data: + elif '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') @@ -1234,6 +1311,9 @@ class AppWindow(object): if err: play_bad = True + should_return: bool + new_data: dict[str, Any] + should_return, new_data = killswitch.check_killswitch('capi.request./market', {}) if should_return: logger.warning("capi.request./market has been disabled by killswitch. Returning.") @@ -1494,11 +1574,19 @@ class AppWindow(object): elif entry['event'] == 'Disembark' and entry.get('Taxi') and entry.get('OnStation'): auto_update = True + should_return: bool + new_data: dict[str, Any] + if auto_update: should_return, new_data = killswitch.check_killswitch('capi.auth', {}) if not should_return: self.w.after(int(SERVER_RETRY * 1000), self.capi_request_data) + if entry['event'] in ('CarrierBuy', 'CarrierStats') and config.get_bool('capi_fleetcarrier'): + should_return, new_data = killswitch.check_killswitch('capi.request.fleetcarrier', {}) + if not should_return: + self.w.after(int(SERVER_RETRY * 1000), self.capi_request_fleetcarrier_data) + if entry['event'] == 'ShutDown': # Enable WinSparkle automatic update checks # NB: Do this blindly, in case option got changed whilst in-game diff --git a/PLUGINS.md b/PLUGINS.md index dd71ca28..eb5cf57c 100644 --- a/PLUGINS.md +++ b/PLUGINS.md @@ -99,8 +99,8 @@ liable to change without notice. `from prefs import prefsVersion` - to allow for versioned preferences. `from companion import CAPIData, SERVER_LIVE, SERVER_LEGACY, SERVER_BETA` - -`CAPIData` is the actual type of `data` as passed into `cmdr_data()` and -`cmdr_data_legacy()`. +`CAPIData` is the actual type of `data` as passed into `cmdr_data()`, +`cmdr_data_legacy()` and `capi_fleetcarrier()`. See [Commander Data from Frontier CAPI](#commander-data-from-frontier-capi)) for further information. @@ -561,9 +561,9 @@ See [Avoiding potential pitfalls](#avoiding-potential-pitfalls). ### Events Once you have created your plugin and EDMarketConnector has loaded it there -are four other functions you can define to be notified by EDMarketConnector +are five other functions you can define to be notified by EDMarketConnector when something happens: `journal_entry()`, `journal_entry_cqc()`, -`dashboard_entry()` and `cmdr_data()`. +`dashboard_entry()`, `cmdr_data()` and `capi_fleetcarrier()`. Your events all get called on the main Tkinter loop so be sure not to block for very long or the app will appear to freeze. If you have a long running @@ -908,11 +908,11 @@ constants. --- -### Commander Data from Frontier CAPI -If a plugin has a `cmdr_data()` function it gets called when the application -has just fetched fresh Cmdr and station data from Frontier's CAPI servers, -**but not for the Legacy galaxy**. See `cmdr_data_legacy()` below for Legacy -data handling. +### Data from Frontier CAPI + +#### Commander, Market and Shipyard Data + +If a plugin has a `cmdr_data()` function it gets called when the application has just fetched fresh CMDR, station and shipyard data from Frontier's CAPI servers, **but not for the Legacy galaxy**. See `cmdr_data_legacy()` below for Legacy data handling. ```python from companion import CAPIData, SERVER_LIVE, SERVER_LEGACY, SERVER_BETA @@ -940,29 +940,59 @@ def cmdr_data(data, is_beta): | :-------- | :--------------: | :------------------------------------------------------------------------------------------------------- | | `data` | `CAPIData` | `/profile` API response, with `/market` and `/shipyard` added under the keys `marketdata` and `shipdata` | | `is_beta` | `bool` | If the game is currently in beta | -`CAPIData` is a class, which you can `from companion import CAPIDATA`, and is -based on `UserDict`. The actual data from CAPI queries is thus accessible -via python's normal `data['key']` syntax. However, being a class, it can also -have extra properties, such as `source_host`, as shown above. Plugin authors -are free to use *that* property, **but MUST NOT rely on any other extra -properties present in `CAPIData`, they are for internal use only.** -The contents of `data` will always have at least the data returned by a CAPI -`/profile` query. If the player is docked at a station, and the relevant -services are available then the `lastStarport` key's value will have been -augmented with `/market` and/or `/shipyard` data. **But do not assume this -will always be the case**. +#### Fleet Carrier Data + +If a plugin has a `capi_fleetcarrier()` function it gets called when the application has just fetched fresh Fleetcarrier data from Frontier's CAPI servers. This is done when `CarrierBuy`or `CarrierStats` events are detected in the Player Journal. To avoid flooding Frontier's CAPI server, a throttle is applied to ensure a significant interval between requests (currently 15 mins). Also be aware that calls to the `/fleetcarrier` CAPI endpoint have been reported to take a very long time to return, potentially up to 20 minutes. Delays in responses from this endpoint could delay other CAPI queries. + +```python +from companion import CAPIData, SERVER_LIVE, SERVER_LEGACY, SERVER_BETA + +def capi_fleetcarrier(data): + """ + We have new data on our Fleet Carrier + """ + if data.get('name') is None or data['name'].get('callsign') is None: + raise ValueError("this isn't possible") + + logger.info(data['name']['callsign']) + + # Determining source galaxy for the data + if data.source_host == SERVER_LIVE: + ... + + elif data.source_host == SERVER_BETA: + ... + + elif data.source_host == SERVER_LEGACY: + ... +``` + +| Parameter | Type | Description | +| :-------- | :--------------: | :------------------------------------------------------------------------------------------------------- | +| `data` | `CAPIData` | `/fleetcarrier` API response | + +#### CAPIData and Available Properties + +`CAPIData` is a class, which you can `from companion import CAPIDATA`, and is based on `UserDict`. The actual data from CAPI queries is thus accessible via python's normal `data['key']` syntax. However, being a class, it can also have extra properties, such as `source_host`, as shown in the code example above. + +Plugin authors are free to use the following properties of `CAPIData`, **but MUST NOT rely on any other extra properties, they are for internal use only.** + +| Property | Type | Description | +| :------------- | :--------------: | :------------------------------------------------------------------------------------------------------- | +| `data` | `Dict` | The data returned by the CAPI query. For the `cmdr_data()` callback, if the player is docked at a station, and the relevant services are available then the `lastStarport` key's value will have been augmented with `/market` and/or `/shipyard` data. **Do not assume this will always be the case**. | +| `source_host` | `str` | `SERVER_LIVE` \| `SERVER_BETA` \| `SERVER_LEGACY` the current galaxy mode. | +| `request_cmdr` | `str` | The name of the active CMDR _at the point the request was made_. In the case of a CAPI request taking a long time to return, the user may have switched CMDR during the request, so this may be different to the current CMDR. | + +See [this documentation](https://github.com/Athanasius/fd-api/blob/main/docs/FrontierDevelopments-CAPI-endpoints.md) for details of the expected content structure and data for CAPI queries. If there is a killswitch in effect for some of the CAPI endpoints, then the data passed to this function might not be as complete as you expect. Code defensively. - #### CAPI data for Legacy -When CAPI data has been retrieved from the separate CAPI host for the Legacy -galaxy, because the Journal gameversion indicated the player is playing/last -played in that galaxy, a different function will be called, -`cmdr_data_legacy()`. + +When CAPI data has been retrieved from the separate CAPI host for the Legacy galaxy, because the Journal gameversion indicated the player is playing / last played in that galaxy, a different function will be called, `cmdr_data_legacy()`. Note that there is no legacy equivalent to `capi_fleetcarrier()`, so always use the `source_host` property to determine the user's galaxy. ```python def cmdr_data_legacy(data, is_beta): @@ -1082,7 +1112,7 @@ time after the corresponding `journal_entry()` event. ## Error messages You can display an error in EDMarketConnector's status area by returning a -string from your `journal_entry()`, `dashboard_entry()` or `cmdr_data()` +string from your `journal_entry()`, `dashboard_entry()`, `cmdr_data()` or `capi_fleetcarrier()` function, or asynchronously (e.g. from a "worker" thread that is performing a long-running operation) by calling `plug.show_error()`. Either method will cause the "bad" sound to be played (unless the user has muted sound). @@ -1311,7 +1341,7 @@ versions of EDMarketConnector: `Settings` > `Plugins` tab. - Check that callback functions `plugin_prefs`, `prefs_changed`, - `journal_entry`, `dashboard_entry` and `cmdr_data`, if used, are declared + `journal_entry`, `dashboard_entry`, `cmdr_data` and `capi_fleetcarrier`, if used, are declared with the correct number of arguments. Older versions of this app were tolerant of missing arguments in these function declarations. diff --git a/companion.py b/companion.py index c89e6319..b7ccd093 100644 --- a/companion.py +++ b/companion.py @@ -46,7 +46,9 @@ else: capi_query_cooldown = 60 # Minimum time between (sets of) CAPI queries +capi_fleetcarrier_query_cooldown = 60 * 15 # Minimum time between CAPI fleetcarrier queries capi_default_requests_timeout = 10 +capi_fleetcarrier_requests_timeout = 60 auth_timeout = 30 # timeout for initial auth # Used by both class Auth and Session @@ -66,7 +68,8 @@ class CAPIData(UserDict): self, data: Union[str, Dict[str, Any], 'CAPIData', None] = None, source_host: Optional[str] = None, - source_endpoint: Optional[str] = None + source_endpoint: Optional[str] = None, + request_cmdr: Optional[str] = None ) -> None: if data is None: super().__init__() @@ -81,6 +84,7 @@ class CAPIData(UserDict): self.source_host = source_host self.source_endpoint = source_endpoint + self.request_cmdr = request_cmdr if source_endpoint is None: return @@ -326,7 +330,10 @@ class Auth(object): """ logger.debug(f'Trying for "{self.cmdr}"') - should_return, _ = killswitch.check_killswitch('capi.auth', {}) + should_return: bool + new_data: dict[str, Any] + + should_return, new_data = killswitch.check_killswitch('capi.auth', {}) if should_return: logger.warning('capi.auth has been disabled via killswitch. Returning.') return None @@ -612,6 +619,8 @@ class Session(object): FRONTIER_CAPI_PATH_PROFILE = '/profile' FRONTIER_CAPI_PATH_MARKET = '/market' FRONTIER_CAPI_PATH_SHIPYARD = '/shipyard' + FRONTIER_CAPI_PATH_FLEETCARRIER = '/fleetcarrier' + # This is a dummy value, to signal to Session.capi_query_worker that we # the 'station' triplet of queries. _CAPI_PATH_STATION = '_edmc_station' @@ -663,7 +672,10 @@ class Session(object): :return: True if login succeeded, False if re-authorization initiated. """ - should_return, _ = killswitch.check_killswitch('capi.auth', {}) + should_return: bool + new_data: dict[str, Any] + + should_return, new_data = killswitch.check_killswitch('capi.auth', {}) if should_return: logger.warning('capi.auth has been disabled via killswitch. Returning.') return False @@ -781,7 +793,10 @@ class Session(object): :return: The resulting CAPI data, of type CAPIData. """ capi_data: CAPIData = CAPIData() - should_return, _ = killswitch.check_killswitch('capi.request.' + capi_endpoint, {}) + should_return: bool + new_data: dict[str, Any] + + should_return, new_data = killswitch.check_killswitch('capi.request.' + capi_endpoint, {}) if should_return: logger.warning(f"capi.request.{capi_endpoint} has been disabled by killswitch. Returning.") return capi_data @@ -804,7 +819,7 @@ class Session(object): # r.status_code = 401 # raise requests.HTTPError capi_json = r.json() - capi_data = CAPIData(capi_json, capi_host, capi_endpoint) + capi_data = CAPIData(capi_json, capi_host, capi_endpoint, monitor.cmdr) self.capi_raw_data.record_endpoint( capi_endpoint, r.content.decode(encoding='utf-8'), datetime.datetime.utcnow() @@ -956,6 +971,10 @@ class Session(object): if query.endpoint == self._CAPI_PATH_STATION: capi_data = capi_station_queries(query.capi_host) + elif query.endpoint == self.FRONTIER_CAPI_PATH_FLEETCARRIER: + capi_data = capi_single_query(query.capi_host, self.FRONTIER_CAPI_PATH_FLEETCARRIER, + timeout=capi_fleetcarrier_requests_timeout) + else: capi_data = capi_single_query(query.capi_host, self.FRONTIER_CAPI_PATH_PROFILE) @@ -1027,6 +1046,35 @@ class Session(object): auto_update=auto_update ) ) + + def fleetcarrier( + self, query_time: int, tk_response_event: Optional[str] = None, + play_sound: bool = False, auto_update: bool = False + ) -> None: + """ + Perform CAPI query for fleetcarrier 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. + """ + capi_host = self.capi_host_for_galaxy() + if not capi_host: + return + + # Ask the thread worker to perform a fleetcarrier query + logger.trace_if('capi.worker', 'Enqueueing fleetcarrier request') + self.capi_request_queue.put( + EDMCCAPIRequest( + capi_host=capi_host, + endpoint=self.FRONTIER_CAPI_PATH_FLEETCARRIER, + tk_response_event=tk_response_event, + query_time=query_time, + play_sound=play_sound, + auto_update=auto_update + ) + ) ###################################################################### ###################################################################### diff --git a/plug.py b/plug.py index 92777acf..d28ce4e6 100644 --- a/plug.py +++ b/plug.py @@ -405,6 +405,30 @@ def notify_capidata( return error +def notify_capi_fleetcarrierdata( + data: companion.CAPIData +) -> Optional[str]: + """ + Send the latest CAPI Fleetcarrier data from the FD servers to each plugin. + + :param data: The CAPIData returned in the CAPI response + :returns: Error message from the first plugin that returns one (if any) + """ + error = None + for plugin in PLUGINS: + fc_callback = plugin._get_func('capi_fleetcarrier') + if fc_callback is not None and callable(fc_callback): + try: + # Pass a copy of the CAPIData in case the callee modifies it + newerror = fc_callback(copy.deepcopy(data)) + error = error or newerror + + except Exception: + logger.exception(f'Plugin "{plugin.name}" failed on receiving Fleetcarrier data') + + return error + + def show_error(err: str) -> None: """ Display an error message in the status line of the main window. diff --git a/plugins/eddb.py b/plugins/eddb.py index e050df32..55ba4be3 100644 --- a/plugins/eddb.py +++ b/plugins/eddb.py @@ -42,7 +42,7 @@ # ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# # ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# import tkinter -from typing import TYPE_CHECKING, Any, Mapping, MutableMapping, Optional +from typing import TYPE_CHECKING, Any, Dict, Mapping, MutableMapping, Optional import requests @@ -168,6 +168,9 @@ def journal_entry( # noqa: CCR001 :param state: `monitor.state` :return: None if no error, else an error string. """ + should_return: bool + new_entry: Dict[str, Any] = {} + should_return, new_entry = killswitch.check_killswitch('plugins.eddb.journal', entry) if should_return: # LANG: Journal Processing disabled due to an active killswitch diff --git a/plugins/eddn.py b/plugins/eddn.py index f6591779..96c839ad 100644 --- a/plugins/eddn.py +++ b/plugins/eddn.py @@ -402,6 +402,9 @@ class EDDNSender: :return: `True` for "now remove this message from the queue" """ logger.trace_if("plugin.eddn.send", "Sending message") + should_return: bool + new_data: dict[str, Any] + should_return, new_data = killswitch.check_killswitch('plugins.eddn.send', json.loads(msg)) if should_return: logger.warning('eddn.send has been disabled via killswitch. Returning.') diff --git a/plugins/inara.py b/plugins/inara.py index 51b7e270..15fdea5b 100644 --- a/plugins/inara.py +++ b/plugins/inara.py @@ -386,6 +386,9 @@ def journal_entry( # noqa: C901, CCR001 return '' + should_return: bool + new_entry: Dict[str, Any] = {} + should_return, new_entry = killswitch.check_killswitch('plugins.inara.journal', entry, logger) if should_return: plug.show_error(_('Inara disabled. See Log.')) # LANG: INARA support disabled via killswitch @@ -1537,7 +1540,6 @@ def new_add_event( def clean_event_list(event_list: List[Event]) -> List[Event]: """Check for killswitched events and remove or modify them as requested.""" out = [] - for e in event_list: bad, new_event = killswitch.check_killswitch(f'plugins.inara.worker.{e.name}', e.data, logger) if bad: diff --git a/prefs.py b/prefs.py index e5ab242b..a356848b 100644 --- a/prefs.py +++ b/prefs.py @@ -470,6 +470,25 @@ class PreferencesDialog(tk.Toplevel): state=tk.NORMAL if config.get_str('journaldir') else tk.DISABLED ).grid(column=2, pady=self.PADY, sticky=tk.EW, row=row.get()) + # CAPI settings + self.capi_fleetcarrier = tk.BooleanVar(value=config.get_bool('capi_fleetcarrier')) + + ttk.Separator(config_frame, orient=tk.HORIZONTAL).grid( + columnspan=4, padx=self.PADX, pady=self.PADY*4, sticky=tk.EW, row=row.get() + ) + + nb.Label( + config_frame, + text=_('CAPI Settings') # LANG: Settings > Configuration - Label for CAPI section + ).grid(padx=self.PADX, sticky=tk.W, row=row.get()) + + nb.Checkbutton( + config_frame, + # LANG: Configuration - Enable or disable the Fleet Carrier CAPI calls + text=_('Enable Fleetcarrier CAPI Queries'), + variable=self.capi_fleetcarrier + ).grid(columnspan=4, padx=self.PADX, pady=(5, 0), sticky=tk.W, row=row.get()) + if sys.platform in ('darwin', 'win32'): ttk.Separator(config_frame, orient=tk.HORIZONTAL).grid( columnspan=4, padx=self.PADX, pady=self.PADY*4, sticky=tk.EW, row=row.get() @@ -1244,6 +1263,8 @@ class PreferencesDialog(tk.Toplevel): else: config.set('journaldir', logdir) + config.set('capi_fleetcarrier', self.capi_fleetcarrier.get()) + if sys.platform in ('darwin', 'win32'): config.set('hotkey_code', self.hotkey_code) config.set('hotkey_mods', self.hotkey_mods)