mirror of
https://github.com/EDCD/EDMarketConnector.git
synced 2025-04-12 15:27:14 +03:00
1734 lines
66 KiB
Python
1734 lines
66 KiB
Python
"""
|
||
inara.py - Sync with INARA.
|
||
|
||
Copyright (c) EDCD, All Rights Reserved
|
||
Licensed under the GNU General Public License.
|
||
See LICENSE file.
|
||
|
||
This is an EDMC 'core' plugin.
|
||
All EDMC plugins are *dynamically* loaded at run-time.
|
||
|
||
We build for Windows using `py2exe`.
|
||
`py2exe` can't possibly know about anything in the dynamically loaded core plugins.
|
||
|
||
Thus, you **MUST** check if any imports you add in this file are only
|
||
referenced in this file (or only in any other core plugin), and if so...
|
||
|
||
YOU MUST ENSURE THAT PERTINENT ADJUSTMENTS ARE MADE IN
|
||
`build.py` TO ENSURE THE FILES ARE ACTUALLY PRESENT
|
||
IN AN END-USER INSTALLATION ON WINDOWS.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import threading
|
||
import time
|
||
import tkinter as tk
|
||
from collections import defaultdict, deque
|
||
from dataclasses import dataclass
|
||
from datetime import datetime, timedelta, timezone
|
||
from operator import itemgetter
|
||
from threading import Lock, Thread
|
||
from tkinter import ttk
|
||
from typing import Any, Callable, Deque, Mapping, NamedTuple, Sequence, cast, Union
|
||
import requests
|
||
import edmc_data
|
||
import killswitch
|
||
import myNotebook as nb # noqa: N813
|
||
import plug
|
||
import timeout_session
|
||
from companion import CAPIData
|
||
from config import applongname, appname, appversion, config, debug_senders
|
||
from EDMCLogging import get_main_logger
|
||
from monitor import monitor
|
||
from ttkHyperlinkLabel import HyperlinkLabel
|
||
from l10n import translations as tr
|
||
|
||
logger = get_main_logger()
|
||
|
||
|
||
_TIMEOUT = 20
|
||
FAKE = ('CQC', 'Training', 'Destination') # Fake systems that shouldn't be sent to Inara
|
||
# We only update Credits to Inara if the delta from the last sent value is
|
||
# greater than certain thresholds
|
||
CREDITS_DELTA_MIN_FRACTION = 0.05 # Fractional difference threshold
|
||
CREDITS_DELTA_MIN_ABSOLUTE = 10_000_000 # Absolute difference threshold
|
||
|
||
|
||
# These need to be defined above This
|
||
class Credentials(NamedTuple):
|
||
"""Credentials holds the set of credentials required to identify an inara API payload to inara."""
|
||
|
||
cmdr: str | None
|
||
fid: str | None
|
||
api_key: str
|
||
|
||
|
||
EVENT_DATA = Union[Mapping[str, Any], Sequence[Mapping[str, Any]]]
|
||
|
||
|
||
@dataclass
|
||
class Event:
|
||
"""Event represents an event for the Inara API."""
|
||
|
||
name: str
|
||
timestamp: str
|
||
data: EVENT_DATA
|
||
|
||
|
||
class This:
|
||
"""Holds module globals."""
|
||
|
||
def __init__(self):
|
||
self.session = timeout_session.new_session()
|
||
self.thread: Thread
|
||
self.parent: tk.Tk
|
||
|
||
# Handle only sending Live galaxy data
|
||
self.legacy_galaxy_last_notified: datetime | None = None
|
||
|
||
self.lastlocation = None # eventData from the last Commander's Flight Log event
|
||
self.lastship = None # eventData from the last addCommanderShip or setCommanderShip event
|
||
|
||
# Cached Cmdr state
|
||
self.cmdr: str | None = None
|
||
self.FID: str | None = None # Frontier ID
|
||
self.multicrew: bool = False # don't send captain's ship info to Inara while on a crew
|
||
self.newuser: bool = False # just entered API Key - send state immediately
|
||
self.newsession: bool = True # starting a new session - wait for Cargo event
|
||
self.undocked: bool = False # just undocked
|
||
self.suppress_docked = False # Skip initial Docked event if started docked
|
||
self.cargo: list[dict[str, Any]] | None = None
|
||
self.materials: list[dict[str, Any]] | None = None
|
||
self.last_credits: int = 0 # Send credit update soon after Startup / new game
|
||
self.storedmodules: list[dict[str, Any]] | None = None
|
||
self.loadout: dict[str, Any] | None = None
|
||
self.fleet: list[dict[str, Any]] | None = None
|
||
self.shipswap: bool = False # just swapped ship
|
||
self.on_foot = False
|
||
|
||
self.timer_run = True
|
||
|
||
# Main window clicks
|
||
self.system_link: tk.Widget = None # type: ignore
|
||
self.system_name: str | None = None # type: ignore
|
||
self.system_address: str | None = None # type: ignore
|
||
self.system_population: int | None = None
|
||
self.station_link: tk.Widget = None # type: ignore
|
||
self.station = None
|
||
self.station_marketid = None
|
||
|
||
# Prefs UI
|
||
self.log: 'tk.IntVar'
|
||
self.log_button: nb.Checkbutton
|
||
self.label: HyperlinkLabel
|
||
self.apikey: nb.EntryMenu
|
||
self.apikey_label: tk.Label
|
||
|
||
self.events: dict[Credentials, Deque[Event]] = defaultdict(deque)
|
||
self.event_lock: Lock = threading.Lock() # protects events, for use when rewriting events
|
||
|
||
def filter_events(self, key: Credentials, predicate: Callable[[Event], bool]) -> None:
|
||
"""
|
||
filter_events is the equivalent of running filter() on any event list in the events dict.
|
||
|
||
it will automatically handle locking, and replacing the event list with the filtered version.
|
||
|
||
:param key: the key to filter
|
||
:param predicate: the predicate to use while filtering
|
||
"""
|
||
with self.event_lock:
|
||
tmp = self.events[key].copy()
|
||
self.events[key].clear()
|
||
self.events[key].extend(filter(predicate, tmp))
|
||
|
||
|
||
this = This()
|
||
show_password_var = tk.BooleanVar()
|
||
|
||
# last time we updated, if unset in config this is 0, which means an instant update
|
||
LAST_UPDATE_CONF_KEY = 'inara_last_update'
|
||
EVENT_COLLECT_TIME = 31 # Minimum time to take collecting events before requesting a send
|
||
WORKER_WAIT_TIME = 35 # Minimum time for worker to wait between sends
|
||
|
||
STATION_UNDOCKED: str = '×' # "Station" name to display when not docked = U+00D7
|
||
|
||
|
||
TARGET_URL = 'https://inara.cz/inapi/v1/'
|
||
DEBUG = 'inara' in debug_senders
|
||
if DEBUG:
|
||
TARGET_URL = f'http://{edmc_data.DEBUG_WEBSERVER_HOST}:{edmc_data.DEBUG_WEBSERVER_PORT}/inara'
|
||
|
||
|
||
# noinspection PyUnresolvedReferences
|
||
def system_url(system_name: str) -> str:
|
||
"""Get a URL for the current system."""
|
||
if this.system_address:
|
||
return requests.utils.requote_uri(f'https://inara.cz/galaxy-starsystem/'
|
||
f'?search={this.system_address}')
|
||
|
||
if system_name:
|
||
return requests.utils.requote_uri(f'https://inara.cz/galaxy-starsystem/'
|
||
f'?search={system_name}')
|
||
|
||
return ''
|
||
|
||
|
||
def station_url(system_name: str, station_name: str) -> str:
|
||
"""
|
||
Get a URL for the current station.
|
||
|
||
If there is no station, the system URL is returned.
|
||
|
||
:param system_name: The name of the current system
|
||
:param station_name: The name of the current station, if any
|
||
:return: A URL to inara for the given system and station
|
||
"""
|
||
if system_name and station_name:
|
||
return requests.utils.requote_uri(f'https://inara.cz/galaxy-station/?search={system_name}%20[{station_name}]')
|
||
|
||
if this.system_name and this.station:
|
||
return requests.utils.requote_uri(
|
||
f'https://inara.cz/galaxy-station/?search={this.system_name}%20[{this.station}]')
|
||
|
||
if system_name:
|
||
return system_url(system_name)
|
||
|
||
return ''
|
||
|
||
|
||
def plugin_start3(plugin_dir: str) -> str:
|
||
"""
|
||
Start this plugin.
|
||
|
||
Start the worker thread to handle sending to Inara API.
|
||
"""
|
||
logger.debug('Starting worker thread...')
|
||
this.thread = Thread(target=new_worker, name='Inara worker')
|
||
this.thread.daemon = True
|
||
this.thread.start()
|
||
logger.debug('Done.')
|
||
|
||
return 'Inara'
|
||
|
||
|
||
def plugin_app(parent: tk.Tk) -> None:
|
||
"""Plugin UI setup Hook."""
|
||
this.parent = parent
|
||
this.system_link = parent.nametowidget(f".{appname.lower()}.system")
|
||
this.station_link = parent.nametowidget(f".{appname.lower()}.station")
|
||
this.system_link.bind_all('<<InaraLocation>>', update_location)
|
||
this.system_link.bind_all('<<InaraShip>>', update_ship)
|
||
|
||
|
||
def plugin_stop() -> None:
|
||
"""Plugin shutdown hook."""
|
||
logger.debug('We have no way to ask new_worker to stop, but...')
|
||
# The Newthis/new_worker doesn't have a method to ask the new_worker to
|
||
# stop. We're relying on it being a daemon thread and thus exiting when
|
||
# there are no non-daemon (i.e. main) threads running.
|
||
|
||
this.timer_run = False
|
||
|
||
logger.debug('Done.')
|
||
|
||
|
||
def toggle_password_visibility():
|
||
"""Toggle if the API Key is visible or not."""
|
||
if show_password_var.get():
|
||
this.apikey.config(show="")
|
||
else:
|
||
this.apikey.config(show="*")
|
||
|
||
|
||
def plugin_prefs(parent: ttk.Notebook, cmdr: str, is_beta: bool) -> nb.Frame:
|
||
"""Plugin Preferences UI hook."""
|
||
PADX = 10 # noqa: N806
|
||
BUTTONX = 12 # noqa: N806 # indent Checkbuttons and Radiobuttons
|
||
PADY = 1 # noqa: N806 # close spacing
|
||
BOXY = 2 # noqa: N806 # box spacing
|
||
SEPY = 10 # noqa: N806 # seperator line spacing
|
||
cur_row = 0
|
||
|
||
frame = nb.Frame(parent)
|
||
frame.columnconfigure(1, weight=1)
|
||
|
||
HyperlinkLabel(
|
||
frame, text='Inara', background=nb.Label().cget('background'), url='https://inara.cz/', underline=True
|
||
).grid(row=cur_row, columnspan=2, padx=PADX, pady=PADY, sticky=tk.W) # Don't translate
|
||
cur_row += 1
|
||
|
||
this.log = tk.IntVar(value=config.get_int('inara_out') and 1)
|
||
this.log_button = nb.Checkbutton(
|
||
frame,
|
||
text=tr.tl('Send flight log and Cmdr status to Inara'), # LANG: Checkbox to enable INARA API Usage
|
||
variable=this.log,
|
||
command=prefsvarchanged
|
||
)
|
||
|
||
this.log_button.grid(row=cur_row, columnspan=2, padx=BUTTONX, pady=PADY, sticky=tk.W)
|
||
cur_row += 1
|
||
|
||
ttk.Separator(frame, orient=tk.HORIZONTAL).grid(
|
||
columnspan=2, padx=PADX, pady=SEPY, sticky=tk.EW, row=cur_row
|
||
)
|
||
cur_row += 1
|
||
|
||
# Section heading in settings
|
||
this.label = HyperlinkLabel(
|
||
frame,
|
||
text=tr.tl('Inara credentials'), # LANG: Text for INARA API keys link ( goes to https://inara.cz/settings-api )
|
||
background=nb.Label().cget('background'),
|
||
url='https://inara.cz/settings-api',
|
||
underline=True
|
||
)
|
||
|
||
this.label.grid(row=cur_row, columnspan=2, padx=PADX, pady=PADY, sticky=tk.W)
|
||
cur_row += 1
|
||
|
||
# LANG: Inara API key label
|
||
this.apikey_label = nb.Label(frame, text=tr.tl('API Key')) # Inara setting
|
||
this.apikey_label.grid(row=cur_row, padx=PADX, pady=PADY, sticky=tk.W)
|
||
this.apikey = nb.EntryMenu(frame, show="*", width=50)
|
||
this.apikey.grid(row=cur_row, column=1, padx=PADX, pady=BOXY, sticky=tk.EW)
|
||
cur_row += 1
|
||
|
||
prefs_cmdr_changed(cmdr, is_beta)
|
||
|
||
show_password_var.set(False) # Password is initially masked
|
||
show_password_checkbox = nb.Checkbutton(
|
||
frame,
|
||
text=tr.tl('Show API Key'), # LANG: Text Inara Show API key
|
||
variable=show_password_var,
|
||
command=toggle_password_visibility,
|
||
)
|
||
show_password_checkbox.grid(row=cur_row, columnspan=2, padx=BUTTONX, pady=PADY, sticky=tk.W)
|
||
|
||
return frame
|
||
|
||
|
||
def prefs_cmdr_changed(cmdr: str, is_beta: bool) -> None:
|
||
"""Plugin commander change hook."""
|
||
this.log_button['state'] = tk.NORMAL if cmdr and not is_beta else tk.DISABLED
|
||
this.apikey['state'] = tk.NORMAL
|
||
this.apikey.delete(0, tk.END)
|
||
if cmdr:
|
||
cred = credentials(cmdr)
|
||
if cred:
|
||
this.apikey.insert(0, cred)
|
||
|
||
state: str = tk.DISABLED
|
||
if cmdr and not is_beta and this.log.get():
|
||
state = tk.NORMAL
|
||
|
||
this.label['state'] = state
|
||
this.apikey_label['state'] = state
|
||
this.apikey['state'] = state
|
||
|
||
|
||
def prefsvarchanged():
|
||
"""Preferences window change hook."""
|
||
state = tk.DISABLED
|
||
if this.log.get():
|
||
state = this.log_button['state']
|
||
|
||
this.label['state'] = state
|
||
this.apikey_label['state'] = state
|
||
this.apikey['state'] = state
|
||
|
||
|
||
def prefs_changed(cmdr: str, is_beta: bool) -> None:
|
||
"""Preferences window closed hook."""
|
||
changed = config.get_int('inara_out') != this.log.get()
|
||
config.set('inara_out', this.log.get())
|
||
|
||
if cmdr and not is_beta:
|
||
this.cmdr = cmdr
|
||
this.FID = None
|
||
cmdrs = config.get_list('inara_cmdrs', default=[])
|
||
apikeys = config.get_list('inara_apikeys', default=[])
|
||
if cmdr in cmdrs:
|
||
idx = cmdrs.index(cmdr)
|
||
apikeys.extend([''] * (1 + idx - len(apikeys)))
|
||
changed |= (apikeys[idx] != this.apikey.get().strip())
|
||
apikeys[idx] = this.apikey.get().strip()
|
||
|
||
else:
|
||
config.set('inara_cmdrs', cmdrs + [cmdr])
|
||
changed = True
|
||
apikeys.append(this.apikey.get().strip())
|
||
|
||
config.set('inara_apikeys', apikeys)
|
||
|
||
if this.log.get() and changed:
|
||
this.newuser = True # Send basic info at next Journal event
|
||
new_add_event(
|
||
'getCommanderProfile', time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), {'searchName': cmdr}
|
||
)
|
||
|
||
|
||
def credentials(cmdr: str | None) -> str | None:
|
||
"""
|
||
Get the credentials for the current commander.
|
||
|
||
:param cmdr: Commander name to search for credentials
|
||
:return: Credentials for the given commander or None
|
||
"""
|
||
if not cmdr:
|
||
return None
|
||
|
||
cmdrs = config.get_list('inara_cmdrs', default=[])
|
||
apikeys = config.get_list('inara_apikeys', default=[])
|
||
|
||
if cmdr in cmdrs:
|
||
idx = cmdrs.index(cmdr)
|
||
if idx < len(apikeys):
|
||
return apikeys[idx]
|
||
|
||
return None
|
||
|
||
|
||
def journal_entry( # noqa: C901, CCR001
|
||
cmdr: str, is_beta: bool, system: str, station: str, entry: dict[str, Any], state: dict[str, Any]
|
||
) -> str:
|
||
"""
|
||
Journal entry hook.
|
||
|
||
:return: str - empty if no error, else error string.
|
||
"""
|
||
# This overall killswitch check is first in case, e.g. an EDMC bug is
|
||
# causing users to spam Inara with 'URL provider' queries, and we want to
|
||
# stop that.
|
||
should_return: bool
|
||
new_entry: dict[str, Any] = {}
|
||
|
||
should_return, new_entry = killswitch.check_killswitch('plugins.inara.journal', entry, logger)
|
||
if should_return:
|
||
plug.show_error(tr.tl('Inara disabled. See Log.')) # LANG: INARA support disabled via killswitch
|
||
logger.trace('returning due to killswitch match')
|
||
return ''
|
||
|
||
# But then we update all the tracking copies before any other checks,
|
||
# because they're relevant for URL providing even if *sending* isn't
|
||
# appropriate.
|
||
this.on_foot = state['OnFoot']
|
||
event_name: str = entry['event']
|
||
this.cmdr = cmdr
|
||
this.FID = state['FID']
|
||
this.multicrew = bool(state['Role'])
|
||
this.system_name = state['SystemName']
|
||
this.system_address = state['SystemAddress']
|
||
this.station = state['StationName']
|
||
this.station_marketid = state['MarketID']
|
||
|
||
if not monitor.is_live_galaxy():
|
||
# Since Update 14 on 2022-11-29 Inara only accepts Live data.
|
||
if (
|
||
(this.legacy_galaxy_last_notified is None or
|
||
(datetime.now(timezone.utc) - this.legacy_galaxy_last_notified) > timedelta(seconds=300))
|
||
and config.get_int('inara_out') and not (is_beta or this.multicrew or credentials(cmdr))
|
||
):
|
||
# LANG: The Inara API only accepts Live galaxy data, not Legacy galaxy data
|
||
logger.info(tr.tl("Inara only accepts Live galaxy data"))
|
||
this.legacy_galaxy_last_notified = datetime.now(timezone.utc)
|
||
return tr.tl("Inara only accepts Live galaxy data") # LANG: Inara - Only Live data
|
||
|
||
return ''
|
||
|
||
should_return, new_entry = killswitch.check_killswitch(
|
||
f'plugins.inara.journal.event.{entry["event"]}', new_entry, logger
|
||
)
|
||
if should_return:
|
||
logger.trace('returning due to killswitch match')
|
||
# this can and WILL break state, but if we're concerned about it sending bad data, we'd disable globally anyway
|
||
return ''
|
||
|
||
entry = new_entry
|
||
if event_name == 'LoadGame' or this.newuser:
|
||
# clear cached state
|
||
if event_name == 'LoadGame':
|
||
# User setup Inara API while at the loading screen - proceed as for new session
|
||
this.newuser = False
|
||
this.newsession = True
|
||
|
||
else:
|
||
this.newuser = True
|
||
this.newsession = False
|
||
|
||
this.undocked = False
|
||
this.suppress_docked = False
|
||
this.cargo = None
|
||
this.materials = None
|
||
this.last_credits = 0
|
||
this.storedmodules = None
|
||
this.loadout = None
|
||
this.fleet = None
|
||
this.shipswap = False
|
||
|
||
elif event_name in ('Resurrect', 'ShipyardBuy', 'ShipyardSell', 'SellShipOnRebuy'):
|
||
# Events that mean a significant change in credits, so we should send credits after next "Update"
|
||
this.last_credits = 0
|
||
|
||
elif event_name in ('ShipyardNew', 'ShipyardSwap') or (event_name == 'Location' and entry['Docked']):
|
||
this.suppress_docked = True
|
||
|
||
if config.get_int('inara_out') and not is_beta and not this.multicrew and credentials(cmdr):
|
||
current_credentials = Credentials(this.cmdr, this.FID, str(credentials(this.cmdr)))
|
||
try:
|
||
if this.newuser or event_name == 'StartUp' or (this.newsession and event_name == 'Cargo'):
|
||
this.newuser = False
|
||
this.newsession = False
|
||
|
||
if state['Reputation']:
|
||
reputation_data = [
|
||
{'majorfactionName': k.lower(), 'majorfactionReputation': v / 100.0}
|
||
for k, v in state['Reputation'].items() if v is not None
|
||
]
|
||
new_add_event('setCommanderReputationMajorFaction', entry['timestamp'], reputation_data)
|
||
|
||
if state['Engineers']:
|
||
engineer_data = [
|
||
{'engineerName': k, 'rankValue': v[0] if isinstance(v, tuple) else None, 'rankStage': v}
|
||
for k, v in state['Engineers'].items()
|
||
]
|
||
new_add_event('setCommanderRankEngineer', entry['timestamp'], engineer_data)
|
||
# Update ship
|
||
if state['ShipID']:
|
||
cur_ship = {
|
||
'shipType': state['ShipType'],
|
||
'shipGameID': state['ShipID'],
|
||
'shipName': state['ShipName'],
|
||
'shipIdent': state['ShipIdent'],
|
||
'isCurrentShip': True,
|
||
}
|
||
if state['HullValue']:
|
||
cur_ship['shipHullValue'] = state['HullValue']
|
||
if state['ModulesValue']:
|
||
cur_ship['shipModulesValue'] = state['ModulesValue']
|
||
cur_ship['shipRebuyCost'] = state['Rebuy']
|
||
new_add_event('setCommanderShip', entry['timestamp'], cur_ship)
|
||
this.loadout = make_loadout(state)
|
||
new_add_event('setCommanderShipLoadout', entry['timestamp'], this.loadout)
|
||
# Trigger off the "only observed as being after Ranks" event so that
|
||
# we have both current Ranks *and* current Progress within them.
|
||
elif event_name == 'Progress':
|
||
rank_data = [
|
||
{'rankName': k.lower(), 'rankValue': v[0], 'rankProgress': v[1] / 100.0}
|
||
for k, v in state['Rank'].items() if v is not None
|
||
]
|
||
new_add_event('setCommanderRankPilot', entry['timestamp'], rank_data)
|
||
|
||
elif event_name == 'Promotion':
|
||
for k, v in state['Rank'].items():
|
||
if k in entry:
|
||
new_add_event(
|
||
'setCommanderRankPilot',
|
||
entry['timestamp'],
|
||
{'rankName': k.lower(), 'rankValue': v[0], 'rankProgress': 0}
|
||
)
|
||
|
||
elif event_name == 'EngineerProgress' and 'Engineer' in entry:
|
||
engineer_rank_data = {
|
||
'engineerName': entry['Engineer'],
|
||
'rankValue': entry['Rank'] if 'Rank' in entry else None,
|
||
'rankStage': entry['Progress'] if 'Progress' in entry else None,
|
||
}
|
||
new_add_event('setCommanderRankEngineer', entry['timestamp'], engineer_rank_data)
|
||
|
||
# PowerPlay status change
|
||
elif event_name == 'PowerplayJoin':
|
||
power_join_data = {'powerName': entry['Power'], 'rankValue': 1}
|
||
new_add_event('setCommanderRankPower', entry['timestamp'], power_join_data)
|
||
|
||
elif event_name == 'PowerplayLeave':
|
||
power_leave_data = {'powerName': entry['Power'], 'rankValue': 0}
|
||
new_add_event('setCommanderRankPower', entry['timestamp'], power_leave_data)
|
||
|
||
elif event_name == 'PowerplayDefect':
|
||
power_defect_data = {'powerName': entry["ToPower"], 'rankValue': 1}
|
||
new_add_event('setCommanderRankPower', entry['timestamp'], power_defect_data)
|
||
|
||
elif event_name == 'Powerplay':
|
||
power_data = {'powerName': entry["Power"], 'rankValue': entry["Rank"], 'meritsValue': entry["Merits"]}
|
||
new_add_event('setCommanderRankPower', entry['timestamp'], power_data)
|
||
|
||
# Ship change
|
||
if event_name == 'Loadout' and this.shipswap:
|
||
this.loadout = make_loadout(state)
|
||
new_add_event('setCommanderShipLoadout', entry['timestamp'], this.loadout)
|
||
this.shipswap = False
|
||
|
||
# Location change
|
||
elif event_name == 'Docked':
|
||
if this.undocked:
|
||
# Undocked and now docking again. Don't send.
|
||
this.undocked = False
|
||
|
||
elif this.suppress_docked:
|
||
# Don't send initial Docked event on new game
|
||
this.suppress_docked = False
|
||
|
||
else:
|
||
to_send = {
|
||
'starsystemName': system,
|
||
'stationName': station,
|
||
'shipType': state['ShipType'],
|
||
'shipGameID': state['ShipID'],
|
||
}
|
||
|
||
if entry.get('Taxi'):
|
||
# we're in a taxi, dont store ShipType or shipGameID
|
||
del to_send['shipType']
|
||
del to_send['shipGameID']
|
||
|
||
# We were in a taxi. What kind?
|
||
if state['Dropship'] is not None and state['Dropship']:
|
||
to_send['isTaxiDropship'] = True
|
||
|
||
elif state['Taxi'] is not None and state['Taxi']:
|
||
to_send['isTaxiShuttle'] = True
|
||
|
||
else: # we dont know one way or another. Given we were told it IS a taxi, assume its a shuttle.
|
||
to_send['isTaxiShuttle'] = True
|
||
|
||
if 'MarketID' in entry:
|
||
to_send['marketID'] = entry['MarketID']
|
||
|
||
# TODO: we _can_ include a Body name here, but I'm not entirely sure how best to go about doing that
|
||
|
||
new_add_event(
|
||
'addCommanderTravelDock',
|
||
entry['timestamp'],
|
||
to_send
|
||
)
|
||
|
||
elif event_name == 'Undocked':
|
||
this.undocked = True
|
||
this.station = None
|
||
|
||
elif event_name == 'SupercruiseEntry':
|
||
this.undocked = False
|
||
|
||
elif event_name == 'SupercruiseExit':
|
||
to_send = {
|
||
'starsystemName': entry['StarSystem'],
|
||
}
|
||
|
||
if entry['BodyType'] == 'Planet':
|
||
to_send['starsystemBodyName'] = entry['Body']
|
||
|
||
new_add_event('setCommanderTravelLocation', entry['timestamp'], to_send)
|
||
|
||
elif event_name == 'ApproachSettlement':
|
||
# If you're near a Settlement on login this event is recorded, but
|
||
# we might not yet have system logged for use.
|
||
if system:
|
||
to_send = {
|
||
'starsystemName': system,
|
||
'stationName': entry['Name'],
|
||
'starsystemBodyName': entry['BodyName'],
|
||
'starsystemBodyCoords': [entry['Latitude'], entry['Longitude']]
|
||
}
|
||
# Not present on, e.g. Ancient Ruins
|
||
if (market_id := entry.get('MarketID')) is not None:
|
||
to_send['marketID'] = market_id
|
||
|
||
new_add_event('setCommanderTravelLocation', entry['timestamp'], to_send)
|
||
|
||
elif event_name == 'FSDJump':
|
||
this.undocked = False
|
||
to_send = {
|
||
'starsystemName': entry['StarSystem'],
|
||
'starsystemCoords': entry['StarPos'],
|
||
'jumpDistance': entry['JumpDist'],
|
||
'shipType': state['ShipType'],
|
||
'shipGameID': state['ShipID'],
|
||
}
|
||
|
||
if state['Taxi'] is not None and state['Taxi']:
|
||
del to_send['shipType']
|
||
del to_send['shipGameID']
|
||
|
||
# taxi. What kind?
|
||
if state['Dropship'] is not None and state['Dropship']:
|
||
to_send['isTaxiDropship'] = True
|
||
|
||
else:
|
||
to_send['isTaxiShuttle'] = True
|
||
|
||
new_add_event(
|
||
'addCommanderTravelFSDJump',
|
||
entry['timestamp'],
|
||
to_send
|
||
)
|
||
|
||
if entry.get('Factions'):
|
||
new_add_event(
|
||
'setCommanderReputationMinorFaction',
|
||
entry['timestamp'],
|
||
[
|
||
{'minorfactionName': f['Name'], 'minorfactionReputation': f['MyReputation'] / 100.0}
|
||
for f in entry['Factions']
|
||
]
|
||
)
|
||
|
||
elif event_name == 'CarrierJump':
|
||
to_send = {
|
||
'starsystemName': entry['StarSystem'],
|
||
'stationName': entry['StationName'],
|
||
'marketID': entry['MarketID'],
|
||
'shipType': state['ShipType'],
|
||
'shipGameID': state['ShipID'],
|
||
}
|
||
|
||
if 'StarPos' in entry:
|
||
to_send['starsystemCoords'] = entry['StarPos']
|
||
|
||
new_add_event(
|
||
'addCommanderTravelCarrierJump',
|
||
entry['timestamp'],
|
||
to_send
|
||
)
|
||
|
||
if entry.get('Factions'):
|
||
new_add_event(
|
||
'setCommanderReputationMinorFaction',
|
||
entry['timestamp'],
|
||
[
|
||
{'minorfactionName': f['Name'], 'minorfactionReputation': f['MyReputation'] / 100.0}
|
||
for f in entry['Factions']
|
||
]
|
||
)
|
||
|
||
# Ignore the following 'Docked' event
|
||
this.suppress_docked = True
|
||
|
||
# Send cargo and materials if changed
|
||
cargo = [{'itemName': k, 'itemCount': state['Cargo'][k]} for k in sorted(state['Cargo'])]
|
||
if this.cargo != cargo:
|
||
new_add_event('setCommanderInventoryCargo', entry['timestamp'], cargo)
|
||
this.cargo = cargo
|
||
|
||
materials = [
|
||
{'itemName': k, 'itemCount': state[category][k]}
|
||
for category in ('Raw', 'Manufactured', 'Encoded')
|
||
for k in sorted(state[category])
|
||
]
|
||
if this.materials != materials:
|
||
new_add_event('setCommanderInventoryMaterials', entry['timestamp'], materials)
|
||
this.materials = materials
|
||
|
||
except Exception as e:
|
||
logger.debug('Adding events', exc_info=e)
|
||
return str(e)
|
||
|
||
# We want to utilise some Statistics data, so don't setCommanderCredits here
|
||
if event_name == 'LoadGame':
|
||
this.last_credits = state['Credits']
|
||
|
||
elif event_name == 'Statistics':
|
||
inara_data = {
|
||
'commanderCredits': state['Credits'],
|
||
'commanderLoan': state['Loan'],
|
||
}
|
||
if entry.get('Bank_Account') is not None:
|
||
if entry['Bank_Account'].get('Current_Wealth') is not None:
|
||
inara_data['commanderAssets'] = entry['Bank_Account']['Current_Wealth']
|
||
|
||
new_add_event(
|
||
'setCommanderCredits',
|
||
entry['timestamp'],
|
||
inara_data
|
||
)
|
||
new_add_event('setCommanderGameStatistics', entry['timestamp'], state['Statistics']) # may be out of date
|
||
|
||
# Selling / swapping ships
|
||
if event_name == 'ShipyardNew':
|
||
new_add_event(
|
||
'addCommanderShip',
|
||
entry['timestamp'],
|
||
{'shipType': entry['ShipType'], 'shipGameID': entry['NewShipID']}
|
||
)
|
||
|
||
this.shipswap = True # Want subsequent Loadout event to be sent immediately
|
||
|
||
elif event_name in ('ShipyardBuy', 'ShipyardSell', 'SellShipOnRebuy', 'ShipyardSwap'):
|
||
if event_name == 'ShipyardSwap':
|
||
this.shipswap = True # Don't know new ship name and ident 'til the following Loadout event
|
||
|
||
if 'StoreShipID' in entry:
|
||
new_add_event(
|
||
'setCommanderShip',
|
||
entry['timestamp'],
|
||
{
|
||
'shipType': entry['StoreOldShip'],
|
||
'shipGameID': entry['StoreShipID'],
|
||
'starsystemName': system,
|
||
'stationName': station,
|
||
}
|
||
)
|
||
|
||
elif 'SellShipID' in entry:
|
||
new_add_event(
|
||
'delCommanderShip',
|
||
entry['timestamp'],
|
||
{
|
||
'shipType': entry.get('SellOldShip', entry['ShipType']),
|
||
'shipGameID': entry['SellShipID'],
|
||
}
|
||
)
|
||
|
||
elif event_name == 'SetUserShipName':
|
||
new_add_event(
|
||
'setCommanderShip',
|
||
entry['timestamp'],
|
||
{
|
||
'shipType': state['ShipType'],
|
||
'shipGameID': state['ShipID'],
|
||
'shipName': state['ShipName'], # Can be None
|
||
'shipIdent': state['ShipIdent'], # Can be None
|
||
'isCurrentShip': True,
|
||
}
|
||
)
|
||
|
||
elif event_name == 'ShipyardTransfer':
|
||
new_add_event(
|
||
'setCommanderShipTransfer',
|
||
entry['timestamp'],
|
||
{
|
||
'shipType': entry['ShipType'],
|
||
'shipGameID': entry['ShipID'],
|
||
'starsystemName': system,
|
||
'stationName': station,
|
||
'transferTime': entry['TransferTime'],
|
||
}
|
||
)
|
||
|
||
# Fleet
|
||
if event_name == 'StoredShips':
|
||
fleet = sorted(
|
||
[
|
||
{
|
||
'shipType': x['ShipType'],
|
||
'shipGameID': x['ShipID'],
|
||
'shipName': x.get('Name'),
|
||
'isHot': x['Hot'],
|
||
'starsystemName': entry['StarSystem'],
|
||
'stationName': entry['StationName'],
|
||
'marketID': entry['MarketID'],
|
||
}
|
||
for x in entry['ShipsHere']
|
||
] +
|
||
[
|
||
{
|
||
'shipType': x['ShipType'],
|
||
'shipGameID': x['ShipID'],
|
||
'shipName': x.get('Name'),
|
||
'isHot': x['Hot'],
|
||
'starsystemName': x.get('StarSystem'), # Not present for ships in transit
|
||
'marketID': x.get('ShipMarketID'), # "
|
||
}
|
||
for x in entry['ShipsRemote']
|
||
],
|
||
key=itemgetter('shipGameID')
|
||
)
|
||
|
||
if this.fleet != fleet:
|
||
this.fleet = fleet
|
||
this.filter_events(current_credentials, lambda e: e.name != 'setCommanderShip')
|
||
|
||
# this.events = [x for x in this.events if x['eventName'] != 'setCommanderShip'] # Remove any unsent
|
||
for ship in this.fleet:
|
||
new_add_event('setCommanderShip', entry['timestamp'], ship)
|
||
# Loadout
|
||
if event_name == 'Loadout':
|
||
loadout = make_loadout(state)
|
||
if this.loadout != loadout:
|
||
this.loadout = loadout
|
||
|
||
this.filter_events(
|
||
current_credentials,
|
||
lambda e: (
|
||
e.name != 'setCommanderShipLoadout'
|
||
or cast(dict, e.data)['shipGameID'] != cast(dict, this.loadout)['shipGameID'])
|
||
)
|
||
|
||
new_add_event('setCommanderShipLoadout', entry['timestamp'], this.loadout)
|
||
|
||
cur_ship = {
|
||
'shipType': state['ShipType'],
|
||
'shipGameID': state['ShipID'],
|
||
'shipName': state['ShipName'], # Can be None
|
||
'shipIdent': state['ShipIdent'], # Can be None
|
||
'isCurrentShip': True,
|
||
'shipMaxJumpRange': entry['MaxJumpRange'],
|
||
'shipCargoCapacity': entry['CargoCapacity']
|
||
}
|
||
if state['HullValue']:
|
||
cur_ship['shipHullValue'] = state['HullValue']
|
||
|
||
if state['ModulesValue']:
|
||
cur_ship['shipModulesValue'] = state['ModulesValue']
|
||
|
||
if state['Rebuy']:
|
||
cur_ship['shipRebuyCost'] = state['Rebuy']
|
||
|
||
new_add_event('setCommanderShip', entry['timestamp'], cur_ship)
|
||
|
||
# Stored modules
|
||
if event_name == 'StoredModules':
|
||
items = {mod['StorageSlot']: mod for mod in entry['Items']} # Impose an order
|
||
modules: list[dict[str, Any]] = []
|
||
for slot in sorted(items):
|
||
item = items[slot]
|
||
module: dict[str, Any] = {
|
||
'itemName': item['Name'],
|
||
'itemValue': item['BuyPrice'],
|
||
'isHot': item['Hot'],
|
||
}
|
||
|
||
# Location can be absent if in transit
|
||
if 'StarSystem' in item:
|
||
module['starsystemName'] = item['StarSystem']
|
||
|
||
if 'MarketID' in item:
|
||
module['marketID'] = item['MarketID']
|
||
|
||
if 'EngineerModifications' in item:
|
||
module['engineering'] = {'blueprintName': item['EngineerModifications']}
|
||
if 'Level' in item:
|
||
module['engineering']['blueprintLevel'] = item['Level']
|
||
|
||
if 'Quality' in item:
|
||
module['engineering']['blueprintQuality'] = item['Quality']
|
||
|
||
modules.append(module)
|
||
|
||
if this.storedmodules != modules:
|
||
# Only send on change
|
||
this.storedmodules = modules
|
||
# Remove any unsent
|
||
this.filter_events(current_credentials, lambda e: e.name != 'setCommanderStorageModules')
|
||
|
||
# this.events = list(filter(lambda e: e['eventName'] != 'setCommanderStorageModules', this.events))
|
||
new_add_event('setCommanderStorageModules', entry['timestamp'], this.storedmodules)
|
||
|
||
# Missions
|
||
if event_name == 'MissionAccepted':
|
||
data: dict[str, Any] = {
|
||
'missionName': entry['Name'],
|
||
'missionGameID': entry['MissionID'],
|
||
'influenceGain': entry['Influence'],
|
||
'reputationGain': entry['Reputation'],
|
||
'starsystemNameOrigin': system,
|
||
'stationNameOrigin': station,
|
||
'minorfactionNameOrigin': entry['Faction'],
|
||
}
|
||
|
||
# optional mission-specific properties
|
||
for (iprop, prop) in (
|
||
('missionExpiry', 'Expiry'), # Listed as optional in the docs, but always seems to be present
|
||
('starsystemNameTarget', 'DestinationSystem'),
|
||
('stationNameTarget', 'DestinationStation'),
|
||
('minorfactionNameTarget', 'TargetFaction'),
|
||
('commodityName', 'Commodity'),
|
||
('commodityCount', 'Count'),
|
||
('targetName', 'Target'),
|
||
('targetType', 'TargetType'),
|
||
('killCount', 'KillCount'),
|
||
('passengerType', 'PassengerType'),
|
||
('passengerCount', 'PassengerCount'),
|
||
('passengerIsVIP', 'PassengerVIPs'),
|
||
('passengerIsWanted', 'PassengerWanted'),
|
||
):
|
||
|
||
if prop in entry:
|
||
data[iprop] = entry[prop]
|
||
|
||
new_add_event('addCommanderMission', entry['timestamp'], data)
|
||
|
||
elif event_name == 'MissionAbandoned':
|
||
new_add_event('setCommanderMissionAbandoned', entry['timestamp'], {'missionGameID': entry['MissionID']})
|
||
|
||
elif event_name == 'MissionCompleted':
|
||
for x in entry.get('PermitsAwarded', []):
|
||
new_add_event('addCommanderPermit', entry['timestamp'], {'starsystemName': x})
|
||
|
||
data = {'missionGameID': entry['MissionID']}
|
||
if 'Donation' in entry:
|
||
data['donationCredits'] = entry['Donation']
|
||
|
||
if 'Reward' in entry:
|
||
data['rewardCredits'] = entry['Reward']
|
||
|
||
if 'PermitsAwarded' in entry:
|
||
data['rewardPermits'] = [{'starsystemName': x} for x in entry['PermitsAwarded']]
|
||
|
||
if 'CommodityReward' in entry:
|
||
data['rewardCommodities'] = [{'itemName': x['Name'], 'itemCount': x['Count']}
|
||
for x in entry['CommodityReward']]
|
||
|
||
if 'MaterialsReward' in entry:
|
||
data['rewardMaterials'] = [{'itemName': x['Name'], 'itemCount': x['Count']}
|
||
for x in entry['MaterialsReward']]
|
||
|
||
factioneffects = []
|
||
for faction in entry.get('FactionEffects', []):
|
||
effect: dict[str, Any] = {'minorfactionName': faction['Faction']}
|
||
for influence in faction.get('Influence', []):
|
||
if 'Influence' in influence:
|
||
highest_gain = influence['Influence']
|
||
if len(effect.get('influenceGain', '')) > len(highest_gain):
|
||
highest_gain = effect['influenceGain']
|
||
|
||
effect['influenceGain'] = highest_gain
|
||
|
||
if 'Reputation' in faction:
|
||
effect['reputationGain'] = faction['Reputation']
|
||
|
||
factioneffects.append(effect)
|
||
|
||
if factioneffects:
|
||
data['minorfactionEffects'] = factioneffects
|
||
|
||
new_add_event('setCommanderMissionCompleted', entry['timestamp'], data)
|
||
|
||
elif event_name == 'MissionFailed':
|
||
new_add_event('setCommanderMissionFailed', entry['timestamp'], {'missionGameID': entry['MissionID']})
|
||
|
||
# Combat
|
||
if event_name == 'Died':
|
||
data = {'starsystemName': system}
|
||
if 'Killers' in entry:
|
||
data['wingOpponentNames'] = [x['Name'] for x in entry['Killers']]
|
||
|
||
elif 'KillerName' in entry:
|
||
data['opponentName'] = entry['KillerName']
|
||
|
||
elif 'KillerShip' in entry:
|
||
data['opponentName'] = entry['KillerShip']
|
||
|
||
# Paranoia in case of e.g. Thargoid activity not having complete data
|
||
opponent_name_issue = 'opponentName' not in data or data['opponentName'] == ""
|
||
wing_opponent_names_issue = 'wingOpponentNames' not in data or data['wingOpponentNames'] == []
|
||
if opponent_name_issue and wing_opponent_names_issue:
|
||
logger.warning('Dropping addCommanderCombatDeath message'
|
||
'because opponentName and wingOpponentNames came out as ""')
|
||
|
||
else:
|
||
new_add_event('addCommanderCombatDeath', entry['timestamp'], data)
|
||
|
||
elif event_name == 'Interdicted':
|
||
data = {
|
||
'starsystemName': system,
|
||
'isPlayer': entry['IsPlayer'],
|
||
'isSubmit': entry['Submitted']
|
||
}
|
||
|
||
if 'Interdictor' in entry:
|
||
data['opponentName'] = entry['Interdictor']
|
||
|
||
elif 'Faction' in entry:
|
||
data['opponentName'] = entry['Faction']
|
||
|
||
elif 'Power' in entry:
|
||
data['opponentName'] = entry['Power']
|
||
|
||
elif 'IsThargoid' in entry and entry['IsThargoid']:
|
||
data['opponentName'] = 'Thargoid'
|
||
|
||
# Paranoia in case of e.g. Thargoid activity not having complete data
|
||
if 'opponentName' not in data or data['opponentName'] == "":
|
||
logger.warning('Dropping addCommanderCombatInterdicted message because opponentName came out as ""')
|
||
|
||
else:
|
||
new_add_event('addCommanderCombatInterdicted', entry['timestamp'], data)
|
||
|
||
elif event_name == 'Interdiction':
|
||
data = {
|
||
'starsystemName': system,
|
||
'isPlayer': entry['IsPlayer'],
|
||
'isSuccess': entry['Success'],
|
||
}
|
||
|
||
if 'Interdicted' in entry:
|
||
data['opponentName'] = entry['Interdicted']
|
||
|
||
elif 'Faction' in entry:
|
||
data['opponentName'] = entry['Faction']
|
||
|
||
elif 'Power' in entry:
|
||
data['opponentName'] = entry['Power']
|
||
|
||
# Shouldn't be needed here as Interdiction events can't target Thargoids (yet)
|
||
# but done just in case of future changes or so
|
||
elif 'IsThargoid' in entry and entry['IsThargoid']:
|
||
data['opponentName'] = 'Thargoid'
|
||
|
||
# Paranoia in case of e.g. Thargoid activity not having complete data
|
||
if 'opponentName' not in data or data['opponentName'] == "":
|
||
logger.warning('Dropping addCommanderCombatInterdiction message because opponentName came out as ""')
|
||
|
||
else:
|
||
new_add_event('addCommanderCombatInterdiction', entry['timestamp'], data)
|
||
|
||
elif event_name == 'EscapeInterdiction':
|
||
data = {
|
||
'starsystemName': system,
|
||
'isPlayer': entry['IsPlayer'],
|
||
}
|
||
|
||
if 'Interdictor' in entry:
|
||
data['opponentName'] = entry['Interdictor']
|
||
|
||
elif 'Faction' in entry:
|
||
data['opponentName'] = entry['Faction']
|
||
|
||
elif 'Power' in entry:
|
||
data['opponentName'] = entry['Power']
|
||
|
||
elif 'isThargoid' in entry and entry['isThargoid']:
|
||
data['opponentName'] = 'Thargoid'
|
||
|
||
# Paranoia in case of e.g. Thargoid activity not having complete data
|
||
if 'opponentName' not in data or data['opponentName'] == "":
|
||
logger.warning('Dropping addCommanderCombatInterdiction message because opponentName came out as ""')
|
||
|
||
else:
|
||
new_add_event('addCommanderCombatInterdictionEscape', entry['timestamp'], data)
|
||
|
||
elif event_name == 'PVPKill':
|
||
new_add_event(
|
||
'addCommanderCombatKill',
|
||
entry['timestamp'],
|
||
{
|
||
'starsystemName': system,
|
||
'opponentName': entry['Victim'],
|
||
}
|
||
)
|
||
|
||
# New Odyssey features
|
||
elif event_name == 'DropshipDeploy':
|
||
new_add_event(
|
||
'addCommanderTravelLand',
|
||
entry['timestamp'],
|
||
{
|
||
'starsystemName': entry['StarSystem'],
|
||
'starsystemBodyName': entry['Body'],
|
||
'isTaxiDropship': True,
|
||
}
|
||
)
|
||
|
||
elif event_name == 'Touchdown':
|
||
# Touchdown has FAR more info available on Odyssey vs Horizons:
|
||
# Horizons:
|
||
# {"timestamp":"2021-05-31T09:10:54Z","event":"Touchdown",
|
||
# "PlayerControlled":true,"Latitude":46.691929,"Longitude":-92.679977}
|
||
#
|
||
# Odyssey:
|
||
# {"timestamp":"2021-05-31T08:48:08Z","event":"Touchdown","PlayerControlled":true,"Taxi":false,
|
||
# "Multicrew":false,"StarSystem":"Gateway","SystemAddress":2832631665362,"Body":"Saunder's Rock","BodyID":2,
|
||
# "OnStation":false,"OnPlanet":true,"Latitude":54.79665,"Longitude":-99.498253}
|
||
#
|
||
# So we're going to do a lot of checking here and bail out if we dont like the look of ANYTHING here
|
||
|
||
to_send_data: dict[str, Any] | None = {} # This is a glorified sentinel until lower down.
|
||
# On Horizons, neither of these exist on TouchDown
|
||
star_system_name = entry.get('StarSystem', this.system_name)
|
||
body_name = entry.get('Body', state['Body'] if state['BodyType'] == 'Planet' else None)
|
||
|
||
if star_system_name is None:
|
||
logger.warning('Refusing to update addCommanderTravelLand as we dont have a StarSystem!')
|
||
to_send_data = None
|
||
|
||
if body_name is None:
|
||
logger.warning('Refusing to update addCommanderTravelLand as we dont have a Body!')
|
||
to_send_data = None
|
||
|
||
if (op := entry.get('OnPlanet')) is not None and not op:
|
||
logger.warning('Refusing to update addCommanderTravelLand when OnPlanet is False!')
|
||
logger.warning(f'{entry=}')
|
||
to_send_data = None
|
||
|
||
if not entry['PlayerControlled']:
|
||
logger.info("Not updating inara addCommanderTravelLand for autonomous recall landing")
|
||
to_send_data = None
|
||
|
||
if to_send_data is not None:
|
||
# Above checks passed. Lets build and send this!
|
||
to_send_data['starsystemName'] = star_system_name # Required
|
||
to_send_data['starsystemBodyName'] = body_name # Required
|
||
|
||
# Following are optional
|
||
|
||
# lat/long is always there unless its an automated (recall) landing. Thus as we're sure its _not_
|
||
# we can assume this exists. If it doesn't its a bug anyway.
|
||
to_send_data['starsystemBodyCoords'] = [entry['Latitude'], entry['Longitude']]
|
||
if state.get('ShipID') is not None:
|
||
to_send_data['shipGameID'] = state['ShipID']
|
||
|
||
if state.get('ShipType') is not None:
|
||
to_send_data['shipType'] = state['ShipType']
|
||
|
||
to_send_data['isTaxiShuttle'] = False
|
||
to_send_data['isTaxiDropShip'] = False
|
||
|
||
new_add_event('addCommanderTravelLand', entry['timestamp'], to_send_data)
|
||
|
||
elif event_name == 'ShipLocker':
|
||
# In ED 4.0.0.400 the event is only full sometimes, other times indicating
|
||
# ShipLocker.json was written.
|
||
if not all(t in entry for t in ('Components', 'Consumables', 'Data', 'Items')):
|
||
# So it's an empty event, core EDMC should have stuffed the data
|
||
# into state['ShipLockerJSON'].
|
||
entry = state['ShipLockerJSON']
|
||
|
||
odyssey_plural_microresource_types = ('Items', 'Components', 'Data', 'Consumables')
|
||
# we're getting new data here. so reset it on inara's side just to be sure that we set everything right
|
||
reset_data = [{'itemType': t} for t in odyssey_plural_microresource_types]
|
||
set_data = []
|
||
for typ in odyssey_plural_microresource_types:
|
||
set_data.extend([
|
||
{'itemName': thing['Name'], 'itemCount': thing['Count'], 'itemType': typ} for thing in entry[typ]
|
||
])
|
||
|
||
new_add_event('resetCommanderInventory', entry['timestamp'], reset_data)
|
||
new_add_event('setCommanderInventory', entry['timestamp'], set_data)
|
||
|
||
elif event_name in ('CreateSuitLoadout', 'SuitLoadout'):
|
||
# CreateSuitLoadout and SuitLoadout are pretty much the same event:
|
||
# ╙─╴% cat Journal.* | jq 'select(.event == "SuitLoadout" or .event == "CreateSuitLoadout") | keys' -c \
|
||
# | uniq
|
||
#
|
||
# ["LoadoutID","LoadoutName","Modules","SuitID","SuitMods","SuitName","SuitName_Localised","event",
|
||
# "timestamp"]
|
||
|
||
to_send = {
|
||
'loadoutGameID': entry['LoadoutID'],
|
||
'loadoutName': entry['LoadoutName'],
|
||
'suitGameID': entry['SuitID'],
|
||
'suitType': entry['SuitName'],
|
||
'suitMods': entry['SuitMods'],
|
||
'suitLoadout': [
|
||
{
|
||
'slotName': x['SlotName'],
|
||
'itemName': x['ModuleName'],
|
||
'itemClass': x['Class'],
|
||
'itemGameID': x['SuitModuleID'],
|
||
'engineering': [{'blueprintName': mod} for mod in x['WeaponMods']],
|
||
} for x in entry['Modules']
|
||
],
|
||
}
|
||
|
||
new_add_event('setCommanderSuitLoadout', entry['timestamp'], to_send)
|
||
|
||
elif event_name == 'DeleteSuitLoadout':
|
||
new_add_event('delCommanderSuitLoadout', entry['timestamp'], {'loadoutGameID': entry['LoadoutID']})
|
||
|
||
elif event_name == 'RenameSuitLoadout':
|
||
to_send = {
|
||
'loadoutGameID': entry['LoadoutID'],
|
||
'loadoutName': entry['LoadoutName'],
|
||
# may as well...
|
||
'suitType': entry['SuitName'],
|
||
'suitGameID': entry['SuitID']
|
||
}
|
||
new_add_event('updateCommanderSuitLoadout', entry['timestamp'], {})
|
||
|
||
elif event_name == 'LoadoutEquipModule':
|
||
to_send = {
|
||
'loadoutGameID': entry['LoadoutID'],
|
||
'loadoutName': entry['LoadoutName'],
|
||
'suitType': entry['SuitName'],
|
||
'suitGameID': entry['SuitID'],
|
||
'suitLoadout': [
|
||
{
|
||
'slotName': entry['SlotName'],
|
||
'itemName': entry['ModuleName'],
|
||
'itemGameID': entry['SuitModuleID'],
|
||
'itemClass': entry['Class'],
|
||
'engineering': [{'blueprintName': mod} for mod in entry['WeaponMods']],
|
||
}
|
||
],
|
||
}
|
||
|
||
new_add_event('updateCommanderSuitLoadout', entry['timestamp'], to_send)
|
||
|
||
elif event_name == 'Location':
|
||
to_send = {
|
||
'starsystemName': entry['StarSystem'],
|
||
'starsystemCoords': entry['StarPos'],
|
||
}
|
||
|
||
if entry['Docked']:
|
||
to_send['stationName'] = entry['StationName']
|
||
to_send['marketID'] = entry['MarketID']
|
||
|
||
if entry['Docked'] and entry['BodyType'] == 'Planet':
|
||
# we're Docked, but we're not on a Station, thus we're docked at a planetary base of some kind
|
||
# and thus, we SHOULD include starsystemBodyName
|
||
to_send['starsystemBodyName'] = entry['Body']
|
||
|
||
if 'Longitude' in entry and 'Latitude' in entry:
|
||
# These were included thus we are landed
|
||
to_send['starsystemBodyCoords'] = [entry['Latitude'], entry['Longitude']]
|
||
# if we're not Docked, but have these, we're either landed or close enough that it doesn't matter.
|
||
to_send['starsystemBodyName'] = entry['Body']
|
||
|
||
new_add_event('setCommanderTravelLocation', entry['timestamp'], to_send)
|
||
|
||
# Community Goals
|
||
if event_name == 'CommunityGoal':
|
||
# Remove any unsent
|
||
this.filter_events(
|
||
current_credentials, lambda e: e.name not in ('setCommunityGoal', 'setCommanderCommunityGoalProgress')
|
||
)
|
||
|
||
# this.events = list(filter(
|
||
# lambda e: e['eventName'] not in ('setCommunityGoal', 'setCommanderCommunityGoalProgress'),
|
||
# this.events
|
||
# ))
|
||
|
||
for goal in entry['CurrentGoals']:
|
||
data = {
|
||
'communitygoalGameID': goal['CGID'],
|
||
'communitygoalName': goal['Title'],
|
||
'starsystemName': goal['SystemName'],
|
||
'stationName': goal['MarketName'],
|
||
'goalExpiry': goal['Expiry'],
|
||
'isCompleted': goal['IsComplete'],
|
||
'contributorsNum': goal['NumContributors'],
|
||
'contributionsTotal': goal['CurrentTotal'],
|
||
}
|
||
|
||
if 'TierReached' in goal:
|
||
data['tierReached'] = int(goal['TierReached'].split()[-1])
|
||
|
||
if 'TopRankSize' in goal:
|
||
data['topRankSize'] = goal['TopRankSize']
|
||
|
||
if 'TopTier' in goal:
|
||
data['tierMax'] = int(goal['TopTier']['Name'].split()[-1])
|
||
data['completionBonus'] = goal['TopTier']['Bonus']
|
||
|
||
new_add_event('setCommunityGoal', entry['timestamp'], data)
|
||
|
||
data = {
|
||
'communitygoalGameID': goal['CGID'],
|
||
'contribution': goal['PlayerContribution'],
|
||
'percentileBand': goal['PlayerPercentileBand'],
|
||
}
|
||
|
||
if 'Bonus' in goal:
|
||
data['percentileBandReward'] = goal['Bonus']
|
||
|
||
if 'PlayerInTopRank' in goal:
|
||
data['isTopRank'] = goal['PlayerInTopRank']
|
||
|
||
new_add_event('setCommanderCommunityGoalProgress', entry['timestamp'], data)
|
||
|
||
# Friends
|
||
if event_name == 'Friends':
|
||
if entry['Status'] in ('Added', 'Online'):
|
||
new_add_event(
|
||
'addCommanderFriend',
|
||
entry['timestamp'],
|
||
{
|
||
'commanderName': entry['Name'],
|
||
'gamePlatform': 'pc',
|
||
}
|
||
)
|
||
|
||
elif entry['Status'] in ('Declined', 'Lost'):
|
||
new_add_event(
|
||
'delCommanderFriend',
|
||
entry['timestamp'],
|
||
{
|
||
'commanderName': entry['Name'],
|
||
'gamePlatform': 'pc',
|
||
}
|
||
)
|
||
|
||
this.newuser = False
|
||
|
||
# Only actually change URLs if we are current provider.
|
||
if config.get_str('system_provider') == 'Inara':
|
||
this.system_link['text'] = this.system_name
|
||
# Do *NOT* set 'url' here, as it's set to a function that will call
|
||
# through correctly. We don't want a static string.
|
||
this.system_link.update_idletasks()
|
||
|
||
if config.get_str('station_provider') == 'Inara':
|
||
to_set: str = cast(str, this.station)
|
||
if not to_set:
|
||
if this.system_population is not None and this.system_population > 0:
|
||
to_set = STATION_UNDOCKED
|
||
else:
|
||
to_set = ''
|
||
|
||
this.station_link['text'] = to_set
|
||
# Do *NOT* set 'url' here, as it's set to a function that will call
|
||
# through correctly. We don't want a static string.
|
||
this.station_link.update_idletasks()
|
||
|
||
return '' # No error
|
||
|
||
|
||
def cmdr_data(data: CAPIData, is_beta): # noqa: CCR001, reanalyze me later
|
||
"""CAPI event hook."""
|
||
this.cmdr = data['commander']['name']
|
||
|
||
# Always store initially, even if we're not the *current* system provider.
|
||
if not this.station_marketid:
|
||
this.station_marketid = data['commander']['docked'] and data['lastStarport']['id']
|
||
|
||
# Only trust CAPI if these aren't yet set
|
||
this.system_name = this.system_name if this.system_name else data['lastSystem']['name']
|
||
|
||
if not this.station and data['commander']['docked']:
|
||
this.station = data['lastStarport']['name']
|
||
|
||
# Override standard URL functions
|
||
if config.get_str('system_provider') == 'Inara':
|
||
this.system_link['text'] = this.system_name
|
||
# Do *NOT* set 'url' here, as it's set to a function that will call
|
||
# through correctly. We don't want a static string.
|
||
this.system_link.update_idletasks()
|
||
|
||
if config.get_str('station_provider') == 'Inara':
|
||
if data['commander']['docked'] or this.on_foot and this.station:
|
||
this.station_link['text'] = this.station
|
||
|
||
elif data['lastStarport']['name'] and data['lastStarport']['name'] != "":
|
||
this.station_link['text'] = STATION_UNDOCKED
|
||
|
||
else:
|
||
this.station_link['text'] = ''
|
||
|
||
# Do *NOT* set 'url' here, as it's set to a function that will call
|
||
# through correctly. We don't want a static string.
|
||
this.station_link.update_idletasks()
|
||
|
||
if config.get_int('inara_out') and not is_beta and not this.multicrew and credentials(this.cmdr):
|
||
# Only here to ensure the conditional is correct for future additions
|
||
pass
|
||
|
||
|
||
def make_loadout(state: dict[str, Any]) -> dict[str, Any]: # noqa: CCR001
|
||
"""
|
||
Construct an inara loadout from an event.
|
||
|
||
:param state: The event / state to construct the event from
|
||
:return: The constructed loadout
|
||
"""
|
||
modules = []
|
||
for m in state['Modules'].values():
|
||
module: dict[str, Any] = {
|
||
'slotName': m['Slot'],
|
||
'itemName': m['Item'],
|
||
'itemHealth': m['Health'],
|
||
'isOn': m['On'],
|
||
'itemPriority': m['Priority'],
|
||
}
|
||
|
||
if 'AmmoInClip' in m:
|
||
module['itemAmmoClip'] = m['AmmoInClip']
|
||
|
||
if 'AmmoInHopper' in m:
|
||
module['itemAmmoHopper'] = m['AmmoInHopper']
|
||
|
||
if 'Value' in m:
|
||
module['itemValue'] = m['Value']
|
||
|
||
if 'Hot' in m:
|
||
module['isHot'] = m['Hot']
|
||
|
||
if 'Engineering' in m:
|
||
engineering: dict[str, Any] = {
|
||
'blueprintName': m['Engineering']['BlueprintName'],
|
||
'blueprintLevel': m['Engineering']['Level'],
|
||
'blueprintQuality': m['Engineering']['Quality'],
|
||
}
|
||
|
||
if 'ExperimentalEffect' in m['Engineering']:
|
||
engineering['experimentalEffect'] = m['Engineering']['ExperimentalEffect']
|
||
|
||
engineering['modifiers'] = []
|
||
for mod in m['Engineering']['Modifiers']:
|
||
modifier: dict[str, Any] = {
|
||
'name': mod['Label'],
|
||
}
|
||
|
||
if 'OriginalValue' in mod:
|
||
modifier['value'] = mod['Value']
|
||
modifier['originalValue'] = mod['OriginalValue']
|
||
modifier['lessIsGood'] = mod['LessIsGood']
|
||
|
||
else:
|
||
modifier['value'] = mod['ValueStr']
|
||
|
||
engineering['modifiers'].append(modifier)
|
||
|
||
module['engineering'] = engineering
|
||
|
||
modules.append(module)
|
||
|
||
return {
|
||
'shipType': state['ShipType'],
|
||
'shipGameID': state['ShipID'],
|
||
'shipLoadout': modules,
|
||
}
|
||
|
||
|
||
def new_add_event(
|
||
name: str,
|
||
timestamp: str,
|
||
data: EVENT_DATA,
|
||
cmdr: str | None = None,
|
||
fid: str | None = None
|
||
):
|
||
"""
|
||
Add a journal event to the queue, to be sent to inara at the next opportunity.
|
||
|
||
If provided, use the given cmdr name over the current one
|
||
|
||
:param name: name of the event
|
||
:param timestamp: timestamp of the event
|
||
:param data: payload for the event
|
||
:param cmdr: the commander to send as, defaults to the current commander
|
||
"""
|
||
if cmdr is None:
|
||
cmdr = this.cmdr
|
||
|
||
if fid is None:
|
||
fid = this.FID
|
||
|
||
api_key = credentials(this.cmdr)
|
||
if api_key is None:
|
||
logger.warning(f"cannot find an API key for cmdr {this.cmdr!r}")
|
||
return
|
||
|
||
key = Credentials(str(cmdr), str(fid), api_key) # this fails type checking due to `this` weirdness, hence str()
|
||
|
||
with this.event_lock:
|
||
this.events[key].append(Event(name, timestamp, data))
|
||
|
||
|
||
def clean_event_list(event_list: list[Event]) -> list[Event]:
|
||
"""
|
||
Check for killswitched events and remove or modify them as requested.
|
||
|
||
:param event_list: list of events to clean
|
||
:return: Cleaned list of events
|
||
"""
|
||
cleaned_events = []
|
||
for event in event_list:
|
||
is_bad, new_event = killswitch.check_killswitch(f'plugins.inara.worker.{event.name}', event.data, logger)
|
||
if is_bad:
|
||
continue
|
||
|
||
event.data = new_event
|
||
cleaned_events.append(event)
|
||
|
||
return cleaned_events
|
||
|
||
|
||
def new_worker():
|
||
"""
|
||
Handle sending events to the Inara API.
|
||
|
||
Will only ever send one message per WORKER_WAIT_TIME, regardless of status.
|
||
"""
|
||
logger.debug('Starting...')
|
||
while True:
|
||
events = get_events()
|
||
disabled_killswitch = killswitch.get_disabled("plugins.inara.worker")
|
||
if disabled_killswitch.disabled:
|
||
logger.warning(f"Inara worker disabled via killswitch. ({disabled_killswitch.reason})")
|
||
continue
|
||
|
||
for creds, event_list in events.items():
|
||
event_list = clean_event_list(event_list)
|
||
if not event_list:
|
||
continue
|
||
|
||
event_data = [
|
||
{'eventName': e.name, 'eventTimestamp': e.timestamp, 'eventData': e.data} for e in event_list
|
||
]
|
||
|
||
data = {
|
||
'header': {
|
||
'appName': applongname,
|
||
'appVersion': str(appversion()),
|
||
'APIkey': creds.api_key,
|
||
'commanderName': creds.cmdr,
|
||
'commanderFrontierID': creds.fid,
|
||
},
|
||
'events': event_data
|
||
}
|
||
|
||
logger.info(f'Sending {len(event_data)} events for {creds.cmdr}')
|
||
logger.trace_if('plugin.inara.events', f'Events:\n{json.dumps(data)}\n')
|
||
|
||
try_send_data(TARGET_URL, data)
|
||
|
||
time.sleep(WORKER_WAIT_TIME)
|
||
|
||
logger.debug('Done.')
|
||
|
||
|
||
def get_events(clear: bool = True) -> dict[Credentials, list[Event]]:
|
||
"""
|
||
Fetch a copy of all events from the current queue.
|
||
|
||
:param clear: whether to clear the queues as we go, defaults to True
|
||
:return: a copy of the event dictionary
|
||
"""
|
||
events_copy: dict[Credentials, list[Event]] = {}
|
||
|
||
with this.event_lock:
|
||
for key, events in this.events.items():
|
||
events_copy[key] = list(events)
|
||
if clear:
|
||
events.clear()
|
||
|
||
return events_copy
|
||
|
||
|
||
def try_send_data(url: str, data: Mapping[str, Any]) -> None:
|
||
"""
|
||
Attempt repeatedly to send the payload.
|
||
|
||
:param url: target URL for the payload
|
||
:param data: the payload
|
||
"""
|
||
for attempt in range(3):
|
||
logger.debug(f"Sending data to API, attempt #{attempt + 1}")
|
||
try:
|
||
if send_data(url, data):
|
||
break
|
||
|
||
except Exception as e:
|
||
logger.debug('Unable to send events', exc_info=e)
|
||
return
|
||
|
||
|
||
def send_data(url: str, data: Mapping[str, Any]) -> bool:
|
||
"""
|
||
Send a set of events to the Inara API.
|
||
|
||
:param url: The target URL to post the data.
|
||
:param data: The data to be POSTed.
|
||
:return: True if the data was sent successfully, False otherwise.
|
||
"""
|
||
response = this.session.post(url, data=json.dumps(data, separators=(',', ':')), timeout=_TIMEOUT)
|
||
response.raise_for_status()
|
||
reply = response.json()
|
||
status = reply['header']['eventStatus']
|
||
|
||
if status // 100 != 2: # 2xx == OK (maybe with warnings)
|
||
handle_api_error(data, status, reply)
|
||
else:
|
||
handle_success_reply(data, reply)
|
||
|
||
return True # Regardless of errors above, we DID manage to send it, therefore inform our caller as such
|
||
|
||
|
||
def handle_api_error(data: Mapping[str, Any], status: int, reply: dict[str, Any]) -> None:
|
||
"""
|
||
Handle API error response.
|
||
|
||
:param data: The original data that was sent.
|
||
:param status: The HTTP status code of the API response.
|
||
:param reply: The JSON reply from the API.
|
||
"""
|
||
error_message = reply['header'].get('eventStatusText', "")
|
||
logger.warning(f'Inara\t{status} {error_message}')
|
||
logger.debug(f'JSON data:\n{json.dumps(data, indent=2, separators = (",", ": "))}')
|
||
# LANG: INARA API returned some kind of error (error message will be contained in {MSG})
|
||
plug.show_error(tr.tl('Error: Inara {MSG}').format(MSG=error_message))
|
||
|
||
|
||
def handle_success_reply(data: Mapping[str, Any], reply: dict[str, Any]) -> None:
|
||
"""
|
||
Handle successful API response.
|
||
|
||
:param data: The original data that was sent.
|
||
:param reply: The JSON reply from the API.
|
||
"""
|
||
for data_event, reply_event in zip(data['events'], reply['events']):
|
||
reply_status = reply_event['eventStatus']
|
||
reply_text = reply_event.get("eventStatusText", "")
|
||
if reply_status != 200:
|
||
handle_individual_error(data_event, reply_status, reply_text)
|
||
handle_special_events(data_event, reply_event)
|
||
|
||
|
||
def handle_individual_error(data_event: dict[str, Any], reply_status: int, reply_text: str) -> None:
|
||
"""
|
||
Handle individual API error.
|
||
|
||
:param data_event: The event data that was sent.
|
||
:param reply_status: The event status code from the API response.
|
||
:param reply_text: The event status text from the API response.
|
||
"""
|
||
if ("Everything was alright, the near-neutral status just wasn't stored."
|
||
not in reply_text):
|
||
logger.warning(f'Inara\t{reply_status} {reply_text}')
|
||
logger.debug(f'JSON data:\n{json.dumps(data_event)}')
|
||
|
||
if reply_status // 100 != 2:
|
||
# LANG: INARA API returned some kind of error (error message will be contained in {MSG})
|
||
plug.show_error(tr.tl('Error: Inara {MSG}').format(
|
||
MSG=f'{data_event["eventName"]}, {reply_text}'
|
||
))
|
||
|
||
|
||
def handle_special_events(data_event: dict[str, Any], reply_event: dict[str, Any]) -> None:
|
||
"""
|
||
Handle special events in the API response.
|
||
|
||
:param data_event: The event data that was sent.
|
||
:param reply_event: The event data from the API reply.
|
||
"""
|
||
if data_event['eventName'] in (
|
||
'addCommanderTravelCarrierJump',
|
||
'addCommanderTravelDock',
|
||
'addCommanderTravelFSDJump',
|
||
'setCommanderTravelLocation'
|
||
):
|
||
this.lastlocation = reply_event.get('eventData', {})
|
||
if not config.shutting_down:
|
||
this.system_link.event_generate('<<InaraLocation>>', when="tail")
|
||
elif data_event['eventName'] in ('addCommanderShip', 'setCommanderShip'):
|
||
this.lastship = reply_event.get('eventData', {})
|
||
if not config.shutting_down:
|
||
this.system_link.event_generate('<<InaraShip>>', when="tail")
|
||
|
||
|
||
def update_location(event=None) -> None:
|
||
"""
|
||
Update other plugins with our response to system and station changes.
|
||
|
||
:param event: Unused and ignored, defaults to None
|
||
"""
|
||
if this.lastlocation:
|
||
for plugin in plug.provides('inara_notify_location'):
|
||
plug.invoke(plugin, None, 'inara_notify_location', this.lastlocation)
|
||
|
||
|
||
def inara_notify_location(event_data) -> None:
|
||
"""Unused."""
|
||
pass
|
||
|
||
|
||
def update_ship(event=None) -> None:
|
||
"""
|
||
Update other plugins with our response to changing.
|
||
|
||
:param event: Unused and ignored, defaults to None
|
||
"""
|
||
if this.lastship:
|
||
for plugin in plug.provides('inara_notify_ship'):
|
||
plug.invoke(plugin, None, 'inara_notify_ship', this.lastship)
|