# # System display and EDSM lookup # # TODO: # 1) Re-factor EDSM API calls out of journal_entry() into own function. # 2) Fix how StartJump already changes things, but only partially. # 3) Possibly this and other two 'provider' plugins could do with being # based on a single class that they extend. There's a lot of duplicated # logic. # 4) Ensure the EDSM API call(back) for setting the image at end of system # text is always fired. i.e. CAPI cmdr_data() processing. import json import requests import sys from queue import Queue from threading import Thread import logging import tkinter as tk from ttkHyperlinkLabel import HyperlinkLabel import myNotebook as nb # noqa: N813 from config import appname, applongname, appversion, config import plug logger = logging.getLogger(appname) EDSM_POLL = 0.1 _TIMEOUT = 20 this = sys.modules[__name__] # For holding module globals this.session = requests.Session() this.queue = Queue() # Items to be sent to EDSM by worker thread this.discardedEvents = [] # List discarded events from EDSM this.lastlookup = False # whether the last lookup succeeded # Game state this.multicrew = False # don't send captain's ship info to EDSM while on a crew this.coordinates = None this.newgame = False # starting up - batch initial burst of events this.newgame_docked = False # starting up while docked this.navbeaconscan = 0 # batch up burst of Scan events after NavBeaconScan this.system_link = None this.system = None this.system_address = None # Frontier SystemAddress this.system_population = None this.station_link = None this.station = None this.station_marketid = None # Frontier MarketID STATION_UNDOCKED: str = '×' # "Station" name to display when not docked = U+00D7 # Main window clicks def system_url(system_name): if this.system_address: return requests.utils.requote_uri(f'https://www.edsm.net/en/system?systemID64={this.system_address}') if system_name: return requests.utils.requote_uri(f'https://www.edsm.net/en/system?systemName={system_name}') return '' def station_url(system_name, station_name): if system_name and station_name: return requests.utils.requote_uri(f'https://www.edsm.net/en/system?systemName={system_name}&stationName={station_name}') # monitor state might think these are gone, but we don't yet if this.system and this.station: return requests.utils.requote_uri(f'https://www.edsm.net/en/system?systemName={this.system}&stationName={this.station}') if system_name: return requests.utils.requote_uri(f'https://www.edsm.net/en/system?systemName={system_name}&stationName=ALL') return '' def plugin_start3(plugin_dir): # Can't be earlier since can only call PhotoImage after window is created this._IMG_KNOWN = tk.PhotoImage(data = 'R0lGODlhEAAQAMIEAFWjVVWkVWS/ZGfFZ////////////////yH5BAEKAAQALAAAAAAQABAAAAMvSLrc/lAFIUIkYOgNXt5g14Dk0AQlaC1CuglM6w7wgs7rMpvNV4q932VSuRiPjQQAOw==') # green circle this._IMG_UNKNOWN = tk.PhotoImage(data = 'R0lGODlhEAAQAKEDAGVLJ+ddWO5fW////yH5BAEKAAMALAAAAAAQABAAAAItnI+pywYRQBtA2CtVvTwjDgrJFlreEJRXgKSqwB5keQ6vOKq1E+7IE5kIh4kCADs=') # red circle this._IMG_NEW = tk.PhotoImage(data = 'R0lGODlhEAAQAMZwANKVHtWcIteiHuiqLPCuHOS1MN22ZeW7ROG6Zuu9MOy+K/i8Kf/DAuvCVf/FAP3BNf/JCf/KAPHHSv7ESObHdv/MBv/GRv/LGP/QBPXOPvjPQfjQSvbRSP/UGPLSae7Sfv/YNvLXgPbZhP7dU//iI//mAP/jH//kFv7fU//fV//ebv/iTf/iUv/kTf/iZ/vgiP/hc/vgjv/jbfriiPriiv7ka//if//jd//sJP/oT//tHv/mZv/sLf/rRP/oYv/rUv/paP/mhv/sS//oc//lkf/mif/sUf/uPv/qcv/uTv/uUv/vUP/qhP/xP//pm//ua//sf//ubf/wXv/thv/tif/slv/tjf/smf/yYP/ulf/2R//2Sv/xkP/2av/0gP/ylf/2df/0i//0j//0lP/5cP/7a//1p//5gf/7ev/3o//2sf/5mP/6kv/2vP/3y//+jP///////////////////////////////////////////////////////////////yH5BAEKAH8ALAAAAAAQABAAAAePgH+Cg4SFhoJKPIeHYT+LhVppUTiPg2hrUkKPXWdlb2xHJk9jXoNJQDk9TVtkYCUkOy4wNjdGfy1UXGJYOksnPiwgFwwYg0NubWpmX1ArHREOFYUyWVNIVkxXQSoQhyMoNVUpRU5EixkcMzQaGy8xhwsKHiEfBQkSIg+GBAcUCIIBBDSYYGiAAUMALFR6FAgAOw==') this._IMG_ERROR = tk.PhotoImage(data = 'R0lGODlhEAAQAKEBAAAAAP///////////yH5BAEKAAIALAAAAAAQABAAAAIwlBWpeR0AIwwNPRmZuVNJinyWuClhBlZjpm5fqnIAHJPtOd3Hou9mL6NVgj2LplEAADs=') # BBC Mode 5 '?' # Migrate old settings if not config.get('edsm_cmdrs'): if isinstance(config.get('cmdrs'), list) and config.get('edsm_usernames') and config.get('edsm_apikeys'): # Migrate <= 2.34 settings config.set('edsm_cmdrs', config.get('cmdrs')) elif config.get('edsm_cmdrname'): # Migrate <= 2.25 settings. edsm_cmdrs is unknown at this time config.set('edsm_usernames', [config.get('edsm_cmdrname') or '']) config.set('edsm_apikeys', [config.get('edsm_apikey') or '']) config.delete('edsm_cmdrname') config.delete('edsm_apikey') if config.getint('output') & 256: # Migrate <= 2.34 setting config.set('edsm_out', 1) config.delete('edsm_autoopen') config.delete('edsm_historical') this.thread = Thread(target = worker, name = 'EDSM worker') this.thread.daemon = True this.thread.start() return 'EDSM' def plugin_app(parent): this.system_link = parent.children['system'] # system label in main window this.system_link.bind_all('<>', update_status) this.station_link = parent.children['station'] # station label in main window def plugin_stop(): # Signal thread to close and wait for it this.queue.put(None) this.thread.join() this.thread = None # Suppress 'Exception ignored in: ' errors this._IMG_KNOWN = this._IMG_UNKNOWN = this._IMG_NEW = this._IMG_ERROR = None def plugin_prefs(parent, cmdr, is_beta): PADX = 10 BUTTONX = 12 # indent Checkbuttons and Radiobuttons PADY = 2 # close spacing frame = nb.Frame(parent) frame.columnconfigure(1, weight=1) HyperlinkLabel(frame, text='Elite Dangerous Star Map', background=nb.Label().cget('background'), url='https://www.edsm.net/', underline=True).grid(columnspan=2, padx=PADX, sticky=tk.W) # Don't translate this.log = tk.IntVar(value = config.getint('edsm_out') and 1) this.log_button = nb.Checkbutton(frame, text=_('Send flight log and Cmdr status to EDSM'), variable=this.log, command=prefsvarchanged) this.log_button.grid(columnspan=2, padx=BUTTONX, pady=(5,0), sticky=tk.W) nb.Label(frame).grid(sticky=tk.W) # big spacer this.label = HyperlinkLabel(frame, text=_('Elite Dangerous Star Map credentials'), background=nb.Label().cget('background'), url='https://www.edsm.net/settings/api', underline=True) # Section heading in settings this.label.grid(columnspan=2, padx=PADX, sticky=tk.W) this.cmdr_label = nb.Label(frame, text=_('Cmdr')) # Main window this.cmdr_label.grid(row=10, padx=PADX, sticky=tk.W) this.cmdr_text = nb.Label(frame) this.cmdr_text.grid(row=10, column=1, padx=PADX, pady=PADY, sticky=tk.W) this.user_label = nb.Label(frame, text=_('Commander Name')) # EDSM setting this.user_label.grid(row=11, padx=PADX, sticky=tk.W) this.user = nb.Entry(frame) this.user.grid(row=11, column=1, padx=PADX, pady=PADY, sticky=tk.EW) this.apikey_label = nb.Label(frame, text=_('API Key')) # EDSM setting this.apikey_label.grid(row=12, padx=PADX, sticky=tk.W) this.apikey = nb.Entry(frame) this.apikey.grid(row=12, column=1, padx=PADX, pady=PADY, sticky=tk.EW) prefs_cmdr_changed(cmdr, is_beta) return frame def prefs_cmdr_changed(cmdr, is_beta): this.log_button['state'] = cmdr and not is_beta and tk.NORMAL or tk.DISABLED this.user['state'] = tk.NORMAL this.user.delete(0, tk.END) this.apikey['state'] = tk.NORMAL this.apikey.delete(0, tk.END) if cmdr: this.cmdr_text['text'] = cmdr + (is_beta and ' [Beta]' or '') cred = credentials(cmdr) if cred: this.user.insert(0, cred[0]) this.apikey.insert(0, cred[1]) else: this.cmdr_text['text'] = _('None') # No hotkey/shortcut currently defined this.label['state'] = this.cmdr_label['state'] = this.cmdr_text['state'] = this.user_label['state'] = this.user['state'] = this.apikey_label['state'] = this.apikey['state'] = cmdr and not is_beta and this.log.get() and tk.NORMAL or tk.DISABLED def prefsvarchanged(): this.label['state'] = this.cmdr_label['state'] = this.cmdr_text['state'] = this.user_label['state'] = this.user['state'] = this.apikey_label['state'] = this.apikey['state'] = this.log.get() and this.log_button['state'] or tk.DISABLED def prefs_changed(cmdr, is_beta): config.set('edsm_out', this.log.get()) if cmdr and not is_beta: cmdrs = config.get('edsm_cmdrs') usernames = config.get('edsm_usernames') or [] apikeys = config.get('edsm_apikeys') or [] if cmdr in cmdrs: idx = cmdrs.index(cmdr) usernames.extend([''] * (1 + idx - len(usernames))) usernames[idx] = this.user.get().strip() apikeys.extend([''] * (1 + idx - len(apikeys))) apikeys[idx] = this.apikey.get().strip() else: config.set('edsm_cmdrs', cmdrs + [cmdr]) usernames.append(this.user.get().strip()) apikeys.append(this.apikey.get().strip()) config.set('edsm_usernames', usernames) config.set('edsm_apikeys', apikeys) def credentials(cmdr): # Credentials for cmdr if not cmdr: return None cmdrs = config.get('edsm_cmdrs') if not cmdrs: # Migrate from <= 2.25 cmdrs = [cmdr] config.set('edsm_cmdrs', cmdrs) if cmdr in cmdrs and config.get('edsm_usernames') and config.get('edsm_apikeys'): idx = cmdrs.index(cmdr) return (config.get('edsm_usernames')[idx], config.get('edsm_apikeys')[idx]) else: return None def journal_entry(cmdr, is_beta, system, station, entry, state): # Always update our system address even if we're not currently the provider for system or station, but dont update # on events that contain "future" data, such as FSDTarget if entry['event'] in ('Location', 'Docked', 'CarrierJump', 'FSDJump'): this.system_address = entry.get('SystemAddress') or this.system_address this.system = entry.get('StarSystem') or this.system # We need pop == 0 to set the value so as to clear 'x' in systems with # no stations. pop = entry.get('Population') if pop is not None: this.system_population = pop this.station = entry.get('StationName') or this.station this.station_marketid = entry.get('MarketID') or this.station_marketid # We might pick up StationName in DockingRequested, make sure we clear it if leaving if entry['event'] in ('Undocked', 'FSDJump', 'SupercruiseEntry'): this.station = None this.station_marketid = None if config.get('station_provider') == 'EDSM': this.station_link['text'] = this.station or (this.system_population and this.system_population > 0 and STATION_UNDOCKED or '') this.station_link['url'] = station_url(this.system, this.station) this.station_link.update_idletasks() # Update display of 'EDSM Status' image if this.system_link['text'] != system: this.system_link['text'] = system or '' this.system_link['image'] = '' this.system_link.update_idletasks() this.multicrew = bool(state['Role']) if 'StarPos' in entry: this.coordinates = entry['StarPos'] elif entry['event'] == 'LoadGame': this.coordinates = None if entry['event'] in ['LoadGame', 'Commander', 'NewCommander']: this.newgame = True this.newgame_docked = False this.navbeaconscan = 0 elif entry['event'] == 'StartUp': this.newgame = False this.newgame_docked = False this.navbeaconscan = 0 elif entry['event'] == 'Location': this.newgame = True this.newgame_docked = entry.get('Docked', False) this.navbeaconscan = 0 elif entry['event'] == 'NavBeaconScan': this.navbeaconscan = entry['NumBodies'] # Send interesting events to EDSM if config.getint('edsm_out') and not is_beta and not this.multicrew and credentials(cmdr) and entry['event'] not in this.discardedEvents: # Introduce transient states into the event transient = { '_systemName': system, '_systemCoordinates': this.coordinates, '_stationName': station, '_shipId': state['ShipID'], } entry.update(transient) if entry['event'] == 'LoadGame': # Synthesise Materials events on LoadGame since we will have missed it materials = { 'timestamp': entry['timestamp'], 'event': 'Materials', 'Raw': [ { 'Name': k, 'Count': v } for k,v in state['Raw'].items() ], 'Manufactured': [ { 'Name': k, 'Count': v } for k,v in state['Manufactured'].items() ], 'Encoded': [ { 'Name': k, 'Count': v } for k,v in state['Encoded'].items() ], } materials.update(transient) this.queue.put((cmdr, materials)) this.queue.put((cmdr, entry)) # Update system data def cmdr_data(data, is_beta): system = data['lastSystem']['name'] # Always store initially, even if we're not the *current* system provider. if not this.station_marketid: this.station_marketid = data['commander']['docked'] and data['lastStarport']['id'] # Only trust CAPI if these aren't yet set this.system = this.system or data['lastSystem']['name'] this.station = this.station or data['commander']['docked'] and data['lastStarport']['name'] # TODO: Fire off the EDSM API call to trigger the callback for the icons if config.get('system_provider') == 'EDSM': this.system_link['text'] = this.system # Do *NOT* set 'url' here, as it's set to a function that will call # through correctly. We don't want a static string. this.system_link.update_idletasks() if config.get('station_provider') == 'EDSM': if data['commander']['docked']: this.station_link['text'] = this.station elif data['lastStarport']['name'] and data['lastStarport']['name'] != "": this.station_link['text'] = STATION_UNDOCKED else: this.station_link['text'] = '' # Do *NOT* set 'url' here, as it's set to a function that will call # through correctly. We don't want a static string. this.station_link.update_idletasks() if not this.system_link['text']: this.system_link['text'] = system this.system_link['image'] = '' this.system_link.update_idletasks() # Worker thread def worker(): pending = [] # Unsent events closing = False while True: item = this.queue.get() if item: (cmdr, entry) = item else: closing = True # Try to send any unsent events before we close retrying = 0 while retrying < 3: try: if item and entry['event'] not in this.discardedEvents: pending.append(entry) # Get list of events to discard if not this.discardedEvents: r = this.session.get('https://www.edsm.net/api-journal-v1/discard', timeout=_TIMEOUT) r.raise_for_status() this.discardedEvents = set(r.json()) this.discardedEvents.discard('Docked') # should_send() assumes that we send 'Docked' events assert this.discardedEvents # wouldn't expect this to be empty pending = [x for x in pending if x['event'] not in this.discardedEvents] # Filter out unwanted events if should_send(pending): (username, apikey) = credentials(cmdr) data = { 'commanderName': username.encode('utf-8'), 'apiKey': apikey, 'fromSoftware': applongname, 'fromSoftwareVersion': appversion, 'message': json.dumps(pending, ensure_ascii=False).encode('utf-8'), } r = this.session.post('https://www.edsm.net/api-journal-v1', data=data, timeout=_TIMEOUT) r.raise_for_status() reply = r.json() (msgnum, msg) = reply['msgnum'], reply['msg'] # 1xx = OK # 2xx = fatal error # 3&4xx not generated at top-level # 5xx = error but events saved for later processing if msgnum // 100 == 2: logger.warning(f'EDSM\t{msgnum} {msg}\t{json.dumps(pending, separators = (",", ": "))}') plug.show_error(_('Error: EDSM {MSG}').format(MSG=msg)) else: for e, r in zip(pending, reply['events']): if not closing and e['event'] in ['StartUp', 'Location', 'FSDJump', 'CarrierJump']: # Update main window's system status this.lastlookup = r # calls update_status in main thread this.system_link.event_generate('<>', when="tail") elif r['msgnum'] // 100 != 1: logger.warning(f'EDSM\t{r["msgnum"]} {r["msg"]}\t' f'{json.dumps(e, separators = (",", ": "))}') pending = [] break except Exception as e: logger.debug('Sending API events', exc_info=e) retrying += 1 else: plug.show_error(_("Error: Can't connect to EDSM")) if closing: return # Whether any of the entries should be sent immediately def should_send(entries): # batch up burst of Scan events after NavBeaconScan if this.navbeaconscan: if entries and entries[-1]['event'] == 'Scan': this.navbeaconscan -= 1 if this.navbeaconscan: return False else: assert(False) this.navbeaconscan = 0 for entry in entries: if (entry['event'] == 'Cargo' and not this.newgame_docked) or entry['event'] == 'Docked': # Cargo is the last event on startup, unless starting when docked in which case Docked is the last event this.newgame = False this.newgame_docked = False return True elif this.newgame: pass elif entry['event'] not in ['CommunityGoal', # Spammed periodically 'ModuleBuy', 'ModuleSell', 'ModuleSwap', # will be shortly followed by "Loadout" 'ShipyardBuy', 'ShipyardNew', 'ShipyardSwap']: # " return True return False # Call edsm_notify_system() in this and other interested plugins with EDSM's response to a 'StartUp', 'Location', 'FSDJump' or 'CarrierJump' event def update_status(event=None): for plugin in plug.provides('edsm_notify_system'): plug.invoke(plugin, None, 'edsm_notify_system', this.lastlookup) # Called with EDSM's response to a 'StartUp', 'Location', 'FSDJump' or 'CarrierJump' event. https://www.edsm.net/en/api-journal-v1 # msgnum: 1xx = OK, 2xx = fatal error, 3xx = error, 4xx = ignorable errors. def edsm_notify_system(reply): if not reply: this.system_link['image'] = this._IMG_ERROR plug.show_error(_("Error: Can't connect to EDSM")) elif reply['msgnum'] // 100 not in (1,4): this.system_link['image'] = this._IMG_ERROR plug.show_error(_('Error: EDSM {MSG}').format(MSG=reply['msg'])) elif reply.get('systemCreated'): this.system_link['image'] = this._IMG_NEW else: this.system_link['image'] = this._IMG_KNOWN