1
0
mirror of https://github.com/EDCD/EDMarketConnector.git synced 2025-04-12 23:37:14 +03:00
Athanasius 89f113e190 Use Preferences Save Serial Number instead of flag
* new class `PrefsVersion` in prefs.py.  A singleton `prefsSaved` (note
   case) is created.
 * When new preferences are added and require defaults on first run the
   code should use:

     `if prefsVersion.shouldSetDefaults(<prior release version>, [<optional old test>]):`

   to check if defaults should be set in preferences.  So if prior release
   was '3.4.6.0' and you've added a new preference with defaults you should
   call this with '3.4.6.0' as the first argument.
   The <optional old test> is really only for historical purposes, as a
   fallback in case no 'PrefsVersion' has yet been set in the user's
   Registry/settings file.
 * Any code that adds such a new preference **MUST** make changes to the
   `versions` dictionary in the PrefsVersion class.
     1. Add the predicted next version to the dictionary, with number one
        higher than 'current'
     2. Set 'current' equal to that new value.
   Obviously if other post-last-release code has already done this then you
   don't need to.

   Failure to update the versions dictionary in this manner will lead to an
   Exception being raised, and the code the preferences are for failing
   (i.e. EDDN means no EDDN tab on Settings).

Closes #407
2020-07-01 15:33:42 +01:00

507 lines
23 KiB
Python

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