diff --git a/EDMarketConnector.py b/EDMarketConnector.py index b9c84705..acb5aa43 100755 --- a/EDMarketConnector.py +++ b/EDMarketConnector.py @@ -62,6 +62,7 @@ import prefs import plug from hotkey import hotkeymgr from monitor import monitor +from interactions import interactions from theme import theme EDDB = eddb.EDDB() @@ -285,6 +286,7 @@ class AppWindow: 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.interaction_event) # cmdrHistory monitoring self.w.bind_all('<>', self.onexit) # Updater # Load updater after UI creation (for WinSparkle) @@ -326,6 +328,9 @@ class AppWindow: # (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 + elif monitor.started: + interactions.start(self.w, monitor.started) + if dologin: self.login() # Login if not already logged in with this Cmdr @@ -702,6 +707,11 @@ class AppWindow: # Plugins plug.notify_journal_entry(monitor.cmdr, monitor.system, monitor.station, entry, monitor.state) + if entry['event'] in ['StartUp', 'LoadGame'] and monitor.started: + # Can start interaction monitoring + if not interactions.start(self.w, monitor.started): + self.status['text'] = 'Error: Check %s' % _('E:D interaction log location') # Setting for the log file that contains recent interactions with other Cmdrs + # Don't send to EDDN while on crew if monitor.captain: return @@ -768,6 +778,15 @@ class AppWindow: if not config.getint('hotkey_mute'): hotkeymgr.play_bad() + # Handle interaction event(s) from cmdrHistory + def interaction_event(self, event): + while True: + entry = interactions.get_entry() + if not entry: + return + + # Currently we don't do anything with these events + plug.notify_interaction(monitor.cmdr, entry) def edsmpoll(self): result = self.edsm.result @@ -879,6 +898,7 @@ class AppWindow: config.set('geometry', '+{1}+{2}'.format(*self.w.geometry().split('+'))) self.w.withdraw() # Following items can take a few seconds, so hide the main window while they happen hotkeymgr.unregister() + interactions.close() monitor.close() self.eddn.close() self.updater.close() diff --git a/PLUGINS.md b/PLUGINS.md index c8bd0b19..d8a5a00e 100644 --- a/PLUGINS.md +++ b/PLUGINS.md @@ -98,6 +98,17 @@ def journal_entry(cmdr, system, station, entry, state): sys.stderr.write("Arrived at {}\n".format(entry['StarSystem'])) ``` +### Player Interaction + +This gets called when the player interacts with another Cmdr in-game. + +If EDMC is started while the game is already running EDMC will send the last few interaction events from the current game session. + +``` +def interaction(cmdr, entry): + sys.stderr.write("Interacted: {} with Cmdr {}\n".format(', '.join(entry['Interactions']), entry['Name'].encode('utf-8')) +``` + ### Getting Commander Data This gets called when EDMC has just fetched fresh Cmdr and station data from Frontier's servers. diff --git a/config.py b/config.py index a2c2aa53..1b4ca58d 100644 --- a/config.py +++ b/config.py @@ -125,6 +125,8 @@ class Config: self.default_journal_dir = join(NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, True)[0], 'Frontier Developments', 'Elite Dangerous') + self.default_interaction_dir = join(self.default_journal_dir, 'CommanderHistory') + self.home = expanduser('~') self.respath = getattr(sys, 'frozen', False) and normpath(join(dirname(sys.executable), pardir, 'Resources')) or dirname(__file__) @@ -187,6 +189,8 @@ class Config: journaldir = KnownFolderPath(FOLDERID_SavedGames) self.default_journal_dir = journaldir and join(journaldir, 'Frontier Developments', 'Elite Dangerous') or None + self.default_interaction_dir = join(KnownFolderPath(FOLDERID_LocalAppData), 'Frontier Developments', 'Elite Dangerous', 'CommanderHistory') + self.respath = dirname(getattr(sys, 'frozen', False) and sys.executable or __file__) self.identifier = applongname @@ -280,6 +284,8 @@ class Config: self.default_journal_dir = None + self.default_interaction_dir = None + self.home = expanduser('~') self.respath = dirname(__file__) diff --git a/interactions.py b/interactions.py new file mode 100644 index 00000000..3ba56208 --- /dev/null +++ b/interactions.py @@ -0,0 +1,141 @@ +import json +from operator import itemgetter +from os import listdir, stat +from os.path import getmtime, isdir, join +from sys import platform + +if __debug__: + from traceback import print_exc + +from config import config + + +if platform=='darwin': + from watchdog.observers import Observer + from watchdog.events import FileSystemEventHandler + +elif platform=='win32': + from watchdog.observers import Observer + from watchdog.events import FileSystemEventHandler + +else: + # Linux's inotify doesn't work over CIFS or NFS, so poll + FileSystemEventHandler = object # dummy + + +# CommanderHistory handler +class Interactions(FileSystemEventHandler): + + _POLL = 5 # Fallback polling interval + + def __init__(self): + FileSystemEventHandler.__init__(self) # futureproofing - not need for current version of watchdog + self.root = None + self.currentdir = None # The actual logdir that we're monitoring + self.observer = None + self.observed = None # a watchdog ObservedWatch, or None if polling + self.seen = [] # interactions that we've already processed + self.interaction_queue = [] # For communicating interactions back to main thread + + def start(self, root, started): + self.root = root + self.session_start = started + + logdir = config.get('interactiondir') or config.default_interaction_dir + if not logdir or not isdir(logdir): + self.stop() + return False + + if self.currentdir and self.currentdir != logdir: + self.stop() + self.currentdir = logdir + + # Set up a watchdog observer. + # 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('interactiondir')) and platform != 'win32' + if not polling and not self.observer: + self.observer = Observer() + self.observer.daemon = True + self.observer.start() + + if not self.observed and not polling: + self.observed = self.observer.schedule(self, self.currentdir) + + if __debug__: + print '%s interactions "%s"' % (polling and 'Polling' or 'Monitoring', self.currentdir) + + # Even if we're not intending to poll, poll at least once to process pre-existing + # data and to check whether the watchdog thread has crashed due to events not\ + # being supported on this filesystem. + self.root.after(self._POLL * 1000/2, self.poll, True) + + return True + + def stop(self): + if __debug__: + print 'Stopping monitoring interactions' + self.currentdir = None + if self.observed: + self.observed = None + self.observer.unschedule_all() + + def close(self): + self.stop() + if self.observer: + self.observer.stop() + self.observer.join() + self.observer = None + + def poll(self, first_time=False): + self.process() + + if first_time: + # Watchdog thread + emitter = self.observed and self.observer._emitter_for_watch[self.observed] # Note: Uses undocumented attribute + if emitter and emitter.is_alive(): + return # Watchdog thread still running - stop polling + + self.root.after(self._POLL * 1000, self.poll) # keep polling + + def on_modified(self, event): + # watchdog callback - DirModifiedEvent on macOS, FileModifiedEvent on Windows + if event.is_directory or stat(event.src_path).st_size: # Can get on_modified events when the file is emptied + self.process(event.src_path if not event.is_directory else None) + + + # Can be called either in watchdog thread or, if polling, in main thread. The code assumes not both. + def process(self, logfile=None): + + if not logfile: + for logfile in [x for x in listdir(self.currentdir) if x.endswith('.cmdrHistory')]: + if self.session_start and getmtime(join(self.currentdir, logfile)) >= self.session_start: + break + else: + return + + try: + # cmdrHistory file is shared between beta and live. So filter out interactions not in this game session. + start = self.session_start + 11644473600 # Game time is 369 years in the future + + with open(join(self.currentdir, logfile), 'rb') as h: + current = [x for x in json.load(h)['Interactions'] if x['Epoch'] >= start] + + new = [x for x in current if x not in self.seen] # O(n^2) comparison but currently limited to 10x10 + self.interaction_queue.extend(sorted(new, key=itemgetter('Epoch'))) # sort by time + self.seen = current + + if self.interaction_queue: + self.root.event_generate('<>', when="tail") + except: + if __debug__: print_exc() + + def get_entry(self): + if not self.interaction_queue: + return None + else: + return self.interaction_queue.pop(0) + +# singleton +interactions = Interactions() diff --git a/monitor.py b/monitor.py index 035467bc..2416c500 100644 --- a/monitor.py +++ b/monitor.py @@ -1,14 +1,12 @@ -import atexit from collections import defaultdict, OrderedDict import json import re import threading -from os import listdir, pardir, rename, unlink, SEEK_SET, SEEK_CUR, SEEK_END -from os.path import basename, exists, isdir, isfile, join -from platform import machine -import sys +from os import listdir, SEEK_SET, SEEK_CUR, SEEK_END +from os.path import basename, isdir, join from sys import platform -from time import gmtime, sleep, strftime +from time import gmtime, sleep, strftime, strptime +from calendar import timegm if __debug__: from traceback import print_exc @@ -27,9 +25,6 @@ elif platform=='win32': from watchdog.events import FileSystemEventHandler import ctypes - CSIDL_LOCAL_APPDATA = 0x001C - CSIDL_PROGRAM_FILESX86 = 0x002A - # _winreg that ships with Python 2 doesn't support unicode, so do this instead from ctypes.wintypes import * @@ -70,6 +65,7 @@ else: FileSystemEventHandler = object # dummy +# Journal handler class EDLogs(FileSystemEventHandler): _POLL = 1 # Polling is cheap, so do it often @@ -134,7 +130,7 @@ class EDLogs(FileSystemEventHandler): self.system = None self.station = None self.coordinates = None - self.state = {} # Initialized in Fileheader + self.started = None # Timestamp of the LoadGame event # Cmdr state shared with EDSM and plugins self.state = { @@ -152,14 +148,10 @@ class EDLogs(FileSystemEventHandler): 'ShipType' : None, } - def set_callback(self, name, callback): - if name in self.callbacks: - self.callbacks[name] = callback - def start(self, root): self.root = root logdir = config.get('journaldir') or config.default_journal_dir - if not logdir or not exists(logdir): + if not logdir or not isdir(logdir): self.stop() return False @@ -176,7 +168,7 @@ class EDLogs(FileSystemEventHandler): self.logfile = None return False - # Set up a watchog observer. This is low overhead so is left running irrespective of whether monitoring is desired. + # Set up a watchdog observer. # 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. @@ -190,7 +182,7 @@ class EDLogs(FileSystemEventHandler): self.observed = self.observer.schedule(self, self.currentdir) if __debug__: - print '%s "%s"' % (polling and 'Polling' or 'Monitoring', self.currentdir) + print '%s Journal "%s"' % (polling and 'Polling' or 'Monitoring', self.currentdir) print 'Start logfile "%s"' % self.logfile if not self.running(): @@ -202,7 +194,7 @@ class EDLogs(FileSystemEventHandler): def stop(self): if __debug__: - print 'Stopping monitoring' + print 'Stopping monitoring Journal' self.currentdir = None self.version = self.mode = self.group = self.cmdr = self.body = self.system = self.station = self.coordinates = None self.is_beta = False @@ -314,6 +306,7 @@ class EDLogs(FileSystemEventHandler): self.system = None self.station = None self.coordinates = None + self.started = None self.state = { 'Cargo' : defaultdict(int), 'Credits' : None, @@ -339,6 +332,7 @@ class EDLogs(FileSystemEventHandler): self.system = None self.station = None self.coordinates = None + self.started = timegm(strptime(entry['timestamp'], '%Y-%m-%dT%H:%M:%SZ')) self.state.update({ 'Credits' : entry['Credits'], 'Loan' : entry['Loan'], diff --git a/plug.py b/plug.py index 0ed89f15..2fa7d5aa 100644 --- a/plug.py +++ b/plug.py @@ -143,6 +143,26 @@ def notify_journal_entry(cmdr, system, station, entry, state): sys.stderr.write('%s: %s\n' % (plugname, plugerr)) +def notify_interaction(cmdr, entry): + """ + Send an interaction entry to each plugin. + :param cmdr: The piloting Cmdr name + :param entry: The interaction entry as a dictionary + :return: + """ + for plugname in PLUGINS: + interaction = _get_plugin_func(plugname, "interaction") + if interaction: + try: + # Pass a copy of the interaction entry in case the callee modifies it + interaction(cmdr, dict(entry)) + except Exception as plugerr: + if __debug__: + print_exc() + else: + sys.stderr.write('%s: %s\n' % (plugname, plugerr)) + + def notify_system_changed(timestamp, system, coordinates): """ Send notification data to each plugin when we arrive at a new system. diff --git a/prefs.py b/prefs.py index e2a52918..153936c2 100644 --- a/prefs.py +++ b/prefs.py @@ -223,6 +223,10 @@ class PreferencesDialog(tk.Toplevel): self.logdir.set(config.get('journaldir') or config.default_journal_dir or '') self.logdir_entry = nb.Entry(configframe, takefocus=False) + self.interactiondir = tk.StringVar() + self.interactiondir.set(config.get('interactiondir') or config.default_interaction_dir or '') + self.interactiondir_entry = nb.Entry(configframe, takefocus=False) + if platform != 'darwin': # Apple's SMB implementation is way too flaky - no filesystem events and bogus NULLs 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 @@ -234,6 +238,15 @@ class PreferencesDialog(tk.Toplevel): 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 + nb.Label(configframe, text = _('E:D interaction log location')+':').grid(columnspan=3, padx=PADX, sticky=tk.W) # Setting for the log file that contains recent interactions with other Cmdrs + self.interactiondir_entry.grid(row=15, columnspan=2, padx=(PADX,0), sticky=tk.EW) + self.interactionbutton = 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 interaction log location'), self.interactiondir)) + self.interactionbutton.grid(row=15, column=2, padx=PADX, sticky=tk.EW) + if config.default_interaction_dir: + nb.Button(configframe, text=_('Default'), command=self.interactiondir_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) @@ -406,6 +419,7 @@ class PreferencesDialog(tk.Toplevel): self.displaypath(self.outdir, self.outdir_entry) self.displaypath(self.logdir, self.logdir_entry) + self.displaypath(self.interactiondir, self.interactiondir_entry) logdir = self.logdir.get() logvalid = logdir and exists(logdir) @@ -494,6 +508,11 @@ class PreferencesDialog(tk.Toplevel): self.logdir.set(config.default_journal_dir) self.outvarchanged() + def interactiondir_reset(self): + if config.default_interaction_dir: + self.interactiondir.set(config.default_interaction_dir) + self.outvarchanged() + def themecolorbrowse(self, index): (rgb, color) = tkColorChooser.askcolor(self.theme_colors[index], title=self.theme_prompts[index], parent=self.parent) if color: @@ -588,6 +607,13 @@ class PreferencesDialog(tk.Toplevel): config.set('journaldir', '') # default location else: config.set('journaldir', logdir) + + interactiondir = self.interactiondir.get() + if config.default_journal_dir and interactiondir.lower() == config.default_interaction_dir.lower(): + config.set('interactiondir', '') # default location + else: + config.set('interactiondir', interactiondir) + if platform in ['darwin','win32']: config.set('hotkey_code', self.hotkey_code) config.set('hotkey_mods', self.hotkey_mods)