diff --git a/EDMarketConnector.py b/EDMarketConnector.py index e6da4786..2efa545f 100755 --- a/EDMarketConnector.py +++ b/EDMarketConnector.py @@ -3,13 +3,15 @@ import sys from sys import platform +from collections import OrderedDict from functools import partial import json from os import mkdir from os.path import expanduser, isdir, join import re import requests -from time import time, localtime, strftime +from time import time, localtime, strftime, strptime +from calendar import timegm import Tkinter as tk import ttk @@ -75,9 +77,6 @@ class AppWindow: self.w.rowconfigure(0, weight=1) self.w.columnconfigure(0, weight=1) - # Special handling for overrideredict - self.w.bind("", self.onmap) - plug.load_plugins() if platform != 'darwin': @@ -124,10 +123,7 @@ class AppWindow: self.theme_button.grid(row=row, columnspan=2, sticky=tk.NSEW) theme.register_alternate((self.button, self.theme_button), {'row':row, 'columnspan':2, 'sticky':tk.NSEW}) self.status.grid(columnspan=2, sticky=tk.EW) - theme.button_bind(self.theme_button, self.getandsend) - self.w.bind('', self.getandsend) - self.w.bind('', self.getandsend) for child in frame.winfo_children(): child.grid_configure(padx=5, pady=(platform=='win32' and 1 or 3)) @@ -242,19 +238,16 @@ class AppWindow: theme.register_highlight(self.station) theme.apply(self.w) + self.w.bind("", self.onmap) # Special handling for overrideredict + self.w.bind('', self.getandsend) + self.w.bind('', self.getandsend) + self.w.bind_all('<>', self.getandsend) # Hotkey monitoring + self.w.bind_all('<>', self.journal_event) # Journal monitoring + self.w.bind_all('<>', self.onexit) # Updater + # Load updater after UI creation (for WinSparkle) import update self.updater = update.Updater(self.w) - self.w.bind_all('<>', self.onexit) # user-generated - - # Install hotkey monitoring - self.w.bind_all('<>', self.getandsend) # user-generated - hotkeymgr.register(self.w, config.getint('hotkey_code'), config.getint('hotkey_mods')) - - # Install log monitoring - monitor.set_callback('Dock', self.getandsend) - monitor.set_callback('Jump', self.system_change) - monitor.start(self.w) # First run if not config.get('username') or not config.get('password'): @@ -312,6 +305,17 @@ class AppWindow: if not getattr(sys, 'frozen', False): self.updater.checkForUpdates() # Sparkle / WinSparkle does this automatically for packaged apps + # Try to obtain exclusive lock on journal cache, even if we don't need it yet + if not eddn.load(): + self.status['text'] = 'Error: Is another copy of this app already running?' # Shouldn't happen - don't bother localizing + + # (Re-)install hotkey monitoring + hotkeymgr.register(self.w, config.getint('hotkey_code'), config.getint('hotkey_mods')) + + # (Re-)install log monitoring + if not monitor.start(self.w): + self.status['text'] = 'Error: Check %s' % _('E:D journal file location') # Location of the new Journal file in E:D 2.2 + self.cooldown() # callback after verification code @@ -331,6 +335,9 @@ class AppWindow: auto_update = not event play_sound = (auto_update or int(event.type) == self.EVENT_VIRTUAL) and not config.getint('hotkey_mute') + if (monitor.cmdr and not monitor.mode) or monitor.is_beta: + return # In CQC - do nothing + if not retrying: if time() < self.holdofftime: # Was invoked by key while in cooldown self.status['text'] = '' @@ -356,6 +363,8 @@ class AppWindow: self.status['text'] = _("Where are you?!") # Shouldn't happen elif not data.get('ship') or not data['ship'].get('modules') or not data['ship'].get('name','').strip(): self.status['text'] = _("What are you flying?!") # Shouldn't happen + elif monitor.cmdr and data['commander']['name'] != monitor.cmdr: + raise companion.CredentialsError() # Companion API credentials don't match Journal elif auto_update and (not data['commander'].get('docked') or (monitor.logfile and self.system['text'] and data['lastSystem']['name'] != self.system['text'])): raise companion.ServerLagging() @@ -379,24 +388,20 @@ class AppWindow: loadout.export(data) if config.getint('output') & config.OUT_SHIP_CORIOLIS: coriolis.export(data) - if config.getint('output') & config.OUT_SYS_EDSM: - # Silently catch any EDSM errors here so that they don't prevent station update - try: - self.edsm.lookup(self.system['text'], EDDB.system(self.system['text'])) - except Exception as e: - if __debug__: print_exc() - else: - self.edsm.link(self.system['text']) - self.edsmpoll() if not (config.getint('output') & (config.OUT_MKT_CSV|config.OUT_MKT_TD|config.OUT_MKT_BPC|config.OUT_MKT_EDDN)): # no station data requested - we're done pass elif not data['commander'].get('docked'): - # signal as error because the user might actually be docked but the server hosting the Companion API hasn't caught up - if not self.status['text']: - self.status['text'] = _("You're not docked at a station!") + if not event and not retrying: + # Silently retry if we got here by 'Automatically update on docking' and the server hasn't caught up + self.w.after(int(SERVER_RETRY * 1000), lambda:self.getandsend(event, True)) + return # early exit to avoid starting cooldown count + else: + # Signal as error because the user might actually be docked but the server hosting the Companion API hasn't caught up + if not self.status['text']: + self.status['text'] = _("You're not docked at a station!") else: # Finally - the data looks sane and we're docked at a station @@ -479,31 +484,100 @@ class AppWindow: except: pass - def system_change(self, event, timestamp, system, coordinates): + # Handle event(s) from the journal + def journal_event(self, event): + while True: + entry = monitor.get_entry() + system_changed = monitor.system and self.system['text'] != monitor.system + station_changed = monitor.station and self.station['text'] != monitor.station - if self.system['text'] != system: - self.system['text'] = system + # Update main window + self.cmdr['text'] = monitor.cmdr or '' + self.system['text'] = monitor.system or '' + self.station['text'] = monitor.station or (EDDB.system(monitor.system) and self.STATION_UNDOCKED or '') + if system_changed or station_changed: + self.status['text'] = '' + if not entry or not monitor.mode: + return # Fake event or in CQC - self.system['image'] = '' - self.station['text'] = EDDB.system(system) and self.STATION_UNDOCKED or '' + plug.notify_journal_entry(monitor.cmdr, monitor.system, monitor.station, entry) - plug.notify_system_changed(timestamp, system, coordinates) + if system_changed: + self.system['image'] = '' + timestamp = timegm(strptime(entry['timestamp'], '%Y-%m-%dT%H:%M:%SZ')) + + # Backwards compatibility + plug.notify_system_changed(timestamp, monitor.system, monitor.coordinates) + + # Update EDSM if we have coordinates - i.e. Location or FSDJump events + if config.getint('output') & config.OUT_SYS_EDSM and monitor.coordinates: + try: + self.status['text'] = _('Sending data to EDSM...') + self.w.update_idletasks() + self.edsm.writelog(timestamp, monitor.system, monitor.coordinates) + self.status['text'] = '' + except Exception as e: + if __debug__: print_exc() + self.status['text'] = unicode(e) + if not config.getint('hotkey_mute'): + hotkeymgr.play_bad() + else: + self.edsm.link(monitor.system) + self.edsmpoll() + + # Auto-Update after docking + if station_changed and not monitor.is_beta and not config.getint('output') & config.OUT_MKT_MANUAL and config.getint('output') & config.OUT_STATION_ANY: + self.w.after(int(SERVER_RETRY * 1000), self.getandsend) + + # Send interesting events to EDDN + try: + if (config.getint('output') & config.OUT_SYS_EDDN and monitor.cmdr and + (entry['event'] == 'FSDJump' and system_changed or + entry['event'] == 'Docked' and station_changed or + entry['event'] == 'Scan' and monitor.system)): + # strip out properties disallowed by the schema + for thing in ['CockpitBreach', 'BoostUsed', 'FuelLevel', 'FuelUsed', 'JumpDist']: + entry.pop(thing, None) + for thing in entry.keys(): + if thing.endswith('_Localised'): + entry.pop(thing, None) + + # add mandatory StarSystem property to Scan events + if 'StarSystem' not in entry: + entry['StarSystem'] = monitor.system + + self.status['text'] = _('Sending data to EDDN...') + eddn.export_journal_entry(monitor.cmdr, monitor.is_beta, entry) + self.status['text'] = '' + + elif (config.getint('output') & config.OUT_MKT_EDDN and monitor.cmdr and + entry['event'] == 'MarketSell' and entry.get('BlackMarket')): + # Construct blackmarket message + msg = OrderedDict([ + ('systemName', monitor.system), + ('stationName', monitor.station), + ('timestamp', entry['timestamp']), + ('name', entry['Type']), + ('sellPrice', entry['SellPrice']), + ('prohibited' , entry.get('IllegalGoods', False)), + ]) + + self.status['text'] = _('Sending data to EDDN...') + eddn.export_blackmarket(monitor.cmdr, monitor.is_beta, msg) + self.status['text'] = '' + + except requests.exceptions.RequestException as e: + if __debug__: print_exc() + self.status['text'] = _("Error: Can't connect to EDDN") + if not config.getint('hotkey_mute'): + hotkeymgr.play_bad() + + except Exception as e: + if __debug__: print_exc() + self.status['text'] = unicode(e) + if not config.getint('hotkey_mute'): + hotkeymgr.play_bad() - if config.getint('output') & config.OUT_SYS_EDSM: - try: - self.status['text'] = _('Sending data to EDSM...') - self.w.update_idletasks() - self.edsm.writelog(timestamp, system, coordinates) # Do EDSM lookup during EDSM export - self.status['text'] = strftime(_('Last updated at {HH}:{MM}:{SS}').format(HH='%H', MM='%M', SS='%S').encode('utf-8'), localtime(timestamp)).decode('utf-8') - except Exception as e: - if __debug__: print_exc() - self.status['text'] = unicode(e) - if not config.getint('hotkey_mute'): - hotkeymgr.play_bad() - else: - self.edsm.link(system) - self.status['text'] = strftime(_('Last updated at {HH}:{MM}:{SS}').format(HH='%H', MM='%M', SS='%S').encode('utf-8'), localtime(timestamp)).decode('utf-8') - self.edsmpoll() def edsmpoll(self): result = self.edsm.result @@ -573,6 +647,7 @@ class AppWindow: if platform!='darwin' or self.w.winfo_rooty()>0: # http://core.tcl.tk/tk/tktview/c84f660833546b1b84e7 config.set('geometry', '+{1}+{2}'.format(*self.w.geometry().split('+'))) config.close() + eddn.close() self.updater.close() self.session.close() self.w.destroy() diff --git a/L10n/cs.strings b/L10n/cs.strings index 71beb215..5b515000 100644 --- a/L10n/cs.strings +++ b/L10n/cs.strings @@ -109,8 +109,8 @@ /* Empire rank. [stats.py] */ "Duke" = "Duke"; -/* Configuration setting. [prefs.py] */ -"E:D log file location" = "Umístění E:D log souboru "; +/* Location of the new Journal file in E:D 2.2. [EDMarketConnector.py] */ +"E:D journal file location" = "E:D umístění souboru deníku"; /* Empire rank. [stats.py] */ "Earl" = "Earl"; diff --git a/L10n/de.strings b/L10n/de.strings index fa6f2a89..b82755b0 100644 --- a/L10n/de.strings +++ b/L10n/de.strings @@ -109,8 +109,8 @@ /* Empire rank. [stats.py] */ "Duke" = "Herzog"; -/* Configuration setting. [prefs.py] */ -"E:D log file location" = "E:D Logdatei Speicherort"; +/* Location of the new Journal file in E:D 2.2. [EDMarketConnector.py] */ +"E:D journal file location" = "E:D Journal Dateispeicherort"; /* Empire rank. [stats.py] */ "Earl" = "Earl"; diff --git a/L10n/en.template b/L10n/en.template index 8975a04f..e725146a 100644 --- a/L10n/en.template +++ b/L10n/en.template @@ -109,8 +109,8 @@ /* Empire rank. [stats.py] */ "Duke" = "Duke"; -/* Configuration setting. [prefs.py] */ -"E:D log file location" = "E:D log file location"; +/* Location of the new Journal file in E:D 2.2. [EDMarketConnector.py] */ +"E:D journal file location" = "E:D journal file location"; /* Empire rank. [stats.py] */ "Earl" = "Earl"; diff --git a/L10n/es.strings b/L10n/es.strings index 918d0bd5..958db532 100644 --- a/L10n/es.strings +++ b/L10n/es.strings @@ -109,8 +109,8 @@ /* Empire rank. [stats.py] */ "Duke" = "Duque"; -/* Configuration setting. [prefs.py] */ -"E:D log file location" = "Localización del log de E:D"; +/* Location of the new Journal file in E:D 2.2. [EDMarketConnector.py] */ +"E:D journal file location" = "Localización del archivo de Journal de E:D"; /* Empire rank. [stats.py] */ "Earl" = "Conde Palatino"; diff --git a/L10n/fr.strings b/L10n/fr.strings index fe1e3436..de2e00f0 100644 --- a/L10n/fr.strings +++ b/L10n/fr.strings @@ -25,6 +25,9 @@ /* Tab heading in settings. [prefs.py] */ "Appearance" = "Apparence"; +/* Output setting. [prefs.py] */ +"Automatically update on docking" = "Mettre à jour automatiquement à l'amarrage"; + /* Cmdr stats. [stats.py] */ "Balance" = "Solde"; @@ -100,11 +103,14 @@ /* Appearance theme and language setting. [l10n.py] */ "Default" = "Par défaut"; +/* Output setting under 'Send system and scan data to the Elite Dangerous Data Network' new in E:D 2.2. [prefs.py] */ +"Delay sending until docked" = "Retarder l'envoi jusqu'à l'amarrage"; + /* Empire rank. [stats.py] */ "Duke" = "Archiduc"; -/* Configuration setting. [prefs.py] */ -"E:D log file location" = "Emplacement des fichiers de log E:D"; +/* Location of the new Journal file in E:D 2.2. [EDMarketConnector.py] */ +"E:D journal file location" = "Emplacement du journal E:D"; /* Empire rank. [stats.py] */ "Earl" = "Marquis"; @@ -376,6 +382,9 @@ /* Output setting. [prefs.py] */ "Send station data to the Elite Dangerous Data Network" = "Envoyer les données de la station à Elite Dangerous Data Network"; +/* Output setting new in E:D 2.2. [prefs.py] */ +"Send system and scan data to the Elite Dangerous Data Network" = "Envoyer les systèmes et les informations de scan à Elite Dangerous Data Network"; + /* [EDMarketConnector.py] */ "Sending data to EDDN..." = "Envoi des données à EDDN..."; diff --git a/L10n/ja.strings b/L10n/ja.strings index 718c510f..26dce213 100644 --- a/L10n/ja.strings +++ b/L10n/ja.strings @@ -109,8 +109,8 @@ /* Empire rank. [stats.py] */ "Duke" = "Duke"; -/* Configuration setting. [prefs.py] */ -"E:D log file location" = "E:Dゲームクライアントのログ出力先"; +/* Location of the new Journal file in E:D 2.2. [EDMarketConnector.py] */ +"E:D journal file location" = "E:Dゲームクライアントのジャーナルファイル出力先"; /* Empire rank. [stats.py] */ "Earl" = "Earl"; diff --git a/L10n/nl.strings b/L10n/nl.strings index 9f5e831b..3f7479be 100644 --- a/L10n/nl.strings +++ b/L10n/nl.strings @@ -109,8 +109,8 @@ /* Empire rank. [stats.py] */ "Duke" = "Duke"; -/* Configuration setting. [prefs.py] */ -"E:D log file location" = "E:D log bestand locatie"; +/* Location of the new Journal file in E:D 2.2. [EDMarketConnector.py] */ +"E:D journal file location" = "E:D journaal bestand locatie"; /* Empire rank. [stats.py] */ "Earl" = "Earl"; diff --git a/L10n/pl.strings b/L10n/pl.strings index 557acf06..0864e553 100644 --- a/L10n/pl.strings +++ b/L10n/pl.strings @@ -103,11 +103,14 @@ /* Appearance theme and language setting. [l10n.py] */ "Default" = "Domyślne"; +/* Output setting under 'Send system and scan data to the Elite Dangerous Data Network' new in E:D 2.2. [prefs.py] */ +"Delay sending until docked" = "Czekaj z wysłaniem na zadokowanie"; + /* Empire rank. [stats.py] */ "Duke" = "Duke"; -/* Configuration setting. [prefs.py] */ -"E:D log file location" = "E:D położenie pliku dziennika"; +/* Location of the new Journal file in E:D 2.2. [EDMarketConnector.py] */ +"E:D journal file location" = "E:D Lokacja pliku dziennika"; /* Empire rank. [stats.py] */ "Earl" = "Earl"; @@ -379,6 +382,9 @@ /* Output setting. [prefs.py] */ "Send station data to the Elite Dangerous Data Network" = "Wyślij dane do Elite Dangerous Data Network"; +/* Output setting new in E:D 2.2. [prefs.py] */ +"Send system and scan data to the Elite Dangerous Data Network" = "Wyślij dane skanowań do EDDN"; + /* [EDMarketConnector.py] */ "Sending data to EDDN..." = "Wysłanie danych do EDDN..."; diff --git a/L10n/ru.strings b/L10n/ru.strings index 5dc8095f..5d8c274c 100644 --- a/L10n/ru.strings +++ b/L10n/ru.strings @@ -109,8 +109,8 @@ /* Empire rank. [stats.py] */ "Duke" = "Герцог"; -/* Configuration setting. [prefs.py] */ -"E:D log file location" = "Расположение файла журнала E:D"; +/* Location of the new Journal file in E:D 2.2. [EDMarketConnector.py] */ +"E:D journal file location" = "Расположение файла журнала E:D"; /* Empire rank. [stats.py] */ "Earl" = "Эрл"; diff --git a/L10n/uk.strings b/L10n/uk.strings index 52f03809..baa4e2c7 100644 --- a/L10n/uk.strings +++ b/L10n/uk.strings @@ -109,8 +109,8 @@ /* Empire rank. [stats.py] */ "Duke" = "Герцог"; -/* Configuration setting. [prefs.py] */ -"E:D log file location" = "Розміщення файлу-журналу (log E:D)"; +/* Location of the new Journal file in E:D 2.2. [EDMarketConnector.py] */ +"E:D journal file location" = "Росташування файлу-журналу E:D"; /* Empire rank. [stats.py] */ "Earl" = "Дворянин"; diff --git a/PLUGINS.md b/PLUGINS.md index fe59cf4b..2a26117b 100644 --- a/PLUGINS.md +++ b/PLUGINS.md @@ -68,21 +68,23 @@ Once you have created your plugin and EDMC has loaded it there are two other fun Your events all get called on the main tkinter loop so be sure not to block for very long or the EDMC will appear to freeze. If you have a long running operation then you should take a look at how to do background updates in tkinter - http://effbot.org/zone/tkinter-threads.htm -### Arriving in a System +### Journal Entry -This gets called when EDMC uses the netlog to notice that you have arrived at a new star system. +This gets called when EDMC sees a new entry in the game's journal. ``` -def system_changed(timestamp, system, coordinates): - """ - We arrived at a new system! - """ - sys.stderr.write("{} {}".format(timestamp, system)) +def journal_entry(cmdr, system, station, entry): + if entry['event'] == 'FSDJump': + # We arrived at a new system! + if 'StarPos' in entry: + sys.stderr.write("Arrived at {} ({},{},{})\n".format(entry['StarSystem'], *tuple(entry['StarPos']))) + else: + sys.stderr.write("Arrived at {}\n".format(entry['StarSystem'])) ``` ### Getting Commander Data -This gets called when EDMC has just fetched fresh data from Frontier's servers. +This gets called when EDMC has just fetched fresh Cmdr and station data from Frontier's servers. ``` def cmdr_data(data): diff --git a/README.md b/README.md index 17438e75..1f5ba081 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ Elite: Dangerous Market Connector (EDMC) ======== -This app downloads your data and station data from the game [Elite: Dangerous](https://www.elitedangerous.com/) and, at your choice, either: +This app downloads your Cmdr's data, system, scan and station data from the game [Elite: Dangerous](https://www.elitedangerous.com/) and, at your choice, either: -* sends the station commodity market prices and other station data to the [Elite Dangerous Data Network](http://eddn-gateway.elite-markets.net/) (“EDDN”) from where you and others can use it via online trading and shopping tools such as [eddb](http://eddb.io/), [Elite Trade Net](http://etn.io/), [Inara](http://inara.cz), [ED-TD](http://ed-td.space/), [Thrudd's Trading Tools](http://www.elitetradingtool.co.uk/), [Roguey's](http://roguey.co.uk/elite-dangerous/), etc. -* saves the station market prices to files on your computer that you can load into trading tools such as [Slopey's BPC Market Tool](https://forums.frontier.co.uk/showthread.php?t=76081), [Trade Dangerous](https://bitbucket.org/kfsone/tradedangerous/wiki/Home), [Thrudd's Trading Tools](http://www.elitetradingtool.co.uk/), [Inara](http://inara.cz), [mEDI's Elite Tools](https://github.com/mEDI-S/mEDI_s-Elite-Tools), etc. +* sends the station commodity market prices, other station data and system and scan data to the [Elite Dangerous Data Network](https://github.com/jamesremuscat/EDDN/wiki) (“EDDN”) from where you and others can use it via online trading, prospecting and shopping tools such as [eddb](http://eddb.io/), [Elite Trade Net](http://etn.io/), [Inara](http://inara.cz), [ED-TD](http://ed-td.space/), [Thrudd's Trading Tools](http://www.elitetradingtool.co.uk/), [Roguey's](http://roguey.co.uk/elite-dangerous/), etc. +* saves the 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), [Thrudd's Trading Tools](http://www.elitetradingtool.co.uk/), [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 flight log to [Elite: Dangerous Star Map](http://www.edsm.net/). @@ -46,7 +46,7 @@ Setup The first time that you run the app you are prompted for your username and password. This is the same username and password combination that you use to log into the Elite: Dangerous launcher, and is required so that the Frontier servers can send the app *your* data and the data for the station that *you* are docked at. -You can also choose here what data to save (refer to the next section for details), whether to “Update” automatically on docking and/or with a hotkey, and whether to attach your Cmdr name or a [pseudo-anonymized](http://en.wikipedia.org/wiki/Pseudonymity) ID to the data. +You can also choose here what data to save (refer to the next section for details), whether to “Update” Cmdr and station data automatically on docking and/or with a hotkey, and whether to attach your Cmdr name or a [pseudo-anonymized](http://en.wikipedia.org/wiki/Pseudonymity) ID to the data. You will be prompted to authenticate with a “verification code”, which you will shortly receive by email from Frontier. Note that each “verification code” is one-time only - if you enter the code incorrectly or quit the app before @@ -59,8 +59,7 @@ option EDMarketConnector → Preferences (Mac) or File → Settings (Windows) an This app can save a variety of data in a variety of formats: -* Station data - * Elite Dangerous Data Network - sends station commodity market, outfitting and shipyard data to “[EDDN](http://eddn-gateway.elite-markets.net/)” from where you and others can use it via online trading tools such as [eddb](http://eddb.io/), [Elite Trade Net](http://etn.io/), [Inara](http://inara.cz), [ED-TD](http://ed-td.space/), [Thrudd's Trading Tools](http://www.elitetradingtool.co.uk/), [Roguey's](http://roguey.co.uk/elite-dangerous/), etc. +* Market data * Slopey's BPC format file - saves commodity market data as files that you can load into [Slopey's BPC Market Tool](https://forums.frontier.co.uk/showthread.php?t=76081). * Trade Dangerous format file - saves commodity market data as files that you can load into [Trade Dangerous](https://bitbucket.org/kfsone/tradedangerous/wiki/Home). * CSV format file - saves commodity market data as files that you can upload to [Thrudd's Trading Tools](http://www.elitetradingtool.co.uk/), [Inara](http://inara.cz) or [mEDI's Elite Tools](https://github.com/mEDI-S/mEDI_s-Elite-Tools). @@ -70,7 +69,15 @@ This app can save a variety of data in a variety of formats: By default these files will be placed in your Documents folder. Since this app will create a lot of files if you use it for a while you may wish to create a separate folder for the files and tell the app to place them there. -Some options work by reading the Elite: Dangerous game's log files. Normally this app will find the log files but if you find some options greyed-out then adjust the “E:D log file location” setting described [below](#doesnt-track-systems-visited). +Some options work by reading the Elite: Dangerous game's “journal” files. If you're running this app on a different machine from the Elite: Dangerous game then adjust the “E:D journal file location” setting on the Configuration tab to point to the game's journal files. + +### EDDN + +* Station data + * Sends station commodity market, outfitting and shipyard data to “[EDDN](https://github.com/jamesremuscat/EDDN/wiki)” from where you and others can use it via online trading tools such as [eddb](http://eddb.io/), [Elite Trade Net](http://etn.io/), [Inara](http://inara.cz), [ED-TD](http://ed-td.space/), [Thrudd's Trading Tools](http://www.elitetradingtool.co.uk/), [Roguey's](http://roguey.co.uk/elite-dangerous/), etc. +* System and scan data + * Sends general system information and the results of your detailed planet scans to “[EDDN](https://github.com/jamesremuscat/EDDN/wiki)” from where you and others can use it via online prospecting tools such as [eddb](http://eddb.io/), [Inara](http://inara.cz), etc. + * You can choose to delay sending this information to EDDN until you're next safely docked at a station. Otherwise the information is sent as soon as you enter a system or perform a scan. ### EDSM @@ -120,13 +127,7 @@ Ensure that you visit the in-game Commodity Market at a station where you intend This problem is tracked as [Issue #92](https://github.com/Marginal/EDMarketConnector/issues/92). ### Doesn't track Systems visited -This app uses Elite: Dangerous' log files to track the systems and stations that you visit. When looking for the log files, this app assumes: - -- That you're running this app and Elite: Dangerous on the same machine. -- That you're running Elite: Dangerous from Steam, if you have both Steam and non-Steam versions installed. -- That you're running “Horizons” 64bit, if you have both Horizons and Season 1 installed. - -If you find that this app isn't automatically tracking the systems that you visit and/or isn't automatically “updating” on docking (if you have that option selected), or if you're running this app on a different machine from the Elite: Dangerous game then adjust the “E:D log file location” setting on the Configuration tab to point to the game's log files using [this info](https://support.frontier.co.uk/kb/faq.php?id=108) as a guide. +This app uses Elite: Dangerous' “journal” files to track the systems and stations that you visit. If you're running this app on a different machine from the Elite: Dangerous game, or if you find that this app isn't automatically tracking the systems that you visit and/or isn't automatically “updating” on docking (if you have that option selected), then adjust the “E:D journal file location” setting on the Configuration tab to point to the game's journal files. Running from source -------- @@ -218,7 +219,7 @@ Acknowledgements * Thanks to Armando Ota for the Slovenian translation. * Thanks to Cmdr Mila Strelok for the Spanish translation. * Thanks to Taras Velychko for the Ukranian translation. -* Thanks to [James Muscat](https://github.com/jamesremuscat) for [EDDN](https://github.com/jamesremuscat/EDDN) and to [Cmdr Anthor](https://github.com/AnthorNet) for the [stats](http://eddn-gateway.elite-markets.net/). +* Thanks to [James Muscat](https://github.com/jamesremuscat) for [EDDN](https://github.com/jamesremuscat/EDDN/wiki) and to [Cmdr Anthor](https://github.com/AnthorNet) for the [stats](http://eddn-gateway.elite-markets.net/). * Thanks to [Andargor](https://github.com/Andargor) for the idea of using the “Companion” interface in [edce-client](https://github.com/Andargor/edce-client). * Uses [Sparkle](https://github.com/sparkle-project/Sparkle) by [Andy Matuschak](http://andymatuschak.org/) and the [Sparkle Project](https://github.com/sparkle-project). * Uses [WinSparkle](https://github.com/vslavik/winsparkle/wiki) by [Václav Slavík](https://github.com/vslavik). diff --git a/companion.py b/companion.py index 0052d66a..33b18d59 100644 --- a/companion.py +++ b/companion.py @@ -77,6 +77,7 @@ ship_map = { 'anaconda' : 'Anaconda', 'asp' : 'Asp Explorer', 'asp_scout' : 'Asp Scout', + 'belugaliner' : 'Beluga Liner', 'cobramkiii' : 'Cobra MkIII', 'cobramkiv' : 'Cobra MkIV', 'cutter' : 'Imperial Cutter', @@ -97,6 +98,7 @@ ship_map = { 'independant_trader' : 'Keelback', 'orca' : 'Orca', 'python' : 'Python', + 'scout' : 'Taipan Fighter', 'sidewinder' : 'Sidewinder', 'type6' : 'Type-6 Transporter', 'type7' : 'Type-7 Transporter', @@ -166,7 +168,7 @@ class Session: self.state = Session.STATE_INIT self.credentials = None - # yuck suppress InsecurePlatformWarning + # yuck suppress InsecurePlatformWarning under Python < 2.7.9 which lacks SNI support try: from requests.packages import urllib3 urllib3.disable_warnings() diff --git a/config.py b/config.py index d8e43ef8..39b4335b 100644 --- a/config.py +++ b/config.py @@ -1,14 +1,14 @@ import numbers import sys from os import getenv, makedirs, mkdir, pardir -from os.path import expanduser, dirname, isdir, join, normpath +from os.path import expanduser, dirname, exists, isdir, join, normpath from sys import platform appname = 'EDMarketConnector' applongname = 'E:D Market Connector' appcmdname = 'EDMC' -appversion = '2.1.7.2' +appversion = '2.2.0.0' update_feed = 'https://marginal.org.uk/edmarketconnector.xml' update_interval = 47*60*60 @@ -19,14 +19,21 @@ if platform=='darwin': elif platform=='win32': import ctypes + from ctypes.wintypes import * + import uuid - CSIDL_PERSONAL = 0x0005 - CSIDL_LOCAL_APPDATA = 0x001C - CSIDL_PROFILE = 0x0028 + FOLDERID_Documents = uuid.UUID('{FDD39AD0-238F-46AF-ADB4-6C85480369C7}') + FOLDERID_LocalAppData = uuid.UUID('{F1B32785-6FBA-4FCF-9D55-7B8E7F157091}') + FOLDERID_Profile = uuid.UUID('{5E6C858F-0E22-4760-9AFE-EA3317B67173}') + FOLDERID_SavedGames = uuid.UUID('{4C5C32FF-BB9D-43b0-B5B4-2D72E54EAAA4}') + + SHGetKnownFolderPath = ctypes.windll.shell32.SHGetKnownFolderPath + SHGetKnownFolderPath.argtypes = [ctypes.c_char_p, DWORD, HANDLE, ctypes.POINTER(ctypes.c_wchar_p)] + + CoTaskMemFree = ctypes.windll.ole32.CoTaskMemFree + CoTaskMemFree.argtypes = [ctypes.c_void_p] # _winreg that ships with Python 2 doesn't support unicode, so do this instead - from ctypes.wintypes import * - HKEY_CURRENT_USER = 0x80000001 KEY_ALL_ACCESS = 0x000F003F REG_CREATED_NEW_KEY = 0x00000001 @@ -67,6 +74,15 @@ elif platform=='win32': RegDeleteValue.restype = LONG RegDeleteValue.argtypes = [HKEY, LPCWSTR] + def KnownFolderPath(guid): + buf = ctypes.c_wchar_p() + if SHGetKnownFolderPath(ctypes.create_string_buffer(guid.bytes_le), 0, 0, ctypes.byref(buf)): + return None + retval = buf.value # copy data + CoTaskMemFree(buf) # and free original + return retval + + elif platform=='linux2': import codecs # requires python-iniparse package - ConfigParser that ships with Python < 3.2 doesn't support unicode @@ -83,9 +99,12 @@ class Config: # OUT_SYS_FILE = 32 # No longer supported # OUT_STAT = 64 # No longer available OUT_SHIP_CORIOLIS = 128 + OUT_STATION_ANY = OUT_MKT_EDDN|OUT_MKT_BPC|OUT_MKT_TD|OUT_MKT_CSV|OUT_SHIP_EDS|OUT_SHIP_CORIOLIS OUT_SYS_EDSM = 256 # OUT_SYS_AUTO = 512 # Now always automatic OUT_MKT_MANUAL = 1024 + OUT_SYS_EDDN = 2048 + OUT_SYS_DELAY = 4096 if platform=='darwin': @@ -98,6 +117,8 @@ class Config: if not isdir(self.plugin_dir): mkdir(self.plugin_dir) + self.default_journal_dir = join(NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, True)[0], 'Frontier Developments', 'Elite Dangerous') + self.home = expanduser('~') self.respath = getattr(sys, 'frozen', False) and normpath(join(dirname(sys.executable), pardir, 'Resources')) or dirname(__file__) @@ -146,19 +167,19 @@ class Config: def __init__(self): - buf = ctypes.create_unicode_buffer(MAX_PATH) - ctypes.windll.shell32.SHGetSpecialFolderPathW(0, buf, CSIDL_LOCAL_APPDATA, 0) - self.app_dir = join(buf.value, appname) + self.app_dir = join(KnownFolderPath(FOLDERID_LocalAppData), appname) if not isdir(self.app_dir): mkdir(self.app_dir) - + self.plugin_dir = join(self.app_dir, 'plugins') if not isdir(self.plugin_dir): mkdir(self.plugin_dir) # expanduser in Python 2 on Windows doesn't handle non-ASCII - http://bugs.python.org/issue13207 - ctypes.windll.shell32.SHGetSpecialFolderPathW(0, buf, CSIDL_PROFILE, 0) - self.home = buf.value + self.home = KnownFolderPath(FOLDERID_Profile) or u'\\' + + journaldir = KnownFolderPath(FOLDERID_SavedGames) + self.default_journal_dir = journaldir and join(journaldir, 'Frontier Developments', 'Elite Dangerous') or None self.respath = dirname(getattr(sys, 'frozen', False) and sys.executable or __file__) @@ -187,9 +208,7 @@ class Config: RegCloseKey(sparklekey) if not self.get('outdir') or not isdir(self.get('outdir')): - buf = ctypes.create_unicode_buffer(MAX_PATH) - ctypes.windll.shell32.SHGetSpecialFolderPathW(0, buf, CSIDL_PERSONAL, 0) - self.set('outdir', buf.value) + self.set('outdir', KnownFolderPath(FOLDERID_Documents)) def get(self, key): typ = DWORD() @@ -251,6 +270,8 @@ class Config: if not isdir(self.plugin_dir): mkdir(self.plugin_dir) + self.default_journal_dir = None + self.home = expanduser('~') self.respath = dirname(__file__) diff --git a/eddn.py b/eddn.py index 48e59889..2d00a84b 100644 --- a/eddn.py +++ b/eddn.py @@ -4,12 +4,20 @@ from collections import OrderedDict import hashlib import json import numbers +from os import SEEK_SET, SEEK_CUR, SEEK_END +from os.path import exists, join from platform import system import re import requests from sys import platform import time +if 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 @@ -25,6 +33,39 @@ class _EDDN: def __init__(self): self.session = requests.Session() + self.replayfile = None # For delayed messages + + 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+') + except: + if exists(filename): + raise # Couldn't open existing file + else: + self.replayfile = open(filename, 'w+') # Create file + if 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 + return True + + def flush(self): + self.replayfile.seek(0, SEEK_SET) + for line in self.replayfile: + self.send(*json.loads(line, object_pairs_hook=OrderedDict)) + self.replayfile.truncate(0) + + def close(self): + if self.replayfile: + self.replayfile.close() def send(self, cmdr, msg): msg['header'] = { @@ -32,7 +73,8 @@ class _EDDN: 'softwareVersion' : appversion, 'uploaderID' : config.getint('anonymous') and hashlib.md5(cmdr.encode('utf-8')).hexdigest() or cmdr.encode('utf-8'), } - msg['message']['timestamp'] = time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime(config.getint('querytime') or int(time.time()))) + if not msg['message'].get('timestamp'): # already present in journal messages + msg['message']['timestamp'] = time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime(config.getint('querytime') or int(time.time()))) r = self.session.post(self.UPLOAD, data=json.dumps(msg), timeout=timeout) if __debug__ and r.status_code != requests.codes.ok: @@ -94,5 +136,27 @@ class _EDDN: } }) + def export_journal_entry(self, cmdr, is_beta, entry): + if config.getint('output') & config.OUT_SYS_DELAY and self.replayfile and entry['event'] != 'Docked': + self.replayfile.seek(0, SEEK_END) + self.replayfile.write('%s\n' % json.dumps([cmdr.encode('utf-8'), { + '$schemaRef' : 'http://schemas.elite-markets.net/eddn/journal/1' + (is_beta and '/test' or ''), + 'message' : entry + }])) + self.replayfile.flush() + else: + self.flush() + self.send(cmdr, { + '$schemaRef' : 'http://schemas.elite-markets.net/eddn/journal/1' + (is_beta and '/test' or ''), + 'message' : entry + }) + + def export_blackmarket(self, cmdr, is_beta, msg): + self.send(cmdr, { + '$schemaRef' : 'http://schemas.elite-markets.net/eddn/blackmarket/1' + (is_beta and '/test' or ''), + 'message' : msg + }) + + # singleton eddn = _EDDN() diff --git a/monitor.py b/monitor.py index 55db5d7c..547368e4 100644 --- a/monitor.py +++ b/monitor.py @@ -1,4 +1,6 @@ import atexit +from collections import OrderedDict +import json import re import threading from os import listdir, pardir, rename, unlink, SEEK_SET, SEEK_CUR, SEEK_END @@ -6,8 +8,7 @@ from os.path import basename, exists, isdir, isfile, join from platform import machine import sys from sys import platform -from time import strptime, localtime, mktime, sleep, time -from datetime import datetime +from time import sleep if __debug__: from traceback import print_exc @@ -59,64 +60,26 @@ else: class EDLogs(FileSystemEventHandler): - _POLL = 5 # New system gets posted to log file before hyperspace ends, so don't need to poll too often + _POLL = 1 # Polling is cheap, so do it often def __init__(self): FileSystemEventHandler.__init__(self) # futureproofing - not need for current version of watchdog self.root = None - self.logdir = self._logdir() # E:D client's default Logs directory, or None if not found self.currentdir = None # The actual logdir that we're monitoring self.logfile = None self.observer = None - self.observed = None + self.observed = None # a watchdog ObservedWatch, or None if polling self.thread = None - self.callbacks = { 'Jump': None, 'Dock': None } - self.last_event = None # for communicating the Jump event + self.event_queue = [] # For communicating journal entries back to main thread - def logging_enabled_in_file(self, appconf): - if not isfile(appconf): - return False - - with open(appconf, 'rU') as f: - content = f.read().lower() - start = content.find('') - if start >= 0 and end >= 0: - return bool(re.search('verboselogging\s*=\s*\"1\"', content[start+8:end])) - else: - return False - - def enable_logging_in_file(self, appconf): - try: - if not exists(appconf): - with open(appconf, 'wt') as f: - f.write('\n\t\n\t\n\n') - return True - - with open(appconf, 'rU') as f: - content = f.read() - f.close() - backup = appconf[:-4] + '_backup.xml' - if exists(backup): - unlink(backup) - rename(appconf, backup) - - with open(appconf, 'wt') as f: - start = content.lower().find('= 0: - f.write(content[:start+8] + '\n\t\tVerboseLogging="1"' + content[start+8:]) - else: - start = content.lower().find("") - if start >= 0: - f.write(content[:start] + '\t\n\t\n' + content[start:]) - else: - f.write(content) # eh ? - return False - - return self.logging_enabled_in_file(appconf) - except: - if __debug__: print_exc() - return False + # Context for journal handling + self.version = None + self.is_beta = False + self.mode = None + self.cmdr = None + self.system = None + self.station = None + self.coordinates = None def set_callback(self, name, callback): if name in self.callbacks: @@ -124,8 +87,8 @@ class EDLogs(FileSystemEventHandler): def start(self, root): self.root = root - logdir = config.get('logdir') or self.logdir - if not self.is_valid_logdir(logdir): + logdir = config.get('journaldir') or config.default_journal_dir + if not logdir or not exists(logdir): self.stop() return False @@ -133,18 +96,20 @@ class EDLogs(FileSystemEventHandler): self.stop() self.currentdir = logdir - if not self._logging_enabled(self.currentdir): - # verbose logging reduces likelihood that Docked/Undocked messages will be delayed - self._enable_logging(self.currentdir) - - self.root.bind_all('<>', self.jump) # user-generated - self.root.bind_all('<>', self.dock) # user-generated + # Latest pre-existing logfile - e.g. if E:D is already running. Assumes logs sort alphabetically. + # Do this before setting up the observer in case the journal directory has gone away + try: + logfiles = sorted([x for x in listdir(self.currentdir) if x.startswith('Journal.')]) + self.logfile = logfiles and join(self.currentdir, logfiles[-1]) or None + except: + self.logfile = None + return False # Set up a watchog observer. This is low overhead so is left running irrespective of whether monitoring is desired. # File system events are unreliable/non-existent over network drives on Linux. # We can't easily tell whether a path points to a network drive, so assume # any non-standard logdir might be on a network drive and poll instead. - polling = bool(config.get('logdir')) and platform != 'win32' + polling = bool(config.get('journaldir')) and platform != 'win32' if not polling and not self.observer: self.observer = Observer() self.observer.daemon = True @@ -154,19 +119,12 @@ class EDLogs(FileSystemEventHandler): if not self.observed and not polling: self.observed = self.observer.schedule(self, self.currentdir) - # Latest pre-existing logfile - e.g. if E:D is already running. Assumes logs sort alphabetically. - try: - logfiles = sorted([x for x in listdir(logdir) if x.startswith('netLog.')]) - self.logfile = logfiles and join(logdir, logfiles[-1]) or None - except: - self.logfile = None - if __debug__: - print '%s "%s"' % (polling and 'Polling' or 'Monitoring', logdir) + print '%s "%s"' % (polling and 'Polling' or 'Monitoring', self.currentdir) print 'Start logfile "%s"' % self.logfile if not self.running(): - self.thread = threading.Thread(target = self.worker, name = 'netLog worker') + self.thread = threading.Thread(target = self.worker, name = 'Journal worker') self.thread.daemon = True self.thread.start() @@ -176,18 +134,19 @@ class EDLogs(FileSystemEventHandler): if __debug__: print 'Stopping monitoring' self.currentdir = None + self.version = self.mode = self.cmdr = self.system = self.station = self.coordinates = None + self.is_beta = False if self.observed: self.observed = None self.observer.unschedule_all() self.thread = None # Orphan the worker thread - will terminate at next poll - self.last_event = None def running(self): return self.thread and self.thread.is_alive() def on_created(self, event): # watchdog callback, e.g. client (re)started. - if not event.is_directory and basename(event.src_path).startswith('netLog.'): + if not event.is_directory and basename(event.src_path).startswith('Journal.'): self.logfile = event.src_path def worker(self): @@ -195,30 +154,17 @@ class EDLogs(FileSystemEventHandler): # event_generate() is the only safe way to poke the main thread from this thread: # https://mail.python.org/pipermail/tkinter-discuss/2013-November/003522.html - # e.g.: - # "{18:00:41} System:"Shinrarta Dezhra" StarPos:(55.719,17.594,27.156)ly NormalFlight\r\n" - # or with verboseLogging: - # "{17:20:18} System:"Shinrarta Dezhra" StarPos:(55.719,17.594,27.156)ly Body:69 RelPos:(0.334918,1.20754,1.23625)km NormalFlight\r\n" - # or: - # "... Supercruise\r\n" - # Note that system name may contain parantheses, e.g. "Pipe (stem) Sector PI-T c3-5". - regexp = re.compile(r'\{(.+)\} System:"(.+)" StarPos:\((.+),(.+),(.+)\)ly.* (\S+)') # (localtime, system, x, y, z, context) - - # e.g.: - # "{14:42:11} GetSafeUniversalAddress Station Count 1 moved 0 Docked Not Landed\r\n" - # or: - # "... Undocked Landed\r\n" - # Don't use the simpler "Commander Put ..." message since its more likely to be delayed. - dockre = re.compile(r'\{(.+)\} GetSafeUniversalAddress Station Count \d+ moved \d+ (\S+) ([^\r\n]+)') # (localtime, docked_status, landed_status) - - docked = False # Whether we're docked - updated = False # Whether we've sent an update since we docked - # Seek to the end of the latest log file logfile = self.logfile if logfile: loghandle = open(logfile, 'r') - loghandle.seek(0, SEEK_END) # seek to EOF + for line in loghandle: + try: + self.parse_entry(line) # Some events are of interest even in the past + except: + if __debug__: + print 'Invalid journal entry "%s"' % repr(line) + self.root.event_generate('<>', when="tail") # Generate null event to update the display at start else: loghandle = None @@ -227,19 +173,13 @@ class EDLogs(FileSystemEventHandler): while True: - if docked and not updated and not config.getint('output') & config.OUT_MKT_MANUAL: - self.root.event_generate('<>', when="tail") - updated = True - if __debug__: - print "%s :\t%s %s" % ('Updated', docked and " docked" or "!docked", updated and " updated" or "!updated") - # Check whether new log file started, e.g. client (re)started. if emitter and emitter.is_alive(): newlogfile = self.logfile # updated by on_created watchdog callback else: # Poll try: - logfiles = sorted([x for x in listdir(self.currentdir) if x.startswith('netLog.')]) + logfiles = sorted([x for x in listdir(self.currentdir) if x.startswith('Journal.')]) newlogfile = logfiles and join(self.currentdir, logfiles[-1]) or None except: if __debug__: print_exc() @@ -255,37 +195,11 @@ class EDLogs(FileSystemEventHandler): print 'New logfile "%s"' % logfile if logfile: - system = visited = coordinates = None loghandle.seek(0, SEEK_CUR) # reset EOF flag - for line in loghandle: - match = regexp.match(line) - if match: - (visited, system, x, y, z, context) = match.groups() - if system == 'ProvingGround': - system = 'CQC' - coordinates = (float(x), float(y), float(z)) - else: - match = dockre.match(line) - if match: - if match.group(2) == 'Undocked': - docked = updated = False - elif match.group(2) == 'Docked': - docked = True - # do nothing now in case the API server is lagging, but update on next poll - if __debug__: - print "%s :\t%s %s" % (match.group(2), docked and " docked" or "!docked", updated and " updated" or "!updated") - - if system and not docked and config.getint('output'): - # Convert local time string to UTC date and time - visited_struct = strptime(visited, '%H:%M:%S') - now = localtime() - if now.tm_hour == 0 and visited_struct.tm_hour == 23: - # Crossed midnight between timestamp and poll - now = localtime(time()-12*60*60) # yesterday - time_struct = datetime(now.tm_year, now.tm_mon, now.tm_mday, visited_struct.tm_hour, visited_struct.tm_min, visited_struct.tm_sec).timetuple() # still local time - self.last_event = (mktime(time_struct), system, coordinates) - self.root.event_generate('<>', when="tail") + self.event_queue.append(line) + if self.event_queue: + self.root.event_generate('<>', when="tail") sleep(self._POLL) @@ -293,166 +207,38 @@ class EDLogs(FileSystemEventHandler): if threading.current_thread() != self.thread: return # Terminate - def jump(self, event): - # Called from Tkinter's main loop - if self.callbacks['Jump'] and self.last_event: - self.callbacks['Jump'](event, *self.last_event) - - def dock(self, event): - # Called from Tkinter's main loop - if self.callbacks['Dock']: - self.callbacks['Dock']() - - def is_valid_logdir(self, path): - return self._is_valid_logdir(path) - - - if platform=='darwin': - - def _logdir(self): - # https://support.frontier.co.uk/kb/faq.php?id=97 - paths = NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, True) - if len(paths) and self._is_valid_logdir(join(paths[0], 'Frontier Developments', 'Elite Dangerous', 'Logs')): - return join(paths[0], 'Frontier Developments', 'Elite Dangerous', 'Logs') - else: - return None - - def _is_valid_logdir(self, path): - # Apple's SMB implementation is too flaky so assume target machine is OSX - return path and isdir(path) and isfile(join(path, pardir, 'AppNetCfg.xml')) - - def _logging_enabled(self, path): - if not self._is_valid_logdir(path): - return False - else: - return self.logging_enabled_in_file(join(path, pardir, 'AppConfigLocal.xml')) - - def _enable_logging(self, path): - if not self._is_valid_logdir(path): - return False - else: - return self.enable_logging_in_file(join(path, pardir, 'AppConfigLocal.xml')) - - - elif platform=='win32': - - def _logdir(self): - - # Try locations described in https://support.elitedangerous.com/kb/faq.php?id=108, in reverse order of age - candidates = [] - - # Steam and Steam libraries - key = HKEY() - if not RegOpenKeyEx(HKEY_CURRENT_USER, r'Software\Valve\Steam', 0, KEY_READ, ctypes.byref(key)): - valtype = DWORD() - valsize = DWORD() - if not RegQueryValueEx(key, 'SteamPath', 0, ctypes.byref(valtype), None, ctypes.byref(valsize)) and valtype.value == REG_SZ: - buf = ctypes.create_unicode_buffer(valsize.value / 2) - if not RegQueryValueEx(key, 'SteamPath', 0, ctypes.byref(valtype), buf, ctypes.byref(valsize)): - steampath = buf.value.replace('/', '\\') # For some reason uses POSIX seperators - steamlibs = [steampath] - try: - # Simple-minded Valve VDF parser - with open(join(steampath, 'config', 'config.vdf'), 'rU') as h: - for line in h: - vals = line.split() - if vals and vals[0].startswith('"BaseInstallFolder_'): - steamlibs.append(vals[1].strip('"').replace('\\\\', '\\')) - except: - pass - for lib in steamlibs: - candidates.append(join(lib, 'steamapps', 'common', 'Elite Dangerous', 'Products')) - RegCloseKey(key) - - # Next try custom installation under the Launcher - if not RegOpenKeyEx(HKEY_LOCAL_MACHINE, - machine().endswith('64') and - r'SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall' or # Assumes that the launcher is a 32bit process - r'SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall', - 0, KEY_READ, ctypes.byref(key)): - buf = ctypes.create_unicode_buffer(MAX_PATH) - i = 0 - while True: - size = DWORD(MAX_PATH) - if RegEnumKeyEx(key, i, buf, ctypes.byref(size), None, None, None, None): - break - - subkey = HKEY() - if not RegOpenKeyEx(key, buf, 0, KEY_READ, ctypes.byref(subkey)): - valtype = DWORD() - valsize = DWORD((len('Frontier Developments')+1)*2) - valbuf = ctypes.create_unicode_buffer(valsize.value / 2) - if not RegQueryValueEx(subkey, 'Publisher', 0, ctypes.byref(valtype), valbuf, ctypes.byref(valsize)) and valtype.value == REG_SZ and valbuf.value == 'Frontier Developments': - if not RegQueryValueEx(subkey, 'InstallLocation', 0, ctypes.byref(valtype), None, ctypes.byref(valsize)) and valtype.value == REG_SZ: - valbuf = ctypes.create_unicode_buffer(valsize.value / 2) - if not RegQueryValueEx(subkey, 'InstallLocation', 0, ctypes.byref(valtype), valbuf, ctypes.byref(valsize)): - candidates.append(join(valbuf.value, 'Products')) - RegCloseKey(subkey) - i += 1 - RegCloseKey(key) - - # Standard non-Steam locations - programs = ctypes.create_unicode_buffer(MAX_PATH) - ctypes.windll.shell32.SHGetSpecialFolderPathW(0, programs, CSIDL_PROGRAM_FILESX86, 0) - candidates.append(join(programs.value, 'Frontier', 'Products')), - - applocal = ctypes.create_unicode_buffer(MAX_PATH) - ctypes.windll.shell32.SHGetSpecialFolderPathW(0, applocal, CSIDL_LOCAL_APPDATA, 0) - candidates.append(join(applocal.value, 'Frontier_Developments', 'Products')) - - for game in ['elite-dangerous-64', 'FORC-FDEV-D-1']: # Look for Horizons in all candidate places first - for base in candidates: - if isdir(base): - for d in listdir(base): - if d.startswith(game) and self._is_valid_logdir(join(base, d, 'Logs')): - return join(base, d, 'Logs') + def parse_entry(self, line): + try: + entry = json.loads(line, object_pairs_hook=OrderedDict) # Preserve property order because why not? + entry['timestamp'] # we expect this to exist + if entry['event'] == 'Fileheader': + self.version = entry['gameversion'] + self.is_beta = 'beta' in entry['gameversion'].lower() + elif entry['event'] == 'LoadGame': + self.cmdr = entry['Commander'] + self.mode = entry.get('GameMode') # 'Open', 'Solo', 'Group', or None for CQC + elif entry['event'] == 'NewCommander': + self.cmdr = entry['Name'] + elif entry['event'] in ['Undocked']: + self.station = None + elif entry['event'] in ['Location', 'FSDJump', 'Docked']: + if 'StarPos' in entry: + self.coordinates = tuple(entry['StarPos']) + elif self.system != entry['StarSystem']: + self.coordinates = None # Docked event doesn't include coordinates + self.system = entry['StarSystem'] == 'ProvingGround' and 'CQC' or entry['StarSystem'] + self.station = entry.get('StationName') # May be None + return entry + except: + if __debug__: + print 'Invalid journal entry "%s"' % repr(line) + return { 'event': None } + def get_entry(self): + if not self.event_queue: return None - - def _is_valid_logdir(self, path): - # Assume target machine is Windows - return path and isdir(path) and isfile(join(path, pardir, 'AppConfig.xml')) - - def _logging_enabled(self, path): - if not self._is_valid_logdir(path): - return False - else: - return (self.logging_enabled_in_file(join(path, pardir, 'AppConfigLocal.xml')) or - self.logging_enabled_in_file(join(path, pardir, 'AppConfig.xml'))) - - def _enable_logging(self, path): - if not self._is_valid_logdir(path): - return False - else: - return self.enable_logging_in_file(isfile(join(path, pardir, 'AppConfigLocal.xml')) and - join(path, pardir, 'AppConfigLocal.xml') or - join(path, pardir, 'AppConfig.xml')) - - - elif platform=='linux2': - - def _logdir(self): - return None - - # Assume target machine is Windows - - def _is_valid_logdir(self, path): - return path and isdir(path) and isfile(join(path, pardir, 'AppConfig.xml')) - - def _logging_enabled(self, path): - if not self._is_valid_logdir(path): - return False - else: - return (self.logging_enabled_in_file(join(path, pardir, 'AppConfigLocal.xml')) or - self.logging_enabled_in_file(join(path, pardir, 'AppConfig.xml'))) - - def _enable_logging(self, path): - if not self._is_valid_logdir(path): - return False - else: - return self.enable_logging_in_file(isfile(join(path, pardir, 'AppConfigLocal.xml')) and - join(path, pardir, 'AppConfigLocal.xml') or - join(path, pardir, 'AppConfig.xml')) + else: + return self.parse_entry(self.event_queue.pop(0)) # singleton diff --git a/plug.py b/plug.py index 6920b90f..7f96c354 100644 --- a/plug.py +++ b/plug.py @@ -87,12 +87,33 @@ def get_plugin_pref(plugname, parent): return None +def notify_journal_entry(cmdr, system, station, entry): + """ + Send a journal entry to each plugin. + :param cmdr: The Cmdr name, or None if not yet known + :param system: The current system, or None if not yet known + :param station: The current station, or None if not docked or not yet known + :param entry: The journal entry as a dictionary + :return: + """ + for plugname in PLUGINS: + journal_entry = _get_plugin_func(plugname, "journal_entry") + if journal_entry: + try: + # Pass a copy of the journal entry in case the callee modifies it + journal_entry(cmdr, system, station, dict(entry)) + except Exception as plugerr: + print plugerr + + def notify_system_changed(timestamp, system, coordinates): """ Send notification data to each plugin when we arrive at a new system. :param timestamp: :param system: :return: + deprecated:: 2.2 + Use :func:`journal_entry` with the 'FSDJump' event. """ for plugname in PLUGINS: system_changed = _get_plugin_func(plugname, "system_changed") diff --git a/plugins/About/load.py b/plugins/About/load.py index 5441f83f..1dbc2e02 100644 --- a/plugins/About/load.py +++ b/plugins/About/load.py @@ -45,18 +45,21 @@ def plugin_app(parent): return plugin_app.status -def system_changed(timestamp, system, coordinates): +def journal_entry(cmdr, system, station, entry): """ - Arrived in a new System - :param timestamp: when we arrived - :param system: the name of the system - :param coordinates: tuple of (x,y,z) ly relative to Sol, or None if unknown + E:D client made a journal entry + :param cmdr: The Cmdr name, or None if not yet known + :param system: The current system, or None if not yet known + :param station: The current station, or None if not docked or not yet known + :param entry: The journal entry as a dictionary :return: """ - if coordinates: - sys.stderr.write("Arrived at {} ({},{},{})\n".format(system, *coordinates)) - else: - sys.stderr.write("Arrived at {}\n".format(system)) + if entry['event'] == 'FSDJump': + # We arrived at a new system! + if 'StarPos' in entry: + sys.stderr.write("Arrived at {} ({},{},{})\n".format(entry['StarSystem'], *tuple(entry['StarPos']))) + else: + sys.stderr.write("Arrived at {}\n".format(entry['StarSystem'])) def cmdr_data(data): diff --git a/prefs.py b/prefs.py index 90c34e7f..f8c9a396 100644 --- a/prefs.py +++ b/prefs.py @@ -1,7 +1,7 @@ #!/usr/bin/python # -*- coding: utf-8 -*- -from os.path import dirname, expanduser, isdir, join, sep +from os.path import dirname, expanduser, exists, isdir, join, sep from sys import platform import Tkinter as tk @@ -11,6 +11,7 @@ from ttkHyperlinkLabel import HyperlinkLabel import myNotebook as nb from config import applongname, config +from eddn import eddn from hotkey import hotkeymgr from l10n import Translations from monitor import monitor @@ -113,11 +114,9 @@ class PreferencesDialog(tk.Toplevel): outframe = nb.Frame(notebook) outframe.columnconfigure(0, weight=1) - output = config.getint('output') or (config.OUT_MKT_EDDN | config.OUT_SHIP_EDS) # default settings + output = config.getint('output') or (config.OUT_MKT_EDDN | config.OUT_SYS_EDDN | config.OUT_SHIP_EDS) # default settings nb.Label(outframe, text=_('Please choose what data to save')).grid(columnspan=2, padx=PADX, sticky=tk.W) - self.out_eddn= tk.IntVar(value = (output & config.OUT_MKT_EDDN) and 1) - nb.Checkbutton(outframe, text=_('Send station data to the Elite Dangerous Data Network'), variable=self.out_eddn, command=self.outvarchanged).grid(columnspan=2, padx=BUTTONX, sticky=tk.W) self.out_csv = tk.IntVar(value = (output & config.OUT_MKT_CSV ) and 1) nb.Checkbutton(outframe, text=_('Market data in CSV format file'), variable=self.out_csv, command=self.outvarchanged).grid(columnspan=2, padx=BUTTONX, sticky=tk.W) self.out_bpc = tk.IntVar(value = (output & config.OUT_MKT_BPC ) and 1) @@ -149,13 +148,30 @@ class PreferencesDialog(tk.Toplevel): notebook.add(outframe, text=_('Output')) # Tab heading in settings + eddnframe = nb.Frame(notebook) + + HyperlinkLabel(eddnframe, text='Elite Dangerous Data Network', background=nb.Label().cget('background'), url='https://github.com/jamesremuscat/EDDN/wiki', underline=True).grid(padx=PADX, sticky=tk.W) # Don't translate + self.eddn_station= tk.IntVar(value = (output & config.OUT_MKT_EDDN) and 1) + nb.Checkbutton(eddnframe, text=_('Send station data to the Elite Dangerous Data Network'), variable=self.eddn_station, command=self.outvarchanged).grid(padx=BUTTONX, pady=(5,0), sticky=tk.W) # Output setting + self.eddn_auto_button = nb.Checkbutton(eddnframe, text=_('Automatically update on docking'), variable=self.out_auto, command=self.outvarchanged) # Output setting + self.eddn_auto_button.grid(padx=BUTTONX, sticky=tk.W) + self.eddn_system = tk.IntVar(value = (output & config.OUT_SYS_EDDN) and 1) + self.eddn_system_button = nb.Checkbutton(eddnframe, text=_('Send system and scan data to the Elite Dangerous Data Network'), variable=self.eddn_system, command=self.outvarchanged) # Output setting new in E:D 2.2 + self.eddn_system_button.grid(padx=BUTTONX, pady=(5,0), sticky=tk.W) + self.eddn_delay= tk.IntVar(value = (output & config.OUT_SYS_DELAY) and 1) + self.eddn_delay_button = nb.Checkbutton(eddnframe, text=_('Delay sending until docked'), variable=self.eddn_delay, command=self.outvarchanged) # Output setting under 'Send system and scan data to the Elite Dangerous Data Network' new in E:D 2.2 + self.eddn_delay_button.grid(padx=BUTTONX, sticky=tk.W) + + notebook.add(eddnframe, text='EDDN') # Not translated + + edsmframe = nb.Frame(notebook) edsmframe.columnconfigure(1, weight=1) HyperlinkLabel(edsmframe, 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 self.edsm_log = tk.IntVar(value = (output & config.OUT_SYS_EDSM) and 1) self.edsm_log_button = nb.Checkbutton(edsmframe, text=_('Send flight log to Elite Dangerous Star Map'), variable=self.edsm_log, command=self.outvarchanged) - self.edsm_log_button.grid(columnspan=2, padx=BUTTONX, sticky=tk.W) + self.edsm_log_button.grid(columnspan=2, padx=BUTTONX, pady=(5,0), sticky=tk.W) nb.Label(edsmframe).grid(sticky=tk.W) # big spacer self.edsm_label = HyperlinkLabel(edsmframe, text=_('Elite Dangerous Star Map credentials'), background=nb.Label().cget('background'), url='https://www.edsm.net/settings/api', underline=True) # Section heading in settings @@ -179,7 +195,7 @@ class PreferencesDialog(tk.Toplevel): configframe.columnconfigure(1, weight=1) self.logdir = nb.Entry(configframe, takefocus=False) - logdir = config.get('logdir') or monitor.logdir + logdir = config.get('journaldir') or config.default_journal_dir if not logdir: pass elif logdir.startswith(config.home): @@ -190,13 +206,14 @@ class PreferencesDialog(tk.Toplevel): if platform != 'darwin': # Apple's SMB implementation is way too flaky - no filesystem events and bogus NULLs - nb.Label(configframe, text = _('E:D log file location')+':').grid(columnspan=3, padx=PADX, sticky=tk.W) # Configuration setting + nb.Label(configframe, text = _('E:D journal file location')+':').grid(columnspan=3, padx=PADX, sticky=tk.W) # Location of the new Journal file in E:D 2.2 self.logdir.grid(row=10, columnspan=2, padx=(PADX,0), sticky=tk.EW) self.logbutton = nb.Button(configframe, text=(platform=='darwin' and _('Change...') or # Folder selection button on OSX _('Browse...')), # Folder selection button on Windows - command = lambda:self.filebrowse(_('E:D log file location'), self.logdir)) + command = lambda:self.filebrowse(_('E:D journal file location'), self.logdir)) self.logbutton.grid(row=10, column=2, padx=PADX, sticky=tk.EW) - nb.Button(configframe, text=_('Default'), command=self.logdir_reset, state = monitor.logdir and tk.NORMAL or tk.DISABLED).grid(column=2, padx=PADX, pady=(5,0), sticky=tk.EW) # Appearance theme and language setting + if config.default_journal_dir: + nb.Button(configframe, text=_('Default'), command=self.logdir_reset, state = config.get('journaldir') and tk.NORMAL or tk.DISABLED).grid(column=2, padx=PADX, pady=(5,0), sticky=tk.EW) # Appearance theme and language setting if platform == 'win32': ttk.Separator(configframe, orient=tk.HORIZONTAL).grid(columnspan=3, padx=PADX, pady=PADY*8, sticky=tk.EW) @@ -295,14 +312,18 @@ class PreferencesDialog(tk.Toplevel): def outvarchanged(self): logdir = self.logdir.get().startswith('~') and join(config.home, self.logdir.get()[2:]) or self.logdir.get() - logvalid = monitor.is_valid_logdir(logdir) + logvalid = logdir and exists(logdir) local = self.out_bpc.get() or self.out_td.get() or self.out_csv.get() or self.out_ship_eds.get() or self.out_ship_coriolis.get() - self.out_auto_button['state'] = (local or self.out_eddn.get()) and logvalid and tk.NORMAL or tk.DISABLED + self.out_auto_button['state'] = local and logvalid and not monitor.is_beta and tk.NORMAL or tk.DISABLED self.outdir_label['state'] = local and tk.NORMAL or tk.DISABLED self.outbutton['state'] = local and tk.NORMAL or tk.DISABLED self.outdir['state'] = local and 'readonly' or tk.DISABLED + self.eddn_auto_button['state'] = self.eddn_station.get() and logvalid and not monitor.is_beta and tk.NORMAL or tk.DISABLED + self.eddn_system_button['state']= logvalid and tk.NORMAL or tk.DISABLED + self.eddn_delay_button['state'] = logvalid and eddn.replayfile and self.eddn_system.get() and tk.NORMAL or tk.DISABLED + self.edsm_log_button['state'] = logvalid and tk.NORMAL or tk.DISABLED edsm_state = logvalid and self.edsm_log.get() and tk.NORMAL or tk.DISABLED self.edsm_label['state'] = edsm_state @@ -350,12 +371,12 @@ class PreferencesDialog(tk.Toplevel): def logdir_reset(self): self.logdir['state'] = tk.NORMAL # must be writable to update self.logdir.delete(0, tk.END) - if not monitor.logdir: - pass - elif monitor.logdir.startswith(config.home): - self.logdir.insert(0, '~' + monitor.logdir[len(config.home):]) + if not config.default_journal_dir: + pass # Can't reset + elif config.default_journal_dir.startswith(config.home): + self.logdir.insert(0, '~' + config.default_journal_dir[len(config.home):]) else: - self.logdir.insert(0, monitor.logdir) + self.logdir.insert(0, config.default_journal_dir) self.logdir['state'] = 'readonly' self.outvarchanged() @@ -425,13 +446,15 @@ class PreferencesDialog(tk.Toplevel): config.set('password', self.password.get().strip()) config.set('output', - (self.out_eddn.get() and config.OUT_MKT_EDDN) + (self.out_bpc.get() and config.OUT_MKT_BPC) + (self.out_td.get() and config.OUT_MKT_TD) + (self.out_csv.get() and config.OUT_MKT_CSV) + (config.OUT_MKT_MANUAL if not self.out_auto.get() else 0) + (self.out_ship_eds.get() and config.OUT_SHIP_EDS) + (self.out_ship_coriolis.get() and config.OUT_SHIP_CORIOLIS) + + (self.eddn_station.get() and config.OUT_MKT_EDDN) + + (self.eddn_system.get() and config.OUT_SYS_EDDN) + + (self.eddn_delay.get() and config.OUT_SYS_DELAY) + (self.edsm_log.get() and config.OUT_SYS_EDSM)) config.set('outdir', self.outdir.get().startswith('~') and join(config.home, self.outdir.get()[2:]) or self.outdir.get()) @@ -439,10 +462,10 @@ class PreferencesDialog(tk.Toplevel): config.set('edsm_apikey', self.edsm_apikey.get().strip()) logdir = self.logdir.get().startswith('~') and join(config.home, self.logdir.get()[2:]) or self.logdir.get() - if monitor.logdir and logdir.lower() == monitor.logdir.lower(): - config.set('logdir', '') # default location + if config.default_journal_dir and logdir.lower() == config.default_journal_dir.lower(): + config.set('journaldir', '') # default location else: - config.set('logdir', logdir) + config.set('journaldir', logdir) if platform in ['darwin','win32']: config.set('hotkey_code', self.hotkey_code) config.set('hotkey_mods', self.hotkey_mods) @@ -466,9 +489,6 @@ class PreferencesDialog(tk.Toplevel): self.callback() def _destroy(self): - # Re-enable hotkey and log monitoring before exit - hotkeymgr.register(self.parent, config.getint('hotkey_code'), config.getint('hotkey_mods')) - monitor.start(self.parent) self.parent.wm_attributes('-topmost', config.getint('always_ontop') and 1 or 0) self.destroy()