# Export to EDDN from collections import OrderedDict import json from os import SEEK_SET, SEEK_CUR, SEEK_END from os.path import exists, join from platform import system import re import requests import sys import uuid import tkinter as tk from ttkHyperlinkLabel import HyperlinkLabel import myNotebook as nb from prefs import prefsVersion if sys.platform != 'win32': from fcntl import lockf, LOCK_EX, LOCK_NB if __debug__: from traceback import print_exc from config import applongname, appversion, config from companion import category_map this = sys.modules[__name__] # For holding module globals # Track location to add to Journal events this.systemaddress = None this.coordinates = None this.planet = None # Avoid duplicates this.marketId = None this.commodities = this.outfitting = this.shipyard = None class EDDN(object): ### SERVER = 'http://localhost:8081' # testing SERVER = 'https://eddn.edcd.io:4430' UPLOAD = '%s/upload/' % SERVER REPLAYPERIOD = 400 # Roughly two messages per second, accounting for send delays [ms] REPLAYFLUSH = 20 # Update log on disk roughly every 10 seconds TIMEOUT= 10 # requests timeout MODULE_RE = re.compile('^Hpt_|^Int_|Armour_', re.IGNORECASE) CANONICALISE_RE = re.compile(r'\$(.+)_name;') def __init__(self, parent): self.parent = parent self.session = requests.Session() self.replayfile = None # For delayed messages self.replaylog = [] def load(self): # Try to obtain exclusive access to the journal cache filename = join(config.app_dir, 'replay.jsonl') try: try: # Try to open existing file self.replayfile = open(filename, 'r+', buffering=1) except: if exists(filename): raise # Couldn't open existing file else: self.replayfile = open(filename, 'w+', buffering=1) # Create file if sys.platform != 'win32': # open for writing is automatically exclusive on Windows lockf(self.replayfile, LOCK_EX|LOCK_NB) except: if __debug__: print_exc() if self.replayfile: self.replayfile.close() self.replayfile = None return False self.replaylog = [line.strip() for line in self.replayfile] return True def flush(self): self.replayfile.seek(0, SEEK_SET) self.replayfile.truncate() for line in self.replaylog: self.replayfile.write('%s\n' % line) self.replayfile.flush() def close(self): if self.replayfile: self.replayfile.close() self.replayfile = None def send(self, cmdr, msg): if config.getint('anonymous'): uploaderID = config.get('uploaderID') if not uploaderID: uploaderID = uuid.uuid4().hex config.set('uploaderID', uploaderID) else: uploaderID = cmdr msg = OrderedDict([ ('$schemaRef', msg['$schemaRef']), ('header', OrderedDict([ ('softwareName', '%s [%s]' % (applongname, sys.platform=='darwin' and "Mac OS" or system())), ('softwareVersion', appversion), ('uploaderID', uploaderID), ])), ('message', msg['message']), ]) r = self.session.post(self.UPLOAD, data=json.dumps(msg), timeout=self.TIMEOUT) if __debug__ and r.status_code != requests.codes.ok: print('Status\t%s' % r.status_code) print('URL\t%s' % r.url) print('Headers\t%s' % r.headers) print('Content:\n%s' % r.text) r.raise_for_status() def sendreplay(self): if not self.replayfile: return # Probably closing app status = self.parent.children['status'] if not self.replaylog: status['text'] = '' return if len(self.replaylog) == 1: status['text'] = _('Sending data to EDDN...') else: status['text'] = '%s [%d]' % (_('Sending data to EDDN...').replace('...',''), len(self.replaylog)) self.parent.update_idletasks() try: cmdr, msg = json.loads(self.replaylog[0], object_pairs_hook=OrderedDict) except: # Couldn't decode - shouldn't happen! if __debug__: print(self.replaylog[0]) print_exc() self.replaylog.pop(0) # Discard and continue else: # Rewrite old schema name if msg['$schemaRef'].startswith('http://schemas.elite-markets.net/eddn/'): msg['$schemaRef'] = 'https://eddn.edcd.io/schemas/' + msg['$schemaRef'][38:] try: self.send(cmdr, msg) self.replaylog.pop(0) if not len(self.replaylog) % self.REPLAYFLUSH: self.flush() except requests.exceptions.RequestException as e: if __debug__: print_exc() status['text'] = _("Error: Can't connect to EDDN") return # stop sending except Exception as e: if __debug__: print_exc() status['text'] = str(e) return # stop sending self.parent.after(self.REPLAYPERIOD, self.sendreplay) def export_commodities(self, data, is_beta): commodities = [] for commodity in data['lastStarport'].get('commodities') or []: if (category_map.get(commodity['categoryname'], True) and # Check marketable not commodity.get('legality')): # check not prohibited commodities.append(OrderedDict([ ('name', commodity['name'].lower()), ('meanPrice', int(commodity['meanPrice'])), ('buyPrice', int(commodity['buyPrice'])), ('stock', int(commodity['stock'])), ('stockBracket', commodity['stockBracket']), ('sellPrice', int(commodity['sellPrice'])), ('demand', int(commodity['demand'])), ('demandBracket', commodity['demandBracket']), ])) if commodity['statusFlags']: commodities[-1]['statusFlags'] = commodity['statusFlags'] commodities.sort(key = lambda c: c['name']) if commodities and this.commodities != commodities: # Don't send empty commodities list - schema won't allow it message = OrderedDict([ ('timestamp', data['timestamp']), ('systemName', data['lastSystem']['name']), ('stationName', data['lastStarport']['name']), ('marketId', data['lastStarport']['id']), ('commodities', commodities), ]) if 'economies' in data['lastStarport']: message['economies'] = sorted(list([x for x in (data['lastStarport']['economies'] or {}).values()]), key=lambda x: x['name']) if 'prohibited' in data['lastStarport']: message['prohibited'] = sorted(list([x for x in (data['lastStarport']['prohibited'] or {}).values()])) self.send(data['commander']['name'], { '$schemaRef' : 'https://eddn.edcd.io/schemas/commodity/3' + (is_beta and '/test' or ''), 'message' : message, }) this.commodities = commodities def export_outfitting(self, data, is_beta): economies = data['lastStarport'].get('economies') or {} modules = data['lastStarport'].get('modules') or {} ships = data['lastStarport'].get('ships') or { 'shipyard_list': {}, 'unavailable_list': [] } # Horizons flag - will hit at least Int_PlanetApproachSuite other than at engineer bases ("Colony"), prison or rescue Megaships, or under Pirate Attack etc horizons = (any(economy['name'] == 'Colony' for economy in economies.values()) or any(module.get('sku') == 'ELITE_HORIZONS_V_PLANETARY_LANDINGS' for module in modules.values()) or any(ship.get('sku') == 'ELITE_HORIZONS_V_PLANETARY_LANDINGS' for ship in list((ships['shipyard_list'] or {}).values()))) outfitting = sorted([self.MODULE_RE.sub(lambda m: m.group(0).capitalize(), module['name'].lower()) for module in modules.values() if self.MODULE_RE.search(module['name']) and module.get('sku') in [None, 'ELITE_HORIZONS_V_PLANETARY_LANDINGS'] and module['name'] != 'Int_PlanetApproachSuite']) if outfitting and this.outfitting != (horizons, outfitting): # Don't send empty modules list - schema won't allow it self.send(data['commander']['name'], { '$schemaRef' : 'https://eddn.edcd.io/schemas/outfitting/2' + (is_beta and '/test' or ''), 'message' : OrderedDict([ ('timestamp', data['timestamp']), ('systemName', data['lastSystem']['name']), ('stationName', data['lastStarport']['name']), ('marketId', data['lastStarport']['id']), ('horizons', horizons), ('modules', outfitting), ]), }) this.outfitting = (horizons, outfitting) def export_shipyard(self, data, is_beta): economies = data['lastStarport'].get('economies') or {} modules = data['lastStarport'].get('modules') or {} ships = data['lastStarport'].get('ships') or { 'shipyard_list': {}, 'unavailable_list': [] } horizons = (any(economy['name'] == 'Colony' for economy in economies.values()) or any(module.get('sku') == 'ELITE_HORIZONS_V_PLANETARY_LANDINGS' for module in modules.values()) or any(ship.get('sku') == 'ELITE_HORIZONS_V_PLANETARY_LANDINGS' for ship in list((ships['shipyard_list'] or {}).values()))) shipyard = sorted([ship['name'].lower() for ship in list((ships['shipyard_list'] or {}).values()) + ships['unavailable_list']]) if shipyard and this.shipyard != (horizons, shipyard): # Don't send empty ships list - shipyard data is only guaranteed present if user has visited the shipyard. self.send(data['commander']['name'], { '$schemaRef' : 'https://eddn.edcd.io/schemas/shipyard/2' + (is_beta and '/test' or ''), 'message' : OrderedDict([ ('timestamp', data['timestamp']), ('systemName', data['lastSystem']['name']), ('stationName', data['lastStarport']['name']), ('marketId', data['lastStarport']['id']), ('horizons', horizons), ('ships', shipyard), ]), }) this.shipyard = (horizons, shipyard) def export_journal_commodities(self, cmdr, is_beta, entry): items = entry.get('Items') or [] commodities = sorted([OrderedDict([ ('name', self.canonicalise(commodity['Name'])), ('meanPrice', commodity['MeanPrice']), ('buyPrice', commodity['BuyPrice']), ('stock', commodity['Stock']), ('stockBracket', commodity['StockBracket']), ('sellPrice', commodity['SellPrice']), ('demand', commodity['Demand']), ('demandBracket', commodity['DemandBracket']), ]) for commodity in items], key = lambda c: c['name']) if commodities and this.commodities != commodities: # Don't send empty commodities list - schema won't allow it self.send(cmdr, { '$schemaRef' : 'https://eddn.edcd.io/schemas/commodity/3' + (is_beta and '/test' or ''), 'message' : OrderedDict([ ('timestamp', entry['timestamp']), ('systemName', entry['StarSystem']), ('stationName', entry['StationName']), ('marketId', entry['MarketID']), ('commodities', commodities), ]), }) this.commodities = commodities def export_journal_outfitting(self, cmdr, is_beta, entry): modules = entry.get('Items') or [] horizons = entry.get('Horizons', False) outfitting = sorted([self.MODULE_RE.sub(lambda m: m.group(0).capitalize(), module['Name']) for module in modules if module['Name'] != 'int_planetapproachsuite']) if outfitting and this.outfitting != (horizons, outfitting): # Don't send empty modules list - schema won't allow it self.send(cmdr, { '$schemaRef' : 'https://eddn.edcd.io/schemas/outfitting/2' + (is_beta and '/test' or ''), 'message' : OrderedDict([ ('timestamp', entry['timestamp']), ('systemName', entry['StarSystem']), ('stationName', entry['StationName']), ('marketId', entry['MarketID']), ('horizons', horizons), ('modules', outfitting), ]), }) this.outfitting = (horizons, outfitting) def export_journal_shipyard(self, cmdr, is_beta, entry): ships = entry.get('PriceList') or [] horizons = entry.get('Horizons', False) shipyard = sorted([ship['ShipType'] for ship in ships]) if shipyard and this.shipyard != (horizons, shipyard): # Don't send empty ships list - shipyard data is only guaranteed present if user has visited the shipyard. self.send(cmdr, { '$schemaRef' : 'https://eddn.edcd.io/schemas/shipyard/2' + (is_beta and '/test' or ''), 'message' : OrderedDict([ ('timestamp', entry['timestamp']), ('systemName', entry['StarSystem']), ('stationName', entry['StationName']), ('marketId', entry['MarketID']), ('horizons', horizons), ('ships', shipyard), ]), }) this.shipyard = (horizons, shipyard) def export_journal_entry(self, cmdr, is_beta, entry): msg = { '$schemaRef' : 'https://eddn.edcd.io/schemas/journal/1' + (is_beta and '/test' or ''), 'message' : entry } if self.replayfile or self.load(): # Store the entry self.replaylog.append(json.dumps([cmdr, msg])) self.replayfile.write('%s\n' % self.replaylog[-1]) if (entry['event'] == 'Docked' or (entry['event'] == 'Location' and entry['Docked']) or not (config.getint('output') & config.OUT_SYS_DELAY)): self.parent.after(self.REPLAYPERIOD, self.sendreplay) # Try to send this and previous entries else: # Can't access replay file! Send immediately. status = self.parent.children['status'] status['text'] = _('Sending data to EDDN...') self.parent.update_idletasks() self.send(cmdr, msg) status['text'] = '' def canonicalise(self, item): match = self.CANONICALISE_RE.match(item) return match and match.group(1) or item # Plugin callbacks def plugin_start3(plugin_dir): return 'EDDN' def plugin_app(parent): this.parent = parent this.eddn = EDDN(parent) # Try to obtain exclusive lock on journal cache, even if we don't need it yet if not this.eddn.load(): this.status['text'] = 'Error: Is another copy of this app already running?' # Shouldn't happen - don't bother localizing def plugin_prefs(parent, cmdr, is_beta): PADX = 10 BUTTONX = 12 # indent Checkbuttons and Radiobuttons PADY = 2 # close spacing if prefsVersion.shouldSetDefaults('0.0.0.0', not bool(config.getint('output'))): output = (config.OUT_MKT_EDDN | config.OUT_SYS_EDDN) # default settings else: output = config.getint('output') eddnframe = nb.Frame(parent) HyperlinkLabel(eddnframe, text='Elite Dangerous Data Network', background=nb.Label().cget('background'), url='https://github.com/EDSM-NET/EDDN/wiki', underline=True).grid(padx=PADX, sticky=tk.W) # Don't translate this.eddn_station= tk.IntVar(value = (output & config.OUT_MKT_EDDN) and 1) this.eddn_station_button = nb.Checkbutton(eddnframe, text=_('Send station data to the Elite Dangerous Data Network'), variable=this.eddn_station, command=prefsvarchanged) # Output setting this.eddn_station_button.grid(padx=BUTTONX, pady=(5,0), sticky=tk.W) this.eddn_system = tk.IntVar(value = (output & config.OUT_SYS_EDDN) and 1) this.eddn_system_button = nb.Checkbutton(eddnframe, text=_('Send system and scan data to the Elite Dangerous Data Network'), variable=this.eddn_system, command=prefsvarchanged) # Output setting new in E:D 2.2 this.eddn_system_button.grid(padx=BUTTONX, pady=(5,0), sticky=tk.W) this.eddn_delay= tk.IntVar(value = (output & config.OUT_SYS_DELAY) and 1) this.eddn_delay_button = nb.Checkbutton(eddnframe, text=_('Delay sending until docked'), variable=this.eddn_delay) # Output setting under 'Send system and scan data to the Elite Dangerous Data Network' new in E:D 2.2 this.eddn_delay_button.grid(padx=BUTTONX, sticky=tk.W) return eddnframe def prefsvarchanged(event=None): this.eddn_station_button['state'] = tk.NORMAL this.eddn_system_button['state']= tk.NORMAL this.eddn_delay_button['state'] = this.eddn.replayfile and this.eddn_system.get() and tk.NORMAL or tk.DISABLED def prefs_changed(cmdr, is_beta): config.set('output', (config.getint('output') & (config.OUT_MKT_TD | config.OUT_MKT_CSV | config.OUT_SHIP |config.OUT_MKT_MANUAL)) + (this.eddn_station.get() and config.OUT_MKT_EDDN) + (this.eddn_system.get() and config.OUT_SYS_EDDN) + (this.eddn_delay.get() and config.OUT_SYS_DELAY)) def plugin_stop(): this.eddn.close() def journal_entry(cmdr, is_beta, system, station, entry, state): # Recursively filter '*_Localised' keys from dict def filter_localised(d): filtered = OrderedDict() for k, v in d.items(): if k.endswith('_Localised'): pass elif hasattr(v, 'items'): # dict -> recurse filtered[k] = filter_localised(v) elif isinstance(v, list): # list of dicts -> recurse filtered[k] = [filter_localised(x) if hasattr(x, 'items') else x for x in v] else: filtered[k] = v return filtered # Track location if entry['event'] in ['Location', 'FSDJump', 'Docked', 'CarrierJump']: if entry['event'] in ('Location', 'CarrierJump'): this.planet = entry.get('Body') if entry.get('BodyType') == 'Planet' else None elif entry['event'] == 'FSDJump': this.planet = None if 'StarPos' in entry: this.coordinates = tuple(entry['StarPos']) elif this.systemaddress != entry.get('SystemAddress'): this.coordinates = None # Docked event doesn't include coordinates this.systemaddress = entry.get('SystemAddress') elif entry['event'] == 'ApproachBody': this.planet = entry['Body'] elif entry['event'] in ['LeaveBody', 'SupercruiseEntry']: this.planet = None # Send interesting events to EDDN, but not when on a crew if (config.getint('output') & config.OUT_SYS_EDDN and not state['Captain'] and (entry['event'] in ('Location', 'FSDJump', 'Docked', 'Scan', 'SAASignalsFound', 'CarrierJump')) and ('StarPos' in entry or this.coordinates)): # strip out properties disallowed by the schema for thing in ['ActiveFine', 'CockpitBreach', 'BoostUsed', 'FuelLevel', 'FuelUsed', 'JumpDist', 'Latitude', 'Longitude', 'Wanted']: entry.pop(thing, None) if 'Factions' in entry: # Filter faction state. `entry` is a shallow copy so replace 'Factions' value rather than modify in-place. entry['Factions'] = [ {k: v for k, v in f.items() if k not in ['HappiestSystem', 'HomeSystem', 'MyReputation', 'SquadronFaction']} for f in entry['Factions']] # add planet to Docked event for planetary stations if known if entry['event'] == 'Docked' and this.planet: entry['Body'] = this.planet entry['BodyType'] = 'Planet' # add mandatory StarSystem, StarPos and SystemAddress properties to Scan events if 'StarSystem' not in entry: if not system: return("system is None, can't add StarSystem") entry['StarSystem'] = system if 'StarPos' not in entry: if not this.coordinates: return("this.coordinates is None, can't add StarPos") entry['StarPos'] = list(this.coordinates) if 'SystemAddress' not in entry: if not this.systemaddress: return("this.systemaddress is None, can't add SystemAddress") entry['SystemAddress'] = this.systemaddress try: this.eddn.export_journal_entry(cmdr, is_beta, filter_localised(entry)) except requests.exceptions.RequestException as e: if __debug__: print_exc() return _("Error: Can't connect to EDDN") except Exception as e: if __debug__: print_exc() return str(e) elif (config.getint('output') & config.OUT_MKT_EDDN and not state['Captain'] and entry['event'] in ['Market', 'Outfitting', 'Shipyard']): try: if this.marketId != entry['MarketID']: this.commodities = this.outfitting = this.shipyard = None this.marketId = entry['MarketID'] with open(join(config.get('journaldir') or config.default_journal_dir, '%s.json' % entry['event']), 'rb') as h: entry = json.load(h) if entry['event'] == 'Market': this.eddn.export_journal_commodities(cmdr, is_beta, entry) elif entry['event'] == 'Outfitting': this.eddn.export_journal_outfitting(cmdr, is_beta, entry) elif entry['event'] == 'Shipyard': this.eddn.export_journal_shipyard(cmdr, is_beta, entry) except requests.exceptions.RequestException as e: if __debug__: print_exc() return _("Error: Can't connect to EDDN") except Exception as e: if __debug__: print_exc() return str(e) def cmdr_data(data, is_beta): if data['commander'].get('docked') and config.getint('output') & config.OUT_MKT_EDDN: try: if this.marketId != data['lastStarport']['id']: this.commodities = this.outfitting = this.shipyard = None this.marketId = data['lastStarport']['id'] status = this.parent.children['status'] old_status = status['text'] if not old_status: status['text'] = _('Sending data to EDDN...') status.update_idletasks() this.eddn.export_commodities(data, is_beta) this.eddn.export_outfitting(data, is_beta) this.eddn.export_shipyard(data, is_beta) if not old_status: status['text'] = '' status.update_idletasks() except requests.RequestException as e: if __debug__: print_exc() return _("Error: Can't connect to EDDN") except Exception as e: if __debug__: print_exc() return str(e)