1
0
mirror of https://github.com/EDCD/EDMarketConnector.git synced 2025-04-21 11:27:38 +03:00

Merge pull request from EDCD/enhancement/odyssey-suits

Add support for Odyssey Suit/Loadout data
This commit is contained in:
Athanasius 2021-04-15 18:15:36 +01:00 committed by GitHub
commit 27cd46e23d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 285 additions and 87 deletions

3
.gitignore vendored

@ -1,6 +1,7 @@
.gitversion
.DS_Store
build
ChangeLog.html
dist.*
dump
*.bak
@ -21,4 +22,4 @@ venv
*.code-workspace
htmlcov/
.ignored
.coverage
.coverage

@ -12,10 +12,10 @@ import sys
import webbrowser
from builtins import object, str
from os import chdir, environ
from os.path import dirname, isdir, join
from os.path import dirname, join
from sys import platform
from time import localtime, strftime, time
from typing import TYPE_CHECKING, Any, Mapping, Optional, Tuple
from typing import TYPE_CHECKING, Optional, Tuple
# Have this as early as possible for people running EDMarketConnector.exe
# from cmd.exe or a bat file or similar. Else they might not be in the correct
@ -55,6 +55,7 @@ import killswitch
from config import appversion, appversion_nobuild, config, copyright
# isort: on
from companion import CAPIData
from EDMCLogging import edmclogger, logger, logging
from journal_lock import JournalLock, JournalLockResult
@ -316,6 +317,8 @@ class AppWindow(object):
EVENT_BUTTON = 4
EVENT_VIRTUAL = 35
PADX = 5
def __init__(self, master: tk.Tk): # noqa: C901, CCR001 # TODO - can possibly factor something out
self.holdofftime = config.get_int('querytime', default=0) + companion.holdoff
@ -350,37 +353,51 @@ class AppWindow(object):
frame.columnconfigure(1, weight=1)
self.cmdr_label = tk.Label(frame)
self.ship_label = tk.Label(frame)
self.system_label = tk.Label(frame)
self.station_label = tk.Label(frame)
self.cmdr_label.grid(row=1, column=0, sticky=tk.W)
self.ship_label.grid(row=2, column=0, sticky=tk.W)
self.system_label.grid(row=3, column=0, sticky=tk.W)
self.station_label.grid(row=4, column=0, sticky=tk.W)
self.cmdr = tk.Label(frame, compound=tk.RIGHT, anchor=tk.W, name='cmdr')
self.ship_label = tk.Label(frame)
self.ship = HyperlinkLabel(frame, compound=tk.RIGHT, url=self.shipyard_url, name='ship')
self.suit_label = tk.Label(frame)
self.suit = tk.Label(frame, compound=tk.RIGHT, anchor=tk.W, name='suit')
self.system_label = tk.Label(frame)
self.system = HyperlinkLabel(frame, compound=tk.RIGHT, url=self.system_url, popup_copy=True, name='system')
self.station_label = tk.Label(frame)
self.station = HyperlinkLabel(frame, compound=tk.RIGHT, url=self.station_url, name='station')
# system and station text is set/updated by the 'provider' plugins
# eddb, edsm and inara. Look for:
#
# parent.children['system'] / parent.children['station']
self.system = HyperlinkLabel(frame, compound=tk.RIGHT, url=self.system_url, popup_copy=True, name='system')
self.station = HyperlinkLabel(frame, compound=tk.RIGHT, url=self.station_url, name='station')
self.cmdr.grid(row=1, column=1, sticky=tk.EW)
self.ship.grid(row=2, column=1, sticky=tk.EW)
self.system.grid(row=3, column=1, sticky=tk.EW)
self.station.grid(row=4, column=1, sticky=tk.EW)
ui_row = 1
self.cmdr_label.grid(row=ui_row, column=0, sticky=tk.W)
self.cmdr.grid(row=ui_row, column=1, sticky=tk.EW)
ui_row += 1
self.ship_label.grid(row=ui_row, column=0, sticky=tk.W)
self.ship.grid(row=ui_row, column=1, sticky=tk.EW)
ui_row += 1
self.suit_grid_row = ui_row
self.suit_shown = False
ui_row += 1
self.system_label.grid(row=ui_row, column=0, sticky=tk.W)
self.system.grid(row=ui_row, column=1, sticky=tk.EW)
ui_row += 1
self.station_label.grid(row=ui_row, column=0, sticky=tk.W)
self.station.grid(row=ui_row, column=1, sticky=tk.EW)
ui_row += 1
for plugin in plug.PLUGINS:
appitem = plugin.get_app(frame)
if appitem:
tk.Frame(frame, highlightthickness=1).grid(columnspan=2, sticky=tk.EW) # separator
if isinstance(appitem, tuple) and len(appitem) == 2:
row = frame.grid_size()[1]
appitem[0].grid(row=row, column=0, sticky=tk.W)
appitem[1].grid(row=row, column=1, sticky=tk.EW)
ui_row = frame.grid_size()[1]
appitem[0].grid(row=ui_row, column=0, sticky=tk.W)
appitem[1].grid(row=ui_row, column=1, sticky=tk.EW)
else:
appitem.grid(columnspan=2, sticky=tk.EW)
@ -389,17 +406,17 @@ class AppWindow(object):
self.theme_button = tk.Label(frame, width=32 if platform == 'darwin' else 28, state=tk.DISABLED)
self.status = tk.Label(frame, name='status', anchor=tk.W)
row = frame.grid_size()[1]
self.button.grid(row=row, columnspan=2, sticky=tk.NSEW)
self.theme_button.grid(row=row, columnspan=2, sticky=tk.NSEW)
ui_row = frame.grid_size()[1]
self.button.grid(row=ui_row, columnspan=2, sticky=tk.NSEW)
self.theme_button.grid(row=ui_row, columnspan=2, sticky=tk.NSEW)
theme.register_alternate((self.button, self.theme_button, self.theme_button),
{'row': row, 'columnspan': 2, 'sticky': tk.NSEW})
{'row': ui_row, 'columnspan': 2, 'sticky': tk.NSEW})
self.status.grid(columnspan=2, sticky=tk.EW)
self.button.bind('<Button-1>', self.getandsend)
theme.button_bind(self.theme_button, self.getandsend)
for child in frame.winfo_children():
child.grid_configure(padx=5, pady=(platform != 'win32' or isinstance(child, tk.Frame)) and 2 or 0)
child.grid_configure(padx=self.PADX, pady=(platform != 'win32' or isinstance(child, tk.Frame)) and 2 or 0)
# The type needs defining for adding the menu entry, but won't be
# properly set until later
@ -496,7 +513,7 @@ class AppWindow(object):
theme_close.grid(row=0, column=4, padx=2)
theme.button_bind(theme_close, self.onexit, image=self.theme_close)
self.theme_file_menu = tk.Label(self.theme_menubar, anchor=tk.W)
self.theme_file_menu.grid(row=1, column=0, padx=5, sticky=tk.W)
self.theme_file_menu.grid(row=1, column=0, padx=self.PADX, sticky=tk.W)
theme.button_bind(self.theme_file_menu,
lambda e: self.file_menu.tk_popup(e.widget.winfo_rootx(),
e.widget.winfo_rooty()
@ -513,7 +530,7 @@ class AppWindow(object):
lambda e: self.help_menu.tk_popup(e.widget.winfo_rootx(),
e.widget.winfo_rooty()
+ e.widget.winfo_height()))
tk.Frame(self.theme_menubar, highlightthickness=1).grid(columnspan=5, padx=5, sticky=tk.EW)
tk.Frame(self.theme_menubar, highlightthickness=1).grid(columnspan=5, padx=self.PADX, sticky=tk.EW)
theme.register(self.theme_minimize) # images aren't automatically registered
theme.register(self.theme_close)
self.blank_menubar = tk.Frame(frame)
@ -586,6 +603,52 @@ class AppWindow(object):
config.delete('logdir', suppress=True)
self.postprefs(False) # Companion login happens in callback from monitor
self.toggle_suit_row(visible=False)
def update_suit_text(self) -> None:
"""Update the suit text for current type and loadout."""
if (suit := monitor.state.get('SuitCurrent')) is None:
self.suit['text'] = f'<{_("Unknown")}>'
return
suitname = suit['locName']
if (suitloadout := monitor.state.get('SuitLoadoutCurrent')) is None:
self.suit['text'] = ''
return
loadout_name = suitloadout['name']
self.suit['text'] = f'{suitname} ({loadout_name})'
def toggle_suit_row(self, visible: Optional[bool] = None) -> None:
"""
Toggle the visibility of the 'Suit' row.
:param visible: Force visibility to this.
"""
if visible is True:
self.suit_shown = False
elif visible is False:
self.suit_shown = True
if not self.suit_shown:
if platform != 'win32':
pady = 2
else:
pady = 0
self.suit_label.grid(row=self.suit_grid_row, column=0, sticky=tk.W, padx=self.PADX, pady=pady)
self.suit.grid(row=self.suit_grid_row, column=1, sticky=tk.EW, padx=self.PADX, pady=pady)
self.suit_shown = True
else:
# Hide the Suit row
self.suit_label.grid_forget()
self.suit.grid_forget()
self.suit_shown = False
def postprefs(self, dologin: bool = True):
"""Perform necessary actions after the Preferences dialog is applied."""
@ -615,6 +678,7 @@ class AppWindow(object):
self.cmdr_label['text'] = _('Cmdr') + ':' # Main window
# Multicrew role label in main window
self.ship_label['text'] = (monitor.state['Captain'] and _('Role') or _('Ship')) + ':' # Main window
self.suit_label['text'] = _('Suit') + ':' # Main window
self.system_label['text'] = _('System') + ':' # Main window
self.station_label['text'] = _('Station') + ':' # Main window
self.button['text'] = self.theme_button['text'] = _('Update') # Update button in main window
@ -692,26 +756,7 @@ class AppWindow(object):
self.cooldown()
def dump_capi_data(self, data: Mapping[str, Any]):
"""Dump CAPI data to file for examination."""
if isdir('dump'):
system = data['lastSystem']['name']
if data['commander'].get('docked'):
station = f'.{data["lastStarport"]["name"]}'
else:
station = ''
timestamp = strftime('%Y-%m-%dT%H.%M.%S', localtime())
with open(f'dump/{system}{station}.{timestamp}.json', 'wb') as h:
h.write(json.dumps(dict(data),
ensure_ascii=False,
indent=2,
sort_keys=True,
separators=(',', ': ')).encode('utf-8'))
def export_market_data(self, data: Mapping[str, Any]) -> bool: # noqa: CCR001
def export_market_data(self, data: CAPIData) -> bool: # noqa: CCR001
"""
Export CAPI market data.
@ -839,7 +884,7 @@ class AppWindow(object):
else:
if __debug__: # Recording
self.dump_capi_data(data)
companion.session.dump_capi_data(data)
if not monitor.state['ShipType']: # Started game in SRV or fighter
self.ship['text'] = ship_name_map.get(data['ship']['name'].lower(), data['ship']['name'])
@ -853,6 +898,19 @@ class AppWindow(object):
if monitor.state['Modules']:
self.ship.configure(state=True)
if monitor.state.get('SuitCurrent') is not None:
if (loadout := data.get('loadout')) is not None:
if (suit := loadout.get('suit')) is not None:
if (suitname := suit.get('locName')) is not None:
# We've been paranoid about loadout->suit->suitname, now just assume loadouts is there
loadout_name = data['loadouts'][f"{loadout['loadoutSlotId']}"]['name']
self.suit['text'] = f'{suitname} ({loadout_name})'
self.toggle_suit_row(visible=True)
else:
self.toggle_suit_row(visible=False)
if data['commander'].get('credits') is not None:
monitor.state['Credits'] = data['commander']['credits']
monitor.state['Loan'] = data['commander'].get('debt', 0)
@ -974,6 +1032,8 @@ class AppWindow(object):
self.ship_label['text'] = _('Ship') + ':' # Main window
self.ship['text'] = ''
self.update_suit_text()
self.edit_menu.entryconfigure(0, state=monitor.system and tk.NORMAL or tk.DISABLED) # Copy
if entry['event'] in (
@ -1273,7 +1333,7 @@ class AppWindow(object):
self.w.update_idletasks()
try:
data = companion.session.station()
data: CAPIData = companion.session.station()
self.status['text'] = ''
default_extension: str = ''

@ -532,6 +532,9 @@
/* Label for 'UI Scaling' option [prefs.py] */
"UI Scale Percentage" = "UI Scale Percentage";
/* General 'Unknown', e.g. suit loadout */
"Unknown" = "Unknown";
/* Update button in main window. [EDMarketConnector.py] */
"Update" = "Update";

@ -531,6 +531,15 @@ Content of `state` (updated to the current journal entry):
| `NavRoute` | `dict` | Last plotted multi-hop route |
| `ModuleInfo` | `dict` | Last loaded ModulesInfo.json data |
| `OnFoot` | `bool` | Whether the Cmdr is on foot |
| `Component` | `dict` | 'Component' MicroResources in Odyssey, `int` count each. |
| `Item` | `dict` | 'Item' MicroResources in Odyssey, `int` count each. |
| `Consumable` | `dict` | 'Consumable' MicroResources in Odyssey, `int` count each. |
| `Data` | `dict` | 'Data' MicroResources in Odyssey, `int` count each. |
| `BackPack` | `dict` | `dict` of Odyssey MicroResources in backpack. |
| `SuitCurrent` | `dict` | CAPI-returned data of currently worn suit. NB: May be `None` if no data. |
| `Suits` | `dict` | CAPI-returned data of owned suits. NB: May be `None` if no data. |
| `SuitLoadoutCurrent` | `dict` | CAPI-returned data of current Suit Loadout. NB: May be `None` if no data. |
| `SuitLoadouts` | `dict` | CAPI-returned data of all Suit Loadouts. NB: May be `None` if no data. |
New in version 4.1.6:
@ -553,8 +562,12 @@ Journal `ModuleInfo` event.
`OnFoot` is an indication as to if the player is on-foot, rather than in a
vehicle.
`Component` is a dict tracking your 'on-foot' materials for upgrading Suits
and on-foot weapons.
`Component`, `Item`, `Consumable` & `Data` are `dict`s tracking your
Odyssey MicroResources in your Ship Locker. `BacKPack` contains `dict`s for
the same when you're on-foot.
`SuitCurrent`, `Suits`, `SuitLoadoutCurrent` & `SuitLoadouts` hold CAPI data
relating to suits and their loadouts.
##### Synthetic Events

@ -589,6 +589,10 @@ class Session(object):
# logger.trace('timestamp not in data, adding from response headers')
data['timestamp'] = time.strftime('%Y-%m-%dT%H:%M:%SZ', parsedate(r.headers['Date'])) # type: ignore
# Update Odyssey Suit data
if endpoint == URL_QUERY:
self.suit_update(data)
return data
def profile(self) -> CAPIData:
@ -632,6 +636,25 @@ class Session(object):
return data
def suit_update(self, data: CAPIData) -> None:
"""
Update monitor.state suit data.
:param data: CAPI data to extra suit data from.
"""
if (current_suit := data.get('suit')) is None:
# Probably no Odyssey on the account, so point attempting more.
return
monitor.state['SuitCurrent'] = current_suit
monitor.state['Suits'] = data.get('suits')
if (suit_loadouts := data.get('loadouts')) is None:
logger.warning('CAPI data had "suit" but no (suit) "loadouts"')
monitor.state['SuitLoadoutCurrent'] = data.get('loadout')
monitor.state['SuitLoadouts'] = suit_loadouts
def close(self) -> None:
"""Close CAPI authorization session."""
self.state = Session.STATE_INIT
@ -656,6 +679,25 @@ class Session(object):
"""Log, as error, status of requests.Response from CAPI request."""
logger.error(f'Frontier CAPI Auth: {r.url} {r.status_code} {r.reason and r.reason or "None"} {r.text}')
def dump_capi_data(self, data: CAPIData) -> None:
"""Dump CAPI data to file for examination."""
if os.path.isdir('dump'):
system = data['lastSystem']['name']
if data['commander'].get('docked'):
station = f'.{data["lastStarport"]["name"]}'
else:
station = ''
timestamp = time.strftime('%Y-%m-%dT%H.%M.%S', time.localtime())
with open(f'dump/{system}{station}.{timestamp}.json', 'wb') as h:
h.write(json.dumps(dict(data),
ensure_ascii=False,
indent=2,
sort_keys=True,
separators=(',', ': ')).encode('utf-8'))
def fixup(data: CAPIData) -> CAPIData: # noqa: C901, CCR001 # Can't be usefully simplified
"""

@ -23,6 +23,11 @@ companion_category_map = {
'NonMarketable': False, # Don't appear in the in-game market so don't report
}
# Map suit symbol names to English localised names
companion_suit_type_map = {
'TacticalSuit_Class1': 'Dominator Suit',
}
# Map Coriolis's names to names displayed in the in-game shipyard.
coriolis_ship_map = {
'Cobra Mk III': 'Cobra MkIII',

@ -68,7 +68,7 @@ class EDLogs(FileSystemEventHandler): # type: ignore # See below
_RE_CANONICALISE = re.compile(r'\$(.+)_name;')
_RE_CATEGORY = re.compile(r'\$MICRORESOURCE_CATEGORY_(.+);')
_RE_LOGFILE = re.compile(r'^Journal(Alpha|Beta)?\.[0-9]{12}\.[0-9]{2}\.log$')
_RE_SHIP_ONFOOT = re.compile(r'^(FlightSuit|UtilitySuit_Class.)$')
_RE_SHIP_ONFOOT = re.compile(r'^(FlightSuit|UtilitySuit_Class.|TacticalSuit_Class.|ExplorationSuit_Class.)$')
def __init__(self) -> None:
# TODO(A_D): A bunch of these should be switched to default values (eg '' for strings) and no longer be Optional
@ -114,42 +114,46 @@ class EDLogs(FileSystemEventHandler): # type: ignore # See below
# Cmdr state shared with EDSM and plugins
# If you change anything here update PLUGINS.md documentation!
self.state: Dict = {
'Captain': None, # On a crew
'Cargo': defaultdict(int),
'Credits': None,
'FID': None, # Frontier Cmdr ID
'Horizons': None, # Does this user have Horizons?
'Loan': None,
'Raw': defaultdict(int),
'Manufactured': defaultdict(int),
'Encoded': defaultdict(int),
'Engineers': {},
'Rank': {},
'Reputation': {},
'Statistics': {},
'Role': None, # Crew role - None, Idle, FireCon, FighterCon
'Friends': set(), # Online friends
'ShipID': None,
'ShipIdent': None,
'ShipName': None,
'ShipType': None,
'HullValue': None,
'ModulesValue': None,
'Rebuy': None,
'Modules': None,
'CargoJSON': None, # The raw data from the last time cargo.json was read
'Route': None, # Last plotted route from Route.json file
'OnFoot': False, # Whether we think you're on-foot
'Component': defaultdict(int), # Odyssey Components in Ship Locker
'Item': defaultdict(int), # Odyssey Items in Ship Locker
'Consumable': defaultdict(int), # Odyssey Consumables in Ship Locker
'Data': defaultdict(int), # Odyssey Data in Ship Locker
'Captain': None, # On a crew
'Cargo': defaultdict(int),
'Credits': None,
'FID': None, # Frontier Cmdr ID
'Horizons': None, # Does this user have Horizons?
'Loan': None,
'Raw': defaultdict(int),
'Manufactured': defaultdict(int),
'Encoded': defaultdict(int),
'Engineers': {},
'Rank': {},
'Reputation': {},
'Statistics': {},
'Role': None, # Crew role - None, Idle, FireCon, FighterCon
'Friends': set(), # Online friends
'ShipID': None,
'ShipIdent': None,
'ShipName': None,
'ShipType': None,
'HullValue': None,
'ModulesValue': None,
'Rebuy': None,
'Modules': None,
'CargoJSON': None, # The raw data from the last time cargo.json was read
'Route': None, # Last plotted route from Route.json file
'OnFoot': False, # Whether we think you're on-foot
'Component': defaultdict(int), # Odyssey Components in Ship Locker
'Item': defaultdict(int), # Odyssey Items in Ship Locker
'Consumable': defaultdict(int), # Odyssey Consumables in Ship Locker
'Data': defaultdict(int), # Odyssey Data in Ship Locker
'BackPack': { # Odyssey BackPack contents
'Component': defaultdict(int), # BackPack Components
'Consumable': defaultdict(int), # BackPack Consumables
'Item': defaultdict(int), # BackPack Items
'Data': defaultdict(int), # Backpack Data
'Component': defaultdict(int), # BackPack Components
'Consumable': defaultdict(int), # BackPack Consumables
'Item': defaultdict(int), # BackPack Items
'Data': defaultdict(int), # Backpack Data
},
'SuitCurrent': None,
'Suits': None,
'SuitLoadoutCurrent': None,
'SuitLoadouts': None,
}
def start(self, root: 'tkinter.Tk') -> bool: # noqa: CCR001
@ -823,6 +827,63 @@ class EDLogs(FileSystemEventHandler): # type: ignore # See below
if self.state['BackPack'][c][m] < 0:
self.state['BackPack'][c][m] = 0
elif event_type == 'SwitchSuitLoadout':
loadoutid = entry['LoadoutID']
new_slot = self.suit_loadout_id_from_loadoutid(loadoutid)
try:
self.state['SuitLoadoutCurrent'] = self.state['SuitLoadouts'][f'{new_slot}']
except KeyError:
logger.exception(f"Getting suit loadout after switch, bad slot: {new_slot} ({loadoutid})")
# Might mean that a new suit loadout was created and we need a new CAPI fetch ?
self.state['SuitCurrent'] = None
self.state['SuitLoadoutCurrent'] = None
else:
try:
new_suitid = self.state['SuitLoadoutCurrent']['suit']['suitId']
except KeyError:
logger.exception(f"Getting switched-to suit ID from slot {new_slot} ({loadoutid})")
else:
try:
self.state['SuitCurrent'] = self.state['Suits'][f'{new_suitid}']
except KeyError:
logger.exception(f"Getting switched-to suit from slot {new_slot} ({loadoutid}")
elif event_type == 'DeleteSuitLoadout':
# We should remove this from the monitor.state record of loadouts. The slotid
# could end up valid due to CreateSuitLoadout events, but we won't have the
# correct new loadout data until next CAPI pull.
loadoutid = entry['LoadoutID']
slotid = self.suit_loadout_id_from_loadoutid(loadoutid)
# This might be a Loadout that was created after our last CAPI pull.
try:
self.state['SuitLoadouts'].pop(f'{slotid}')
except KeyError:
logger.exception(f"slot id {slotid} doesn't exist, not in last CAPI pull ?")
elif event_type == 'CreateSuitLoadout':
# We know we won't have data for this new one
pass
# `BuySuit` has no useful info as of 4.0.0.13
elif event_type == 'SellSuit':
# Remove from known suits
# As of Odyssey Alpha Phase 2, Hotfix 5 (4.0.0.13) this isn't possible as this event
# doesn't contain the specific suit ID as per CAPI `suits` dict.
pass
# `LoadoutEquipModule` has no instance-specific ID as of 4.0.0.13
# `BuyWeapon` has no instance-specific ID as of 4.0.0.13
# `SellWeapon` has no instance-specific ID as of 4.0.0.13
# `UpgradeWeapon` has no instance-specific ID as of 4.0.0.13
elif event_type == 'NavRoute':
# Added in ED 3.7 - multi-hop route details in NavRoute.json
with open(join(self.currentdir, 'NavRoute.json'), 'rb') as rf: # type: ignore
@ -1027,6 +1088,19 @@ class EDLogs(FileSystemEventHandler): # type: ignore # See below
logger.debug(f'Invalid journal entry:\n{line!r}\n', exc_info=ex)
return {'event': None}
def suit_loadout_id_from_loadoutid(self, journal_loadoutid: int) -> int:
"""
Determine the CAPI-oriented numeric slot id for a Suit Loadout.
:param journal_loadoutid: Journal `LoadoutID` integer value.
:return:
"""
# Observed LoadoutID in SwitchSuitLoadout events are, e.g.
# 4293000005 for CAPI slot 5.
# This *might* actually be "lower 6 bits", but maybe it's not.
slotid = journal_loadoutid - 4293000000
return slotid
def canonicalise(self, item: Optional[str]) -> str:
"""
Produce canonical name for a ship module.