forked from EDCD/EDMarketConnector
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy patheddn.py
1790 lines (1458 loc) · 70.9 KB
/
eddn.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""Handle exporting data to EDDN."""
# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $#
# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $#
#
# 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 `setup.py`
# SO AS TO ENSURE THE FILES ARE ACTUALLY PRESENT IN AN END-USER
# INSTALLATION ON WINDOWS.
#
#
# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $#
# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $#
import itertools
import json
import pathlib
import re
import sys
import tkinter as tk
from collections import OrderedDict
from os import SEEK_SET
from os.path import join
from platform import system
from textwrap import dedent
from typing import TYPE_CHECKING, Any, Dict, Iterator, List, Mapping, MutableMapping, Optional
from typing import OrderedDict as OrderedDictT
from typing import TextIO, Tuple, Union
import requests
import edmc_data
import killswitch
import myNotebook as nb # noqa: N813
import plug
from companion import CAPIData, category_map
from config import applongname, appversion_nobuild, config, debug_senders, user_agent
from EDMCLogging import get_main_logger
from monitor import monitor
from myNotebook import Frame
from prefs import prefsVersion
from ttkHyperlinkLabel import HyperlinkLabel
from util import text
if sys.platform != 'win32':
from fcntl import LOCK_EX, LOCK_NB, lockf
if TYPE_CHECKING:
def _(x: str) -> str:
return x
logger = get_main_logger()
class This:
"""Holds module globals."""
def __init__(self):
# Track if we're on foot
self.on_foot = False
# Horizons ?
self.horizons = False
# Running under Odyssey?
self.odyssey = False
# Track location to add to Journal events
self.systemaddress: Optional[str] = None
self.coordinates: Optional[Tuple] = None
self.body_name: Optional[str] = None
self.body_id: Optional[int] = None
# Track Status.json data
self.status_body_name: Optional[str] = None
# Avoid duplicates
self.marketId: Optional[str] = None
self.commodities: Optional[List[OrderedDictT[str, Any]]] = None
self.outfitting: Optional[Tuple[bool, List[str]]] = None
self.shipyard: Optional[Tuple[bool, List[Mapping[str, Any]]]] = None
# For the tkinter parent window, so we can call update_idletasks()
self.parent: tk.Tk
# To hold EDDN class instance
self.eddn: EDDN
# tkinter UI bits.
self.eddn_station: tk.IntVar
self.eddn_station_button: nb.Checkbutton
self.eddn_system: tk.IntVar
self.eddn_system_button: nb.Checkbutton
self.eddn_delay: tk.IntVar
self.eddn_delay_button: nb.Checkbutton
# Tracking UI
self.ui: tk.Frame
self.ui_j_body_name: tk.Label
self.ui_j_body_id: tk.Label
self.ui_s_body_name: tk.Label
this = This()
# This SKU is tagged on any module or ship that you must have Horizons for.
HORIZONS_SKU = 'ELITE_HORIZONS_V_PLANETARY_LANDINGS'
# ELITE_HORIZONS_V_COBRA_MK_IV_1000` is for the Cobra Mk IV, but
# is also available in the base game, if you have entitlement.
# `ELITE_HORIZONS_V_GUARDIAN_FSDBOOSTER` is for the Guardian FSD Boosters,
# which you need Horizons in order to unlock, but could be on sale even in the
# base game due to entitlement.
# Thus do **NOT** use either of these in addition to the PLANETARY_LANDINGS
# one.
# TODO: a good few of these methods are static or could be classmethods. they should be created as such.
class EDDN:
"""EDDN Data export."""
DEFAULT_URL = 'https://eddn.edcd.io:4430/upload/'
if 'eddn' in debug_senders:
DEFAULT_URL = f'http://{edmc_data.DEBUG_WEBSERVER_HOST}:{edmc_data.DEBUG_WEBSERVER_PORT}/eddn'
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(r'^Hpt_|^Int_|Armour_', re.IGNORECASE)
CANONICALISE_RE = re.compile(r'\$(.+)_name;')
UNKNOWN_SCHEMA_RE = re.compile(
r"^FAIL: \[JsonValidationException\('Schema "
r"https://eddn.edcd.io/schemas/(?P<schema_name>.+)/(?P<schema_version>[0-9]+) is unknown, "
r"unable to validate.',\)\]$"
)
def __init__(self, parent: tk.Tk):
self.parent: tk.Tk = parent
self.session = requests.Session()
self.session.headers['User-Agent'] = user_agent
self.replayfile: Optional[TextIO] = None # For delayed messages
self.replaylog: List[str] = []
if config.eddn_url is not None:
self.eddn_url = config.eddn_url
else:
self.eddn_url = self.DEFAULT_URL
def load_journal_replay(self) -> bool:
"""
Load cached journal entries from disk.
:return: a bool indicating success
"""
# 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 FileNotFoundError:
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 OSError:
logger.exception('Failed opening "replay.jsonl"')
if self.replayfile:
self.replayfile.close()
self.replayfile = None
return False
else:
self.replaylog = [line.strip() for line in self.replayfile]
return True
def flush(self):
"""Flush the replay file, clearing any data currently there that is not in the replaylog list."""
if self.replayfile is None:
logger.error('replayfile is None!')
return
self.replayfile.seek(0, SEEK_SET)
self.replayfile.truncate()
for line in self.replaylog:
self.replayfile.write(f'{line}\n')
self.replayfile.flush()
def close(self):
"""Close down the EDDN class instance."""
logger.debug('Closing replayfile...')
if self.replayfile:
self.replayfile.close()
self.replayfile = None
logger.debug('Done.')
logger.debug('Closing EDDN requests.Session.')
self.session.close()
def send(self, cmdr: str, msg: Mapping[str, Any]) -> None:
"""
Send sends an update to EDDN.
:param cmdr: the CMDR to use as the uploader ID.
:param msg: the payload to send.
"""
should_return, new_data = killswitch.check_killswitch('plugins.eddn.send', msg)
if should_return:
logger.warning('eddn.send has been disabled via killswitch. Returning.')
return
msg = new_data
uploader_id = cmdr
to_send: OrderedDictT[str, OrderedDict[str, Any]] = OrderedDict([
('$schemaRef', msg['$schemaRef']),
('header', OrderedDict([
('softwareName', f'{applongname} [{system() if sys.platform != "darwin" else "Mac OS"}]'),
('softwareVersion', str(appversion_nobuild())),
('uploaderID', uploader_id),
])),
('message', msg['message']),
])
# About the smallest request is going to be (newlines added for brevity):
# {"$schemaRef":"https://eddn.edcd.io/schemas/commodity/3","header":{"softwareName":"E:D Market
# Connector Windows","softwareVersion":"5.3.0-beta4extra","uploaderID":"abcdefghijklm"},"messag
# e":{"systemName":"delphi","stationName":"The Oracle","marketId":128782803,"timestamp":"2022-0
# 1-26T12:00:00Z","commodities":[]}}
#
# Which comes to 315 bytes (including \n) and compresses to 244 bytes. So lets just compress everything
encoded, compressed = text.gzip(json.dumps(to_send, separators=(',', ':')), max_size=0)
headers: None | dict[str, str] = None
if compressed:
headers = {'Content-Encoding': 'gzip'}
r = self.session.post(self.eddn_url, data=encoded, timeout=self.TIMEOUT, headers=headers)
if r.status_code != requests.codes.ok:
# Check if EDDN is still objecting to an empty commodities list
if (
r.status_code == 400
and msg['$schemaRef'] == 'https://eddn.edcd.io/schemas/commodity/3'
and msg['message']['commodities'] == []
and r.text == "FAIL: [<ValidationError: '[] is too short'>]"
):
logger.trace_if('plugin.eddn', "EDDN is still objecting to empty commodities data")
return # We want to silence warnings otherwise
if r.status_code == 413:
extra_data = {
'schema_ref': msg.get('$schemaRef', 'Unset $schemaRef!'),
'sent_data_len': str(len(encoded)),
}
if '/journal/' in extra_data['schema_ref']:
extra_data['event'] = msg.get('message', {}).get('event', 'No Event Set')
self._log_response(r, header_msg='Got a 413 while POSTing data', **extra_data)
return # drop the error
if not self.UNKNOWN_SCHEMA_RE.match(r.text):
self._log_response(r, header_msg='Status from POST wasn\'t 200 (OK)')
r.raise_for_status()
def _log_response(
self,
response: requests.Response,
header_msg='Failed to POST to EDDN',
**kwargs
) -> None:
"""
Log a response object with optional additional data.
:param response: The response to log
:param header_msg: A header message to add to the log, defaults to 'Failed to POST to EDDN'
:param kwargs: Any other notes to add, will be added below the main data in the same format.
"""
additional_data = "\n".join(
f'''{name.replace('_', ' ').title():<8}:\t{value}''' for name, value in kwargs.items()
)
logger.debug(dedent(f'''\
{header_msg}:
Status :\t{response.status_code}
URL :\t{response.url}
Headers :\t{response.headers}
Content :\t{response.text}
''')+additional_data)
def sendreplay(self) -> None: # noqa: CCR001
"""Send cached Journal lines to EDDN."""
if not self.replayfile:
return # Probably closing app
status: tk.Widget = self.parent.children['status']
if not self.replaylog:
status['text'] = ''
return
localized: str = _('Sending data to EDDN...') # LANG: Status text shown while attempting to send data
if len(self.replaylog) == 1:
status['text'] = localized
else:
status['text'] = f'{localized.replace("...", "")} [{len(self.replaylog)}]'
self.parent.update_idletasks()
# Paranoia check in case this function gets chain-called.
if not self.replaylog:
# import traceback
# logger.error(
# f'self.replaylog (type: {type(self.replaylog)}) is falsey after update_idletasks(). Traceback:\n'
# f'{"".join(traceback.format_list(traceback.extract_stack()))}')
return
try:
cmdr, msg = json.loads(self.replaylog[0], object_pairs_hook=OrderedDict)
except json.JSONDecodeError as e:
# Couldn't decode - shouldn't happen!
logger.debug(f'\n{self.replaylog[0]}\n', exc_info=e)
# Discard and continue
self.replaylog.pop(0)
else:
# TODO: Check message against *current* relevant schema so we don't try
# to send an old message that's now invalid.
# Rewrite old schema name
if msg['$schemaRef'].startswith('http://schemas.elite-markets.net/eddn/'):
msg['$schemaRef'] = str(msg['$schemaRef']).replace(
'http://schemas.elite-markets.net/eddn/',
'https://eddn.edcd.io/schemas/'
)
try:
self.send(cmdr, msg)
self.replaylog.pop(0)
if not len(self.replaylog) % self.REPLAYFLUSH:
self.flush()
except requests.exceptions.HTTPError as e:
if unknown_schema := self.UNKNOWN_SCHEMA_RE.match(e.response.text):
logger.debug(f"EDDN doesn't (yet?) know about schema: {unknown_schema['schema_name']}"
f"/{unknown_schema['schema_version']}")
# NB: This dropping is to cater for the time when EDDN
# doesn't *yet* support a new schema.
self.replaylog.pop(0) # Drop the message
self.flush() # Truncates the file, then writes the extant data
elif e.response.status_code == 400:
# EDDN straight up says no, so drop the message
logger.debug(f"EDDN responded '400' to the message, dropping:\n{msg!r}")
self.replaylog.pop(0) # Drop the message
self.flush() # Truncates the file, then writes the extant data
else:
status['text'] = self.http_error_to_log(e)
except requests.exceptions.RequestException as e:
logger.debug('Failed sending', exc_info=e)
# LANG: Error while trying to send data to EDDN
status['text'] = _("Error: Can't connect to EDDN")
return # stop sending
except Exception as e:
logger.debug('Failed sending', exc_info=e)
status['text'] = str(e)
return # stop sending
self.parent.after(self.REPLAYPERIOD, self.sendreplay)
@staticmethod
def http_error_to_log(exception: requests.exceptions.HTTPError) -> str:
"""Convert an exception from raise_for_status to a log message and displayed error."""
status_code = exception.errno
if status_code == 429: # HTTP UPGRADE REQUIRED
logger.warning('EDMC is sending schemas that are too old')
# LANG: EDDN has banned this version of our client
return _('EDDN Error: EDMC is too old for EDDN. Please update.')
elif status_code == 400:
# we a validation check or something else.
logger.warning(f'EDDN Error: {status_code} -- {exception.response}')
# LANG: EDDN returned an error that indicates something about what we sent it was wrong
return _('EDDN Error: Validation Failed (EDMC Too Old?). See Log')
else:
logger.warning(f'Unknown status code from EDDN: {status_code} -- {exception.response}')
# LANG: EDDN returned some sort of HTTP error, one we didn't expect. {STATUS} contains a number
return _('EDDN Error: Returned {STATUS} status code').format(STATUS=status_code)
def export_commodities(self, data: Mapping[str, Any], is_beta: bool) -> None: # noqa: CCR001
"""
Update EDDN with the commodities on the current (lastStarport) station.
Once the send is complete, this.commodities is updated with the new data.
NB: This does *not* go through the replaylog, unlike most of the
Journal-sourced data. This kind of timely data is often rejected by
listeners if 'too old' anyway, so little point.
:param data: a dict containing the starport data
:param is_beta: whether or not we're currently in beta mode
"""
modules, ships = self.safe_modules_and_ships(data)
horizons: bool = capi_is_horizons(
data['lastStarport'].get('economies', {}),
modules,
ships
)
commodities: List[OrderedDictT[str, Any]] = []
for commodity in data['lastStarport'].get('commodities') or []:
# Check 'marketable' and 'not prohibited'
if (category_map.get(commodity['categoryname'], True)
and not commodity.get('legality')):
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'])
# This used to have a check `commodities and ` at the start so as to
# not send an empty commodities list, as the EDDN Schema doesn't allow
# it (as of 2020-09-28).
# BUT, Fleet Carriers can go from having buy/sell orders to having
# none and that really does need to be recorded over EDDN so that, e.g.
# EDDB can update in a timely manner.
if this.commodities != commodities:
message: OrderedDictT[str, Any] = OrderedDict([
('timestamp', data['timestamp']),
('systemName', data['lastSystem']['name']),
('stationName', data['lastStarport']['name']),
('marketId', data['lastStarport']['id']),
('commodities', commodities),
('horizons', horizons),
('odyssey', this.odyssey),
])
if 'economies' in data['lastStarport']:
message['economies'] = sorted(
(x for x in (data['lastStarport']['economies'] or {}).values()), key=lambda x: x['name']
)
if 'prohibited' in data['lastStarport']:
message['prohibited'] = sorted(x for x in (data['lastStarport']['prohibited'] or {}).values())
self.send(data['commander']['name'], {
'$schemaRef': f'https://eddn.edcd.io/schemas/commodity/3{"/test" if is_beta else ""}',
'message': message,
})
this.commodities = commodities
def safe_modules_and_ships(self, data: Mapping[str, Any]) -> Tuple[Dict, Dict]:
"""
Produce a sanity-checked version of ships and modules from CAPI data.
Principally this catches where the supplied CAPI data either doesn't
contain expected elements, or they're not of the expected type (e.g.
a list instead of a dict).
:param data: The raw CAPI data.
:return: Sanity-checked data.
"""
modules: Dict[str, Any] = data['lastStarport'].get('modules')
if modules is None or not isinstance(modules, dict):
if modules is None:
logger.debug('modules was None. FC or Damaged Station?')
elif isinstance(modules, list):
if len(modules) == 0:
logger.debug('modules is empty list. FC or Damaged Station?')
else:
logger.error(f'modules is non-empty list: {modules!r}')
else:
logger.error(f'modules was not None, a list, or a dict! type = {type(modules)}')
# Set a safe value
modules = {}
ships: Dict[str, Any] = data['lastStarport'].get('ships')
if ships is None or not isinstance(ships, dict):
if ships is None:
logger.debug('ships was None')
else:
logger.error(f'ships was neither None nor a Dict! Type = {type(ships)}')
# Set a safe value
ships = {'shipyard_list': {}, 'unavailable_list': []}
return modules, ships
def export_outfitting(self, data: CAPIData, is_beta: bool) -> None:
"""
Update EDDN with the current (lastStarport) station's outfitting options, if any.
Once the send is complete, this.outfitting is updated with the given data.
NB: This does *not* go through the replaylog, unlike most of the
Journal-sourced data. This kind of timely data is often rejected by
listeners if 'too old' anyway, so little point.
:param data: dict containing the outfitting data
:param is_beta: whether or not we're currently in beta mode
"""
modules, ships = self.safe_modules_and_ships(data)
# Horizons flag - will hit at least Int_PlanetApproachSuite other than at engineer bases ("Colony"),
# prison or rescue Megaships, or under Pirate Attack etc
horizons: bool = capi_is_horizons(
data['lastStarport'].get('economies', {}),
modules,
ships
)
to_search: Iterator[Mapping[str, Any]] = filter(
lambda m: self.MODULE_RE.search(m['name']) and m.get('sku') in (None, HORIZONS_SKU)
and m['name'] != 'Int_PlanetApproachSuite', # noqa: E131
modules.values()
)
outfitting: List[str] = sorted(
self.MODULE_RE.sub(lambda match: match.group(0).capitalize(), mod['name'].lower()) for mod in to_search
)
# Don't send empty modules list - schema won't allow it
if outfitting and this.outfitting != (horizons, outfitting):
self.send(data['commander']['name'], {
'$schemaRef': f'https://eddn.edcd.io/schemas/outfitting/2{"/test" if is_beta else ""}',
'message': OrderedDict([
('timestamp', data['timestamp']),
('systemName', data['lastSystem']['name']),
('stationName', data['lastStarport']['name']),
('marketId', data['lastStarport']['id']),
('horizons', horizons),
('modules', outfitting),
('odyssey', this.odyssey),
]),
})
this.outfitting = (horizons, outfitting)
def export_shipyard(self, data: CAPIData, is_beta: bool) -> None:
"""
Update EDDN with the current (lastStarport) station's outfitting options, if any.
Once the send is complete, this.shipyard is updated to the new data.
NB: This does *not* go through the replaylog, unlike most of the
Journal-sourced data. This kind of timely data is often rejected by
listeners if 'too old' anyway, so little point.
:param data: dict containing the shipyard data
:param is_beta: whether or not we are in beta mode
"""
modules, ships = self.safe_modules_and_ships(data)
horizons: bool = capi_is_horizons(
data['lastStarport'].get('economies', {}),
modules,
ships
)
shipyard: List[Mapping[str, Any]] = sorted(
itertools.chain(
(ship['name'].lower() for ship in (ships['shipyard_list'] or {}).values()),
(ship['name'].lower() for ship in ships['unavailable_list'] or {}),
)
)
# Don't send empty ships list - shipyard data is only guaranteed present if user has visited the shipyard.
if shipyard and this.shipyard != (horizons, shipyard):
self.send(data['commander']['name'], {
'$schemaRef': f'https://eddn.edcd.io/schemas/shipyard/2{"/test" if is_beta else ""}',
'message': OrderedDict([
('timestamp', data['timestamp']),
('systemName', data['lastSystem']['name']),
('stationName', data['lastStarport']['name']),
('marketId', data['lastStarport']['id']),
('horizons', horizons),
('ships', shipyard),
('odyssey', this.odyssey),
]),
})
this.shipyard = (horizons, shipyard)
def export_journal_commodities(self, cmdr: str, is_beta: bool, entry: Mapping[str, Any]) -> None:
"""
Update EDDN with Journal commodities data from the current station (lastStarport).
As a side effect, it also updates this.commodities with the data.
NB: This does *not* go through the replaylog, unlike most of the
Journal-sourced data. This kind of timely data is often rejected by
listeners if 'too old' anyway, so little point.
:param cmdr: The commander to send data under
:param is_beta: whether or not we're in beta mode
:param entry: the journal entry containing the commodities data
"""
items: List[Mapping[str, Any]] = entry.get('Items') or []
commodities: List[OrderedDictT[str, Any]] = 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'])
# This used to have a check `commodities and ` at the start so as to
# not send an empty commodities list, as the EDDN Schema doesn't allow
# it (as of 2020-09-28).
# BUT, Fleet Carriers can go from having buy/sell orders to having
# none and that really does need to be recorded over EDDN so that, e.g.
# EDDB can update in a timely manner.
if this.commodities != commodities:
self.send(cmdr, {
'$schemaRef': f'https://eddn.edcd.io/schemas/commodity/3{"/test" if is_beta else ""}',
'message': OrderedDict([
('timestamp', entry['timestamp']),
('systemName', entry['StarSystem']),
('stationName', entry['StationName']),
('marketId', entry['MarketID']),
('commodities', commodities),
('horizons', this.horizons),
('odyssey', this.odyssey),
]),
})
this.commodities = commodities
def export_journal_outfitting(self, cmdr: str, is_beta: bool, entry: Mapping[str, Any]) -> None:
"""
Update EDDN with Journal oufitting data from the current station (lastStarport).
As a side effect, it also updates this.outfitting with the data.
NB: This does *not* go through the replaylog, unlike most of the
Journal-sourced data. This kind of timely data is often rejected by
listeners if 'too old' anyway, so little point.
:param cmdr: The commander to send data under
:param is_beta: Whether or not we're in beta mode
:param entry: The relevant journal entry
"""
modules: List[Mapping[str, Any]] = entry.get('Items', [])
horizons: bool = 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'])
outfitting: List[str] = sorted(
self.MODULE_RE.sub(lambda m: m.group(0).capitalize(), mod['Name']) for mod in
filter(lambda m: m['Name'] != 'int_planetapproachsuite', modules)
)
# Don't send empty modules list - schema won't allow it
if outfitting and this.outfitting != (horizons, outfitting):
self.send(cmdr, {
'$schemaRef': f'https://eddn.edcd.io/schemas/outfitting/2{"/test" if is_beta else ""}',
'message': OrderedDict([
('timestamp', entry['timestamp']),
('systemName', entry['StarSystem']),
('stationName', entry['StationName']),
('marketId', entry['MarketID']),
('horizons', horizons),
('modules', outfitting),
('odyssey', entry['odyssey'])
]),
})
this.outfitting = (horizons, outfitting)
def export_journal_shipyard(self, cmdr: str, is_beta: bool, entry: Mapping[str, Any]) -> None:
"""
Update EDDN with Journal shipyard data from the current station (lastStarport).
As a side effect, this.shipyard is updated with the data.
NB: This does *not* go through the replaylog, unlike most of the
Journal-sourced data. This kind of timely data is often rejected by
listeners if 'too old' anyway, so little point.
:param cmdr: the commander to send this update under
:param is_beta: Whether or not we're in beta mode
:param entry: the relevant journal entry
"""
ships: List[Mapping[str, Any]] = entry.get('PriceList') or []
horizons: bool = entry.get('Horizons', False)
shipyard = sorted(ship['ShipType'] for ship in ships)
# Don't send empty ships list - shipyard data is only guaranteed present if user has visited the shipyard.
if shipyard and this.shipyard != (horizons, shipyard):
self.send(cmdr, {
'$schemaRef': f'https://eddn.edcd.io/schemas/shipyard/2{"/test" if is_beta else ""}',
'message': OrderedDict([
('timestamp', entry['timestamp']),
('systemName', entry['StarSystem']),
('stationName', entry['StationName']),
('marketId', entry['MarketID']),
('horizons', horizons),
('ships', shipyard),
('odyssey', entry['odyssey'])
]),
})
# this.shipyard = (horizons, shipyard)
def export_journal_entry(self, cmdr: str, entry: Mapping[str, Any], msg: Mapping[str, Any]) -> None:
"""
Update EDDN with an event from the journal.
Additionally if other lines have been saved for retry, it may send
those as well.
:param cmdr: Commander name as passed in through `journal_entry()`.
:param entry: The full journal event dictionary (due to checks in this function).
:param msg: The EDDN message body to be sent.
"""
if self.replayfile or self.load_journal_replay():
# Store the entry
self.replaylog.append(json.dumps([cmdr, msg]))
self.replayfile.write(f'{self.replaylog[-1]}\n') # type: ignore
if (
entry['event'] == 'Docked' or (entry['event'] == 'Location' and entry['Docked']) or not
(config.get_int('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.
# LANG: Status text shown while attempting to send data
self.parent.children['status']['text'] = _('Sending data to EDDN...')
self.parent.update_idletasks()
self.send(cmdr, msg)
self.parent.children['status']['text'] = ''
def export_journal_generic(self, cmdr: str, is_beta: bool, entry: Mapping[str, Any]) -> None:
"""
Send an EDDN event on the journal schema.
:param cmdr: the commander under which this upload is made
:param is_beta: whether or not we are in beta mode
:param entry: the journal entry to send
"""
msg = {
'$schemaRef': f'https://eddn.edcd.io/schemas/journal/1{"/test" if is_beta else ""}',
'message': entry
}
this.eddn.export_journal_entry(cmdr, entry, msg)
def entry_augment_system_data(
self,
entry: MutableMapping[str, Any],
system_name: str,
system_coordinates: list
) -> Union[str, MutableMapping[str, Any]]:
"""
Augment a journal entry with necessary system data.
:param entry: The journal entry to be augmented.
:param system_name: Name of current star system.
:param system_coordinates: Coordinates of current star system.
:return: The augmented version of entry.
"""
# If 'SystemName' or 'System' is there, it's directly from a journal event.
# If they're not there *and* 'StarSystem' isn't either, then we add the latter.
if 'SystemName' not in entry and 'System' not in entry and 'StarSystem' not in entry:
if system_name is None or not isinstance(system_name, str) or system_name == '':
# Bad assumptions if this is the case
logger.warning(f'No system name in entry, and system_name was not set either! entry:\n{entry!r}\n')
return "passed-in system_name is empty, can't add System"
else:
entry['StarSystem'] = system_name
if 'SystemAddress' not in entry:
if this.systemaddress is None:
logger.warning("this.systemaddress is None, can't add SystemAddress")
return "this.systemaddress is None, can't add SystemAddress"
entry['SystemAddress'] = this.systemaddress
if 'StarPos' not in entry:
# Prefer the passed-in, probably monitor.state version
if system_coordinates is not None:
entry['StarPos'] = system_coordinates
# TODO: Deprecate in-plugin tracking
elif this.coordinates is not None:
entry['StarPos'] = list(this.coordinates)
else:
logger.warning("Neither this_coordinates or this.coordinates set, can't add StarPos")
return 'No source for adding StarPos to EDDN message !'
return entry
def export_journal_fssdiscoveryscan(
self, cmdr: str, system_name: str, system_starpos: list, is_beta: bool, entry: Mapping[str, Any]
) -> Optional[str]:
"""
Send an FSSDiscoveryScan to EDDN on the correct schema.
:param cmdr: the commander under which this upload is made
:param system_name: Name of current star system
:param system_starpos: Coordinates of current star system
:param is_beta: whether or not we are in beta mode
:param entry: the journal entry to send
"""
#######################################################################
# Elisions
entry = filter_localised(entry)
entry.pop('Progress')
#######################################################################
#######################################################################
# Augmentations
#######################################################################
ret = this.eddn.entry_augment_system_data(entry, system_name, system_starpos)
if isinstance(ret, str):
return ret
entry = ret
#######################################################################
msg = {
'$schemaRef': f'https://eddn.edcd.io/schemas/fssdiscoveryscan/1{"/test" if is_beta else ""}',
'message': entry
}
this.eddn.export_journal_entry(cmdr, entry, msg)
return None
def export_journal_navbeaconscan(
self, cmdr: str, system_name: str, system_starpos: list, is_beta: bool, entry: Mapping[str, Any]
) -> Optional[str]:
"""
Send an NavBeaconScan to EDDN on the correct schema.
:param cmdr: the commander under which this upload is made
:param system_name: Name of the current system.
:param system_starpos: Coordinates of current star system
:param is_beta: whether or not we are in beta mode
:param entry: the journal entry to send
"""
# { "timestamp":"2021-09-24T13:57:24Z", "event":"NavBeaconScan", "SystemAddress":670417626481, "NumBodies":18 }
#######################################################################
# Elisions
entry = filter_localised(entry)
#######################################################################
#######################################################################
# Augmentations
#######################################################################
ret = this.eddn.entry_augment_system_data(entry, system_name, system_starpos)
if isinstance(ret, str):
return ret
entry = ret
#######################################################################
msg = {
'$schemaRef': f'https://eddn.edcd.io/schemas/navbeaconscan/1{"/test" if is_beta else ""}',
'message': entry
}
this.eddn.export_journal_entry(cmdr, entry, msg)
return None
def export_journal_codexentry( # noqa: CCR001
self, cmdr: str, system_starpos: list, is_beta: bool, entry: MutableMapping[str, Any]
) -> Optional[str]:
"""
Send a CodexEntry to EDDN on the correct schema.
:param cmdr: the commander under which this upload is made
:param system_starpos: Coordinates of current star system
:param is_beta: whether or not we are in beta mode
:param entry: the journal entry to send
"""
# {
# "timestamp":"2021-09-26T12:29:39Z",
# "event":"CodexEntry",
# "EntryID":1400414,
# "Name":"$Codex_Ent_Gas_Vents_SilicateVapourGeysers_Name;",
# "Name_Localised":"Silicate Vapour Gas Vent",
# "SubCategory":"$Codex_SubCategory_Geology_and_Anomalies;",
# "SubCategory_Localised":"Geology and anomalies",
# "Category":"$Codex_Category_Biology;",
# "Category_Localised":"Biological and Geological",
# "Region":"$Codex_RegionName_18;",
# "Region_Localised":"Inner Orion Spur",
# "System":"Bestia",
# "SystemAddress":147916327267,
# "Latitude":23.197777, "Longitude":51.803349,
# "IsNewEntry":true,
# "VoucherAmount":50000
# }
#######################################################################
# Elisions
entry = filter_localised(entry)
# Keys specific to this event
for k in ('IsNewEntry', 'NewTraitsDiscovered'):
if k in entry:
del entry[k]
#######################################################################
#######################################################################
# Augmentations
#######################################################################
# General 'system' augmentations
ret = this.eddn.entry_augment_system_data(entry, entry['System'], system_starpos)
if isinstance(ret, str):
return ret
entry = ret
# Set BodyName if it's available from Status.json
if this.status_body_name is None or not isinstance(this.status_body_name, str):
logger.warning(f'this.status_body_name was not set properly:'
f' "{this.status_body_name}" ({type(this.status_body_name)})')
else:
entry['BodyName'] = this.status_body_name
# Only set BodyID if journal BodyName matches the Status.json one.
# This avoids binary body issues.
if this.status_body_name == this.body_name:
if this.body_id is not None and isinstance(this.body_id, int):
entry['BodyID'] = this.body_id
else:
logger.warning(f'this.body_id was not set properly: "{this.body_id}" ({type(this.body_id)})')
#######################################################################
# Check just the top-level strings with minLength=1 in the schema
for k in ('System', 'Name', 'Region', 'Category', 'SubCategory'):
v = entry[k]
if v is None or isinstance(v, str) and v == '':
logger.warning(f'post-processing entry contains entry["{k}"] = {v} {(type(v))}')
# We should drop this message and VERY LOUDLY inform the
# user, in the hopes they'll open a bug report with the
# raw Journal event that caused this.
return 'CodexEntry had empty string, PLEASE ALERT THE EDMC DEVELOPERS'
# Also check traits
if 'Traits' in entry:
for v in entry['Traits']:
if v is None or isinstance(v, str) and v == '':
logger.warning(f'post-processing entry[\'Traits\'] contains {v} {(type(v))}\n{entry["Traits"]}\n')
return 'CodexEntry Trait had empty string, PLEASE ALERT THE EDMC DEVELOPERS'
msg = {
'$schemaRef': f'https://eddn.edcd.io/schemas/codexentry/1{"/test" if is_beta else ""}',
'message': entry
}
this.eddn.export_journal_entry(cmdr, entry, msg)
return None
def export_journal_scanbarycentre(
self, cmdr: str, system_starpos: list, is_beta: bool, entry: Mapping[str, Any]
) -> Optional[str]: