From 85cae73529fd88b3030034898daa3735166605e9 Mon Sep 17 00:00:00 2001 From: Jonathan Harris Date: Sun, 5 Nov 2017 20:48:07 +0000 Subject: [PATCH] Add support for syncing with Inara --- L10n/en.template | 14 +- README.md | 7 +- plugins/edsm.py | 2 +- plugins/inara.py | 349 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 369 insertions(+), 3 deletions(-) create mode 100644 plugins/inara.py diff --git a/L10n/en.template b/L10n/en.template index 9fef51c7..765908a2 100644 --- a/L10n/en.template +++ b/L10n/en.template @@ -157,6 +157,9 @@ /* [edsm.py] */ "Error: Can't connect to EDSM" = "Error: Can't connect to EDSM"; +/* [inara.py] */ +"Error: Can't connect to Inara" = "Error: Can't connect to Inara"; + /* [edsm.py] */ "Error: EDSM {MSG}" = "Error: EDSM {MSG}"; @@ -169,6 +172,9 @@ /* Raised when the Companion API server thinks that the user has not purchased E:D. i.e. doesn't have the correct 'SKU'. [companion.py] */ "Error: Frontier server SKU problem" = "Error: Frontier server SKU problem"; +/* [inara.py] */ +"Error: Inara {MSG}" = "Error: Inara {MSG}"; + /* [companion.py] */ "Error: Invalid Credentials" = "Error: Invalid Credentials"; @@ -235,6 +241,9 @@ /* Tab heading in settings. [prefs.py] */ "Identity" = "Identity"; +/* Section heading in settings. [inara.py] */ +"Inara credentials" = "Inara credentials"; + /* Hotkey/Shortcut settings prompt on OSX. [prefs.py] */ "Keyboard shortcut" = "Keyboard shortcut"; @@ -431,7 +440,10 @@ "Semi Professional" = "Semi Professional"; /* [edsm.py] */ -"Send flight log to Elite Dangerous Star Map" = "Send flight log to Elite Dangerous Star Map"; +"Send flight log and Cmdr status to EDSM" = "Send flight log and Cmdr status to EDSM"; + +/* [inara.py] */ +"Send flight log and Cmdr status to Inara" = "Send flight log and Cmdr status to Inara"; /* Output setting. [prefs.py] */ "Send station data to the Elite Dangerous Data Network" = "Send station data to the Elite Dangerous Data Network"; diff --git a/README.md b/README.md index b16ea059..c7bd5b95 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ This app downloads your Cmdr's details and system, faction, scan and station dat * saves station commodity market prices to files on your computer that you can load into trading tools such as [Trade Dangerous](https://bitbucket.org/kfsone/tradedangerous/wiki/Home), [Inara](http://inara.cz), [mEDI's Elite Tools](https://github.com/mEDI-S/mEDI_s-Elite-Tools), etc. * saves a record of your ship loadout to files on your computer that you can load into outfitting tools such as [E:D Shipyard](http://www.edshipyard.com), [Coriolis](http://coriolis.io) or [Elite Trade Net](http://etn.io/). * sends your Cmdr's details, ship details, materials and flight log to [Elite: Dangerous Star Map](http://www.edsm.net/). +* sends your Cmdr's details, ship details, materials and flight log to [Inara](https://inara.cz). You can run the app on the same machine on which you're running Elite: Dangerous or on another machine connected via a network share. @@ -85,7 +86,11 @@ Some options work by reading the Elite: Dangerous game's log files. If you're ru ### EDSM -You can send a record of your Cmdr's details, ship details, materials and flight log to [Elite: Dangerous Star Map](http://www.edsm.net/). You will need to register for an account and then follow the “[Elite Dangerous Star Map credentials](http://www.edsm.net/settings/api)” link to obtain your API key. +You can send a record of your Cmdr's details, ship details, cargo, materials and flight log to [Elite: Dangerous Star Map](http://www.edsm.net/). You will need to register for an account and then follow the “[Elite Dangerous Star Map credentials](http://www.edsm.net/settings/api)” link to obtain your API key. + +### Inara + +You can send a record of your Cmdr's details, ship details, cargo, materials and flight log to [Inara](https://inara.cz/). You will need to register for an account and then follow the “[Inara credentials](https://inara.cz/settings-api/)” link to obtain your API key. Uninstall diff --git a/plugins/edsm.py b/plugins/edsm.py index 37ca0cb5..46e028f3 100644 --- a/plugins/edsm.py +++ b/plugins/edsm.py @@ -93,7 +93,7 @@ def plugin_prefs(parent, cmdr, is_beta): 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 to Elite Dangerous Star Map'), variable=this.log, command=prefsvarchanged) + 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 diff --git a/plugins/inara.py b/plugins/inara.py new file mode 100644 index 00000000..47e62af2 --- /dev/null +++ b/plugins/inara.py @@ -0,0 +1,349 @@ +# +# Inara sync +# + +from collections import OrderedDict +import json +import requests +import sys +import time +import urllib2 +from calendar import timegm +from Queue import Queue +from threading import Thread + +import Tkinter as tk +from ttkHyperlinkLabel import HyperlinkLabel +import myNotebook as nb + +from config import appname, applongname, appversion, config +import companion +import coriolis +import edshipyard +import outfitting +import plug + +if __debug__: + from traceback import print_exc + +_TIMEOUT = 20 +FAKE = ['CQC', 'Training', 'Destination'] # Fake systems that shouldn't be sent to Inara + + +this = sys.modules[__name__] # For holding module globals +this.session = requests.Session() +this.queue = Queue() # Items to be sent to Inara by worker thread + +# Game state +this.multicrew = False # don't send captain's ship info to Inara while on a crew + +# Cached Cmdr state +this.location = None +this.cargo = None +this.materials = None + +def plugin_start(): + + # Migrate old settings + if not config.get('inara_cmdrs'): + if isinstance(config.get('cmdrs'), list) and config.get('inara_usernames') and config.get('inara_apikeys'): + # Migrate <= 2.34 settings + config.set('inara_cmdrs', config.get('cmdrs')) + elif config.get('inara_cmdrname'): + # Migrate <= 2.25 settings. inara_cmdrs is unknown at this time + config.set('inara_usernames', [config.get('inara_cmdrname') or '']) + config.set('inara_apikeys', [config.get('inara_apikey') or '']) + config.delete('inara_cmdrname') + config.delete('inara_apikey') + if config.getint('output') & 256: + # Migrate <= 2.34 setting + config.set('inara_out', 1) + config.delete('inara_autoopen') + config.delete('inara_historical') + + this.thread = Thread(target = worker, name = 'Inara worker') + this.thread.daemon = True + this.thread.start() + + return 'Inara' + +def plugin_close(): + # Signal thread to close and wait for it + this.queue.put(None) + this.thread.join() + this.thread = 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='Inara', background=nb.Label().cget('background'), url='https://inara.cz/', underline=True).grid(columnspan=2, padx=PADX, sticky=tk.W) # Don't translate + this.log = tk.IntVar(value = config.getint('inara_out') and 1) + this.log_button = nb.Checkbutton(frame, text=_('Send flight log and Cmdr status to Inara'), 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=_('Inara credentials'), background=nb.Label().cget('background'), url='https://inara.cz/settings-api', underline=True) # Section heading in settings + this.label.grid(columnspan=2, padx=PADX, sticky=tk.W) + + 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.apikey['state'] = tk.NORMAL + this.apikey.delete(0, tk.END) + if cmdr: + cred = credentials(cmdr) + if cred: + this.apikey.insert(0, cred) + this.label['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.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('inara_out', this.log.get()) + + print 'prefs_changed', cmdr, is_beta + + if cmdr and not is_beta: + cmdrs = config.get('inara_cmdrs') or [] + apikeys = config.get('inara_apikeys') or [] + if cmdr in cmdrs: + idx = cmdrs.index(cmdr) + apikeys.extend([''] * (1 + idx - len(apikeys))) + apikeys[idx] = this.apikey.get().strip() + else: + config.set('inara_cmdrs', cmdrs + [cmdr]) + apikeys.append(this.apikey.get().strip()) + config.set('inara_apikeys', apikeys) + # TODO: schedule a call with callback if changed + +def credentials(cmdr): + # Credentials for cmdr + if not cmdr: + return None + + cmdrs = config.get('inara_cmdrs') or [] + if cmdr in cmdrs and config.get('inara_apikeys'): + return config.get('inara_apikeys')[cmdrs.index(cmdr)] + else: + return None + + +def journal_entry(cmdr, is_beta, system, station, entry, state): + + this.multicrew = bool(state['Role']) + + if entry['event'] == 'LoadGame': + # clear cached state + this.location = None + this.cargo = None + this.materials = None + + # Send location and status on new game or StartUp. Assumes Location is the last event on a new game (other than Docked). + # Always send an update on Docked, Undocked, FSDJump, Promotion and EngineerProgress. + # Also send material and cargo (if changed) whenever we send an update. + + if config.getint('inara_out') and not is_beta and not multicrew and credentials(cmdr): + try: + events = [] + + # Send credits to Inara on new game (but not on startup - data might be old) + if entry['event'] == 'Location': + # TODO: 'commanderAssets' + add_event(events, 'setCommanderCredits', entry['timestamp'], + OrderedDict([ + ('commanderCredits', state['Credits']), + ('commanderLoan', state['Loan']), + ])) + + # Send rank info to Inara on startup or change + if entry['event'] in ['StartUp', 'Location'] and state['Rank']: + for k,v in state['Rank'].iteritems(): + if v is not None: + add_event(events, 'setCommanderRankPilot', entry['timestamp'], + OrderedDict([ + ('rankName', k.lower()), + ('rankValue', v[0]), + ('rankProgress', v[1] / 100.0), + ])) + elif entry['event'] == 'Promotion': + for k,v in state['Rank'].iteritems(): + if k in entry: + add_event(events, 'setCommanderRankPilot', entry['timestamp'], + OrderedDict([ + ('rankName', k.lower()), + ('rankValue', v[0]), + ('rankProgress', 0), + ])) + + # Send engineer status to Inara on change (not available on startup) + if entry['event'] == 'EngineerProgress': + if 'Rank' in entry: + add_event(events, 'setCommanderRankEngineer', entry['timestamp'], + OrderedDict([ + ('engineerName', entry['Engineer']), + ('rankValue', entry['Rank']), + ])) + else: + add_event(events, 'setCommanderRankEngineer', entry['timestamp'], + OrderedDict([ + ('engineerName', entry['Engineer']), + ('rankStage', entry['Progress']), + ])) + + # Send PowerPlay status to Inara on change (not available on startup, and promotion not available at all) + if entry['event'] == 'PowerplayJoin': + add_event(events, 'setCommanderRankPower', entry['timestamp'], + OrderedDict([ + ('powerName', entry['Power']), + ('rankValue', 1), + ])) + elif entry['event'] == 'PowerplayLeave': + add_event(events, 'setCommanderRankPower', entry['timestamp'], + OrderedDict([ + ('powerName', entry['Power']), + ('rankValue', 0), + ])) + elif entry['event'] == 'PowerplayDefect': + add_event(events, 'setCommanderRankPower', entry['timestamp'], + OrderedDict([ + ('powerName', entry['ToPower']), + ('rankValue', 1), + ])) + + # Update location + if entry['event'] == 'Location': + if entry.get('Docked'): + add_event(events, 'setCommanderTravelLocation', entry['timestamp'], + OrderedDict([ + ('starsystemName', entry['StarSystem']), + ('stationName', entry['StationName']), + ('shipType', companion.ship_map.get(state['ShipType'], state['ShipType'])), + ('shipGameID', state['ShipID']), + ])) + this.location = (entry['StarSystem'], entry['StationName']) + else: + add_event(events, 'setCommanderTravelLocation', entry['timestamp'], + OrderedDict([ + ('starsystemName', entry['StarSystem']), + ('shipType', companion.ship_map.get(state['ShipType'], state['ShipType'])), + ('shipGameID', state['ShipID']), + ])) + this.location = (entry['StarSystem'], None) + + elif entry['event'] == 'Docked' and this.location != (entry['StarSystem'], entry['StationName']): + # Don't send docked event on new game - i.e. following 'Location' event + add_event(events, 'addCommanderTravelDock', entry['timestamp'], + OrderedDict([ + ('starsystemName', entry['StarSystem']), + ('stationName', entry['StationName']), + ('shipType', companion.ship_map.get(state['ShipType'], state['ShipType'])), + ('shipGameID', state['ShipID']), + ])) + this.location = (entry['StarSystem'], entry['StationName']) + + elif entry['event'] == 'Undocked' and this.location: + add_event(events, 'setCommanderTravelLocation', entry['timestamp'], + OrderedDict([ + ('starsystemName', this.location[0]), + ('shipType', companion.ship_map.get(state['ShipType'], state['ShipType'])), + ('shipGameID', state['ShipID']), + ])) + this.location = (this.location[0], None) + + elif entry['event'] == 'FSDJump': + add_event(events, 'addCommanderTravelFSDJump', entry['timestamp'], + OrderedDict([ + ('starsystemName', entry['StarSystem']), + ('jumpDistance', entry['JumpDist']), + ('shipType', companion.ship_map.get(state['ShipType'], state['ShipType'])), + ('shipGameID', state['ShipID']), + ])) + this.location = (entry['StarSystem'], None) + + if events: + # Send cargo and materials to Inara if changed and if we're sending any other kind of update + cargo = [ OrderedDict([('itemName', k), ('itemCount', state['Cargo'][k])]) for k in sorted(state['Cargo']) ] + if this.cargo != cargo: + add_event(events, 'setCommanderInventoryCargo', entry['timestamp'], cargo) + this.cargo = cargo + materials = [] + for category in ['Raw', 'Manufactured', 'Encoded']: + materials.extend([ OrderedDict([('itemName', k), ('itemCount', state[category][k])]) for k in sorted(state[category]) ]) + if this.materials != materials: + add_event(events, 'setCommanderInventoryMaterials', entry['timestamp'], materials) + this.materials = materials + + # Queue a call to Inara + call(cmdr, events) + + except Exception as e: + if __debug__: print_exc() + return unicode(e) + +def add_event(events, name, timestamp, data): + events.append(OrderedDict([ + ('eventName', name), + ('eventTimestamp', timestamp), + ('eventData', data), + ])) + + +# Queue a call to Inara, handled in Worker thread +def call(cmdr, events, callback=None): + args = OrderedDict([ + ('header', OrderedDict([ + ('appName', applongname), + ('appVersion', appversion), + ('isDeveloped', True), # TODO: Remove before release + ('APIkey', credentials(cmdr)), + ('commanderName', cmdr.encode('utf-8')), + ])), + ('events', events), + ]) + this.queue.put(('https://inara.cz/inapi/v1/', json.dumps(args, separators = (',', ':')), None)) + +# Worker thread +def worker(): + while True: + item = this.queue.get() + if not item: + return # Closing + else: + (url, data, callback) = item + + retrying = 0 + while retrying < 3: + try: + r = this.session.post(url, data=data, timeout=_TIMEOUT) + r.raise_for_status() + reply = r.json() + status = reply['header']['eventStatus'] + if callback: + callback(reply) + elif status // 100 != 2: # 2xx == OK (maybe with warnings) + plug.show_error(_('Error: Inara {MSG}').format(MSG = reply['header'].get('eventStatusText', status))) + if __debug__: print r.content + break + except: + if __debug__: print_exc() + retrying += 1 + else: + if callback: + callback(None) + else: + plug.show_error(_("Error: Can't connect to Inara"))