mirror of
https://github.com/EDCD/EDMarketConnector.git
synced 2025-06-03 00:51:11 +03:00
Merge branch 'develop' into main
This commit is contained in:
commit
364aaf2aef
61
.github/workflows/pr-annotate-with-flake8.yml
vendored
61
.github/workflows/pr-annotate-with-flake8.yml
vendored
@ -1,7 +1,9 @@
|
||||
# This workflow will:
|
||||
#
|
||||
# install Python dependencies
|
||||
# Run flake8 to add annotations to the PR
|
||||
# 1. Store some github context in env vars.
|
||||
# 2. Only checkout if base and head ref owners are the same.
|
||||
# 3. Check if any *.py files are in the diff.
|
||||
# 4. Only then install python and perform the annotation with flake8.
|
||||
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
|
||||
|
||||
name: PR-annotate-flake8
|
||||
@ -11,19 +13,68 @@ on:
|
||||
branches: [ develop ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
flake8_annotate:
|
||||
runs-on: ubuntu-18.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
##################################################################
|
||||
# Check if the base and head repos are the same, if not we won't
|
||||
# be able to add annotations.
|
||||
##################################################################
|
||||
- name: Check head and base repo same
|
||||
env:
|
||||
BASE_REPO_OWNER: ${{github.event.pull_request.base.repo.owner.login}}
|
||||
HEAD_REPO_OWNER: ${{github.event.pull_request.head.repo.owner.login}}
|
||||
BASE_REF: ${{github.base_ref}}
|
||||
run: |
|
||||
# Just to be running something
|
||||
env
|
||||
##################################################################
|
||||
|
||||
##################################################################
|
||||
# Perform the checkout
|
||||
##################################################################
|
||||
- name: Checkout
|
||||
if: ${{ github.event.pull_request.base.repo.owner.login == github.event.pull_request.head.repo.owner.login }}
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
##################################################################
|
||||
|
||||
##################################################################
|
||||
# flake8-your-pr 'fails' if no *.py files changed
|
||||
# So we check if any were affected and store that in env
|
||||
##################################################################
|
||||
- name: Check for PY files
|
||||
if: ${{ github.event.pull_request.base.repo.owner.login == github.event.pull_request.head.repo.owner.login }}
|
||||
run: |
|
||||
# Checkout might not have happened.
|
||||
if [ ! -d ".git" ]; then exit 0 ; fi
|
||||
# Get a list of files ending with ".py", stuff in environment.
|
||||
# We don't rely on exit status because Workflows run with
|
||||
# "set -e" so any failure in a pipe is total failure.
|
||||
PYFILES=$(git diff --name-only "refs/remotes/origin/${BASE_REF}" -- | egrep '.py$' || true)
|
||||
# Use magic output to store in env for rest of workflow
|
||||
echo "::set-env name=PYFILES::${PYFILES}"
|
||||
##################################################################
|
||||
|
||||
##################################################################
|
||||
# Get Python set up
|
||||
##################################################################
|
||||
- name: Set up Python 3.7
|
||||
if: ${{ env.PYFILES != '' }}
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: 3.7
|
||||
##################################################################
|
||||
|
||||
##################################################################
|
||||
# Perform the annotation
|
||||
##################################################################
|
||||
- name: Annotate with Flake8
|
||||
# Only if at least one *.py file was affected
|
||||
if: ${{ env.PYFILES != '' }}
|
||||
uses: "tayfun/flake8-your-pr@master"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
##################################################################
|
||||
|
87
.github/workflows/pr-checks.yml
vendored
Normal file
87
.github/workflows/pr-checks.yml
vendored
Normal file
@ -0,0 +1,87 @@
|
||||
# This workflow will:
|
||||
#
|
||||
# * install Python dependencies
|
||||
# * lint with a single version of Python
|
||||
#
|
||||
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
|
||||
|
||||
name: PR-Checks
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ develop ]
|
||||
|
||||
jobs:
|
||||
flake8:
|
||||
runs-on: ubuntu-18.04
|
||||
|
||||
steps:
|
||||
|
||||
####################################################################
|
||||
# Checkout the necessary commits
|
||||
####################################################################
|
||||
# We need the repo from the 'head' of the PR, not what it's
|
||||
# based on.
|
||||
- name: Checkout head commits
|
||||
# https://github.com/actions/checkout
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
repository: ${{github.event.pull_request.head.repo.full_name}}
|
||||
fetch-depth: 0
|
||||
|
||||
# But we do need the base references
|
||||
- name: Fetch base commits
|
||||
env:
|
||||
BASE_REPO_URL: ${{github.event.pull_request.base.repo.svn_url}}
|
||||
BASE_REPO_OWNER: ${{github.event.pull_request.base.repo.owner.login}}
|
||||
|
||||
run: |
|
||||
# Add the 'base' repo as a new remote
|
||||
git remote add ${BASE_REPO_OWNER} ${BASE_REPO_URL}
|
||||
# And then fetch its references
|
||||
git fetch ${BASE_REPO_OWNER}
|
||||
####################################################################
|
||||
|
||||
####################################################################
|
||||
# Get Python set up
|
||||
####################################################################
|
||||
- name: Set up Python 3.7
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: 3.7
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install flake8
|
||||
if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt; elif [ -f requirements.txt ]; then pip install -r requirements.txt ; fi
|
||||
####################################################################
|
||||
|
||||
####################################################################
|
||||
# Show full github context to aid debugging.
|
||||
####################################################################
|
||||
- name: Show github context
|
||||
run: |
|
||||
env
|
||||
cat $GITHUB_EVENT_PATH
|
||||
####################################################################
|
||||
|
||||
####################################################################
|
||||
# Lint with flake8
|
||||
####################################################################
|
||||
- name: Lint with flake8
|
||||
env:
|
||||
BASE_REPO_URL: ${{github.event.pull_request.base.repo.svn_url}}
|
||||
BASE_REPO_OWNER: ${{github.event.pull_request.base.repo.owner.login}}
|
||||
BASE_REF: ${{github.base_ref}}
|
||||
|
||||
run: |
|
||||
# Explicitly check for some errors
|
||||
# E9 - Runtime (syntax and the like)
|
||||
# F63 - 'tests' checking
|
||||
# F7 - syntax errors
|
||||
# F82 - undefined checking
|
||||
git diff "refs/remotes/${BASE_REPO_OWNER}/${BASE_REF}" -- | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics --diff
|
||||
# Can optionally add `--exit-zero` to the flake8 arguments so that
|
||||
# this doesn't fail the build.
|
||||
git diff "refs/remotes/${BASE_REPO_OWNER}/${BASE_REF}" -- | flake8 . --count --statistics --diff
|
||||
####################################################################
|
4
.gitignore
vendored
4
.gitignore
vendored
@ -13,3 +13,7 @@ appcast_win_*.xml
|
||||
appcast_mac_*.xml
|
||||
EDMarketConnector.VisualElementsManifest.xml
|
||||
*.zip
|
||||
|
||||
.idea
|
||||
.vscode
|
||||
venv
|
||||
|
@ -1,2 +0,0 @@
|
||||
[tool.autopep8]
|
||||
max_line_length = 120
|
@ -184,14 +184,14 @@ Coding Conventions
|
||||
Yes:
|
||||
|
||||
```python
|
||||
if somethingTrue:
|
||||
Things_we_then_do()
|
||||
if something_true:
|
||||
one_thing_we_do()
|
||||
```
|
||||
|
||||
No:
|
||||
|
||||
```python
|
||||
if somethingTrue: One_thing_we_do()
|
||||
if something_true: one_thing_we_do()
|
||||
```
|
||||
|
||||
Yes, some existing code still flouts this rule.
|
||||
@ -224,6 +224,43 @@ No:
|
||||
* Going forwards please do place [type hints](https://docs.python.org/3/library/typing.html) on the declarations of your functions, both their arguments and return
|
||||
types.
|
||||
|
||||
* Use `logging` not `print()`, and definitely not `sys.stdout.write()`!
|
||||
`EDMarketConnector.py` sets up a `logging.Logger` for this under the
|
||||
`appname`, so:
|
||||
|
||||
import logging
|
||||
from config import appname
|
||||
logger = logging.getLogger(appname)
|
||||
|
||||
logger.info(f'Some message with a {variable}')
|
||||
|
||||
try:
|
||||
something
|
||||
except Exception as e: # Try to be more specific
|
||||
logger.error(f'Error in ... with ...', exc_info=e)
|
||||
|
||||
**DO NOT** use the following, as you might cause a circular import:
|
||||
|
||||
from EDMarketConnector import logger
|
||||
|
||||
We have implemented a `logging.Filter` that adds support for the following
|
||||
in `logging.Formatter()` strings:
|
||||
|
||||
1. `%(qualname)s` which gets the full `<module>.ClassA(.ClassB...).func`
|
||||
of the calling function.
|
||||
1. `%(class)s` which gets just the enclosing class name(s) of the calling
|
||||
function.
|
||||
|
||||
If you want to see how we did this, check `EDMCLogging.py`.
|
||||
|
||||
So don't worry about adding anything about the class or function you're
|
||||
logging from, it's taken care of.
|
||||
|
||||
*Do use a pertinent message, even when using `exc_info=...` to log an
|
||||
exception*. e.g. Logging will know you were in your `get_foo()` function
|
||||
but you should still tell it what actually (failed to have) happened
|
||||
in there.
|
||||
|
||||
* In general, please follow [PEP8](https://www.python.org/dev/peps/pep-0008/).
|
||||
|
||||
---
|
||||
|
174
EDMC.py
174
EDMC.py
@ -5,9 +5,9 @@
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import requests
|
||||
import sys
|
||||
import os
|
||||
from typing import Any, Optional
|
||||
|
||||
# workaround for https://github.com/EDCD/EDMarketConnector/issues/568
|
||||
os.environ["EDMC_NO_UI"] = "1"
|
||||
@ -39,14 +39,39 @@ import eddn
|
||||
SERVER_RETRY = 5 # retry pause for Companion servers [s]
|
||||
EXIT_SUCCESS, EXIT_SERVER, EXIT_CREDENTIALS, EXIT_VERIFICATION, EXIT_LAGGING, EXIT_SYS_ERR = range(6)
|
||||
|
||||
JOURNAL_RE = re.compile(r'^Journal(Beta)?\.[0-9]{12}\.[0-9]{2}\.log$')
|
||||
|
||||
|
||||
# quick and dirty version comparison assuming "strict" numeric only version numbers
|
||||
def versioncmp(versionstring):
|
||||
return list(map(int, versionstring.split('.')))
|
||||
|
||||
|
||||
def deep_get(target: dict, *args: str, default=None) -> Any:
|
||||
if not hasattr(target, 'get'):
|
||||
raise ValueError(f"Cannot call get on {target} ({type(target)})")
|
||||
|
||||
current = target
|
||||
for arg in args:
|
||||
res = current.get(arg)
|
||||
if res is None:
|
||||
return default
|
||||
|
||||
current = res
|
||||
|
||||
return current
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
# arg parsing
|
||||
parser = argparse.ArgumentParser(prog=appcmdname, description='Prints the current system and station (if docked) to stdout and optionally writes player status, ship locations, ship loadout and/or station data to file. Requires prior setup through the accompanying GUI app.')
|
||||
parser = argparse.ArgumentParser(
|
||||
prog=appcmdname,
|
||||
description='Prints the current system and station (if docked) to stdout and optionally writes player '
|
||||
'status, ship locations, ship loadout and/or station data to file. '
|
||||
'Requires prior setup through the accompanying GUI app.'
|
||||
)
|
||||
|
||||
parser.add_argument('-v', '--version', help='print program version and exit', action='store_const', const=True)
|
||||
parser.add_argument('-a', metavar='FILE', help='write ship loadout to FILE in Companion API json format')
|
||||
parser.add_argument('-e', metavar='FILE', help='write ship loadout to FILE in E:D Shipyard plain text format')
|
||||
@ -63,11 +88,9 @@ try:
|
||||
|
||||
if args.version:
|
||||
updater = Updater(provider='internal')
|
||||
newversion: EDMCVersion = updater.check_appcast()
|
||||
newversion: Optional[EDMCVersion] = updater.check_appcast()
|
||||
if newversion:
|
||||
print('{CURRENT} ("{UPDATE}" is available)'.format(
|
||||
CURRENT=appversion,
|
||||
UPDATE=newversion.title))
|
||||
print(f'{appversion} ({newversion.title!r} is available)')
|
||||
else:
|
||||
print(appversion)
|
||||
sys.exit(EXIT_SUCCESS)
|
||||
@ -76,26 +99,29 @@ try:
|
||||
# Import and collate from JSON dump
|
||||
data = json.load(open(args.j))
|
||||
config.set('querytime', int(getmtime(args.j)))
|
||||
|
||||
else:
|
||||
# Get state from latest Journal file
|
||||
try:
|
||||
logdir = config.get('journaldir') or config.default_journal_dir
|
||||
logfiles = sorted([x for x in os.listdir(logdir) if re.search('^Journal(Beta)?\.[0-9]{12}\.[0-9]{2}\.log$', x)],
|
||||
key=lambda x: x.split('.')[1:])
|
||||
logfiles = sorted((x for x in os.listdir(logdir) if JOURNAL_RE.search(x)), key=lambda x: x.split('.')[1:])
|
||||
|
||||
logfile = join(logdir, logfiles[-1])
|
||||
|
||||
with open(logfile, 'r') as loghandle:
|
||||
for line in loghandle:
|
||||
try:
|
||||
monitor.parse_entry(line)
|
||||
except:
|
||||
except Exception:
|
||||
if __debug__:
|
||||
print('Invalid journal entry "%s"' % repr(line))
|
||||
print(f'Invalid journal entry {line!r}')
|
||||
|
||||
except Exception as e:
|
||||
print("Can't read Journal file: {}\n".format(str(e)), file=sys.stderr)
|
||||
print(f"Can't read Journal file: {str(e)}", file=sys.stderr)
|
||||
sys.exit(EXIT_SYS_ERR)
|
||||
|
||||
if not monitor.cmdr:
|
||||
sys.stderr.write('Not available while E:D is at the main menu\n')
|
||||
print('Not available while E:D is at the main menu', file=sys.stderr)
|
||||
sys.exit(EXIT_SYS_ERR)
|
||||
|
||||
# Get data from Companion API
|
||||
@ -103,73 +129,100 @@ try:
|
||||
cmdrs = config.get('cmdrs') or []
|
||||
if args.p in cmdrs:
|
||||
idx = cmdrs.index(args.p)
|
||||
|
||||
else:
|
||||
for idx, cmdr in enumerate(cmdrs):
|
||||
if cmdr.lower() == args.p.lower():
|
||||
break
|
||||
|
||||
else:
|
||||
raise companion.CredentialsError()
|
||||
|
||||
companion.session.login(cmdrs[idx], monitor.is_beta)
|
||||
|
||||
else:
|
||||
cmdrs = config.get('cmdrs') or []
|
||||
if monitor.cmdr not in cmdrs:
|
||||
raise companion.CredentialsError()
|
||||
|
||||
companion.session.login(monitor.cmdr, monitor.is_beta)
|
||||
|
||||
querytime = int(time())
|
||||
data = companion.session.station()
|
||||
config.set('querytime', querytime)
|
||||
|
||||
# Validation
|
||||
if not data.get('commander') or not data['commander'].get('name','').strip():
|
||||
sys.stderr.write('Who are you?!\n')
|
||||
if not deep_get(data, 'commander', 'name', default='').strip():
|
||||
print('Who are you?!', file=sys.stderr)
|
||||
sys.exit(EXIT_SERVER)
|
||||
elif (not data.get('lastSystem', {}).get('name') or
|
||||
(data['commander'].get('docked') and not data.get('lastStarport', {}).get('name'))): # Only care if docked
|
||||
sys.stderr.write('Where are you?!\n') # Shouldn't happen
|
||||
|
||||
elif not deep_get(data, 'lastSystem', 'name') or \
|
||||
data['commander'].get('docked') and not \
|
||||
deep_get(data, 'lastStarport', 'name'): # Only care if docked
|
||||
|
||||
print('Where are you?!', file=sys.stderr) # Shouldn't happen
|
||||
sys.exit(EXIT_SERVER)
|
||||
elif not data.get('ship') or not data['ship'].get('modules') or not data['ship'].get('name','').strip():
|
||||
sys.stderr.write('What are you flying?!\n') # Shouldn't happen
|
||||
|
||||
elif not deep_get(data, 'ship', 'modules') or not deep_get(data, 'ship', 'name', default=''):
|
||||
print('What are you flying?!', file=sys.stderr) # Shouldn't happen
|
||||
sys.exit(EXIT_SERVER)
|
||||
|
||||
elif args.j:
|
||||
pass # Skip further validation
|
||||
|
||||
elif data['commander']['name'] != monitor.cmdr:
|
||||
sys.stderr.write('Wrong Cmdr\n') # Companion API return doesn't match Journal
|
||||
print('Wrong Cmdr', file=sys.stderr) # Companion API return doesn't match Journal
|
||||
sys.exit(EXIT_CREDENTIALS)
|
||||
elif ((data['lastSystem']['name'] != monitor.system) or
|
||||
((data['commander']['docked'] and data['lastStarport']['name'] or None) != monitor.station) or
|
||||
(data['ship']['id'] != monitor.state['ShipID']) or
|
||||
(data['ship']['name'].lower() != monitor.state['ShipType'])):
|
||||
sys.stderr.write('Frontier server is lagging\n')
|
||||
|
||||
elif data['lastSystem']['name'] != monitor.system or \
|
||||
((data['commander']['docked'] and data['lastStarport']['name'] or None) != monitor.station) or \
|
||||
data['ship']['id'] != monitor.state['ShipID'] or \
|
||||
data['ship']['name'].lower() != monitor.state['ShipType']:
|
||||
|
||||
print('Frontier server is lagging', file=sys.stderr)
|
||||
sys.exit(EXIT_LAGGING)
|
||||
|
||||
# stuff we can do when not docked
|
||||
if args.d:
|
||||
with open(args.d, 'wb') as h:
|
||||
h.write(json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True, separators=(',', ': ')).encode('utf-8'))
|
||||
out = json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True, separators=(',', ': '))
|
||||
with open(args.d, 'wb') as f:
|
||||
f.write(out.encode("utf-8"))
|
||||
|
||||
if args.a:
|
||||
loadout.export(data, args.a)
|
||||
|
||||
if args.e:
|
||||
edshipyard.export(data, args.e)
|
||||
|
||||
if args.l:
|
||||
stats.export_ships(data, args.l)
|
||||
|
||||
if args.t:
|
||||
stats.export_status(data, args.t)
|
||||
|
||||
if data['commander'].get('docked'):
|
||||
print('%s,%s' % (data.get('lastSystem', {}).get('name', 'Unknown'), data.get('lastStarport', {}).get('name', 'Unknown')))
|
||||
print('{},{}'.format(
|
||||
deep_get(data, 'lastSystem', 'name', default='Unknown'),
|
||||
deep_get(data, 'lastStarport', 'name', default='Unknown')
|
||||
))
|
||||
|
||||
else:
|
||||
print(data.get('lastSystem', {}).get('name', 'Unknown'))
|
||||
print(deep_get(data, 'lastSystem', 'name', default='Unknown'))
|
||||
|
||||
if (args.m or args.o or args.s or args.n or args.j):
|
||||
if not data['commander'].get('docked'):
|
||||
sys.stderr.write("You're not docked at a station!\n")
|
||||
print("You're not docked at a station!", file=sys.stderr)
|
||||
sys.exit(EXIT_SUCCESS)
|
||||
elif not data.get('lastStarport', {}).get('name'):
|
||||
sys.stderr.write("Unknown station!\n")
|
||||
|
||||
elif not deep_get(data, 'lastStarport', 'name'):
|
||||
print("Unknown station!", file=sys.stderr)
|
||||
sys.exit(EXIT_LAGGING)
|
||||
elif not (data['lastStarport'].get('commodities') or data['lastStarport'].get('modules')): # Ignore possibly missing shipyard info
|
||||
sys.stderr.write("Station doesn't have anything!\n")
|
||||
|
||||
# Ignore possibly missing shipyard info
|
||||
elif not data['lastStarport'].get('commodities') or data['lastStarport'].get('modules'):
|
||||
print("Station doesn't have anything!", file=sys.stderr)
|
||||
sys.exit(EXIT_SUCCESS)
|
||||
|
||||
else:
|
||||
sys.exit(EXIT_SUCCESS)
|
||||
|
||||
@ -186,31 +239,39 @@ try:
|
||||
# Fixup anomalies in the commodity data
|
||||
fixed = companion.fixup(data)
|
||||
commodity.export(fixed, COMMODITY_DEFAULT, args.m)
|
||||
|
||||
else:
|
||||
sys.stderr.write("Station doesn't have a market\n")
|
||||
print("Station doesn't have a market", file=sys.stderr)
|
||||
|
||||
if args.o:
|
||||
if data['lastStarport'].get('modules'):
|
||||
outfitting.export(data, args.o)
|
||||
else:
|
||||
sys.stderr.write("Station doesn't supply outfitting\n")
|
||||
|
||||
if (args.s or args.n) and not args.j and not data['lastStarport'].get('ships') and data['lastStarport']['services'].get('shipyard'):
|
||||
else:
|
||||
print("Station doesn't supply outfitting", file=sys.stderr)
|
||||
|
||||
if (args.s or args.n) and not args.j and not \
|
||||
data['lastStarport'].get('ships') and data['lastStarport']['services'].get('shipyard'):
|
||||
|
||||
# Retry for shipyard
|
||||
sleep(SERVER_RETRY)
|
||||
data2 = companion.session.station()
|
||||
if (data2['commander'].get('docked') and # might have undocked while we were waiting for retry in which case station data is unreliable
|
||||
data2.get('lastSystem', {}).get('name') == monitor.system and
|
||||
data2.get('lastStarport', {}).get('name') == monitor.station):
|
||||
data = data2
|
||||
new_data = companion.session.station()
|
||||
# might have undocked while we were waiting for retry in which case station data is unreliable
|
||||
if new_data['commander'].get('docked') and \
|
||||
deep_get(new_data, 'lastSystem', 'name') == monitor.system and \
|
||||
deep_get(new_data, 'lastStarport', 'name') == monitor.station:
|
||||
|
||||
data = new_data
|
||||
|
||||
if args.s:
|
||||
if data['lastStarport'].get('ships', {}).get('shipyard_list'):
|
||||
if deep_get(data, 'lastStarport', 'ships', 'shipyard_list'):
|
||||
shipyard.export(data, args.s)
|
||||
|
||||
elif not args.j and monitor.stationservices and 'Shipyard' in monitor.stationservices:
|
||||
sys.stderr.write("Failed to get shipyard data\n")
|
||||
print("Failed to get shipyard data", file=sys.stderr)
|
||||
|
||||
else:
|
||||
sys.stderr.write("Station doesn't have a shipyard\n")
|
||||
print("Station doesn't have a shipyard", file=sys.stderr)
|
||||
|
||||
if args.n:
|
||||
try:
|
||||
@ -218,17 +279,24 @@ try:
|
||||
eddn_sender.export_commodities(data, monitor.is_beta)
|
||||
eddn_sender.export_outfitting(data, monitor.is_beta)
|
||||
eddn_sender.export_shipyard(data, monitor.is_beta)
|
||||
|
||||
except Exception as e:
|
||||
sys.stderr.write("Failed to send data to EDDN: %s\n" % unicode(e).encode('ascii', 'replace'))
|
||||
print(f"Failed to send data to EDDN: {str(e)}", file=sys.stderr)
|
||||
|
||||
sys.exit(EXIT_SUCCESS)
|
||||
|
||||
except companion.ServerError as e:
|
||||
sys.stderr.write('Server is down\n')
|
||||
except companion.ServerError:
|
||||
print('Server is down', file=sys.stderr)
|
||||
sys.exit(EXIT_SERVER)
|
||||
except companion.SKUError as e:
|
||||
sys.stderr.write('Server SKU problem\n')
|
||||
|
||||
except companion.SKUError:
|
||||
print('Server SKU problem', file=sys.stderr)
|
||||
sys.exit(EXIT_SERVER)
|
||||
except companion.CredentialsError as e:
|
||||
sys.stderr.write('Invalid Credentials\n')
|
||||
|
||||
except companion.CredentialsError:
|
||||
print('Invalid Credentials', file=sys.stderr)
|
||||
sys.exit(EXIT_CREDENTIALS)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
293
EDMCLogging.py
Normal file
293
EDMCLogging.py
Normal file
@ -0,0 +1,293 @@
|
||||
"""
|
||||
Set up required logging for the application.
|
||||
|
||||
This module provides for a common logging-powered log facility.
|
||||
Mostly it implements a logging.Filter() in order to get two extra
|
||||
members on the logging.LogRecord instance for use in logging.Formatter()
|
||||
strings.
|
||||
"""
|
||||
|
||||
# So that any warning about accessing a protected member is only in one place.
|
||||
from sys import _getframe as getframe
|
||||
import inspect
|
||||
import logging
|
||||
import pathlib
|
||||
from typing import Tuple
|
||||
|
||||
from config import config
|
||||
|
||||
# TODO: Tests:
|
||||
#
|
||||
# 1. Call from bare function in file.
|
||||
# 2. Call from `if __name__ == "__main__":` section
|
||||
#
|
||||
# 3. Call from 1st level function in 1st level Class in file
|
||||
# 4. Call from 2nd level function in 1st level Class in file
|
||||
# 5. Call from 3rd level function in 1st level Class in file
|
||||
#
|
||||
# 6. Call from 1st level function in 2nd level Class in file
|
||||
# 7. Call from 2nd level function in 2nd level Class in file
|
||||
# 8. Call from 3rd level function in 2nd level Class in file
|
||||
#
|
||||
# 9. Call from 1st level function in 3rd level Class in file
|
||||
# 10. Call from 2nd level function in 3rd level Class in file
|
||||
# 11. Call from 3rd level function in 3rd level Class in file
|
||||
#
|
||||
# 12. Call from 2nd level file, all as above.
|
||||
#
|
||||
# 13. Call from *module*
|
||||
#
|
||||
# 14. Call from *package*
|
||||
|
||||
_default_loglevel = logging.DEBUG
|
||||
|
||||
|
||||
class Logger:
|
||||
"""
|
||||
Wrapper class for all logging configuration and code.
|
||||
|
||||
Class instantiation requires the 'logger name' and optional loglevel.
|
||||
It is intended that this 'logger name' be re-used in all files/modules
|
||||
that need to log.
|
||||
|
||||
Users of this class should then call getLogger() to get the
|
||||
logging.Logger instance.
|
||||
"""
|
||||
|
||||
def __init__(self, logger_name: str, loglevel: int = _default_loglevel):
|
||||
"""
|
||||
Set up a `logging.Logger` with our preferred configuration.
|
||||
|
||||
This includes using an EDMCContextFilter to add 'class' and 'qualname'
|
||||
expansions for logging.Formatter().
|
||||
"""
|
||||
self.logger = logging.getLogger(logger_name)
|
||||
# Configure the logging.Logger
|
||||
self.logger.setLevel(loglevel)
|
||||
|
||||
# Set up filter for adding class name
|
||||
self.logger_filter = EDMCContextFilter()
|
||||
self.logger.addFilter(self.logger_filter)
|
||||
|
||||
self.logger_channel = logging.StreamHandler()
|
||||
self.logger_channel.setLevel(loglevel)
|
||||
|
||||
self.logger_formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(module)s.%(qualname)s:%(lineno)d: %(message)s') # noqa: E501
|
||||
self.logger_formatter.default_time_format = '%Y-%m-%d %H:%M:%S'
|
||||
self.logger_formatter.default_msec_format = '%s.%03d'
|
||||
|
||||
self.logger_channel.setFormatter(self.logger_formatter)
|
||||
self.logger.addHandler(self.logger_channel)
|
||||
|
||||
def get_logger(self) -> logging.Logger:
|
||||
"""
|
||||
Obtain the self.logger of the class instance.
|
||||
|
||||
Not to be confused with logging.getLogger().
|
||||
"""
|
||||
return self.logger
|
||||
|
||||
|
||||
def get_plugin_logger(name: str, loglevel: int = _default_loglevel) -> logging.Logger:
|
||||
"""
|
||||
Return a logger suitable for a plugin.
|
||||
|
||||
'Found' plugins need their own logger to call out where the logging is
|
||||
coming from, but we don't need to set up *everything* for them.
|
||||
|
||||
The name will be '{config.appname}.{plugin.name}', e.g.
|
||||
'EDMarketConnector.miggytest'. This means that any logging sent through
|
||||
there *also* goes to the channels defined in the 'EDMarketConnector'
|
||||
logger, so we can let that take care of the formatting.
|
||||
|
||||
If we add our own channel then the output gets duplicated (assuming same
|
||||
logLevel set).
|
||||
|
||||
However we do need to attach our filter to this still. That's not at
|
||||
the channel level.
|
||||
:param name: Name of this Logger.
|
||||
:param loglevel: Optional logLevel for this Logger.
|
||||
:return: logging.Logger instance, all set up.
|
||||
"""
|
||||
plugin_logger = logging.getLogger(name)
|
||||
plugin_logger.setLevel(loglevel)
|
||||
|
||||
plugin_logger.addFilter(EDMCContextFilter())
|
||||
|
||||
return plugin_logger
|
||||
|
||||
|
||||
class EDMCContextFilter(logging.Filter):
|
||||
"""
|
||||
Implements filtering to add extra format specifiers, and tweak others.
|
||||
|
||||
logging.Filter sub-class to place extra attributes of the calling site
|
||||
into the record.
|
||||
"""
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
"""
|
||||
Attempt to set/change fields in the LogRecord.
|
||||
|
||||
1. class = class name(s) of the call site, if applicable
|
||||
2. qualname = __qualname__ of the call site. This simplifies
|
||||
logging.Formatter() as you can use just this no matter if there is
|
||||
a class involved or not, so you get a nice clean:
|
||||
<file/module>.<classA>[.classB....].<function>
|
||||
|
||||
If we fail to be able to properly set either then:
|
||||
|
||||
1. Use print() to alert, to be SURE a message is seen.
|
||||
2. But also return strings noting the error, so there'll be
|
||||
something in the log output if it happens.
|
||||
|
||||
:param record: The LogRecord we're "filtering"
|
||||
:return: bool - Always true in order for this record to be logged.
|
||||
"""
|
||||
(class_name, qualname, module_name) = self.caller_attributes(module_name=getattr(record, 'module'))
|
||||
|
||||
# Only set if we got a useful value
|
||||
if module_name:
|
||||
setattr(record, 'module', module_name)
|
||||
|
||||
# Only set if not already provided by logging itself
|
||||
if getattr(record, 'class', None) is None:
|
||||
setattr(record, 'class', class_name)
|
||||
|
||||
# Only set if not already provided by logging itself
|
||||
if getattr(record, 'qualname', None) is None:
|
||||
setattr(record, 'qualname', qualname)
|
||||
|
||||
return True
|
||||
|
||||
@classmethod # noqa: CCR001 - this is as refactored as is sensible
|
||||
def caller_attributes(cls, module_name: str = '') -> Tuple[str, str, str]:
|
||||
"""
|
||||
Determine extra or changed fields for the caller.
|
||||
|
||||
1. qualname finds the relevant object and its __qualname__
|
||||
2. caller_class_names is just the full class names of the calling
|
||||
class if relevant.
|
||||
3. module is munged if we detect the caller is an EDMC plugin,
|
||||
whether internal or found.
|
||||
"""
|
||||
frame = cls.find_caller_frame()
|
||||
|
||||
caller_qualname = caller_class_names = ''
|
||||
if frame:
|
||||
# <https://stackoverflow.com/questions/2203424/python-how-to-retrieve-class-information-from-a-frame-object#2220759>
|
||||
frame_info = inspect.getframeinfo(frame)
|
||||
args, _, _, value_dict = inspect.getargvalues(frame)
|
||||
if len(args) and args[0] in ('self', 'cls'):
|
||||
frame_class = value_dict[args[0]]
|
||||
|
||||
if frame_class:
|
||||
# Find __qualname__ of the caller
|
||||
fn = getattr(frame_class, frame_info.function)
|
||||
if fn and fn.__qualname__:
|
||||
caller_qualname = fn.__qualname__
|
||||
|
||||
# Find containing class name(s) of caller, if any
|
||||
if frame_class.__class__ and frame_class.__class__.__qualname__:
|
||||
caller_class_names = frame_class.__class__.__qualname__
|
||||
|
||||
# It's a call from the top level module file
|
||||
elif frame_info.function == '<module>':
|
||||
caller_class_names = '<none>'
|
||||
caller_qualname = value_dict['__name__']
|
||||
|
||||
elif frame_info.function != '':
|
||||
caller_class_names = '<none>'
|
||||
caller_qualname = frame_info.function
|
||||
|
||||
module_name = cls.munge_module_name(frame_info, module_name)
|
||||
|
||||
# https://docs.python.org/3.7/library/inspect.html#the-interpreter-stack
|
||||
del frame
|
||||
|
||||
if caller_qualname == '':
|
||||
print('ALERT! Something went wrong with finding caller qualname for logging!')
|
||||
caller_qualname = '<ERROR in EDMCLogging.caller_class_and_qualname() for "qualname">'
|
||||
|
||||
if caller_class_names == '':
|
||||
print('ALERT! Something went wrong with finding caller class name(s) for logging!')
|
||||
caller_class_names = '<ERROR in EDMCLogging.caller_class_and_qualname() for "class">'
|
||||
|
||||
return caller_class_names, caller_qualname, module_name
|
||||
|
||||
@classmethod
|
||||
def find_caller_frame(cls):
|
||||
"""
|
||||
Find the stack frame of the logging caller.
|
||||
|
||||
:returns: 'frame' object such as from sys._getframe()
|
||||
"""
|
||||
# Go up through stack frames until we find the first with a
|
||||
# type(f_locals.self) of logging.Logger. This should be the start
|
||||
# of the frames internal to logging.
|
||||
frame: 'frame' = getframe(0)
|
||||
while frame:
|
||||
if isinstance(frame.f_locals.get('self'), logging.Logger):
|
||||
frame = frame.f_back # Want to start on the next frame below
|
||||
break
|
||||
frame = frame.f_back
|
||||
# Now continue up through frames until we find the next one where
|
||||
# that is *not* true, as it should be the call site of the logger
|
||||
# call
|
||||
while frame:
|
||||
if not isinstance(frame.f_locals.get('self'), logging.Logger):
|
||||
break # We've found the frame we want
|
||||
frame = frame.f_back
|
||||
return frame
|
||||
|
||||
@classmethod
|
||||
def munge_module_name(cls, frame_info: inspect.Traceback, module_name: str) -> str:
|
||||
"""
|
||||
Adjust module_name based on the file path for the given frame.
|
||||
|
||||
We want to distinguish between other code and both our internal plugins
|
||||
and the 'found' ones.
|
||||
|
||||
For internal plugins we want "plugins.<filename>".
|
||||
For 'found' plugins we want "<plugins>.<plugin_name>...".
|
||||
|
||||
:param frame_info: The frame_info of the caller.
|
||||
:param module_name: The module_name string to munge.
|
||||
:return: The munged module_name.
|
||||
"""
|
||||
file_name = pathlib.Path(frame_info.filename).expanduser()
|
||||
plugin_dir = pathlib.Path(config.plugin_dir).expanduser()
|
||||
internal_plugin_dir = pathlib.Path(config.internal_plugin_dir).expanduser()
|
||||
# Find the first parent called 'plugins'
|
||||
plugin_top = file_name
|
||||
while plugin_top and plugin_top.name != '':
|
||||
if plugin_top.parent.name == 'plugins':
|
||||
break
|
||||
|
||||
plugin_top = plugin_top.parent
|
||||
|
||||
# Check we didn't walk up to the root/anchor
|
||||
if plugin_top.name != '':
|
||||
# Check we're still inside config.plugin_dir
|
||||
if plugin_top.parent == plugin_dir:
|
||||
# In case of deeper callers we need a range of the file_name
|
||||
pt_len = len(plugin_top.parts)
|
||||
name_path = '.'.join(file_name.parts[(pt_len - 1):-1])
|
||||
module_name = f'<plugins>.{name_path}.{module_name}'
|
||||
|
||||
# Check we're still inside the installation folder.
|
||||
elif file_name.parent == internal_plugin_dir:
|
||||
# Is this a deeper caller ?
|
||||
pt_len = len(plugin_top.parts)
|
||||
name_path = '.'.join(file_name.parts[(pt_len - 1):-1])
|
||||
|
||||
# Pre-pend 'plugins.<plugin folder>.' to module
|
||||
if name_path == '':
|
||||
# No sub-folder involved so module_name is sufficient
|
||||
module_name = f'plugins.{module_name}'
|
||||
|
||||
else:
|
||||
# Sub-folder(s) involved, so include them
|
||||
module_name = f'plugins.{name_path}.{module_name}'
|
||||
|
||||
return module_name
|
@ -5,19 +5,15 @@ from builtins import str
|
||||
from builtins import object
|
||||
import sys
|
||||
from sys import platform
|
||||
from collections import OrderedDict
|
||||
from functools import partial
|
||||
import json
|
||||
from os import chdir, environ
|
||||
from os.path import dirname, expanduser, isdir, join
|
||||
from os.path import dirname, isdir, join
|
||||
import re
|
||||
import html
|
||||
import requests
|
||||
from time import gmtime, time, localtime, strftime, strptime
|
||||
import _strptime # Workaround for http://bugs.python.org/issue7980
|
||||
from calendar import timegm
|
||||
from time import time, localtime, strftime
|
||||
import webbrowser
|
||||
|
||||
import EDMCLogging
|
||||
from config import appname, applongname, appversion, appversion_nobuild, copyright, config
|
||||
|
||||
if getattr(sys, 'frozen', False):
|
||||
@ -36,7 +32,6 @@ import tkinter.messagebox
|
||||
from ttkHyperlinkLabel import HyperlinkLabel
|
||||
|
||||
if __debug__:
|
||||
from traceback import print_exc
|
||||
if platform != 'win32':
|
||||
import pdb
|
||||
import signal
|
||||
@ -99,10 +94,10 @@ class AppWindow(object):
|
||||
if platform == 'win32':
|
||||
self.w.wm_iconbitmap(default='EDMarketConnector.ico')
|
||||
else:
|
||||
self.w.tk.call('wm', 'iconphoto', self.w, '-default', tk.PhotoImage(file = join(config.respath, 'EDMarketConnector.png')))
|
||||
self.theme_icon = tk.PhotoImage(data = 'R0lGODlhFAAQAMZQAAoKCQoKCgsKCQwKCQsLCgwLCg4LCQ4LCg0MCg8MCRAMCRANChINCREOChIOChQPChgQChgRCxwTCyYVCSoXCS0YCTkdCTseCT0fCTsjDU0jB0EnDU8lB1ElB1MnCFIoCFMoCEkrDlkqCFwrCGEuCWIuCGQvCFs0D1w1D2wyCG0yCF82D182EHE0CHM0CHQ1CGQ5EHU2CHc3CHs4CH45CIA6CIE7CJdECIdLEolMEohQE5BQE41SFJBTE5lUE5pVE5RXFKNaFKVbFLVjFbZkFrxnFr9oFsNqFsVrF8RsFshtF89xF9NzGNh1GNl2GP+KG////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////yH5BAEKAH8ALAAAAAAUABAAAAeegAGCgiGDhoeIRDiIjIZGKzmNiAQBQxkRTU6am0tPCJSGShuSAUcLoIIbRYMFra4FAUgQAQCGJz6CDQ67vAFJJBi0hjBBD0w9PMnJOkAiJhaIKEI7HRoc19ceNAolwbWDLD8uAQnl5ga1I9CHEjEBAvDxAoMtFIYCBy+kFDKHAgM3ZtgYSLAGgwkp3pEyBOJCC2ELB31QATGioAoVAwEAOw==')
|
||||
self.theme_minimize = tk.BitmapImage(data = '#define im_width 16\n#define im_height 16\nstatic unsigned char im_bits[] = {\n 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,\n 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0x3f,\n 0xfc, 0x3f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 };\n')
|
||||
self.theme_close = tk.BitmapImage(data = '#define im_width 16\n#define im_height 16\nstatic unsigned char im_bits[] = {\n 0x00, 0x00, 0x00, 0x00, 0x0c, 0x30, 0x1c, 0x38, 0x38, 0x1c, 0x70, 0x0e,\n 0xe0, 0x07, 0xc0, 0x03, 0xc0, 0x03, 0xe0, 0x07, 0x70, 0x0e, 0x38, 0x1c,\n 0x1c, 0x38, 0x0c, 0x30, 0x00, 0x00, 0x00, 0x00 };\n')
|
||||
self.w.tk.call('wm', 'iconphoto', self.w, '-default', tk.PhotoImage(file=join(config.respath, 'EDMarketConnector.png'))) # noqa: E501
|
||||
self.theme_icon = tk.PhotoImage(data='R0lGODlhFAAQAMZQAAoKCQoKCgsKCQwKCQsLCgwLCg4LCQ4LCg0MCg8MCRAMCRANChINCREOChIOChQPChgQChgRCxwTCyYVCSoXCS0YCTkdCTseCT0fCTsjDU0jB0EnDU8lB1ElB1MnCFIoCFMoCEkrDlkqCFwrCGEuCWIuCGQvCFs0D1w1D2wyCG0yCF82D182EHE0CHM0CHQ1CGQ5EHU2CHc3CHs4CH45CIA6CIE7CJdECIdLEolMEohQE5BQE41SFJBTE5lUE5pVE5RXFKNaFKVbFLVjFbZkFrxnFr9oFsNqFsVrF8RsFshtF89xF9NzGNh1GNl2GP+KG////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////yH5BAEKAH8ALAAAAAAUABAAAAeegAGCgiGDhoeIRDiIjIZGKzmNiAQBQxkRTU6am0tPCJSGShuSAUcLoIIbRYMFra4FAUgQAQCGJz6CDQ67vAFJJBi0hjBBD0w9PMnJOkAiJhaIKEI7HRoc19ceNAolwbWDLD8uAQnl5ga1I9CHEjEBAvDxAoMtFIYCBy+kFDKHAgM3ZtgYSLAGgwkp3pEyBOJCC2ELB31QATGioAoVAwEAOw==') # noqa: E501
|
||||
self.theme_minimize = tk.BitmapImage(data='#define im_width 16\n#define im_height 16\nstatic unsigned char im_bits[] = {\n 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,\n 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0x3f,\n 0xfc, 0x3f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 };\n') # noqa: E501
|
||||
self.theme_close = tk.BitmapImage(data='#define im_width 16\n#define im_height 16\nstatic unsigned char im_bits[] = {\n 0x00, 0x00, 0x00, 0x00, 0x0c, 0x30, 0x1c, 0x38, 0x38, 0x1c, 0x70, 0x0e,\n 0xe0, 0x07, 0xc0, 0x03, 0xc0, 0x03, 0xe0, 0x07, 0x70, 0x0e, 0x38, 0x1c,\n 0x1c, 0x38, 0x0c, 0x30, 0x00, 0x00, 0x00, 0x00 };\n') # noqa: E501
|
||||
|
||||
frame = tk.Frame(self.w, name=appname.lower())
|
||||
frame.grid(sticky=tk.NSEW)
|
||||
@ -139,14 +134,15 @@ class AppWindow(object):
|
||||
else:
|
||||
appitem.grid(columnspan=2, sticky=tk.EW)
|
||||
|
||||
self.button = ttk.Button(frame, text=_('Update'), width=28, default=tk.ACTIVE, state=tk.DISABLED) # Update button in main window
|
||||
self.theme_button = tk.Label(frame, width = platform == 'darwin' and 32 or 28, state=tk.DISABLED)
|
||||
# Update button in main window
|
||||
self.button = ttk.Button(frame, text=_('Update'), width=28, default=tk.ACTIVE, state=tk.DISABLED)
|
||||
self.theme_button = tk.Label(frame, width=32 if platform == 'darwin' else 28, state=tk.DISABLED)
|
||||
self.status = tk.Label(frame, name='status', anchor=tk.W)
|
||||
|
||||
row = frame.grid_size()[1]
|
||||
self.button.grid(row=row, columnspan=2, sticky=tk.NSEW)
|
||||
self.theme_button.grid(row=row, columnspan=2, sticky=tk.NSEW)
|
||||
theme.register_alternate((self.button, self.theme_button, self.theme_button), {'row':row, 'columnspan':2, 'sticky':tk.NSEW})
|
||||
theme.register_alternate((self.button, self.theme_button, self.theme_button), {'row': row, 'columnspan': 2, 'sticky': tk.NSEW}) # noqa: E501
|
||||
self.status.grid(columnspan=2, sticky=tk.EW)
|
||||
self.button.bind('<Button-1>', self.getandsend)
|
||||
theme.button_bind(self.theme_button, self.getandsend)
|
||||
@ -157,7 +153,8 @@ class AppWindow(object):
|
||||
self.menubar = tk.Menu()
|
||||
if platform == 'darwin':
|
||||
# Can't handle (de)iconify if topmost is set, so suppress iconify button
|
||||
# http://wiki.tcl.tk/13428 and p15 of https://developer.apple.com/legacy/library/documentation/Carbon/Conceptual/HandlingWindowsControls/windowscontrols.pdf
|
||||
# http://wiki.tcl.tk/13428 and p15 of
|
||||
# https://developer.apple.com/legacy/library/documentation/Carbon/Conceptual/HandlingWindowsControls/windowscontrols.pdf
|
||||
root.call('tk::unsupported::MacWindowStyle', 'style', root, 'document', 'closeBox resizable')
|
||||
|
||||
# https://www.tcl.tk/man/tcl/TkCmd/menu.htm
|
||||
@ -207,7 +204,7 @@ class AppWindow(object):
|
||||
self.help_menu.add_command(command=self.help_privacy)
|
||||
self.help_menu.add_command(command=self.help_releases)
|
||||
self.help_menu.add_command(command=lambda: self.updater.checkForUpdates())
|
||||
self.help_menu.add_command(command=lambda:not self.help_about.showing and self.help_about(self.w))
|
||||
self.help_menu.add_command(command=lambda: not self.HelpAbout.showing and self.HelpAbout(self.w))
|
||||
|
||||
self.menubar.add_cascade(menu=self.help_menu)
|
||||
if platform == 'win32':
|
||||
@ -215,7 +212,9 @@ class AppWindow(object):
|
||||
self.always_ontop = tk.BooleanVar(value=config.getint('always_ontop'))
|
||||
self.system_menu = tk.Menu(self.menubar, name='system', tearoff=tk.FALSE)
|
||||
self.system_menu.add_separator()
|
||||
self.system_menu.add_checkbutton(label=_('Always on top'), variable = self.always_ontop, command=self.ontop_changed) # Appearance setting
|
||||
self.system_menu.add_checkbutton(label=_('Always on top'),
|
||||
variable=self.always_ontop,
|
||||
command=self.ontop_changed) # Appearance setting
|
||||
self.menubar.add_cascade(menu=self.system_menu)
|
||||
self.w.bind('<Control-c>', self.copy)
|
||||
self.w.protocol("WM_DELETE_WINDOW", self.onexit)
|
||||
@ -227,7 +226,9 @@ class AppWindow(object):
|
||||
# Alternate title bar and menu for dark theme
|
||||
self.theme_menubar = tk.Frame(frame)
|
||||
self.theme_menubar.columnconfigure(2, weight=1)
|
||||
theme_titlebar = tk.Label(self.theme_menubar, text=applongname, image=self.theme_icon, cursor='fleur', anchor=tk.W, compound=tk.LEFT)
|
||||
theme_titlebar = tk.Label(self.theme_menubar, text=applongname,
|
||||
image=self.theme_icon, cursor='fleur',
|
||||
anchor=tk.W, compound=tk.LEFT)
|
||||
theme_titlebar.grid(columnspan=3, padx=2, sticky=tk.NSEW)
|
||||
self.drag_offset = None
|
||||
theme_titlebar.bind('<Button-1>', self.drag_start)
|
||||
@ -241,13 +242,22 @@ class AppWindow(object):
|
||||
theme.button_bind(theme_close, self.onexit, image=self.theme_close)
|
||||
self.theme_file_menu = tk.Label(self.theme_menubar, anchor=tk.W)
|
||||
self.theme_file_menu.grid(row=1, column=0, padx=5, sticky=tk.W)
|
||||
theme.button_bind(self.theme_file_menu, lambda e: self.file_menu.tk_popup(e.widget.winfo_rootx(), e.widget.winfo_rooty() + e.widget.winfo_height()))
|
||||
theme.button_bind(self.theme_file_menu,
|
||||
lambda e: self.file_menu.tk_popup(e.widget.winfo_rootx(),
|
||||
e.widget.winfo_rooty()
|
||||
+ e.widget.winfo_height()))
|
||||
self.theme_edit_menu = tk.Label(self.theme_menubar, anchor=tk.W)
|
||||
self.theme_edit_menu.grid(row=1, column=1, sticky=tk.W)
|
||||
theme.button_bind(self.theme_edit_menu, lambda e: self.edit_menu.tk_popup(e.widget.winfo_rootx(), e.widget.winfo_rooty() + e.widget.winfo_height()))
|
||||
theme.button_bind(self.theme_edit_menu,
|
||||
lambda e: self.edit_menu.tk_popup(e.widget.winfo_rootx(),
|
||||
e.widget.winfo_rooty()
|
||||
+ e.widget.winfo_height()))
|
||||
self.theme_help_menu = tk.Label(self.theme_menubar, anchor=tk.W)
|
||||
self.theme_help_menu.grid(row=1, column=2, sticky=tk.W)
|
||||
theme.button_bind(self.theme_help_menu, lambda e: self.help_menu.tk_popup(e.widget.winfo_rootx(), e.widget.winfo_rooty() + e.widget.winfo_height()))
|
||||
theme.button_bind(self.theme_help_menu,
|
||||
lambda e: self.help_menu.tk_popup(e.widget.winfo_rootx(),
|
||||
e.widget.winfo_rooty()
|
||||
+ e.widget.winfo_height()))
|
||||
tk.Frame(self.theme_menubar, highlightthickness=1).grid(columnspan=5, padx=5, sticky=tk.EW)
|
||||
theme.register(self.theme_minimize) # images aren't automatically registered
|
||||
theme.register(self.theme_close)
|
||||
@ -255,23 +265,26 @@ class AppWindow(object):
|
||||
tk.Label(self.blank_menubar).grid()
|
||||
tk.Label(self.blank_menubar).grid()
|
||||
tk.Frame(self.blank_menubar, height=2).grid()
|
||||
theme.register_alternate((self.menubar, self.theme_menubar, self.blank_menubar), {'row':0, 'columnspan':2, 'sticky':tk.NSEW})
|
||||
theme.register_alternate((self.menubar, self.theme_menubar, self.blank_menubar),
|
||||
{'row': 0, 'columnspan': 2, 'sticky': tk.NSEW})
|
||||
self.w.resizable(tk.TRUE, tk.FALSE)
|
||||
|
||||
# update geometry
|
||||
if config.get('geometry'):
|
||||
match = re.match('\+([\-\d]+)\+([\-\d]+)', config.get('geometry'))
|
||||
match = re.match('\+([\-\d]+)\+([\-\d]+)', config.get('geometry')) # noqa: W605
|
||||
if match:
|
||||
if platform == 'darwin':
|
||||
if int(match.group(2)) >= 0: # http://core.tcl.tk/tk/tktview/c84f660833546b1b84e7
|
||||
# http://core.tcl.tk/tk/tktview/c84f660833546b1b84e7
|
||||
if int(match.group(2)) >= 0:
|
||||
self.w.geometry(config.get('geometry'))
|
||||
elif platform == 'win32':
|
||||
# Check that the titlebar will be at least partly on screen
|
||||
import ctypes
|
||||
from ctypes.wintypes import POINT
|
||||
# https://msdn.microsoft.com/en-us/library/dd145064
|
||||
MONITOR_DEFAULTTONULL = 0
|
||||
if ctypes.windll.user32.MonitorFromPoint(POINT(int(match.group(1)) + 16, int(match.group(2)) + 16), MONITOR_DEFAULTTONULL):
|
||||
MONITOR_DEFAULTTONULL = 0 # noqa: N806
|
||||
if ctypes.windll.user32.MonitorFromPoint(POINT(int(match.group(1)) + 16, int(match.group(2)) + 16),
|
||||
MONITOR_DEFAULTTONULL):
|
||||
self.w.geometry(config.get('geometry'))
|
||||
else:
|
||||
self.w.geometry(config.get('geometry'))
|
||||
@ -282,9 +295,9 @@ class AppWindow(object):
|
||||
|
||||
self.w.bind('<Map>', self.onmap) # Special handling for overrideredict
|
||||
self.w.bind('<Enter>', self.onenter) # Special handling for transparency
|
||||
self.w.bind('<FocusIn>', self.onenter) # "
|
||||
self.w.bind('<Leave>', self.onleave) # "
|
||||
self.w.bind('<FocusOut>', self.onleave) # "
|
||||
self.w.bind('<FocusIn>', self.onenter) # Special handling for transparency
|
||||
self.w.bind('<Leave>', self.onleave) # Special handling for transparency
|
||||
self.w.bind('<FocusOut>', self.onleave) # Special handling for transparency
|
||||
self.w.bind('<Return>', self.getandsend)
|
||||
self.w.bind('<KP_Enter>', self.getandsend)
|
||||
self.w.bind_all('<<Invoke>>', self.getandsend) # Hotkey monitoring
|
||||
@ -321,7 +334,6 @@ class AppWindow(object):
|
||||
|
||||
self.postprefs(False) # Companion login happens in callback from monitor
|
||||
|
||||
|
||||
# callback after the Preferences dialog is applied
|
||||
def postprefs(self, dologin=True):
|
||||
self.prefsdialog = None
|
||||
@ -337,7 +349,7 @@ class AppWindow(object):
|
||||
|
||||
# (Re-)install log monitoring
|
||||
if not monitor.start(self.w):
|
||||
self.status['text'] = 'Error: Check %s' % _('E:D journal file location') # Location of the new Journal file in E:D 2.2
|
||||
self.status['text'] = f'Error: Check {_("E:D journal file location")}'
|
||||
|
||||
if dologin and monitor.cmdr:
|
||||
self.login() # Login if not already logged in with this Cmdr
|
||||
@ -345,8 +357,8 @@ class AppWindow(object):
|
||||
# set main window labels, e.g. after language change
|
||||
def set_labels(self):
|
||||
self.cmdr_label['text'] = _('Cmdr') + ':' # Main window
|
||||
self.ship_label['text'] = (monitor.state['Captain'] and _('Role') or # Multicrew role label in main window
|
||||
_('Ship')) + ':' # Main window
|
||||
# Multicrew role label in main window
|
||||
self.ship_label['text'] = (monitor.state['Captain'] and _('Role') or _('Ship')) + ':' # Main window
|
||||
self.system_label['text'] = _('System') + ':' # Main window
|
||||
self.station_label['text'] = _('Station') + ':' # Main window
|
||||
self.button['text'] = self.theme_button['text'] = _('Update') # Update button in main window
|
||||
@ -370,20 +382,20 @@ class AppWindow(object):
|
||||
self.theme_edit_menu['text'] = _('Edit') # Menu title
|
||||
self.theme_help_menu['text'] = _('Help') # Menu title
|
||||
|
||||
## File menu
|
||||
# File menu
|
||||
self.file_menu.entryconfigure(0, label=_('Status')) # Menu item
|
||||
self.file_menu.entryconfigure(1, label=_('Save Raw Data...')) # Menu item
|
||||
self.file_menu.entryconfigure(2, label=_('Settings')) # Item in the File menu on Windows
|
||||
self.file_menu.entryconfigure(4, label=_('Exit')) # Item in the File menu on Windows
|
||||
|
||||
## Help menu
|
||||
# Help menu
|
||||
self.help_menu.entryconfigure(0, label=_('Documentation')) # Help menu item
|
||||
self.help_menu.entryconfigure(1, label=_('Privacy Policy')) # Help menu item
|
||||
self.help_menu.entryconfigure(2, label=_('Release Notes')) # Help menu item
|
||||
self.help_menu.entryconfigure(3, label=_('Check for Updates...')) # Menu item
|
||||
self.help_menu.entryconfigure(4, label=_("About {APP}").format(APP=applongname)) # App menu entry
|
||||
|
||||
## Edit menu
|
||||
# Edit menu
|
||||
self.edit_menu.entryconfigure(0, label=_('Copy')) # As in Copy and Paste
|
||||
|
||||
def login(self):
|
||||
@ -399,7 +411,8 @@ class AppWindow(object):
|
||||
self.w.update_idletasks()
|
||||
try:
|
||||
if companion.session.login(monitor.cmdr, monitor.is_beta):
|
||||
self.status['text'] = _('Authentication successful') # Successfully authenticated with the Frontier website
|
||||
# Successfully authenticated with the Frontier website
|
||||
self.status['text'] = _('Authentication successful')
|
||||
if platform == 'darwin':
|
||||
self.view_menu.entryconfigure(0, state=tk.NORMAL) # Status
|
||||
self.file_menu.entryconfigure(0, state=tk.NORMAL) # Save Raw Data
|
||||
@ -409,7 +422,7 @@ class AppWindow(object):
|
||||
except (companion.CredentialsError, companion.ServerError, companion.ServerLagging) as e:
|
||||
self.status['text'] = str(e)
|
||||
except Exception as e:
|
||||
if __debug__: print_exc()
|
||||
logger.debug('Frontier CAPI Auth', exc_info=e)
|
||||
self.status['text'] = str(e)
|
||||
self.cooldown()
|
||||
|
||||
@ -447,26 +460,36 @@ class AppWindow(object):
|
||||
# Validation
|
||||
if not data.get('commander', {}).get('name'):
|
||||
self.status['text'] = _("Who are you?!") # Shouldn't happen
|
||||
elif (not data.get('lastSystem', {}).get('name') or
|
||||
(data['commander'].get('docked') and not data.get('lastStarport', {}).get('name'))): # Only care if docked
|
||||
elif (not data.get('lastSystem', {}).get('name')
|
||||
or (data['commander'].get('docked')
|
||||
and not data.get('lastStarport', {}).get('name'))): # Only care if docked
|
||||
self.status['text'] = _("Where are you?!") # Shouldn't happen
|
||||
elif not data.get('ship', {}).get('name') or not data.get('ship', {}).get('modules'):
|
||||
self.status['text'] = _("What are you flying?!") # Shouldn't happen
|
||||
elif monitor.cmdr and data['commander']['name'] != monitor.cmdr:
|
||||
raise companion.CmdrError() # Companion API return doesn't match Journal
|
||||
elif ((auto_update and not data['commander'].get('docked')) or
|
||||
(data['lastSystem']['name'] != monitor.system) or
|
||||
((data['commander']['docked'] and data['lastStarport']['name'] or None) != monitor.station) or
|
||||
(data['ship']['id'] != monitor.state['ShipID']) or
|
||||
(data['ship']['name'].lower() != monitor.state['ShipType'])):
|
||||
# Companion API return doesn't match Journal
|
||||
raise companion.CmdrError()
|
||||
elif ((auto_update and not data['commander'].get('docked'))
|
||||
or (data['lastSystem']['name'] != monitor.system)
|
||||
or ((data['commander']['docked']
|
||||
and data['lastStarport']['name'] or None) != monitor.station)
|
||||
or (data['ship']['id'] != monitor.state['ShipID'])
|
||||
or (data['ship']['name'].lower() != monitor.state['ShipType'])):
|
||||
raise companion.ServerLagging()
|
||||
|
||||
else:
|
||||
|
||||
if __debug__: # Recording
|
||||
if isdir('dump'):
|
||||
with open('dump/%s%s.%s.json' % (data['lastSystem']['name'], data['commander'].get('docked') and '.'+data['lastStarport']['name'] or '', strftime('%Y-%m-%dT%H.%M.%S', localtime())), 'wb') as h:
|
||||
h.write(json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True, separators=(',', ': ')).encode('utf-8'))
|
||||
with open('dump/{system}{station}.{timestamp}.json'.format(
|
||||
system=data['lastSystem']['name'],
|
||||
station=data['commander'].get('docked') and '.'+data['lastStarport']['name'] or '',
|
||||
timestamp=strftime('%Y-%m-%dT%H.%M.%S', localtime())), 'wb') as h:
|
||||
h.write(json.dumps(data,
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
separators=(',', ': ')).encode('utf-8'))
|
||||
|
||||
if not monitor.state['ShipType']: # Started game in SRV or fighter
|
||||
self.ship['text'] = companion.ship_map.get(data['ship']['name'].lower(), data['ship']['name'])
|
||||
@ -487,10 +510,13 @@ class AppWindow(object):
|
||||
if config.getint('output') & (config.OUT_STATION_ANY):
|
||||
if not data['commander'].get('docked'):
|
||||
if not self.status['text']:
|
||||
# Signal as error because the user might actually be docked but the server hosting the Companion API hasn't caught up
|
||||
# Signal as error because the user might actually be docked
|
||||
# but the server hosting the Companion API hasn't caught up
|
||||
self.status['text'] = _("You're not docked at a station!")
|
||||
play_bad = True
|
||||
elif (config.getint('output') & config.OUT_MKT_EDDN) and not (data['lastStarport'].get('commodities') or data['lastStarport'].get('modules')): # Ignore possibly missing shipyard info
|
||||
# Ignore possibly missing shipyard info
|
||||
elif (config.getint('output') & config.OUT_MKT_EDDN)\
|
||||
and not (data['lastStarport'].get('commodities') or data['lastStarport'].get('modules')):
|
||||
if not self.status['text']:
|
||||
self.status['text'] = _("Station doesn't have anything!")
|
||||
elif not data['lastStarport'].get('commodities'):
|
||||
@ -523,12 +549,12 @@ class AppWindow(object):
|
||||
self.login()
|
||||
|
||||
except Exception as e: # Including CredentialsError, ServerError
|
||||
if __debug__: print_exc()
|
||||
logger.debug('"other" exception', exc_info=e)
|
||||
self.status['text'] = str(e)
|
||||
play_bad = True
|
||||
|
||||
if not self.status['text']: # no errors
|
||||
self.status['text'] = strftime(_('Last updated at %H:%M:%S').format(HH='%H', MM='%M', SS='%S'), localtime(querytime))
|
||||
self.status['text'] = strftime(_('Last updated at %H:%M:%S'), localtime(querytime))
|
||||
if play_sound and play_bad:
|
||||
hotkeymgr.play_bad()
|
||||
|
||||
@ -538,17 +564,24 @@ class AppWindow(object):
|
||||
# Try again to get shipyard data and send to EDDN. Don't report errors if can't get or send the data.
|
||||
try:
|
||||
data = companion.session.station()
|
||||
if __debug__:
|
||||
print('Retry for shipyard - ' + (data['commander'].get('docked') and (data.get('lastStarport', {}).get('ships') and 'Success' or 'Failure') or 'Undocked!'))
|
||||
if data['commander'].get('docked'):
|
||||
if data.get('lastStarport', {}).get('ships'):
|
||||
report = 'Success'
|
||||
else:
|
||||
report = 'Failure'
|
||||
else:
|
||||
report = 'Undocked!'
|
||||
logger.debug(f'Retry for shipyard - {report}')
|
||||
if not data['commander'].get('docked'):
|
||||
pass # might have undocked while we were waiting for retry in which case station data is unreliable
|
||||
# might have un-docked while we were waiting for retry in which case station data is unreliable
|
||||
pass
|
||||
elif (data.get('lastSystem', {}).get('name') == monitor.system and
|
||||
data.get('lastStarport', {}).get('name') == monitor.station and
|
||||
data.get('lastStarport', {}).get('ships', {}).get('shipyard_list')):
|
||||
self.eddn.export_shipyard(data, monitor.is_beta)
|
||||
elif tries > 1: # bogus data - retry
|
||||
self.w.after(int(SERVER_RETRY * 1000), lambda: self.retry_for_shipyard(tries-1))
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Handle event(s) from the journal
|
||||
@ -572,16 +605,19 @@ class AppWindow(object):
|
||||
# Update main window
|
||||
self.cooldown()
|
||||
if monitor.cmdr and monitor.state['Captain']:
|
||||
self.cmdr['text'] = '%s / %s' % (monitor.cmdr, monitor.state['Captain'])
|
||||
self.cmdr['text'] = f'{monitor.cmdr} / {monitor.state["Captain"]}'
|
||||
self.ship_label['text'] = _('Role') + ':' # Multicrew role label in main window
|
||||
self.ship.configure(state=tk.NORMAL, text=crewroletext(monitor.state['Role']), url=None)
|
||||
elif monitor.cmdr:
|
||||
if monitor.group:
|
||||
self.cmdr['text'] = '%s / %s' % (monitor.cmdr, monitor.group)
|
||||
self.cmdr['text'] = f'{monitor.cmdr} / {monitor.group}'
|
||||
else:
|
||||
self.cmdr['text'] = monitor.cmdr
|
||||
self.ship_label['text'] = _('Ship') + ':' # Main window
|
||||
self.ship.configure(text = monitor.state['ShipName'] or companion.ship_map.get(monitor.state['ShipType'], monitor.state['ShipType']) or '',
|
||||
self.ship.configure(
|
||||
text=monitor.state['ShipName']
|
||||
or companion.ship_map.get(monitor.state['ShipType'], monitor.state['ShipType'])
|
||||
or '',
|
||||
url=self.shipyard_url)
|
||||
else:
|
||||
self.cmdr['text'] = ''
|
||||
@ -590,7 +626,21 @@ class AppWindow(object):
|
||||
|
||||
self.edit_menu.entryconfigure(0, state=monitor.system and tk.NORMAL or tk.DISABLED) # Copy
|
||||
|
||||
if entry['event'] in ['Undocked', 'StartJump', 'SetUserShipName', 'ShipyardBuy', 'ShipyardSell', 'ShipyardSwap', 'ModuleBuy', 'ModuleSell', 'MaterialCollected', 'MaterialDiscarded', 'ScientificResearch', 'EngineerCraft', 'Synthesis', 'JoinACrew']:
|
||||
if entry['event'] in (
|
||||
'Undocked',
|
||||
'StartJump',
|
||||
'SetUserShipName',
|
||||
'ShipyardBuy',
|
||||
'ShipyardSell',
|
||||
'ShipyardSwap',
|
||||
'ModuleBuy',
|
||||
'ModuleSell',
|
||||
'MaterialCollected',
|
||||
'MaterialDiscarded',
|
||||
'ScientificResearch',
|
||||
'EngineerCraft',
|
||||
'Synthesis',
|
||||
'JoinACrew'):
|
||||
self.status['text'] = '' # Periodically clear any old error
|
||||
self.w.update_idletasks()
|
||||
|
||||
@ -607,37 +657,48 @@ class AppWindow(object):
|
||||
# Disable WinSparkle automatic update checks, IFF configured to do so when in-game
|
||||
if config.getint('disable_autoappupdatecheckingame') and 1:
|
||||
self.updater.setAutomaticUpdatesCheck(False)
|
||||
print('Monitor: Disable WinSparkle automatic update checks')
|
||||
logger.info('Monitor: Disable WinSparkle automatic update checks')
|
||||
# Can start dashboard monitoring
|
||||
if not dashboard.start(self.w, monitor.started):
|
||||
print("Can't start Status monitoring")
|
||||
logger.info("Can't start Status monitoring")
|
||||
|
||||
# Export loadout
|
||||
if entry['event'] == 'Loadout' and not monitor.state['Captain'] and config.getint('output') & config.OUT_SHIP:
|
||||
if entry['event'] == 'Loadout' and not monitor.state['Captain']\
|
||||
and config.getint('output') & config.OUT_SHIP:
|
||||
monitor.export_ship()
|
||||
|
||||
# Plugins
|
||||
err = plug.notify_journal_entry(monitor.cmdr, monitor.is_beta, monitor.system, monitor.station, entry, monitor.state)
|
||||
err = plug.notify_journal_entry(monitor.cmdr,
|
||||
monitor.is_beta,
|
||||
monitor.system,
|
||||
monitor.station,
|
||||
entry,
|
||||
monitor.state)
|
||||
if err:
|
||||
self.status['text'] = err
|
||||
if not config.getint('hotkey_mute'):
|
||||
hotkeymgr.play_bad()
|
||||
|
||||
# Auto-Update after docking, but not if auth callback is pending
|
||||
if entry['event'] in ['StartUp', 'Location', 'Docked'] and monitor.station and not config.getint('output') & config.OUT_MKT_MANUAL and config.getint('output') & config.OUT_STATION_ANY and companion.session.state != companion.Session.STATE_AUTH:
|
||||
if entry['event'] in ('StartUp', 'Location', 'Docked')\
|
||||
and monitor.station\
|
||||
and not config.getint('output') & config.OUT_MKT_MANUAL\
|
||||
and config.getint('output') & config.OUT_STATION_ANY\
|
||||
and companion.session.state != companion.Session.STATE_AUTH:
|
||||
self.w.after(int(SERVER_RETRY * 1000), self.getandsend)
|
||||
|
||||
if entry['event'] == 'ShutDown':
|
||||
# Enable WinSparkle automatic update checks
|
||||
# NB: Do this blindly, in case option got changed whilst in-game
|
||||
self.updater.setAutomaticUpdatesCheck(True)
|
||||
print('Monitor: Enable WinSparkle automatic update checks')
|
||||
logger.info('Monitor: Enable WinSparkle automatic update checks')
|
||||
|
||||
# cAPI auth
|
||||
def auth(self, event=None):
|
||||
try:
|
||||
companion.session.auth_callback()
|
||||
self.status['text'] = _('Authentication successful') # Successfully authenticated with the Frontier website
|
||||
# Successfully authenticated with the Frontier website
|
||||
self.status['text'] = _('Authentication successful')
|
||||
if platform == 'darwin':
|
||||
self.view_menu.entryconfigure(0, state=tk.NORMAL) # Status
|
||||
self.file_menu.entryconfigure(0, state=tk.NORMAL) # Save Raw Data
|
||||
@ -647,7 +708,7 @@ class AppWindow(object):
|
||||
except companion.ServerError as e:
|
||||
self.status['text'] = str(e)
|
||||
except Exception as e:
|
||||
if __debug__: print_exc()
|
||||
logger.debug('Frontier CAPI Auth:', exc_info=e)
|
||||
self.status['text'] = str(e)
|
||||
self.cooldown()
|
||||
|
||||
@ -696,7 +757,9 @@ class AppWindow(object):
|
||||
|
||||
def cooldown(self):
|
||||
if time() < self.holdofftime:
|
||||
self.button['text'] = self.theme_button['text'] = _('cooldown {SS}s').format(SS = int(self.holdofftime - time())) # Update button in main window
|
||||
# Update button in main window
|
||||
self.button['text'] = self.theme_button['text'] \
|
||||
= _('cooldown {SS}s').format(SS=int(self.holdofftime - time()))
|
||||
self.w.after(1000, self.cooldown)
|
||||
else:
|
||||
self.button['text'] = self.theme_button['text'] = _('Update') # Update button in main window
|
||||
@ -713,7 +776,7 @@ class AppWindow(object):
|
||||
def copy(self, event=None):
|
||||
if monitor.system:
|
||||
self.w.clipboard_clear()
|
||||
self.w.clipboard_append(monitor.station and '%s,%s' % (monitor.system, monitor.station) or monitor.system)
|
||||
self.w.clipboard_append(monitor.station and f'{monitor.system},{monitor.station}' or monitor.system)
|
||||
|
||||
def help_general(self, event=None):
|
||||
webbrowser.open('https://github.com/EDCD/EDMarketConnector/wiki')
|
||||
@ -724,7 +787,7 @@ class AppWindow(object):
|
||||
def help_releases(self, event=None):
|
||||
webbrowser.open('https://github.com/EDCD/EDMarketConnector/releases')
|
||||
|
||||
class help_about(tk.Toplevel):
|
||||
class HelpAbout(tk.Toplevel):
|
||||
showing = False
|
||||
|
||||
def __init__(self, parent):
|
||||
@ -741,8 +804,9 @@ class AppWindow(object):
|
||||
self.transient(parent)
|
||||
|
||||
# position over parent
|
||||
if platform!='darwin' or parent.winfo_rooty()>0: # http://core.tcl.tk/tk/tktview/c84f660833546b1b84e7
|
||||
self.geometry("+%d+%d" % (parent.winfo_rootx(), parent.winfo_rooty()))
|
||||
# http://core.tcl.tk/tk/tktview/c84f660833546b1b84e7
|
||||
if platform != 'darwin' or parent.winfo_rooty() > 0:
|
||||
self.geometry(f'+{parent.winfo_rootx():d}+{parent.winfo_rooty():d}')
|
||||
|
||||
# remove decoration
|
||||
if platform == 'win32':
|
||||
@ -753,10 +817,6 @@ class AppWindow(object):
|
||||
frame = ttk.Frame(self)
|
||||
frame.grid(sticky=tk.NSEW)
|
||||
|
||||
PADX = 10
|
||||
BUTTONX = 12 # indent Checkbuttons and Radiobuttons
|
||||
PADY = 2 # close spacing
|
||||
|
||||
row = 1
|
||||
############################################################
|
||||
# applongname
|
||||
@ -772,8 +832,8 @@ class AppWindow(object):
|
||||
self.appversion_label = tk.Label(frame, text=appversion)
|
||||
self.appversion_label.grid(row=row, column=0, sticky=tk.E)
|
||||
self.appversion = HyperlinkLabel(frame, compound=tk.RIGHT, text=_('Release Notes'),
|
||||
url='https://github.com/EDCD/EDMarketConnector/releases/tag/Release/{VERSION}'.format(
|
||||
VERSION=appversion_nobuild),
|
||||
url='https://github.com/EDCD/EDMarketConnector/releases/tag/Release/'
|
||||
f'{appversion_nobuild}',
|
||||
underline=True)
|
||||
self.appversion.grid(row=row, column=2, sticky=tk.W)
|
||||
row += 1
|
||||
@ -802,7 +862,7 @@ class AppWindow(object):
|
||||
self.protocol("WM_DELETE_WINDOW", self._destroy)
|
||||
############################################################
|
||||
|
||||
print('Current version is {}'.format(appversion))
|
||||
logger.info(f'Current version is {appversion}')
|
||||
|
||||
def apply(self):
|
||||
self._destroy()
|
||||
@ -819,22 +879,35 @@ class AppWindow(object):
|
||||
try:
|
||||
data = companion.session.station()
|
||||
self.status['text'] = ''
|
||||
default_extension: str = ''
|
||||
if platform == 'darwin':
|
||||
default_extension = '.json'
|
||||
last_system: str = data.get("lastSystem", {}).get("name", "Unknown")
|
||||
last_starport: str = ''
|
||||
if data['commander'].get('docked'):
|
||||
last_starport = '.'+data.get('lastStarport', {}).get('name', 'Unknown')
|
||||
timestamp: str = strftime('%Y-%m-%dT%H.%M.%S', localtime())
|
||||
f = tkinter.filedialog.asksaveasfilename(parent=self.w,
|
||||
defaultextension = platform=='darwin' and '.json' or '',
|
||||
defaultextension=default_extension,
|
||||
filetypes=[('JSON', '.json'), ('All Files', '*')],
|
||||
initialdir=config.get('outdir'),
|
||||
initialfile = '%s%s.%s.json' % (data.get('lastSystem', {}).get('name', 'Unknown'), data['commander'].get('docked') and '.'+data.get('lastStarport', {}).get('name', 'Unknown') or '', strftime('%Y-%m-%dT%H.%M.%S', localtime())))
|
||||
initialfile=f'{last_system}{last_starport}.{timestamp}')
|
||||
if f:
|
||||
with open(f, 'wb') as h:
|
||||
h.write(json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True, separators=(',', ': ')).encode('utf-8'))
|
||||
h.write(json.dumps(data,
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
separators=(',', ': ')).encode('utf-8'))
|
||||
except companion.ServerError as e:
|
||||
self.status['text'] = str(e)
|
||||
except Exception as e:
|
||||
if __debug__: print_exc()
|
||||
logger.debug('"other" exception', exc_info=e)
|
||||
self.status['text'] = str(e)
|
||||
|
||||
def onexit(self, event=None):
|
||||
if platform!='darwin' or self.w.winfo_rooty()>0: # http://core.tcl.tk/tk/tktview/c84f660833546b1b84e7
|
||||
# http://core.tcl.tk/tk/tktview/c84f660833546b1b84e7
|
||||
if platform != 'darwin' or self.w.winfo_rooty() > 0:
|
||||
config.set('geometry', '+{1}+{2}'.format(*self.w.geometry().split('+')))
|
||||
self.w.withdraw() # Following items can take a few seconds, so hide the main window while they happen
|
||||
protocolhandler.close()
|
||||
@ -852,7 +925,9 @@ class AppWindow(object):
|
||||
|
||||
def drag_continue(self, event):
|
||||
if self.drag_offset:
|
||||
self.w.geometry('+%d+%d' % (event.x_root - self.drag_offset[0], event.y_root - self.drag_offset[1]))
|
||||
offset_x = event.x_root - self.drag_offset[0]
|
||||
offset_y = event.y_root - self.drag_offset[1]
|
||||
self.w.geometry(f'+{offset_x:d}+{offset_y:d}')
|
||||
|
||||
def drag_end(self, event):
|
||||
self.drag_offset = None
|
||||
@ -880,66 +955,95 @@ class AppWindow(object):
|
||||
self.theme_menubar.grid_remove()
|
||||
self.blank_menubar.grid(row=0, columnspan=2, sticky=tk.NSEW)
|
||||
|
||||
# Run the app
|
||||
if __name__ == "__main__":
|
||||
|
||||
def enforce_single_instance() -> None:
|
||||
# Ensure only one copy of the app is running under this user account. OSX does this automatically. Linux TODO.
|
||||
if platform == 'win32':
|
||||
import ctypes
|
||||
from ctypes.wintypes import *
|
||||
EnumWindows = ctypes.windll.user32.EnumWindows
|
||||
GetClassName = ctypes.windll.user32.GetClassNameW
|
||||
GetClassName.argtypes = [HWND, LPWSTR, ctypes.c_int]
|
||||
GetWindowText = ctypes.windll.user32.GetWindowTextW
|
||||
GetWindowText.argtypes = [HWND, LPWSTR, ctypes.c_int]
|
||||
GetWindowTextLength = ctypes.windll.user32.GetWindowTextLengthW
|
||||
GetProcessHandleFromHwnd = ctypes.windll.oleacc.GetProcessHandleFromHwnd
|
||||
from ctypes.wintypes import HWND, LPWSTR, LPCWSTR, INT, BOOL, LPARAM
|
||||
EnumWindows = ctypes.windll.user32.EnumWindows # noqa: N806
|
||||
GetClassName = ctypes.windll.user32.GetClassNameW # noqa: N806
|
||||
GetClassName.argtypes = [HWND, LPWSTR, ctypes.c_int] # noqa: N806
|
||||
GetWindowText = ctypes.windll.user32.GetWindowTextW # noqa: N806
|
||||
GetWindowText.argtypes = [HWND, LPWSTR, ctypes.c_int] # noqa: N806
|
||||
GetWindowTextLength = ctypes.windll.user32.GetWindowTextLengthW # noqa: N806
|
||||
GetProcessHandleFromHwnd = ctypes.windll.oleacc.GetProcessHandleFromHwnd # noqa: N806
|
||||
|
||||
SW_RESTORE = 9
|
||||
SetForegroundWindow = ctypes.windll.user32.SetForegroundWindow
|
||||
ShowWindow = ctypes.windll.user32.ShowWindow
|
||||
ShowWindowAsync = ctypes.windll.user32.ShowWindowAsync
|
||||
SW_RESTORE = 9 # noqa: N806
|
||||
SetForegroundWindow = ctypes.windll.user32.SetForegroundWindow # noqa: N806
|
||||
ShowWindow = ctypes.windll.user32.ShowWindow # noqa: N806
|
||||
ShowWindowAsync = ctypes.windll.user32.ShowWindowAsync # noqa: N806
|
||||
|
||||
COINIT_MULTITHREADED = 0
|
||||
COINIT_APARTMENTTHREADED = 0x2
|
||||
COINIT_DISABLE_OLE1DDE = 0x4
|
||||
CoInitializeEx = ctypes.windll.ole32.CoInitializeEx
|
||||
COINIT_MULTITHREADED = 0 # noqa: N806,F841
|
||||
COINIT_APARTMENTTHREADED = 0x2 # noqa: N806
|
||||
COINIT_DISABLE_OLE1DDE = 0x4 # noqa: N806
|
||||
CoInitializeEx = ctypes.windll.ole32.CoInitializeEx # noqa: N806
|
||||
|
||||
ShellExecute = ctypes.windll.shell32.ShellExecuteW
|
||||
ShellExecute = ctypes.windll.shell32.ShellExecuteW # noqa: N806
|
||||
ShellExecute.argtypes = [HWND, LPCWSTR, LPCWSTR, LPCWSTR, LPCWSTR, INT]
|
||||
|
||||
def WindowTitle(h):
|
||||
def window_title(h):
|
||||
if h:
|
||||
l = GetWindowTextLength(h) + 1
|
||||
buf = ctypes.create_unicode_buffer(l)
|
||||
if GetWindowText(h, buf, l):
|
||||
text_length = GetWindowTextLength(h) + 1
|
||||
buf = ctypes.create_unicode_buffer(text_length)
|
||||
if GetWindowText(h, buf, text_length):
|
||||
return buf.value
|
||||
return None
|
||||
|
||||
@ctypes.WINFUNCTYPE(BOOL, HWND, LPARAM)
|
||||
def enumwindowsproc(hWnd, lParam):
|
||||
def enumwindowsproc(window_handle, l_param):
|
||||
# class name limited to 256 - https://msdn.microsoft.com/en-us/library/windows/desktop/ms633576
|
||||
cls = ctypes.create_unicode_buffer(257)
|
||||
if GetClassName(hWnd, cls, 257) and cls.value == 'TkTopLevel' and WindowTitle(hWnd) == applongname and GetProcessHandleFromHwnd(hWnd):
|
||||
if GetClassName(window_handle, cls, 257)\
|
||||
and cls.value == 'TkTopLevel'\
|
||||
and window_title(window_handle) == applongname\
|
||||
and GetProcessHandleFromHwnd(window_handle):
|
||||
# If GetProcessHandleFromHwnd succeeds then the app is already running as this user
|
||||
if len(sys.argv) > 1 and sys.argv[1].startswith(protocolhandler.redirect):
|
||||
# Browser invoked us directly with auth response. Forward the response to the other app instance.
|
||||
CoInitializeEx(0, COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE)
|
||||
ShowWindow(hWnd, SW_RESTORE) # Wait for it to be responsive to avoid ShellExecute recursing
|
||||
# Wait for it to be responsive to avoid ShellExecute recursing
|
||||
ShowWindow(window_handle, SW_RESTORE)
|
||||
ShellExecute(0, None, sys.argv[1], None, None, SW_RESTORE)
|
||||
else:
|
||||
ShowWindowAsync(hWnd, SW_RESTORE)
|
||||
SetForegroundWindow(hWnd)
|
||||
ShowWindowAsync(window_handle, SW_RESTORE)
|
||||
SetForegroundWindow(window_handle)
|
||||
sys.exit(0)
|
||||
return True
|
||||
|
||||
EnumWindows(enumwindowsproc, 0)
|
||||
|
||||
|
||||
def test_logging():
|
||||
logger.debug('Test from EDMarketConnector.py top-level test_logging()')
|
||||
|
||||
|
||||
# Run the app
|
||||
if __name__ == "__main__":
|
||||
# Keep this as the very first code run to be as sure as possible of no
|
||||
# output until after this redirect is done, if needed.
|
||||
if getattr(sys, 'frozen', False):
|
||||
# By default py2exe tries to write log to dirname(sys.executable) which fails when installed
|
||||
import tempfile
|
||||
sys.stdout = sys.stderr = open(join(tempfile.gettempdir(), '%s.log' % appname), 'wt', 1) # unbuffered not allowed for text in python3, so use line buffering
|
||||
print('%s %s %s' % (applongname, appversion, strftime('%Y-%m-%dT%H:%M:%S', localtime())))
|
||||
# unbuffered not allowed for text in python3, so use `1 for line buffering
|
||||
sys.stdout = sys.stderr = open(join(tempfile.gettempdir(), f'{appname}.log'), mode='wt', buffering=1)
|
||||
|
||||
enforce_single_instance()
|
||||
|
||||
logger = EDMCLogging.Logger(appname).get_logger()
|
||||
|
||||
# TODO: unittests in place of these
|
||||
# logger.debug('Test from __main__')
|
||||
# test_logging()
|
||||
class A(object):
|
||||
class B(object):
|
||||
def __init__(self):
|
||||
logger.debug('A call from A.B.__init__')
|
||||
|
||||
# abinit = A.B()
|
||||
|
||||
# Plain, not via `logger`
|
||||
print(f'{applongname} {appversion}')
|
||||
|
||||
Translations.install(config.get('language') or None) # Can generate errors so wait til log set up
|
||||
|
||||
|
@ -236,6 +236,7 @@
|
||||
"Language" = "Idioma";
|
||||
|
||||
/* [EDMarketConnector.py] - Leave '%H:%M:%S' as-is */
|
||||
>>>>>>> develop
|
||||
"Last updated at %H:%M:%S" = "Última actualización: %H:%M:%S";
|
||||
|
||||
/* Federation rank. [stats.py] */
|
||||
|
@ -48,6 +48,9 @@ breaking with future code changes.**
|
||||
`from monitor import gamerunning` - in case a plugin needs to know if we
|
||||
think the game is running.
|
||||
|
||||
`import timeout_session` - provides a method called `new_session` that creates a requests.session with a default timeout
|
||||
on all requests. Recommended to reduce noise in HTTP requests
|
||||
|
||||
|
||||
```python
|
||||
from ttkHyperlinkLabel import HyperlinkLabel
|
||||
|
83
companion.py
83
companion.py
@ -4,9 +4,10 @@ from builtins import object
|
||||
import base64
|
||||
import csv
|
||||
import requests
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
# TODO: see https://github.com/EDCD/EDMarketConnector/issues/569
|
||||
from http.cookiejar import LWPCookieJar # No longer needed but retained in case plugins use it
|
||||
from http.cookiejar import LWPCookieJar # noqa: F401 - No longer needed but retained in case plugins use it
|
||||
from email.utils import parsedate
|
||||
import hashlib
|
||||
import numbers
|
||||
@ -14,14 +15,15 @@ import os
|
||||
from os.path import join
|
||||
import random
|
||||
import time
|
||||
from traceback import print_exc
|
||||
import urllib.parse
|
||||
import webbrowser
|
||||
|
||||
from config import appname, appversion, config
|
||||
from protocol import protocolhandler
|
||||
import logging
|
||||
logger = logging.getLogger(appname)
|
||||
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
if TYPE_CHECKING:
|
||||
_ = lambda x: x # noqa # to make flake8 stop complaining that the hacked in _ method doesnt exist
|
||||
|
||||
@ -196,29 +198,28 @@ class Auth(object):
|
||||
return data.get('access_token')
|
||||
|
||||
else:
|
||||
print('Auth\tCan\'t refresh token for {}'.format(self.cmdr))
|
||||
logger.error(f"Frontier CAPI Auth: Can't refresh token for \"{self.cmdr}\"")
|
||||
self.dump(r)
|
||||
|
||||
except Exception:
|
||||
print('Auth\tCan\'t refresh token for {}'.format(self.cmdr))
|
||||
print_exc()
|
||||
except Exception as e:
|
||||
logger.exception(f"Frontier CAPI Auth: Can't refresh token for \"{self.cmdr}\"")
|
||||
|
||||
else:
|
||||
print('Auth\tNo token for {}'.format(self.cmdr))
|
||||
logger.error(f"Frontier CAPI Auth: No token for \"{self.cmdr}\"")
|
||||
|
||||
# New request
|
||||
print('Auth\tNew authorization request')
|
||||
logger.info('Frontier CAPI Auth: New authorization request')
|
||||
v = random.SystemRandom().getrandbits(8 * 32)
|
||||
self.verifier = self.base64URLEncode(v.to_bytes(32, byteorder='big')).encode('utf-8')
|
||||
self.verifier = self.base64_url_encode(v.to_bytes(32, byteorder='big')).encode('utf-8')
|
||||
s = random.SystemRandom().getrandbits(8 * 32)
|
||||
self.state = self.base64URLEncode(s.to_bytes(32, byteorder='big'))
|
||||
self.state = self.base64_url_encode(s.to_bytes(32, byteorder='big'))
|
||||
# Won't work under IE: https://blogs.msdn.microsoft.com/ieinternals/2011/07/13/understanding-protocols/
|
||||
webbrowser.open(
|
||||
'{server_auth}{url_auth}?response_type=code&audience=frontier&scope=capi&client_id={client_id}&code_challenge={challenge}&code_challenge_method=S256&state={state}&redirect_uri={redirect}'.format( # noqa: E501 # I cant make this any shorter
|
||||
server_auth=SERVER_AUTH,
|
||||
url_auth=URL_AUTH,
|
||||
client_id=CLIENT_ID,
|
||||
challenge=self.base64URLEncode(hashlib.sha256(self.verifier).digest()),
|
||||
challenge=self.base64_url_encode(hashlib.sha256(self.verifier).digest()),
|
||||
state=self.state,
|
||||
redirect=protocolhandler.redirect
|
||||
)
|
||||
@ -228,16 +229,16 @@ class Auth(object):
|
||||
# Handle OAuth authorization code callback.
|
||||
# Returns access token if successful, otherwise raises CredentialsError
|
||||
if '?' not in payload:
|
||||
print('Auth\tMalformed response {!r}'.format(payload))
|
||||
logger.error(f'Frontier CAPI Auth: Malformed response\n{payload}\n')
|
||||
raise CredentialsError('malformed payload') # Not well formed
|
||||
|
||||
data = urllib.parse.parse_qs(payload[(payload.index('?') + 1):])
|
||||
if not self.state or not data.get('state') or data['state'][0] != self.state:
|
||||
print('Auth\tUnexpected response {!r}'.format(payload))
|
||||
logger.error(f'Frontier CAPI Auth: Unexpected response\n{payload}\n')
|
||||
raise CredentialsError('Unexpected response from authorization {!r}'.format(payload)) # Unexpected reply
|
||||
|
||||
if not data.get('code'):
|
||||
print('Auth\tNegative response {!r}'.format(payload))
|
||||
logger.error(f'Frontier CAPI Auth: Negative response\n{payload}\n')
|
||||
error = next(
|
||||
(data[k] for k in ('error_description', 'error', 'message') if k in data), ('<unknown error>',)
|
||||
)
|
||||
@ -256,7 +257,7 @@ class Auth(object):
|
||||
r = self.session.post(SERVER_AUTH + URL_TOKEN, data=data, timeout=auth_timeout)
|
||||
data = r.json()
|
||||
if r.status_code == requests.codes.ok:
|
||||
print('Auth\tNew token for {}'.format(self.cmdr))
|
||||
logger.info(f'Frontier CAPI Auth: New token for \"{self.cmdr}\"')
|
||||
cmdrs = config.get('cmdrs')
|
||||
idx = cmdrs.index(self.cmdr)
|
||||
tokens = config.get('fdev_apikeys') or []
|
||||
@ -268,21 +269,20 @@ class Auth(object):
|
||||
return data.get('access_token')
|
||||
|
||||
except Exception as e:
|
||||
print('Auth\tCan\'t get token for {}'.format(self.cmdr))
|
||||
print_exc()
|
||||
logger.exception(f"Frontier CAPI Auth: Can't get token for \"{self.cmdr}\"")
|
||||
if r:
|
||||
self.dump(r)
|
||||
|
||||
raise CredentialsError('unable to get token') from e
|
||||
|
||||
print('Auth\tCan\'t get token for {}'.format(self.cmdr))
|
||||
logger.error(f"Frontier CAPI Auth: Can't get token for \"{self.cmdr}\"")
|
||||
self.dump(r)
|
||||
error = next((data[k] for k in ('error_description', 'error', 'message') if k in data), ('<unknown error>',))
|
||||
raise CredentialsError('Error: {!r}'.format(error)[0])
|
||||
|
||||
@staticmethod
|
||||
def invalidate(cmdr):
|
||||
print('Auth\tInvalidated token for {}'.format(cmdr))
|
||||
logger.info(f'Frontier CAPI Auth: Invalidated token for "{cmdr}"')
|
||||
cmdrs = config.get('cmdrs')
|
||||
idx = cmdrs.index(cmdr)
|
||||
tokens = config.get('fdev_apikeys') or []
|
||||
@ -292,9 +292,9 @@ class Auth(object):
|
||||
config.save() # Save settings now for use by command-line app
|
||||
|
||||
def dump(self, r):
|
||||
print('Auth\t' + r.url, r.status_code, r.reason if r.reason else 'None', r.text)
|
||||
logger.debug(f'Frontier CAPI Auth: {r.url} {r.status_code} {r.reason if r.reason else "None"} {r.text}')
|
||||
|
||||
def base64URLEncode(self, text):
|
||||
def base64_url_encode(self, text):
|
||||
return base64.urlsafe_b64encode(text).decode().replace('=', '')
|
||||
|
||||
|
||||
@ -379,10 +379,8 @@ class Session(object):
|
||||
r = self.session.get(self.server + endpoint, timeout=timeout)
|
||||
|
||||
except Exception as e:
|
||||
if __debug__:
|
||||
print_exc()
|
||||
|
||||
raise ServerError('unable to get endpoint {}'.format(endpoint)) from e
|
||||
logger.debug('Attempting GET', exc_info=e)
|
||||
raise ServerError(f'unable to get endpoint {endpoint}') from e
|
||||
|
||||
if r.url.startswith(SERVER_AUTH):
|
||||
# Redirected back to Auth server - force full re-authentication
|
||||
@ -402,7 +400,7 @@ class Session(object):
|
||||
data = r.json() # May also fail here if token expired since response is empty
|
||||
|
||||
except (requests.HTTPError, ValueError) as e:
|
||||
print_exc()
|
||||
logger.exception('Frontier CAPI Auth: GET ')
|
||||
self.dump(r)
|
||||
self.close()
|
||||
|
||||
@ -410,6 +408,7 @@ class Session(object):
|
||||
self.invalidate()
|
||||
self.retrying = False
|
||||
self.login()
|
||||
logger.error('Frontier CAPI Auth: query failed after refresh')
|
||||
raise CredentialsError('query failed after refresh') from e
|
||||
|
||||
elif self.login(): # Maybe our token expired. Re-authorize in any case
|
||||
@ -418,6 +417,7 @@ class Session(object):
|
||||
|
||||
else:
|
||||
self.retrying = False
|
||||
logger.error('Frontier CAPI Auth: HTTP error or invalid JSON')
|
||||
raise CredentialsError('HTTP error or invalid JSON') from e
|
||||
|
||||
self.retrying = False
|
||||
@ -461,9 +461,8 @@ class Session(object):
|
||||
try:
|
||||
self.session.close()
|
||||
|
||||
except Exception:
|
||||
if __debug__:
|
||||
print_exc()
|
||||
except Exception as e:
|
||||
logger.debug('Frontier CAPI Auth: closing', exc_info=e)
|
||||
|
||||
self.session = None
|
||||
|
||||
@ -473,7 +472,7 @@ class Session(object):
|
||||
Auth.invalidate(self.credentials['cmdr'])
|
||||
|
||||
def dump(self, r):
|
||||
print('cAPI\t' + r.url, r.status_code, r.reason and r.reason or 'None', r.text)
|
||||
logger.error(f'Frontier CAPI Auth: {r.url} {r.status_code} {r.reason and r.reason or "None"} {r.text}')
|
||||
|
||||
# Returns a shallow copy of the received data suitable for export to older tools
|
||||
# English commodity names and anomalies fixed up
|
||||
@ -497,15 +496,7 @@ def fixup(data):
|
||||
# But also see https://github.com/Marginal/EDMarketConnector/issues/32
|
||||
for thing in ('buyPrice', 'sellPrice', 'demand', 'demandBracket', 'stock', 'stockBracket'):
|
||||
if not isinstance(commodity.get(thing), numbers.Number):
|
||||
if __debug__:
|
||||
print(
|
||||
'Invalid {!r}:{!r} ({}) for {!r}'.format(
|
||||
thing,
|
||||
commodity.get(thing),
|
||||
type(commodity.get(thing)),
|
||||
commodity.get('name', '')
|
||||
)
|
||||
)
|
||||
logger.debug(f'Invalid {thing}:{commodity.get(thing)} ({type(commodity.get(thing))}) for {commodity.get("name", "")}') # noqa: E501
|
||||
break
|
||||
|
||||
else:
|
||||
@ -520,20 +511,16 @@ def fixup(data):
|
||||
pass
|
||||
|
||||
elif not commodity.get('categoryname'):
|
||||
if __debug__:
|
||||
print('Missing "categoryname" for {!r}'.format(commodity.get('name', '')))
|
||||
logger.debug(f'Missing "categoryname" for {commodity.get("name", "")}')
|
||||
|
||||
elif not commodity.get('name'):
|
||||
if __debug__:
|
||||
print('Missing "name" for a commodity in {!r}'.format(commodity.get('categoryname', '')))
|
||||
logger.debug(f'Missing "name" for a commodity in {commodity.get("categoryname", "")}')
|
||||
|
||||
elif not commodity['demandBracket'] in range(4):
|
||||
if __debug__:
|
||||
print('Invalid "demandBracket":{!r} for {!r}'.format(commodity['demandBracket'], commodity['name']))
|
||||
logger.debug(f'Invalid "demandBracket":{commodity["demandBracket"]} for {commodity["name"]}')
|
||||
|
||||
elif not commodity['stockBracket'] in range(4):
|
||||
if __debug__:
|
||||
print('Invalid "stockBracket":{!r} for {!r}'.format(commodity['stockBracket'], commodity['name']))
|
||||
logger.debug(f'Invalid "stockBracket":{commodity["stockBracket"]} for {commodity["name"]}')
|
||||
|
||||
else:
|
||||
# Rewrite text fields
|
||||
|
483
monitor.py
483
monitor.py
@ -3,11 +3,15 @@ import json
|
||||
import re
|
||||
import threading
|
||||
from operator import itemgetter
|
||||
from os import listdir, SEEK_SET, SEEK_CUR, SEEK_END
|
||||
from os import listdir, SEEK_SET, SEEK_END
|
||||
from os.path import basename, expanduser, isdir, join
|
||||
from sys import platform
|
||||
from time import gmtime, localtime, sleep, strftime, strptime, time
|
||||
from calendar import timegm
|
||||
from typing import Any, Optional, OrderedDict as OrderedDictT, Tuple, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import tkinter
|
||||
|
||||
if __debug__:
|
||||
from traceback import print_exc
|
||||
@ -18,7 +22,6 @@ from companion import ship_file_name
|
||||
|
||||
if platform == 'darwin':
|
||||
from AppKit import NSWorkspace
|
||||
from Foundation import NSSearchPathForDirectoriesInDomains, NSApplicationSupportDirectory, NSUserDomainMask
|
||||
from watchdog.observers import Observer
|
||||
from watchdog.events import FileSystemEventHandler
|
||||
from fcntl import fcntl
|
||||
@ -28,7 +31,7 @@ elif platform=='win32':
|
||||
from watchdog.observers import Observer
|
||||
from watchdog.events import FileSystemEventHandler
|
||||
import ctypes
|
||||
from ctypes.wintypes import *
|
||||
from ctypes.wintypes import BOOL, HWND, LPARAM, LPWSTR
|
||||
|
||||
EnumWindows = ctypes.windll.user32.EnumWindows
|
||||
EnumWindowsProc = ctypes.WINFUNCTYPE(BOOL, HWND, LPARAM)
|
||||
@ -47,20 +50,23 @@ else:
|
||||
|
||||
|
||||
# Journal handler
|
||||
class EDLogs(FileSystemEventHandler):
|
||||
class EDLogs(FileSystemEventHandler): # type: ignore # See below
|
||||
# Magic with FileSystemEventHandler can confuse type checkers when they do not have access to every import
|
||||
|
||||
_POLL = 1 # Polling is cheap, so do it often
|
||||
_RE_CANONICALISE = re.compile(r'\$(.+)_name;')
|
||||
_RE_CATEGORY = re.compile(r'\$MICRORESOURCE_CATEGORY_(.+);')
|
||||
_RE_LOGFILE = re.compile(r'^Journal(Beta)?\.[0-9]{12}\.[0-9]{2}\.log$')
|
||||
|
||||
def __init__(self):
|
||||
# TODO(A_D): A bunch of these should be switched to default values (eg '' for strings) and no longer be Optional
|
||||
FileSystemEventHandler.__init__(self) # futureproofing - not need for current version of watchdog
|
||||
self.root = None
|
||||
self.currentdir = None # The actual logdir that we're monitoring
|
||||
self.logfile = None
|
||||
self.currentdir: Optional[str] = None # The actual logdir that we're monitoring
|
||||
self.logfile: Optional[str] = None
|
||||
self.observer = None
|
||||
self.observed = None # a watchdog ObservedWatch, or None if polling
|
||||
self.thread = None
|
||||
self.thread: Optional[threading.Thread] = None
|
||||
self.event_queue = [] # For communicating journal entries back to main thread
|
||||
|
||||
# On startup we might be:
|
||||
@ -71,22 +77,22 @@ class EDLogs(FileSystemEventHandler):
|
||||
# If 3 we need to inject a special 'StartUp' event since consumers won't see the LoadGame event.
|
||||
self.live = False
|
||||
|
||||
self.game_was_running = False # For generation the "ShutDown" event
|
||||
self.game_was_running = False # For generation of the "ShutDown" event
|
||||
|
||||
# Context for journal handling
|
||||
self.version = None
|
||||
self.version: Optional[str] = None
|
||||
self.is_beta = False
|
||||
self.mode = None
|
||||
self.group = None
|
||||
self.cmdr = None
|
||||
self.planet = None
|
||||
self.system = None
|
||||
self.station = None
|
||||
self.station_marketid = None
|
||||
self.stationtype = None
|
||||
self.coordinates = None
|
||||
self.systemaddress = None
|
||||
self.started = None # Timestamp of the LoadGame event
|
||||
self.mode: Optional[str] = None
|
||||
self.group: Optional[str] = None
|
||||
self.cmdr: Optional[str] = None
|
||||
self.planet: Optional[str] = None
|
||||
self.system: Optional[str] = None
|
||||
self.station: Optional[str] = None
|
||||
self.station_marketid: Optional[int] = None
|
||||
self.stationtype: Optional[str] = None
|
||||
self.coordinates: Optional[Tuple[int, int, int]] = None
|
||||
self.systemaddress: Optional[int] = None
|
||||
self.started: Optional[int] = None # Timestamp of the LoadGame event
|
||||
|
||||
# Cmdr state shared with EDSM and plugins
|
||||
# If you change anything here update PLUGINS.md documentation!
|
||||
@ -116,7 +122,7 @@ class EDLogs(FileSystemEventHandler):
|
||||
'Modules': None,
|
||||
}
|
||||
|
||||
def start(self, root):
|
||||
def start(self, root: 'tkinter.Tk'):
|
||||
self.root = root
|
||||
journal_dir = config.get('journaldir') or config.default_journal_dir
|
||||
|
||||
@ -133,15 +139,20 @@ class EDLogs(FileSystemEventHandler):
|
||||
|
||||
if self.currentdir and self.currentdir != logdir:
|
||||
self.stop()
|
||||
|
||||
self.currentdir = logdir
|
||||
|
||||
# Latest pre-existing logfile - e.g. if E:D is already running. Assumes logs sort alphabetically.
|
||||
# Do this before setting up the observer in case the journal directory has gone away
|
||||
try:
|
||||
logfiles = sorted([x for x in listdir(self.currentdir) if re.search('^Journal(Beta)?\.[0-9]{12}\.[0-9]{2}\.log$', x)],
|
||||
key=lambda x: x.split('.')[1:])
|
||||
self.logfile = logfiles and join(self.currentdir, logfiles[-1]) or None
|
||||
except:
|
||||
try: # TODO: This should be replaced with something specific ONLY wrapping listdir
|
||||
logfiles = sorted(
|
||||
(x for x in listdir(self.currentdir) if self._RE_LOGFILE.search(x)), # type: ignore # config is weird
|
||||
key=lambda x: x.split('.')[1:]
|
||||
)
|
||||
|
||||
self.logfile = join(self.currentdir, logfiles[-1]) if logfiles else None # type: ignore # config is weird
|
||||
|
||||
except Exception:
|
||||
self.logfile = None
|
||||
return False
|
||||
|
||||
@ -154,6 +165,7 @@ class EDLogs(FileSystemEventHandler):
|
||||
self.observer = Observer()
|
||||
self.observer.daemon = True
|
||||
self.observer.start()
|
||||
|
||||
elif polling and self.observer:
|
||||
self.observer.stop()
|
||||
self.observer = None
|
||||
@ -162,8 +174,8 @@ class EDLogs(FileSystemEventHandler):
|
||||
self.observed = self.observer.schedule(self, self.currentdir)
|
||||
|
||||
if __debug__:
|
||||
print('%s Journal "%s"' % (polling and 'Polling' or 'Monitoring', self.currentdir))
|
||||
print('Start logfile "%s"' % self.logfile)
|
||||
print('{} Journal {!r}'.format('Polling' if polling else 'Monitoring', self.currentdir))
|
||||
print('Start logfile {!r}'.format(self.logfile))
|
||||
|
||||
if not self.running():
|
||||
self.thread = threading.Thread(target=self.worker, name='Journal worker')
|
||||
@ -175,19 +187,32 @@ class EDLogs(FileSystemEventHandler):
|
||||
def stop(self):
|
||||
if __debug__:
|
||||
print('Stopping monitoring Journal')
|
||||
|
||||
self.currentdir = None
|
||||
self.version = self.mode = self.group = self.cmdr = self.planet = self.system = self.station = self.station_marketid = self.stationtype = self.stationservices = self.coordinates = self.systemaddress = None
|
||||
self.version = None
|
||||
self.mode = None
|
||||
self.group = None
|
||||
self.cmdr = None
|
||||
self.planet = None
|
||||
self.system = None
|
||||
self.station = None
|
||||
self.station_marketid = None
|
||||
self.stationtype = None
|
||||
self.stationservices = None
|
||||
self.coordinates = None
|
||||
self.systemaddress = None
|
||||
self.is_beta = False
|
||||
if self.observed:
|
||||
self.observed = None
|
||||
self.observer.unschedule_all()
|
||||
|
||||
self.thread = None # Orphan the worker thread - will terminate at next poll
|
||||
|
||||
def close(self):
|
||||
thread = self.thread
|
||||
self.stop()
|
||||
if self.observer:
|
||||
self.observer.stop()
|
||||
|
||||
if self.observer:
|
||||
self.observer.join()
|
||||
self.observer = None
|
||||
@ -197,7 +222,8 @@ class EDLogs(FileSystemEventHandler):
|
||||
|
||||
def on_created(self, event):
|
||||
# watchdog callback, e.g. client (re)started.
|
||||
if not event.is_directory and re.search('^Journal(Beta)?\.[0-9]{12}\.[0-9]{2}\.log$', basename(event.src_path)):
|
||||
if not event.is_directory and self._RE_LOGFILE.search(basename(event.src_path)):
|
||||
|
||||
self.logfile = event.src_path
|
||||
|
||||
def worker(self):
|
||||
@ -211,13 +237,17 @@ class EDLogs(FileSystemEventHandler):
|
||||
loghandle = open(logfile, 'rb', 0) # unbuffered
|
||||
if platform == 'darwin':
|
||||
fcntl(loghandle, F_GLOBAL_NOCACHE, -1) # required to avoid corruption on macOS over SMB
|
||||
|
||||
for line in loghandle:
|
||||
try:
|
||||
self.parse_entry(line) # Some events are of interest even in the past
|
||||
except:
|
||||
|
||||
except Exception:
|
||||
if __debug__:
|
||||
print('Invalid journal entry "%s"' % repr(line))
|
||||
logpos = loghandle.tell()
|
||||
print('Invalid journal entry {!r}'.format(line))
|
||||
|
||||
log_pos = loghandle.tell()
|
||||
|
||||
else:
|
||||
loghandle = None
|
||||
|
||||
@ -226,7 +256,7 @@ class EDLogs(FileSystemEventHandler):
|
||||
if self.live:
|
||||
if self.game_was_running:
|
||||
# Game is running locally
|
||||
entry = OrderedDict([
|
||||
entry: OrderedDictT[str, Any] = OrderedDict([
|
||||
('timestamp', strftime('%Y-%m-%dT%H:%M:%SZ', gmtime())),
|
||||
('event', 'StartUp'),
|
||||
('StarSystem', self.system),
|
||||
@ -234,19 +264,26 @@ class EDLogs(FileSystemEventHandler):
|
||||
('SystemAddress', self.systemaddress),
|
||||
('Population', self.systempopulation),
|
||||
])
|
||||
|
||||
if self.planet:
|
||||
entry['Body'] = self.planet
|
||||
|
||||
entry['Docked'] = bool(self.station)
|
||||
|
||||
if self.station:
|
||||
entry['StationName'] = self.station
|
||||
entry['StationType'] = self.stationtype
|
||||
entry['MarketID'] = self.station_marketid
|
||||
|
||||
self.event_queue.append(json.dumps(entry, separators=(', ', ':')))
|
||||
|
||||
else:
|
||||
self.event_queue.append(None) # Generate null event to update the display (with possibly out-of-date info)
|
||||
# Generate null event to update the display (with possibly out-of-date info)
|
||||
self.event_queue.append(None)
|
||||
self.live = False
|
||||
|
||||
# Watchdog thread
|
||||
# Watchdog thread -- there is a way to get this by using self.observer.emitters and checking for an attribute:
|
||||
# watch, but that may have unforseen differences in behaviour.
|
||||
emitter = self.observed and self.observer._emitter_for_watch[self.observed] # Note: Uses undocumented attribute
|
||||
|
||||
while True:
|
||||
@ -257,33 +294,44 @@ class EDLogs(FileSystemEventHandler):
|
||||
else:
|
||||
# Poll
|
||||
try:
|
||||
logfiles = sorted([x for x in listdir(self.currentdir) if re.search('^Journal(Beta)?\.[0-9]{12}\.[0-9]{2}\.log$', x)],
|
||||
key=lambda x: x.split('.')[1:])
|
||||
newlogfile = logfiles and join(self.currentdir, logfiles[-1]) or None
|
||||
except:
|
||||
if __debug__: print_exc()
|
||||
logfiles = sorted(
|
||||
(x for x in listdir(self.currentdir) if self._RE_LOGFILE.search(x)),
|
||||
key=lambda x: x.split('.')[1:]
|
||||
)
|
||||
|
||||
newlogfile = join(self.currentdir, logfiles[-1]) if logfiles else None # type: ignore
|
||||
|
||||
except Exception:
|
||||
if __debug__:
|
||||
print_exc()
|
||||
|
||||
newlogfile = None
|
||||
|
||||
if logfile != newlogfile:
|
||||
logfile = newlogfile
|
||||
if loghandle:
|
||||
loghandle.close()
|
||||
|
||||
if logfile:
|
||||
loghandle = open(logfile, 'rb', 0) # unbuffered
|
||||
if platform == 'darwin':
|
||||
fcntl(loghandle, F_GLOBAL_NOCACHE, -1) # required to avoid corruption on macOS over SMB
|
||||
logpos = 0
|
||||
|
||||
log_pos = 0
|
||||
|
||||
if __debug__:
|
||||
print('New logfile "%s"' % logfile)
|
||||
print('New logfile {!r}'.format(logfile))
|
||||
|
||||
if logfile:
|
||||
loghandle.seek(0, SEEK_END) # required to make macOS notice log change over SMB
|
||||
loghandle.seek(logpos, SEEK_SET) # reset EOF flag
|
||||
loghandle.seek(log_pos, SEEK_SET) # reset EOF flag # TODO: log_pos reported as possibly unbound
|
||||
for line in loghandle:
|
||||
self.event_queue.append(line)
|
||||
|
||||
if self.event_queue:
|
||||
self.root.event_generate('<<JournalEvent>>', when="tail")
|
||||
logpos = loghandle.tell()
|
||||
|
||||
log_pos = loghandle.tell()
|
||||
|
||||
sleep(self._POLL)
|
||||
|
||||
@ -293,21 +341,28 @@ class EDLogs(FileSystemEventHandler):
|
||||
|
||||
if self.game_was_running:
|
||||
if not self.game_running():
|
||||
self.event_queue.append('{ "timestamp":"%s", "event":"ShutDown" }' % strftime('%Y-%m-%dT%H:%M:%SZ', gmtime()))
|
||||
self.event_queue.append(
|
||||
'{{ "timestamp":"{}", "event":"ShutDown" }}'.format(strftime('%Y-%m-%dT%H:%M:%SZ', gmtime()))
|
||||
)
|
||||
|
||||
self.root.event_generate('<<JournalEvent>>', when="tail")
|
||||
self.game_was_running = False
|
||||
|
||||
else:
|
||||
self.game_was_running = self.game_running()
|
||||
|
||||
|
||||
def parse_entry(self, line):
|
||||
def parse_entry(self, line: str):
|
||||
# TODO(A_D): a bunch of these can be simplified to use if itertools.product and filters
|
||||
if line is None:
|
||||
return {'event': None} # Fake startup event
|
||||
|
||||
try:
|
||||
entry = json.loads(line, object_pairs_hook=OrderedDict) # Preserve property order because why not?
|
||||
entry['timestamp'] # we expect this to exist
|
||||
if entry['event'] == 'Fileheader':
|
||||
# Preserve property order because why not?
|
||||
entry: OrderedDictT[str, Any] = json.loads(line, object_pairs_hook=OrderedDict)
|
||||
entry['timestamp'] # we expect this to exist # TODO: replace with assert? or an if key in check
|
||||
|
||||
event_type = entry['event']
|
||||
if event_type == 'Fileheader':
|
||||
self.live = False
|
||||
self.version = entry['gameversion']
|
||||
self.is_beta = 'beta' in entry['gameversion'].lower()
|
||||
@ -348,11 +403,14 @@ class EDLogs(FileSystemEventHandler):
|
||||
'Rebuy': None,
|
||||
'Modules': None,
|
||||
}
|
||||
elif entry['event'] == 'Commander':
|
||||
|
||||
elif event_type == 'Commander':
|
||||
self.live = True # First event in 3.0
|
||||
elif entry['event'] == 'LoadGame':
|
||||
|
||||
elif event_type == 'LoadGame':
|
||||
self.cmdr = entry['Commander']
|
||||
self.mode = entry.get('GameMode') # 'Open', 'Solo', 'Group', or None for CQC (and Training - but no LoadGame event)
|
||||
# 'Open', 'Solo', 'Group', or None for CQC (and Training - but no LoadGame event)
|
||||
self.mode = entry.get('GameMode')
|
||||
self.group = entry.get('Group')
|
||||
self.planet = None
|
||||
self.system = None
|
||||
@ -363,7 +421,8 @@ class EDLogs(FileSystemEventHandler):
|
||||
self.coordinates = None
|
||||
self.systemaddress = None
|
||||
self.started = timegm(strptime(entry['timestamp'], '%Y-%m-%dT%H:%M:%SZ'))
|
||||
self.state.update({ # Don't set Ship, ShipID etc since this will reflect Fighter or SRV if starting in those
|
||||
# Don't set Ship, ShipID etc since this will reflect Fighter or SRV if starting in those
|
||||
self.state.update({
|
||||
'Captain': None,
|
||||
'Credits': entry['Credits'],
|
||||
'FID': entry.get('FID'), # From 3.3
|
||||
@ -375,16 +434,20 @@ class EDLogs(FileSystemEventHandler):
|
||||
'Statistics': {},
|
||||
'Role': None,
|
||||
})
|
||||
elif entry['event'] == 'NewCommander':
|
||||
|
||||
elif event_type == 'NewCommander':
|
||||
self.cmdr = entry['Name']
|
||||
self.group = None
|
||||
elif entry['event'] == 'SetUserShipName':
|
||||
|
||||
elif event_type == 'SetUserShipName':
|
||||
self.state['ShipID'] = entry['ShipID']
|
||||
if 'UserShipId' in entry: # Only present when changing the ship's ident
|
||||
self.state['ShipIdent'] = entry['UserShipId']
|
||||
|
||||
self.state['ShipName'] = entry.get('UserShipName')
|
||||
self.state['ShipType'] = self.canonicalise(entry['Ship'])
|
||||
elif entry['event'] == 'ShipyardBuy':
|
||||
|
||||
elif event_type == 'ShipyardBuy':
|
||||
self.state['ShipID'] = None
|
||||
self.state['ShipIdent'] = None
|
||||
self.state['ShipName'] = None
|
||||
@ -393,7 +456,8 @@ class EDLogs(FileSystemEventHandler):
|
||||
self.state['ModulesValue'] = None
|
||||
self.state['Rebuy'] = None
|
||||
self.state['Modules'] = None
|
||||
elif entry['event'] == 'ShipyardSwap':
|
||||
|
||||
elif event_type == 'ShipyardSwap':
|
||||
self.state['ShipID'] = entry['ShipID']
|
||||
self.state['ShipIdent'] = None
|
||||
self.state['ShipName'] = None
|
||||
@ -402,9 +466,10 @@ class EDLogs(FileSystemEventHandler):
|
||||
self.state['ModulesValue'] = None
|
||||
self.state['Rebuy'] = None
|
||||
self.state['Modules'] = None
|
||||
elif (entry['event'] == 'Loadout' and
|
||||
not 'fighter' in self.canonicalise(entry['Ship']) and
|
||||
not 'buggy' in self.canonicalise(entry['Ship'])):
|
||||
|
||||
elif (event_type == 'Loadout' and
|
||||
'fighter' not in self.canonicalise(entry['Ship']) and
|
||||
'buggy' not in self.canonicalise(entry['Ship'])):
|
||||
self.state['ShipID'] = entry['ShipID']
|
||||
self.state['ShipIdent'] = entry['ShipIdent']
|
||||
|
||||
@ -429,8 +494,10 @@ class EDLogs(FileSystemEventHandler):
|
||||
module.get('AmmoInClip') == module.get('AmmoInHopper') == 1): # lasers
|
||||
module.pop('AmmoInClip')
|
||||
module.pop('AmmoInHopper')
|
||||
|
||||
self.state['Modules'][module['Slot']] = module
|
||||
elif entry['event'] == 'ModuleBuy':
|
||||
|
||||
elif event_type == 'ModuleBuy':
|
||||
self.state['Modules'][entry['Slot']] = {
|
||||
'Slot': entry['Slot'],
|
||||
'Item': self.canonicalise(entry['BuyItem']),
|
||||
@ -439,123 +506,174 @@ class EDLogs(FileSystemEventHandler):
|
||||
'Health': 1.0,
|
||||
'Value': entry['BuyPrice'],
|
||||
}
|
||||
elif entry['event'] == 'ModuleSell':
|
||||
|
||||
elif event_type == 'ModuleSell':
|
||||
self.state['Modules'].pop(entry['Slot'], None)
|
||||
elif entry['event'] == 'ModuleSwap':
|
||||
toitem = self.state['Modules'].get(entry['ToSlot'])
|
||||
self.state['Modules'][entry['ToSlot']] = self.state['Modules'][entry['FromSlot']]
|
||||
if toitem:
|
||||
self.state['Modules'][entry['FromSlot']] = toitem
|
||||
|
||||
elif event_type == 'ModuleSwap':
|
||||
to_item = self.state['Modules'].get(entry['ToSlot'])
|
||||
to_slot = entry['ToSlot']
|
||||
from_slot = entry['FromSlot']
|
||||
modules = self.state['Modules']
|
||||
modules[to_slot] = modules[from_slot]
|
||||
if to_item:
|
||||
modules[from_slot] = to_item
|
||||
|
||||
else:
|
||||
self.state['Modules'].pop(entry['FromSlot'], None)
|
||||
elif entry['event'] in ['Undocked']:
|
||||
modules.pop(from_slot, None)
|
||||
|
||||
elif event_type == 'Undocked':
|
||||
self.station = None
|
||||
self.station_marketid = None
|
||||
self.stationtype = None
|
||||
self.stationservices = None
|
||||
elif entry['event'] in ['Location', 'FSDJump', 'Docked', 'CarrierJump']:
|
||||
if entry['event'] in ('Location', 'CarrierJump'):
|
||||
|
||||
elif event_type in ('Location', 'FSDJump', 'Docked', 'CarrierJump'):
|
||||
if event_type in ('Location', 'CarrierJump'):
|
||||
self.planet = entry.get('Body') if entry.get('BodyType') == 'Planet' else None
|
||||
elif entry['event'] == 'FSDJump':
|
||||
|
||||
elif event_type == 'FSDJump':
|
||||
self.planet = None
|
||||
|
||||
if 'StarPos' in entry:
|
||||
self.coordinates = tuple(entry['StarPos'])
|
||||
|
||||
elif self.system != entry['StarSystem']:
|
||||
self.coordinates = None # Docked event doesn't include coordinates
|
||||
|
||||
self.systemaddress = entry.get('SystemAddress')
|
||||
|
||||
if entry['event'] in ['Location', 'FSDJump', 'CarrierJump']:
|
||||
if event_type in ('Location', 'FSDJump', 'CarrierJump'):
|
||||
self.systempopulation = entry.get('Population')
|
||||
|
||||
(self.system, self.station) = (entry['StarSystem'] == 'ProvingGround' and 'CQC' or entry['StarSystem'],
|
||||
entry.get('StationName')) # May be None
|
||||
self.system = 'CQC' if entry['StarSystem'] == 'ProvingGround' else entry['StarSystem']
|
||||
self.station = entry.get('StationName') # May be None
|
||||
self.station_marketid = entry.get('MarketID') # May be None
|
||||
self.stationtype = entry.get('StationType') # May be None
|
||||
self.stationservices = entry.get('StationServices') # None under E:D < 2.4
|
||||
elif entry['event'] == 'ApproachBody':
|
||||
|
||||
elif event_type == 'ApproachBody':
|
||||
self.planet = entry['Body']
|
||||
elif entry['event'] in ['LeaveBody', 'SupercruiseEntry']:
|
||||
|
||||
elif event_type in ('LeaveBody', 'SupercruiseEntry'):
|
||||
self.planet = None
|
||||
|
||||
elif entry['event'] in ['Rank', 'Promotion']:
|
||||
elif event_type in ('Rank', 'Promotion'):
|
||||
payload = dict(entry)
|
||||
payload.pop('event')
|
||||
payload.pop('timestamp')
|
||||
for k,v in payload.items():
|
||||
self.state['Rank'][k] = (v,0)
|
||||
elif entry['event'] == 'Progress':
|
||||
|
||||
self.state['Rank'].update({k: (v, 0) for k, v in payload.items()})
|
||||
|
||||
elif event_type == 'Progress':
|
||||
rank = self.state['Rank']
|
||||
for k, v in entry.items():
|
||||
if k in self.state['Rank']:
|
||||
self.state['Rank'][k] = (self.state['Rank'][k][0], min(v, 100)) # perhaps not taken promotion mission yet
|
||||
elif entry['event'] in ['Reputation', 'Statistics']:
|
||||
if k in rank:
|
||||
# perhaps not taken promotion mission yet
|
||||
rank[k] = (rank[k][0], min(v, 100))
|
||||
|
||||
elif event_type in ('Reputation', 'Statistics'):
|
||||
payload = OrderedDict(entry)
|
||||
payload.pop('event')
|
||||
payload.pop('timestamp')
|
||||
self.state[entry['event']] = payload
|
||||
self.state[event_type] = payload
|
||||
|
||||
elif entry['event'] == 'EngineerProgress':
|
||||
elif event_type == 'EngineerProgress':
|
||||
engineers = self.state['Engineers']
|
||||
if 'Engineers' in entry: # Startup summary
|
||||
self.state['Engineers'] = { e['Engineer']: (e['Rank'], e.get('RankProgress', 0)) if 'Rank' in e else e['Progress'] for e in entry['Engineers'] }
|
||||
else: # Promotion
|
||||
self.state['Engineers'][entry['Engineer']] = (entry['Rank'], entry.get('RankProgress', 0)) if 'Rank' in entry else entry['Progress']
|
||||
self.state['Engineers'] = {
|
||||
e['Engineer']: ((e['Rank'], e.get('RankProgress', 0)) if 'Rank' in e else e['Progress'])
|
||||
for e in entry['Engineers']
|
||||
}
|
||||
|
||||
elif entry['event'] == 'Cargo' and entry.get('Vessel') == 'Ship':
|
||||
else: # Promotion
|
||||
engineer = entry['Engineer']
|
||||
if 'Rank' in entry:
|
||||
engineers[engineer] = (entry['Rank'], entry.get('RankProgress', 0))
|
||||
|
||||
else:
|
||||
engineers[engineer] = entry['Progress']
|
||||
|
||||
elif event_type == 'Cargo' and entry.get('Vessel') == 'Ship':
|
||||
self.state['Cargo'] = defaultdict(int)
|
||||
if 'Inventory' not in entry: # From 3.3 full Cargo event (after the first one) is written to a separate file
|
||||
with open(join(self.currentdir, 'Cargo.json'), 'rb') as h:
|
||||
# From 3.3 full Cargo event (after the first one) is written to a separate file
|
||||
if 'Inventory' not in entry:
|
||||
with open(join(self.currentdir, 'Cargo.json'), 'rb') as h: # type: ignore
|
||||
entry = json.load(h, object_pairs_hook=OrderedDict) # Preserve property order because why not?
|
||||
|
||||
self.state['Cargo'].update({self.canonicalise(x['Name']): x['Count'] for x in entry['Inventory']})
|
||||
elif entry['event'] in ['CollectCargo', 'MarketBuy', 'BuyDrones', 'MiningRefined']:
|
||||
|
||||
elif event_type in ('CollectCargo', 'MarketBuy', 'BuyDrones', 'MiningRefined'):
|
||||
commodity = self.canonicalise(entry['Type'])
|
||||
self.state['Cargo'][commodity] += entry.get('Count', 1)
|
||||
elif entry['event'] in ['EjectCargo', 'MarketSell', 'SellDrones']:
|
||||
|
||||
elif event_type in ('EjectCargo', 'MarketSell', 'SellDrones'):
|
||||
commodity = self.canonicalise(entry['Type'])
|
||||
self.state['Cargo'][commodity] -= entry.get('Count', 1)
|
||||
if self.state['Cargo'][commodity] <= 0:
|
||||
self.state['Cargo'].pop(commodity)
|
||||
elif entry['event'] == 'SearchAndRescue':
|
||||
cargo = self.state['Cargo']
|
||||
cargo[commodity] -= entry.get('Count', 1)
|
||||
if cargo[commodity] <= 0:
|
||||
cargo.pop(commodity)
|
||||
|
||||
elif event_type == 'SearchAndRescue':
|
||||
for item in entry.get('Items', []):
|
||||
commodity = self.canonicalise(item['Name'])
|
||||
self.state['Cargo'][commodity] -= item.get('Count', 1)
|
||||
if self.state['Cargo'][commodity] <= 0:
|
||||
self.state['Cargo'].pop(commodity)
|
||||
cargo = self.state['Cargo']
|
||||
cargo[commodity] -= item.get('Count', 1)
|
||||
if cargo[commodity] <= 0:
|
||||
cargo.pop(commodity)
|
||||
|
||||
elif entry['event'] == 'Materials':
|
||||
for category in ['Raw', 'Manufactured', 'Encoded']:
|
||||
elif event_type == 'Materials':
|
||||
for category in ('Raw', 'Manufactured', 'Encoded'):
|
||||
self.state[category] = defaultdict(int)
|
||||
self.state[category].update({ self.canonicalise(x['Name']): x['Count'] for x in entry.get(category, []) })
|
||||
elif entry['event'] == 'MaterialCollected':
|
||||
self.state[category].update({
|
||||
self.canonicalise(x['Name']): x['Count'] for x in entry.get(category, [])
|
||||
})
|
||||
|
||||
elif event_type == 'MaterialCollected':
|
||||
material = self.canonicalise(entry['Name'])
|
||||
self.state[entry['Category']][material] += entry['Count']
|
||||
elif entry['event'] in ['MaterialDiscarded', 'ScientificResearch']:
|
||||
|
||||
elif event_type in ('MaterialDiscarded', 'ScientificResearch'):
|
||||
material = self.canonicalise(entry['Name'])
|
||||
self.state[entry['Category']][material] -= entry['Count']
|
||||
if self.state[entry['Category']][material] <= 0:
|
||||
self.state[entry['Category']].pop(material)
|
||||
elif entry['event'] == 'Synthesis':
|
||||
for category in ['Raw', 'Manufactured', 'Encoded']:
|
||||
state_category = self.state[entry['Category']]
|
||||
state_category[material] -= entry['Count']
|
||||
if state_category[material] <= 0:
|
||||
state_category.pop(material)
|
||||
|
||||
elif event_type == 'Synthesis':
|
||||
for category in ('Raw', 'Manufactured', 'Encoded'):
|
||||
for x in entry['Materials']:
|
||||
material = self.canonicalise(x['Name'])
|
||||
if material in self.state[category]:
|
||||
self.state[category][material] -= x['Count']
|
||||
if self.state[category][material] <= 0:
|
||||
self.state[category].pop(material)
|
||||
elif entry['event'] == 'MaterialTrade':
|
||||
category = self.category(entry['Paid']['Category'])
|
||||
self.state[category][entry['Paid']['Material']] -= entry['Paid']['Quantity']
|
||||
if self.state[category][entry['Paid']['Material']] <= 0:
|
||||
self.state[category].pop(entry['Paid']['Material'])
|
||||
category = self.category(entry['Received']['Category'])
|
||||
self.state[category][entry['Received']['Material']] += entry['Received']['Quantity']
|
||||
|
||||
elif entry['event'] == 'EngineerCraft' or (entry['event'] == 'EngineerLegacyConvert' and not entry.get('IsPreview')):
|
||||
for category in ['Raw', 'Manufactured', 'Encoded']:
|
||||
elif event_type == 'MaterialTrade':
|
||||
category = self.category(entry['Paid']['Category'])
|
||||
state_category = self.state[category]
|
||||
paid = entry['Paid']
|
||||
received = entry['Received']
|
||||
|
||||
state_category[paid['Material']] -= paid['Quantity']
|
||||
if state_category[paid['Material']] <= 0:
|
||||
state_category.pop(paid['Material'])
|
||||
|
||||
category = self.category(received['Category'])
|
||||
state_category[received['Material']] += received['Quantity']
|
||||
|
||||
elif event_type == 'EngineerCraft' or (
|
||||
event_type == 'EngineerLegacyConvert' and not entry.get('IsPreview')
|
||||
):
|
||||
|
||||
for category in ('Raw', 'Manufactured', 'Encoded'):
|
||||
for x in entry.get('Ingredients', []):
|
||||
material = self.canonicalise(x['Name'])
|
||||
if material in self.state[category]:
|
||||
self.state[category][material] -= x['Count']
|
||||
if self.state[category][material] <= 0:
|
||||
self.state[category].pop(material)
|
||||
|
||||
module = self.state['Modules'][entry['Slot']]
|
||||
assert(module['Item'] == self.canonicalise(entry['Module']))
|
||||
module['Engineering'] = {
|
||||
@ -567,48 +685,56 @@ class EDLogs(FileSystemEventHandler):
|
||||
'Quality': entry['Quality'],
|
||||
'Modifiers': entry['Modifiers'],
|
||||
}
|
||||
|
||||
if 'ExperimentalEffect' in entry:
|
||||
module['Engineering']['ExperimentalEffect'] = entry['ExperimentalEffect']
|
||||
module['Engineering']['ExperimentalEffect_Localised'] = entry['ExperimentalEffect_Localised']
|
||||
|
||||
else:
|
||||
module['Engineering'].pop('ExperimentalEffect', None)
|
||||
module['Engineering'].pop('ExperimentalEffect_Localised', None)
|
||||
|
||||
elif entry['event'] == 'MissionCompleted':
|
||||
elif event_type == 'MissionCompleted':
|
||||
for reward in entry.get('CommodityReward', []):
|
||||
commodity = self.canonicalise(reward['Name'])
|
||||
self.state['Cargo'][commodity] += reward.get('Count', 1)
|
||||
|
||||
for reward in entry.get('MaterialsReward', []):
|
||||
if 'Category' in reward: # Category not present in E:D 3.0
|
||||
category = self.category(reward['Category'])
|
||||
material = self.canonicalise(reward['Name'])
|
||||
self.state[category][material] += reward.get('Count', 1)
|
||||
elif entry['event'] == 'EngineerContribution':
|
||||
|
||||
elif event_type == 'EngineerContribution':
|
||||
commodity = self.canonicalise(entry.get('Commodity'))
|
||||
if commodity:
|
||||
self.state['Cargo'][commodity] -= entry['Quantity']
|
||||
if self.state['Cargo'][commodity] <= 0:
|
||||
self.state['Cargo'].pop(commodity)
|
||||
|
||||
material = self.canonicalise(entry.get('Material'))
|
||||
if material:
|
||||
for category in ['Raw', 'Manufactured', 'Encoded']:
|
||||
for category in ('Raw', 'Manufactured', 'Encoded'):
|
||||
if material in self.state[category]:
|
||||
self.state[category][material] -= entry['Quantity']
|
||||
if self.state[category][material] <= 0:
|
||||
self.state[category].pop(material)
|
||||
elif entry['event'] == 'TechnologyBroker':
|
||||
|
||||
elif event_type == 'TechnologyBroker':
|
||||
for thing in entry.get('Ingredients', []): # 3.01
|
||||
for category in ['Cargo', 'Raw', 'Manufactured', 'Encoded']:
|
||||
for category in ('Cargo', 'Raw', 'Manufactured', 'Encoded'):
|
||||
item = self.canonicalise(thing['Name'])
|
||||
if item in self.state[category]:
|
||||
self.state[category][item] -= thing['Count']
|
||||
if self.state[category][item] <= 0:
|
||||
self.state[category].pop(item)
|
||||
|
||||
for thing in entry.get('Commodities', []): # 3.02
|
||||
commodity = self.canonicalise(thing['Name'])
|
||||
self.state['Cargo'][commodity] -= thing['Count']
|
||||
if self.state['Cargo'][commodity] <= 0:
|
||||
self.state['Cargo'].pop(commodity)
|
||||
|
||||
for thing in entry.get('Materials', []): # 3.02
|
||||
material = self.canonicalise(thing['Name'])
|
||||
category = thing['Category']
|
||||
@ -616,7 +742,7 @@ class EDLogs(FileSystemEventHandler):
|
||||
if self.state[category][material] <= 0:
|
||||
self.state[category].pop(material)
|
||||
|
||||
elif entry['event'] == 'JoinACrew':
|
||||
elif event_type == 'JoinACrew':
|
||||
self.state['Captain'] = entry['Captain']
|
||||
self.state['Role'] = 'Idle'
|
||||
self.planet = None
|
||||
@ -627,9 +753,11 @@ class EDLogs(FileSystemEventHandler):
|
||||
self.stationservices = None
|
||||
self.coordinates = None
|
||||
self.systemaddress = None
|
||||
elif entry['event'] == 'ChangeCrewRole':
|
||||
|
||||
elif event_type == 'ChangeCrewRole':
|
||||
self.state['Role'] = entry['Role']
|
||||
elif entry['event'] == 'QuitACrew':
|
||||
|
||||
elif event_type == 'QuitACrew':
|
||||
self.state['Captain'] = None
|
||||
self.state['Role'] = None
|
||||
self.planet = None
|
||||
@ -641,38 +769,52 @@ class EDLogs(FileSystemEventHandler):
|
||||
self.coordinates = None
|
||||
self.systemaddress = None
|
||||
|
||||
elif entry['event'] == 'Friends':
|
||||
if entry['Status'] in ['Online', 'Added']:
|
||||
elif event_type == 'Friends':
|
||||
if entry['Status'] in ('Online', 'Added'):
|
||||
self.state['Friends'].add(entry['Name'])
|
||||
|
||||
else:
|
||||
self.state['Friends'].discard(entry['Name'])
|
||||
|
||||
return entry
|
||||
except:
|
||||
except Exception:
|
||||
if __debug__:
|
||||
print('Invalid journal entry "%s"' % repr(line))
|
||||
print('Invalid journal entry {!r}'.format(line))
|
||||
print_exc()
|
||||
|
||||
return {'event': None}
|
||||
|
||||
# Commodities, Modules and Ships can appear in different forms e.g. "$HNShockMount_Name;", "HNShockMount", and "hnshockmount",
|
||||
# "$int_cargorack_size6_class1_name;" and "Int_CargoRack_Size6_Class1", "python" and "Python", etc.
|
||||
# Commodities, Modules and Ships can appear in different forms e.g. "$HNShockMount_Name;", "HNShockMount",
|
||||
# and "hnshockmount", "$int_cargorack_size6_class1_name;" and "Int_CargoRack_Size6_Class1",
|
||||
# "python" and "Python", etc.
|
||||
# This returns a simple lowercased name e.g. 'hnshockmount', 'int_cargorack_size6_class1', 'python', etc
|
||||
def canonicalise(self, item):
|
||||
if not item: return ''
|
||||
def canonicalise(self, item: Optional[str]):
|
||||
if not item:
|
||||
return ''
|
||||
|
||||
item = item.lower()
|
||||
match = self._RE_CANONICALISE.match(item)
|
||||
return match and match.group(1) or item
|
||||
|
||||
def category(self, item):
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
return item
|
||||
|
||||
def category(self, item: str):
|
||||
match = self._RE_CATEGORY.match(item)
|
||||
return (match and match.group(1) or item).capitalize()
|
||||
|
||||
if match:
|
||||
return match.group(1).capitalize()
|
||||
|
||||
return item.capitalize()
|
||||
|
||||
def get_entry(self):
|
||||
if not self.event_queue:
|
||||
return None
|
||||
|
||||
else:
|
||||
entry = self.parse_entry(self.event_queue.pop(0))
|
||||
if not self.live and entry['event'] not in [None, 'Fileheader']:
|
||||
if not self.live and entry['event'] not in (None, 'Fileheader'):
|
||||
# Game not running locally, but Journal has been updated
|
||||
self.live = True
|
||||
if self.station:
|
||||
@ -687,6 +829,7 @@ class EDLogs(FileSystemEventHandler):
|
||||
('StarPos', self.coordinates),
|
||||
('SystemAddress', self.systemaddress),
|
||||
])
|
||||
|
||||
else:
|
||||
entry = OrderedDict([
|
||||
('timestamp', strftime('%Y-%m-%dT%H:%M:%SZ', gmtime())),
|
||||
@ -696,25 +839,28 @@ class EDLogs(FileSystemEventHandler):
|
||||
('StarPos', self.coordinates),
|
||||
('SystemAddress', self.systemaddress),
|
||||
])
|
||||
|
||||
self.event_queue.append(json.dumps(entry, separators=(', ', ':')))
|
||||
|
||||
elif self.live and entry['event'] == 'Music' and entry.get('MusicTrack') == 'MainMenu':
|
||||
self.event_queue.append('{ "timestamp":"%s", "event":"ShutDown" }' % strftime('%Y-%m-%dT%H:%M:%SZ', gmtime()))
|
||||
self.event_queue.append(
|
||||
'{{ "timestamp":"{}", "event":"ShutDown" }}'.format(strftime('%Y-%m-%dT%H:%M:%SZ', gmtime()))
|
||||
)
|
||||
|
||||
return entry
|
||||
|
||||
def game_running(self):
|
||||
|
||||
if platform == 'darwin':
|
||||
for app in NSWorkspace.sharedWorkspace().runningApplications():
|
||||
if app.bundleIdentifier() == 'uk.co.frontier.EliteDangerous':
|
||||
return True
|
||||
|
||||
elif platform == 'win32':
|
||||
|
||||
def WindowTitle(h):
|
||||
if h:
|
||||
l = GetWindowTextLength(h) + 1
|
||||
buf = ctypes.create_unicode_buffer(l)
|
||||
if GetWindowText(h, buf, l):
|
||||
length = GetWindowTextLength(h) + 1
|
||||
buf = ctypes.create_unicode_buffer(length)
|
||||
if GetWindowText(h, buf, length):
|
||||
return buf.value
|
||||
return None
|
||||
|
||||
@ -725,59 +871,80 @@ class EDLogs(FileSystemEventHandler):
|
||||
if handle: # If GetProcessHandleFromHwnd succeeds then the app is already running as this user
|
||||
CloseHandle(handle)
|
||||
return False # stop enumeration
|
||||
|
||||
return True
|
||||
|
||||
return not EnumWindows(EnumWindowsProc(callback), 0)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
# Return a subset of the received data describing the current ship as a Loadout event
|
||||
def ship(self, timestamped=True):
|
||||
if not self.state['Modules']:
|
||||
return None
|
||||
|
||||
standard_order = ['ShipCockpit', 'CargoHatch', 'Armour', 'PowerPlant', 'MainEngines', 'FrameShiftDrive', 'LifeSupport', 'PowerDistributor', 'Radar', 'FuelTank']
|
||||
standard_order = (
|
||||
'ShipCockpit', 'CargoHatch', 'Armour', 'PowerPlant', 'MainEngines', 'FrameShiftDrive', 'LifeSupport',
|
||||
'PowerDistributor', 'Radar', 'FuelTank'
|
||||
)
|
||||
|
||||
d = OrderedDict()
|
||||
if timestamped:
|
||||
d['timestamp'] = strftime('%Y-%m-%dT%H:%M:%SZ', gmtime())
|
||||
|
||||
d['event'] = 'Loadout'
|
||||
d['Ship'] = self.state['ShipType']
|
||||
d['ShipID'] = self.state['ShipID']
|
||||
|
||||
if self.state['ShipName']:
|
||||
d['ShipName'] = self.state['ShipName']
|
||||
|
||||
if self.state['ShipIdent']:
|
||||
d['ShipIdent'] = self.state['ShipIdent']
|
||||
|
||||
# sort modules by slot - hardpoints, standard, internal
|
||||
d['Modules'] = []
|
||||
for slot in sorted(self.state['Modules'], key=lambda x: ('Hardpoint' not in x, x not in standard_order and len(standard_order) or standard_order.index(x), 'Slot' not in x, x)):
|
||||
|
||||
for slot in sorted(
|
||||
self.state['Modules'],
|
||||
key=lambda x: (
|
||||
'Hardpoint' not in x,
|
||||
len(standard_order) if x not in standard_order else standard_order.index(x),
|
||||
'Slot' not in x,
|
||||
x
|
||||
)
|
||||
):
|
||||
|
||||
module = dict(self.state['Modules'][slot])
|
||||
module.pop('Health', None)
|
||||
module.pop('Value', None)
|
||||
d['Modules'].append(module)
|
||||
return d
|
||||
|
||||
return d
|
||||
|
||||
# Export ship loadout as a Loadout event
|
||||
def export_ship(self, filename=None):
|
||||
# TODO(A_D): Some type checking has been disabled in here due to config.get getting weird outputs
|
||||
string = json.dumps(self.ship(False), ensure_ascii=False, indent=2, separators=(',', ': ')) # pretty print
|
||||
|
||||
if filename:
|
||||
with open(filename, 'wt') as h:
|
||||
h.write(string)
|
||||
|
||||
return
|
||||
|
||||
ship = ship_file_name(self.state['ShipName'], self.state['ShipType'])
|
||||
regexp = re.compile(re.escape(ship) + '\.\d\d\d\d\-\d\d\-\d\dT\d\d\.\d\d\.\d\d\.txt')
|
||||
oldfiles = sorted([x for x in listdir(config.get('outdir')) if regexp.match(x)])
|
||||
regexp = re.compile(re.escape(ship) + r'\.\d{4}\-\d\d\-\d\dT\d\d\.\d\d\.\d\d\.txt')
|
||||
oldfiles = sorted((x for x in listdir(config.get('outdir')) if regexp.match(x))) # type: ignore
|
||||
if oldfiles:
|
||||
with open(join(config.get('outdir'), oldfiles[-1]), 'rU') as h:
|
||||
with open(join(config.get('outdir'), oldfiles[-1]), 'rU') as h: # type: ignore
|
||||
if h.read() == string:
|
||||
return # same as last time - don't write
|
||||
|
||||
# Write
|
||||
filename = join(config.get('outdir'), '%s.%s.txt' % (ship, strftime('%Y-%m-%dT%H.%M.%S', localtime(time()))))
|
||||
filename = join( # type: ignore
|
||||
config.get('outdir'), '{}.{}.txt'.format(ship, strftime('%Y-%m-%dT%H.%M.%S', localtime(time())))
|
||||
)
|
||||
|
||||
with open(filename, 'wt') as h:
|
||||
h.write(string)
|
||||
|
||||
|
84
plug.py
84
plug.py
@ -7,15 +7,17 @@ import os
|
||||
import importlib
|
||||
import sys
|
||||
import operator
|
||||
import threading # We don't use it, but plugins might
|
||||
from traceback import print_exc
|
||||
|
||||
import threading # noqa: F401 - We don't use it, but plugins might
|
||||
from typing import Optional
|
||||
import logging
|
||||
import tkinter as tk
|
||||
import myNotebook as nb
|
||||
|
||||
from config import config
|
||||
from time import time as time
|
||||
import myNotebook as nb # noqa: N813
|
||||
|
||||
from config import config, appname
|
||||
import EDMCLogging
|
||||
|
||||
logger = logging.getLogger(appname)
|
||||
|
||||
# Dashboard Flags constants
|
||||
FlagsDocked = 1 << 0 # on a landing pad
|
||||
@ -79,7 +81,7 @@ last_error = {
|
||||
|
||||
class Plugin(object):
|
||||
|
||||
def __init__(self, name, loadfile):
|
||||
def __init__(self, name: str, loadfile: str, plugin_logger: Optional[logging.Logger]):
|
||||
"""
|
||||
Load a single plugin
|
||||
:param name: module name
|
||||
@ -90,25 +92,28 @@ class Plugin(object):
|
||||
self.name = name # Display name.
|
||||
self.folder = name # basename of plugin folder. None for internal plugins.
|
||||
self.module = None # None for disabled plugins.
|
||||
self.logger = plugin_logger
|
||||
|
||||
if loadfile:
|
||||
sys.stdout.write('loading plugin {} from "{}"\n'.format(name.replace('.', '_'), loadfile))
|
||||
logger.info(f'loading plugin "{name.replace(".", "_")}" from "{loadfile}"')
|
||||
try:
|
||||
module = importlib.machinery.SourceFileLoader('plugin_{}'.format(name.encode(encoding='ascii', errors='replace').decode('utf-8').replace('.', '_')), loadfile).load_module()
|
||||
module = importlib.machinery.SourceFileLoader('plugin_{}'.format(
|
||||
name.encode(encoding='ascii', errors='replace').decode('utf-8').replace('.', '_')),
|
||||
loadfile).load_module()
|
||||
if getattr(module, 'plugin_start3', None):
|
||||
newname = module.plugin_start3(os.path.dirname(loadfile))
|
||||
self.name = newname and str(newname) or name
|
||||
self.module = module
|
||||
elif getattr(module, 'plugin_start', None):
|
||||
sys.stdout.write('plugin %s needs migrating\n' % name)
|
||||
logger.warning(f'plugin {name} needs migrating\n')
|
||||
PLUGINS_not_py3.append(self)
|
||||
else:
|
||||
sys.stdout.write('plugin %s has no plugin_start3() function\n' % name)
|
||||
except:
|
||||
print_exc()
|
||||
logger.error(f'plugin {name} has no plugin_start3() function')
|
||||
except Exception as e:
|
||||
logger.exception(f': Failed for Plugin "{name}"')
|
||||
raise
|
||||
else:
|
||||
sys.stdout.write('plugin %s disabled\n' % name)
|
||||
logger.info(f'plugin {name} disabled')
|
||||
|
||||
def _get_func(self, funcname):
|
||||
"""
|
||||
@ -136,8 +141,8 @@ class Plugin(object):
|
||||
elif not isinstance(appitem, tk.Widget):
|
||||
raise AssertionError
|
||||
return appitem
|
||||
except:
|
||||
print_exc()
|
||||
except Exception as e:
|
||||
logger.exception(f'Failed for Plugin "{self.name}"')
|
||||
return None
|
||||
|
||||
def get_prefs(self, parent, cmdr, is_beta):
|
||||
@ -156,8 +161,8 @@ class Plugin(object):
|
||||
if not isinstance(frame, nb.Frame):
|
||||
raise AssertionError
|
||||
return frame
|
||||
except:
|
||||
print_exc()
|
||||
except Exception as e:
|
||||
logger.exception(f'Failed for Plugin "{self.name}"')
|
||||
return None
|
||||
|
||||
|
||||
@ -171,11 +176,11 @@ def load_plugins(master):
|
||||
for name in sorted(os.listdir(config.internal_plugin_dir)):
|
||||
if name.endswith('.py') and not name[0] in ['.', '_']:
|
||||
try:
|
||||
plugin = Plugin(name[:-3], os.path.join(config.internal_plugin_dir, name))
|
||||
plugin = Plugin(name[:-3], os.path.join(config.internal_plugin_dir, name), logger)
|
||||
plugin.folder = None # Suppress listing in Plugins prefs tab
|
||||
internal.append(plugin)
|
||||
except:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.exception(f'Failure loading internal Plugin "{name}"')
|
||||
PLUGINS.extend(sorted(internal, key=lambda p: operator.attrgetter('name')(p).lower()))
|
||||
|
||||
# Add plugin folder to load path so packages can be loaded from plugin folder
|
||||
@ -189,16 +194,23 @@ def load_plugins(master):
|
||||
pass
|
||||
elif name.endswith('.disabled'):
|
||||
name, discard = name.rsplit('.', 1)
|
||||
found.append(Plugin(name, None))
|
||||
found.append(Plugin(name, None, logger))
|
||||
else:
|
||||
try:
|
||||
# Add plugin's folder to load path in case plugin has internal package dependencies
|
||||
sys.path.append(os.path.join(config.plugin_dir, name))
|
||||
found.append(Plugin(name, os.path.join(config.plugin_dir, name, 'load.py')))
|
||||
except:
|
||||
|
||||
# Create a logger for this 'found' plugin. Must be before the
|
||||
# load.py is loaded.
|
||||
plugin_logger = EDMCLogging.get_plugin_logger(f'{appname}.{name}')
|
||||
|
||||
found.append(Plugin(name, os.path.join(config.plugin_dir, name, 'load.py'), plugin_logger))
|
||||
except Exception as e:
|
||||
logger.exception(f'Failure loading found Plugin "{name}"')
|
||||
pass
|
||||
PLUGINS.extend(sorted(found, key=lambda p: operator.attrgetter('name')(p).lower()))
|
||||
|
||||
|
||||
def provides(fn_name):
|
||||
"""
|
||||
Find plugins that provide a function
|
||||
@ -240,8 +252,8 @@ def notify_stop():
|
||||
try:
|
||||
newerror = plugin_stop()
|
||||
error = error or newerror
|
||||
except:
|
||||
print_exc()
|
||||
except Exception as e:
|
||||
logger.exception(f'Plugin "{plugin.name}" failed')
|
||||
return error
|
||||
|
||||
|
||||
@ -257,8 +269,8 @@ def notify_prefs_cmdr_changed(cmdr, is_beta):
|
||||
if prefs_cmdr_changed:
|
||||
try:
|
||||
prefs_cmdr_changed(cmdr, is_beta)
|
||||
except:
|
||||
print_exc()
|
||||
except Exception as e:
|
||||
logger.exception(f'Plugin "{plugin.name}" failed')
|
||||
|
||||
|
||||
def notify_prefs_changed(cmdr, is_beta):
|
||||
@ -275,8 +287,8 @@ def notify_prefs_changed(cmdr, is_beta):
|
||||
if prefs_changed:
|
||||
try:
|
||||
prefs_changed(cmdr, is_beta)
|
||||
except:
|
||||
print_exc()
|
||||
except Exception as e:
|
||||
logger.exception(f'Plugin "{plugin.name}" failed')
|
||||
|
||||
|
||||
def notify_journal_entry(cmdr, is_beta, system, station, entry, state):
|
||||
@ -298,8 +310,8 @@ def notify_journal_entry(cmdr, is_beta, system, station, entry, state):
|
||||
# Pass a copy of the journal entry in case the callee modifies it
|
||||
newerror = journal_entry(cmdr, is_beta, system, station, dict(entry), dict(state))
|
||||
error = error or newerror
|
||||
except:
|
||||
print_exc()
|
||||
except Exception as e:
|
||||
logger.exception(f'Plugin "{plugin.name}" failed')
|
||||
return error
|
||||
|
||||
|
||||
@ -319,8 +331,8 @@ def notify_dashboard_entry(cmdr, is_beta, entry):
|
||||
# Pass a copy of the status entry in case the callee modifies it
|
||||
newerror = status(cmdr, is_beta, dict(entry))
|
||||
error = error or newerror
|
||||
except:
|
||||
print_exc()
|
||||
except Exception as e:
|
||||
logger.exception(f'Plugin "{plugin.name}" failed')
|
||||
return error
|
||||
|
||||
|
||||
@ -338,8 +350,8 @@ def notify_newdata(data, is_beta):
|
||||
try:
|
||||
newerror = cmdr_data(data, is_beta)
|
||||
error = error or newerror
|
||||
except:
|
||||
print_exc()
|
||||
except Exception as e:
|
||||
logger.exception(f'Plugin "{plugin.name}" failed')
|
||||
return error
|
||||
|
||||
|
||||
|
@ -7,17 +7,21 @@ import io
|
||||
|
||||
# Migrate settings from <= 3.01
|
||||
from config import config
|
||||
|
||||
if not config.get('shipyard_provider') and config.getint('shipyard'):
|
||||
config.set('shipyard_provider', 'Coriolis')
|
||||
|
||||
config.delete('shipyard')
|
||||
|
||||
|
||||
def plugin_start3(plugin_dir):
|
||||
def plugin_start3(_):
|
||||
return 'Coriolis'
|
||||
|
||||
# Return a URL for the current ship
|
||||
|
||||
def shipyard_url(loadout, is_beta):
|
||||
string = json.dumps(loadout, ensure_ascii=False, sort_keys=True, separators=(',', ':')).encode('utf-8') # most compact representation
|
||||
"""Return a URL for the current ship"""
|
||||
# most compact representation
|
||||
string = json.dumps(loadout, ensure_ascii=False, sort_keys=True, separators=(',', ':')).encode('utf-8')
|
||||
if not string:
|
||||
return False
|
||||
|
||||
@ -25,4 +29,7 @@ def shipyard_url(loadout, is_beta):
|
||||
with gzip.GzipFile(fileobj=out, mode='w') as f:
|
||||
f.write(string)
|
||||
|
||||
return (is_beta and 'https://beta.coriolis.io/import?data=' or 'https://coriolis.io/import?data=') + base64.urlsafe_b64encode(out.getvalue()).decode().replace('=', '%3D')
|
||||
encoded = base64.urlsafe_b64encode(out.getvalue()).decode().replace('=', '%3D')
|
||||
url = 'https://beta.coriolis.io/import?data=' if is_beta else 'https://coriolis.io/import?data='
|
||||
|
||||
return f'{url}{encoded}'
|
||||
|
@ -24,23 +24,27 @@
|
||||
|
||||
|
||||
import sys
|
||||
from typing import Any, Optional, TYPE_CHECKING
|
||||
import requests
|
||||
|
||||
from config import config
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from tkinter import Tk
|
||||
|
||||
STATION_UNDOCKED: str = u'×' # "Station" name to display when not docked = U+00D7
|
||||
|
||||
this = sys.modules[__name__] # For holding module globals
|
||||
STATION_UNDOCKED: str = '×' # "Station" name to display when not docked = U+00D7
|
||||
|
||||
this: Any = sys.modules[__name__] # For holding module globals
|
||||
|
||||
# Main window clicks
|
||||
this.system_link = None
|
||||
this.system = None
|
||||
this.system_address = None
|
||||
this.system_population = None
|
||||
this.station_link = None
|
||||
this.station = None
|
||||
this.station_marketid = None
|
||||
this.system_link: Optional[str] = None
|
||||
this.system: Optional[str] = None
|
||||
this.system_address: Optional[str] = None
|
||||
this.system_population: Optional[int] = None
|
||||
this.station_link: 'Optional[Tk]' = None
|
||||
this.station: Optional[str] = None
|
||||
this.station_marketid: Optional[int] = None
|
||||
|
||||
|
||||
def system_url(system_name: str) -> str:
|
||||
@ -61,7 +65,8 @@ def station_url(system_name: str, station_name: str) -> str:
|
||||
def plugin_start3(plugin_dir):
|
||||
return 'eddb'
|
||||
|
||||
def plugin_app(parent):
|
||||
|
||||
def plugin_app(parent: 'Tk'):
|
||||
this.system_link = parent.children['system'] # system label in main window
|
||||
this.system = None
|
||||
this.system_address = None
|
||||
@ -75,7 +80,6 @@ def prefs_changed(cmdr, is_beta):
|
||||
# through correctly. We don't want a static string.
|
||||
pass
|
||||
|
||||
|
||||
def journal_entry(cmdr, is_beta, system, station, entry, state):
|
||||
# Always update our system address even if we're not currently the provider for system or station, but dont update
|
||||
# on events that contain "future" data, such as FSDTarget
|
||||
@ -105,7 +109,15 @@ def journal_entry(cmdr, is_beta, system, station, entry, state):
|
||||
|
||||
# But only actually change the URL if we are current station provider.
|
||||
if config.get('station_provider') == 'eddb':
|
||||
this.station_link['text'] = this.station or (this.system_population and this.system_population > 0 and STATION_UNDOCKED or '')
|
||||
text = this.station
|
||||
if not text:
|
||||
if this.system_population is not None and this.system_population > 0:
|
||||
text = STATION_UNDOCKED
|
||||
|
||||
else:
|
||||
text = ''
|
||||
|
||||
this.station_link['text'] = 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()
|
||||
@ -113,11 +125,15 @@ def journal_entry(cmdr, is_beta, system, station, entry, state):
|
||||
|
||||
def cmdr_data(data, is_beta):
|
||||
# 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']
|
||||
if not this.station_marketid and data['commander']['docked']:
|
||||
this.station_marketid = data['lastStarport']['id']
|
||||
|
||||
# Only trust CAPI if these aren't yet set
|
||||
this.system = this.system or data['lastSystem']['name']
|
||||
this.station = this.station or data['commander']['docked'] and data['lastStarport']['name']
|
||||
if not this.system:
|
||||
this.system = data['lastSystem']['name']
|
||||
|
||||
if not this.station and data['commander']['docked']:
|
||||
this.station = data['lastStarport']['name']
|
||||
|
||||
# Override standard URL functions
|
||||
if config.get('system_provider') == 'eddb':
|
||||
@ -125,15 +141,17 @@ def cmdr_data(data, is_beta):
|
||||
# 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('station_provider') == 'eddb':
|
||||
if data['commander']['docked']:
|
||||
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()
|
||||
|
||||
|
542
plugins/eddn.py
542
plugins/eddn.py
@ -1,32 +1,40 @@
|
||||
# Export to EDDN
|
||||
|
||||
from collections import OrderedDict
|
||||
import itertools
|
||||
import json
|
||||
from os import SEEK_SET, SEEK_CUR, SEEK_END
|
||||
import logging
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
import tkinter as tk
|
||||
from collections import OrderedDict
|
||||
from os import SEEK_SET
|
||||
from os.path import exists, join
|
||||
from platform import system
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Any, AnyStr, Dict, Iterator, List, Mapping, MutableMapping, Optional
|
||||
from typing import OrderedDict as OrderedDictT
|
||||
from typing import Sequence, TextIO, Tuple, Union
|
||||
|
||||
import requests
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
import tkinter as tk
|
||||
from ttkHyperlinkLabel import HyperlinkLabel
|
||||
import myNotebook as nb
|
||||
|
||||
import myNotebook as nb # noqa: N813
|
||||
from companion import category_map
|
||||
from config import applongname, appname, appversion, config
|
||||
from myNotebook import Frame
|
||||
from prefs import prefsVersion
|
||||
from ttkHyperlinkLabel import HyperlinkLabel
|
||||
|
||||
if sys.platform != 'win32':
|
||||
from fcntl import lockf, LOCK_EX, LOCK_NB
|
||||
|
||||
if __debug__:
|
||||
from traceback import print_exc
|
||||
|
||||
from config import applongname, appversion, config
|
||||
from companion import category_map
|
||||
if TYPE_CHECKING:
|
||||
def _(x: str) -> str:
|
||||
return x
|
||||
|
||||
logger = logging.getLogger(appname)
|
||||
|
||||
this = sys.modules[__name__] # For holding module globals
|
||||
this: Any = sys.modules[__name__] # For holding module globals
|
||||
|
||||
# Track location to add to Journal events
|
||||
this.systemaddress = None
|
||||
@ -35,130 +43,180 @@ this.planet = None
|
||||
|
||||
# Avoid duplicates
|
||||
this.marketId = None
|
||||
this.commodities = this.outfitting = this.shipyard = None
|
||||
this.commodities = None
|
||||
this.outfitting: Optional[Tuple[bool, MutableMapping[str, Any]]] = None
|
||||
this.shipyard = None
|
||||
|
||||
HORIZ_SKU = 'ELITE_HORIZONS_V_PLANETARY_LANDINGS'
|
||||
|
||||
|
||||
class EDDN(object):
|
||||
# TODO: a good few of these methods are static or could be classmethods. they should be created as such.
|
||||
|
||||
### SERVER = 'http://localhost:8081' # testing
|
||||
class EDDN:
|
||||
# SERVER = 'http://localhost:8081' # testing
|
||||
SERVER = 'https://eddn.edcd.io:4430'
|
||||
UPLOAD = '%s/upload/' % SERVER
|
||||
UPLOAD = f'{SERVER}/upload/'
|
||||
REPLAYPERIOD = 400 # Roughly two messages per second, accounting for send delays [ms]
|
||||
REPLAYFLUSH = 20 # Update log on disk roughly every 10 seconds
|
||||
TIMEOUT = 10 # requests timeout
|
||||
MODULE_RE = re.compile('^Hpt_|^Int_|Armour_', re.IGNORECASE)
|
||||
MODULE_RE = re.compile(r'^Hpt_|^Int_|Armour_', re.IGNORECASE)
|
||||
CANONICALISE_RE = re.compile(r'\$(.+)_name;')
|
||||
|
||||
def __init__(self, parent):
|
||||
self.parent = parent
|
||||
def __init__(self, parent: tk.Tk):
|
||||
self.parent: tk.Tk = parent
|
||||
self.session = requests.Session()
|
||||
self.replayfile = None # For delayed messages
|
||||
self.replaylog = []
|
||||
self.replayfile: Optional[TextIO] = None # For delayed messages
|
||||
self.replaylog: List[str] = []
|
||||
|
||||
def load(self):
|
||||
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:
|
||||
|
||||
except Exception:
|
||||
if exists(filename):
|
||||
raise # Couldn't open existing file
|
||||
|
||||
else:
|
||||
self.replayfile = open(filename, 'w+', buffering=1) # Create file
|
||||
|
||||
if sys.platform != 'win32': # open for writing is automatically exclusive on Windows
|
||||
lockf(self.replayfile, LOCK_EX | LOCK_NB)
|
||||
except:
|
||||
if __debug__: print_exc()
|
||||
|
||||
except Exception as e:
|
||||
logger.debug('Failed opening "replay.jsonl"', exc_info=e)
|
||||
if self.replayfile:
|
||||
self.replayfile.close()
|
||||
|
||||
self.replayfile = None
|
||||
return False
|
||||
|
||||
self.replaylog = [line.strip() for line in self.replayfile]
|
||||
return True
|
||||
|
||||
def flush(self):
|
||||
"""
|
||||
flush flushes the replay file, clearing any data currently there that is not in the replaylog list
|
||||
"""
|
||||
self.replayfile.seek(0, SEEK_SET)
|
||||
self.replayfile.truncate()
|
||||
for line in self.replaylog:
|
||||
self.replayfile.write('%s\n' % line)
|
||||
self.replayfile.write(f'{line}\n')
|
||||
|
||||
self.replayfile.flush()
|
||||
|
||||
def close(self):
|
||||
"""
|
||||
close closes the replay file
|
||||
"""
|
||||
if self.replayfile:
|
||||
self.replayfile.close()
|
||||
|
||||
self.replayfile = None
|
||||
|
||||
def send(self, cmdr, msg):
|
||||
uploaderID = cmdr
|
||||
def send(self, cmdr: str, msg: Mapping[str, Any]) -> None:
|
||||
"""
|
||||
Send sends an update to EDDN
|
||||
|
||||
msg = OrderedDict([
|
||||
:param cmdr: the CMDR to use as the uploader ID
|
||||
:param msg: the payload to send
|
||||
"""
|
||||
uploader_id = cmdr
|
||||
|
||||
to_send: OrderedDictT[str, str] = OrderedDict([
|
||||
('$schemaRef', msg['$schemaRef']),
|
||||
('header', OrderedDict([
|
||||
('softwareName', '%s [%s]' % (applongname, sys.platform=='darwin' and "Mac OS" or system())),
|
||||
('softwareName', f'{applongname} [{system() if sys.platform != "darwin" else "Mac OS"}]'),
|
||||
('softwareVersion', appversion),
|
||||
('uploaderID', uploaderID),
|
||||
('uploaderID', uploader_id),
|
||||
])),
|
||||
('message', msg['message']),
|
||||
])
|
||||
|
||||
r = self.session.post(self.UPLOAD, data=json.dumps(msg), timeout=self.TIMEOUT)
|
||||
if __debug__ and r.status_code != requests.codes.ok:
|
||||
print('Status\t%s' % r.status_code)
|
||||
print('URL\t%s' % r.url)
|
||||
print('Headers\t%s' % r.headers)
|
||||
print('Content:\n%s' % r.text)
|
||||
r = self.session.post(self.UPLOAD, data=json.dumps(to_send), timeout=self.TIMEOUT)
|
||||
if r.status_code != requests.codes.ok:
|
||||
logger.debug(f':\nStatus\t{r.status_code}URL\t{r.url}Headers\t{r.headers}Content:\n{r.text}')
|
||||
|
||||
r.raise_for_status()
|
||||
|
||||
def sendreplay(self):
|
||||
def sendreplay(self) -> None:
|
||||
"""
|
||||
sendreplay updates EDDN with cached journal lines
|
||||
"""
|
||||
if not self.replayfile:
|
||||
return # Probably closing app
|
||||
|
||||
status = self.parent.children['status']
|
||||
status: Dict[str, Any] = self.parent.children['status']
|
||||
|
||||
if not self.replaylog:
|
||||
status['text'] = ''
|
||||
return
|
||||
|
||||
localized: str = _('Sending data to EDDN...')
|
||||
if len(self.replaylog) == 1:
|
||||
status['text'] = _('Sending data to EDDN...')
|
||||
status['text'] = localized
|
||||
|
||||
else:
|
||||
status['text'] = '%s [%d]' % (_('Sending data to EDDN...').replace('...',''), len(self.replaylog))
|
||||
status['text'] = f'{localized.replace("...", "")} [{len(self.replaylog)}]'
|
||||
|
||||
self.parent.update_idletasks()
|
||||
|
||||
try:
|
||||
cmdr, msg = json.loads(self.replaylog[0], object_pairs_hook=OrderedDict)
|
||||
except:
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
# Couldn't decode - shouldn't happen!
|
||||
if __debug__:
|
||||
print(self.replaylog[0])
|
||||
print_exc()
|
||||
self.replaylog.pop(0) # Discard and continue
|
||||
logger.debug(f'\n{self.replaylog[0]}\n', exc_info=e)
|
||||
# Discard and continue
|
||||
self.replaylog.pop(0)
|
||||
|
||||
else:
|
||||
# Rewrite old schema name
|
||||
if msg['$schemaRef'].startswith('http://schemas.elite-markets.net/eddn/'):
|
||||
msg['$schemaRef'] = 'https://eddn.edcd.io/schemas/' + msg['$schemaRef'][38:]
|
||||
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.RequestException as e:
|
||||
if __debug__: print_exc()
|
||||
logger.debug('Failed sending', exc_info=e)
|
||||
status['text'] = _("Error: Can't connect to EDDN")
|
||||
return # stop sending
|
||||
|
||||
except Exception as e:
|
||||
if __debug__: print_exc()
|
||||
logger.debug('Failed sending', exc_info=e)
|
||||
status['text'] = str(e)
|
||||
return # stop sending
|
||||
|
||||
self.parent.after(self.REPLAYPERIOD, self.sendreplay)
|
||||
|
||||
def export_commodities(self, data, is_beta):
|
||||
commodities = []
|
||||
def export_commodities(self, data: Mapping[str, Any], is_beta: bool) -> None:
|
||||
"""
|
||||
export_commodities updates EDDN with the commodities on the current (lastStarport) station.
|
||||
Once the send is complete, this.commodities is updated with the new data.
|
||||
|
||||
:param data: a dict containing the starport data
|
||||
:param is_beta: whether or not we're currently in beta mode
|
||||
"""
|
||||
commodities: List[OrderedDictT[str, Any]] = []
|
||||
for commodity in data['lastStarport'].get('commodities') or []:
|
||||
if (category_map.get(commodity['categoryname'], True) and # Check marketable
|
||||
not commodity.get('legality')): # check not prohibited
|
||||
# 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'])),
|
||||
@ -169,40 +227,67 @@ class EDDN(object):
|
||||
('demand', int(commodity['demand'])),
|
||||
('demandBracket', commodity['demandBracket']),
|
||||
]))
|
||||
|
||||
if commodity['statusFlags']:
|
||||
commodities[-1]['statusFlags'] = commodity['statusFlags']
|
||||
|
||||
commodities.sort(key=lambda c: c['name'])
|
||||
|
||||
if commodities and this.commodities != commodities: # Don't send empty commodities list - schema won't allow it
|
||||
message = OrderedDict([
|
||||
message: OrderedDictT[str, Any] = OrderedDict([
|
||||
('timestamp', data['timestamp']),
|
||||
('systemName', data['lastSystem']['name']),
|
||||
('stationName', data['lastStarport']['name']),
|
||||
('marketId', data['lastStarport']['id']),
|
||||
('commodities', commodities),
|
||||
])
|
||||
|
||||
if 'economies' in data['lastStarport']:
|
||||
message['economies'] = sorted(list([x for x in (data['lastStarport']['economies'] or {}).values()]), key=lambda x: x['name'])
|
||||
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(list([x for x in (data['lastStarport']['prohibited'] or {}).values()]))
|
||||
message['prohibited'] = sorted(x for x in (data['lastStarport']['prohibited'] or {}).values())
|
||||
|
||||
self.send(data['commander']['name'], {
|
||||
'$schemaRef' : 'https://eddn.edcd.io/schemas/commodity/3' + (is_beta and '/test' or ''),
|
||||
'$schemaRef': f'https://eddn.edcd.io/schemas/commodity/3{"/test" if is_beta else ""}',
|
||||
'message': message,
|
||||
})
|
||||
|
||||
this.commodities = commodities
|
||||
|
||||
def export_outfitting(self, data, is_beta):
|
||||
economies = data['lastStarport'].get('economies') or {}
|
||||
modules = data['lastStarport'].get('modules') or {}
|
||||
ships = data['lastStarport'].get('ships') or { 'shipyard_list': {}, 'unavailable_list': [] }
|
||||
# Horizons flag - will hit at least Int_PlanetApproachSuite other than at engineer bases ("Colony"), prison or rescue Megaships, or under Pirate Attack etc
|
||||
horizons = (any(economy['name'] == 'Colony' for economy in economies.values()) or
|
||||
any(module.get('sku') == 'ELITE_HORIZONS_V_PLANETARY_LANDINGS' for module in modules.values()) or
|
||||
any(ship.get('sku') == 'ELITE_HORIZONS_V_PLANETARY_LANDINGS' for ship in list((ships['shipyard_list'] or {}).values())))
|
||||
outfitting = sorted([self.MODULE_RE.sub(lambda m: m.group(0).capitalize(), module['name'].lower()) for module in modules.values() if self.MODULE_RE.search(module['name']) and module.get('sku') in [None, 'ELITE_HORIZONS_V_PLANETARY_LANDINGS'] and module['name'] != 'Int_PlanetApproachSuite'])
|
||||
if outfitting and this.outfitting != (horizons, outfitting): # Don't send empty modules list - schema won't allow it
|
||||
def export_outfitting(self, data: Mapping[str, Any], is_beta: bool) -> None:
|
||||
"""
|
||||
export_outfitting updates EDDN with the current (lastStarport) station's outfitting options, if any.
|
||||
Once the send is complete, this.outfitting is updated with the given data.
|
||||
|
||||
:param data: dict containing the outfitting data
|
||||
:param is_beta: whether or not we're currently in beta mode
|
||||
"""
|
||||
modules: Dict[str, Any] = data['lastStarport'].get('modules') or {}
|
||||
|
||||
# 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 = is_horizons(
|
||||
data['lastStarport'].get('economies', {}),
|
||||
modules,
|
||||
data['lastStarport'].get('ships', {'shipyard_list': {}, 'unavailable_list': []})
|
||||
)
|
||||
|
||||
to_search: Iterator[Mapping[str, Any]] = filter(
|
||||
lambda m: self.MODULE_RE.search(m['name']) and m.get('sku') in (None, HORIZ_SKU) and
|
||||
m['name'] != 'Int_PlanetApproachSuite',
|
||||
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' : 'https://eddn.edcd.io/schemas/outfitting/2' + (is_beta and '/test' or ''),
|
||||
'$schemaRef': f'https://eddn.edcd.io/schemas/outfitting/2{"/test" if is_beta else ""}',
|
||||
'message': OrderedDict([
|
||||
('timestamp', data['timestamp']),
|
||||
('systemName', data['lastSystem']['name']),
|
||||
@ -212,19 +297,34 @@ class EDDN(object):
|
||||
('modules', outfitting),
|
||||
]),
|
||||
})
|
||||
|
||||
this.outfitting = (horizons, outfitting)
|
||||
|
||||
def export_shipyard(self, data, is_beta):
|
||||
economies = data['lastStarport'].get('economies') or {}
|
||||
modules = data['lastStarport'].get('modules') or {}
|
||||
ships = data['lastStarport'].get('ships') or { 'shipyard_list': {}, 'unavailable_list': [] }
|
||||
horizons = (any(economy['name'] == 'Colony' for economy in economies.values()) or
|
||||
any(module.get('sku') == 'ELITE_HORIZONS_V_PLANETARY_LANDINGS' for module in modules.values()) or
|
||||
any(ship.get('sku') == 'ELITE_HORIZONS_V_PLANETARY_LANDINGS' for ship in list((ships['shipyard_list'] or {}).values())))
|
||||
shipyard = sorted([ship['name'].lower() for ship in list((ships['shipyard_list'] or {}).values()) + ships['unavailable_list']])
|
||||
if shipyard and this.shipyard != (horizons, shipyard): # Don't send empty ships list - shipyard data is only guaranteed present if user has visited the shipyard.
|
||||
def export_shipyard(self, data: Dict[str, Any], is_beta: bool) -> None:
|
||||
"""
|
||||
export_shipyard updates EDDN with the current (lastStarport) station's outfitting options, if any.
|
||||
once the send is complete, this.shipyard is updated to the new data.
|
||||
|
||||
:param data: dict containing the shipyard data
|
||||
:param is_beta: whether or not we are in beta mode
|
||||
"""
|
||||
ships: Dict[str, Any] = data['lastStarport'].get('ships', {'shipyard_list': {}, 'unavailable_list': []})
|
||||
horizons: bool = is_horizons(
|
||||
data['lastStarport'].get('economies', {}),
|
||||
data['lastStarport'].get('modules', {}),
|
||||
ships
|
||||
)
|
||||
|
||||
shipyard: List[Mapping[str, Any]] = sorted(
|
||||
itertools.chain(
|
||||
(ship['name'].lower() for ship in (ships['shipyard_list'] or {}).values()),
|
||||
ships['unavailable_list']
|
||||
)
|
||||
)
|
||||
# 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' : 'https://eddn.edcd.io/schemas/shipyard/2' + (is_beta and '/test' or ''),
|
||||
'$schemaRef': f'https://eddn.edcd.io/schemas/shipyard/2{"/test" if is_beta else ""}',
|
||||
'message': OrderedDict([
|
||||
('timestamp', data['timestamp']),
|
||||
('systemName', data['lastSystem']['name']),
|
||||
@ -234,11 +334,20 @@ class EDDN(object):
|
||||
('ships', shipyard),
|
||||
]),
|
||||
})
|
||||
|
||||
this.shipyard = (horizons, shipyard)
|
||||
|
||||
def export_journal_commodities(self, cmdr, is_beta, entry):
|
||||
items = entry.get('Items') or []
|
||||
commodities = sorted([OrderedDict([
|
||||
def export_journal_commodities(self, cmdr: str, is_beta: bool, entry: Mapping[str, Any]) -> None:
|
||||
"""
|
||||
export_journal_commodities updates EDDN with the commodities list on the current station (lastStarport) from
|
||||
data in the journal. As a side effect, it also updates this.commodities with the data
|
||||
|
||||
: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: Sequence[OrderedDictT[AnyStr, Any]] = sorted((OrderedDict([
|
||||
('name', self.canonicalise(commodity['Name'])),
|
||||
('meanPrice', commodity['MeanPrice']),
|
||||
('buyPrice', commodity['BuyPrice']),
|
||||
@ -247,11 +356,11 @@ class EDDN(object):
|
||||
('sellPrice', commodity['SellPrice']),
|
||||
('demand', commodity['Demand']),
|
||||
('demandBracket', commodity['DemandBracket']),
|
||||
]) for commodity in items], key = lambda c: c['name'])
|
||||
]) for commodity in items), key=lambda c: c['name'])
|
||||
|
||||
if commodities and this.commodities != commodities: # Don't send empty commodities list - schema won't allow it
|
||||
self.send(cmdr, {
|
||||
'$schemaRef' : 'https://eddn.edcd.io/schemas/commodity/3' + (is_beta and '/test' or ''),
|
||||
'$schemaRef': f'https://eddn.edcd.io/schemas/commodity/3{"/test" if is_beta else ""}',
|
||||
'message': OrderedDict([
|
||||
('timestamp', entry['timestamp']),
|
||||
('systemName', entry['StarSystem']),
|
||||
@ -260,15 +369,30 @@ class EDDN(object):
|
||||
('commodities', commodities),
|
||||
]),
|
||||
})
|
||||
this.commodities = commodities
|
||||
|
||||
def export_journal_outfitting(self, cmdr, is_beta, entry):
|
||||
modules = entry.get('Items') or []
|
||||
horizons = entry.get('Horizons', False)
|
||||
outfitting = sorted([self.MODULE_RE.sub(lambda m: m.group(0).capitalize(), module['Name']) for module in modules if module['Name'] != 'int_planetapproachsuite'])
|
||||
if outfitting and this.outfitting != (horizons, outfitting): # Don't send empty modules list - schema won't allow it
|
||||
this.commodities: OrderedDictT[str, Any] = commodities
|
||||
|
||||
def export_journal_outfitting(self, cmdr: str, is_beta: bool, entry: Mapping[str, Any]) -> None:
|
||||
"""
|
||||
export_journal_outfitting updates EDDN with station outfitting based on a journal entry. As a side effect,
|
||||
it also updates this.outfitting with the data
|
||||
|
||||
: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' : 'https://eddn.edcd.io/schemas/outfitting/2' + (is_beta and '/test' or ''),
|
||||
'$schemaRef': f'https://eddn.edcd.io/schemas/outfitting/2{"/test" if is_beta else ""}',
|
||||
'message': OrderedDict([
|
||||
('timestamp', entry['timestamp']),
|
||||
('systemName', entry['StarSystem']),
|
||||
@ -278,15 +402,25 @@ class EDDN(object):
|
||||
('modules', outfitting),
|
||||
]),
|
||||
})
|
||||
|
||||
this.outfitting = (horizons, outfitting)
|
||||
|
||||
def export_journal_shipyard(self, cmdr, is_beta, entry):
|
||||
ships = entry.get('PriceList') or []
|
||||
horizons = entry.get('Horizons', False)
|
||||
shipyard = sorted([ship['ShipType'] for ship in ships])
|
||||
if shipyard and this.shipyard != (horizons, shipyard): # Don't send empty ships list - shipyard data is only guaranteed present if user has visited the shipyard.
|
||||
def export_journal_shipyard(self, cmdr: str, is_beta: bool, entry: Mapping[str, Any]) -> None:
|
||||
"""
|
||||
export_journal_shipyard updates EDDN with station shipyard data based on a journal entry. As a side effect,
|
||||
this.shipyard is updated with the data.
|
||||
|
||||
: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' : 'https://eddn.edcd.io/schemas/shipyard/2' + (is_beta and '/test' or ''),
|
||||
'$schemaRef': f'https://eddn.edcd.io/schemas/shipyard/2{"/test" if is_beta else ""}',
|
||||
'message': OrderedDict([
|
||||
('timestamp', entry['timestamp']),
|
||||
('systemName', entry['StarSystem']),
|
||||
@ -296,130 +430,206 @@ class EDDN(object):
|
||||
('ships', shipyard),
|
||||
]),
|
||||
})
|
||||
|
||||
this.shipyard = (horizons, shipyard)
|
||||
|
||||
def export_journal_entry(self, cmdr, is_beta, entry):
|
||||
def export_journal_entry(self, cmdr: str, is_beta: bool, entry: Mapping[str, Any]) -> None:
|
||||
"""
|
||||
export_journal_entry updates EDDN with a line from the journal. Additionally if additional lines are cached,
|
||||
it may send those as well.
|
||||
|
||||
: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' : 'https://eddn.edcd.io/schemas/journal/1' + (is_beta and '/test' or ''),
|
||||
'$schemaRef': f'https://eddn.edcd.io/schemas/journal/1{"/test" if is_beta else ""}',
|
||||
'message': entry
|
||||
}
|
||||
if self.replayfile or self.load():
|
||||
|
||||
if self.replayfile or self.load_journal_replay():
|
||||
# Store the entry
|
||||
self.replaylog.append(json.dumps([cmdr, msg]))
|
||||
self.replayfile.write('%s\n' % self.replaylog[-1])
|
||||
self.replayfile.write(f'{self.replaylog[-1]}\n')
|
||||
|
||||
if (entry['event'] == 'Docked' or
|
||||
(entry['event'] == 'Location' and entry['Docked']) or
|
||||
not (config.getint('output') & config.OUT_SYS_DELAY)):
|
||||
if (
|
||||
entry['event'] == 'Docked' or (entry['event'] == 'Location' and entry['Docked']) or not
|
||||
(config.getint('output') & config.OUT_SYS_DELAY)
|
||||
):
|
||||
self.parent.after(self.REPLAYPERIOD, self.sendreplay) # Try to send this and previous entries
|
||||
|
||||
else:
|
||||
# Can't access replay file! Send immediately.
|
||||
status = self.parent.children['status']
|
||||
status: MutableMapping[str, str] = self.parent.children['status']
|
||||
status['text'] = _('Sending data to EDDN...')
|
||||
self.parent.update_idletasks()
|
||||
self.send(cmdr, msg)
|
||||
status['text'] = ''
|
||||
|
||||
def canonicalise(self, item):
|
||||
def canonicalise(self, item: str) -> str:
|
||||
match = self.CANONICALISE_RE.match(item)
|
||||
return match and match.group(1) or item
|
||||
|
||||
|
||||
# Plugin callbacks
|
||||
|
||||
def plugin_start3(plugin_dir):
|
||||
def plugin_start3(plugin_dir: str) -> str:
|
||||
return 'EDDN'
|
||||
|
||||
def plugin_app(parent):
|
||||
|
||||
def plugin_app(parent: tk.Tk) -> None:
|
||||
this.parent = parent
|
||||
this.eddn = EDDN(parent)
|
||||
# Try to obtain exclusive lock on journal cache, even if we don't need it yet
|
||||
if not this.eddn.load():
|
||||
this.status['text'] = 'Error: Is another copy of this app already running?' # Shouldn't happen - don't bother localizing
|
||||
if not this.eddn.load_journal_replay():
|
||||
# Shouldn't happen - don't bother localizing
|
||||
this.status['text'] = 'Error: Is another copy of this app already running?'
|
||||
|
||||
def plugin_prefs(parent, cmdr, is_beta):
|
||||
|
||||
PADX = 10
|
||||
BUTTONX = 12 # indent Checkbuttons and Radiobuttons
|
||||
PADY = 2 # close spacing
|
||||
def plugin_prefs(parent, cmdr: str, is_beta: bool) -> Frame:
|
||||
PADX = 10 # noqa: N806
|
||||
BUTTONX = 12 # noqa: N806 # indent Checkbuttons and Radiobuttons
|
||||
|
||||
if prefsVersion.shouldSetDefaults('0.0.0.0', not bool(config.getint('output'))):
|
||||
output = (config.OUT_MKT_EDDN | config.OUT_SYS_EDDN) # default settings
|
||||
output: int = (config.OUT_MKT_EDDN | config.OUT_SYS_EDDN) # default settings
|
||||
|
||||
else:
|
||||
output = config.getint('output')
|
||||
output: int = config.getint('output')
|
||||
|
||||
eddnframe = nb.Frame(parent)
|
||||
|
||||
HyperlinkLabel(eddnframe, text='Elite Dangerous Data Network', background=nb.Label().cget('background'), url='https://github.com/EDSM-NET/EDDN/wiki', underline=True).grid(padx=PADX, sticky=tk.W) # Don't translate
|
||||
HyperlinkLabel(
|
||||
eddnframe,
|
||||
text='Elite Dangerous Data Network',
|
||||
background=nb.Label().cget('background'),
|
||||
url='https://github.com/EDSM-NET/EDDN/wiki',
|
||||
underline=True
|
||||
).grid(padx=PADX, sticky=tk.W) # Don't translate
|
||||
|
||||
this.eddn_station = tk.IntVar(value=(output & config.OUT_MKT_EDDN) and 1)
|
||||
this.eddn_station_button = nb.Checkbutton(eddnframe, text=_('Send station data to the Elite Dangerous Data Network'), variable=this.eddn_station, command=prefsvarchanged) # Output setting
|
||||
this.eddn_station_button = nb.Checkbutton(
|
||||
eddnframe,
|
||||
text=_('Send station data to the Elite Dangerous Data Network'),
|
||||
variable=this.eddn_station,
|
||||
command=prefsvarchanged
|
||||
) # Output setting
|
||||
|
||||
this.eddn_station_button.grid(padx=BUTTONX, pady=(5, 0), sticky=tk.W)
|
||||
this.eddn_system = tk.IntVar(value=(output & config.OUT_SYS_EDDN) and 1)
|
||||
this.eddn_system_button = nb.Checkbutton(eddnframe, text=_('Send system and scan data to the Elite Dangerous Data Network'), variable=this.eddn_system, command=prefsvarchanged) # Output setting new in E:D 2.2
|
||||
# Output setting new in E:D 2.2
|
||||
this.eddn_system_button = nb.Checkbutton(
|
||||
eddnframe,
|
||||
text=_('Send system and scan data to the Elite Dangerous Data Network'),
|
||||
variable=this.eddn_system,
|
||||
command=prefsvarchanged
|
||||
)
|
||||
|
||||
this.eddn_system_button.grid(padx=BUTTONX, pady=(5, 0), sticky=tk.W)
|
||||
this.eddn_delay = tk.IntVar(value=(output & config.OUT_SYS_DELAY) and 1)
|
||||
this.eddn_delay_button = nb.Checkbutton(eddnframe, text=_('Delay sending until docked'), variable=this.eddn_delay) # Output setting under 'Send system and scan data to the Elite Dangerous Data Network' new in E:D 2.2
|
||||
# Output setting under 'Send system and scan data to the Elite Dangerous Data Network' new in E:D 2.2
|
||||
this.eddn_delay_button = nb.Checkbutton(
|
||||
eddnframe,
|
||||
text=_('Delay sending until docked'),
|
||||
variable=this.eddn_delay
|
||||
)
|
||||
this.eddn_delay_button.grid(padx=BUTTONX, sticky=tk.W)
|
||||
|
||||
return eddnframe
|
||||
|
||||
def prefsvarchanged(event=None):
|
||||
|
||||
def prefsvarchanged(event=None) -> None:
|
||||
this.eddn_station_button['state'] = tk.NORMAL
|
||||
this.eddn_system_button['state'] = tk.NORMAL
|
||||
this.eddn_delay_button['state'] = this.eddn.replayfile and this.eddn_system.get() and tk.NORMAL or tk.DISABLED
|
||||
|
||||
def prefs_changed(cmdr, is_beta):
|
||||
config.set('output',
|
||||
|
||||
def prefs_changed(cmdr: str, is_beta: bool) -> None:
|
||||
config.set(
|
||||
'output',
|
||||
(config.getint('output') & (config.OUT_MKT_TD | config.OUT_MKT_CSV | config.OUT_SHIP | config.OUT_MKT_MANUAL)) +
|
||||
(this.eddn_station.get() and config.OUT_MKT_EDDN) +
|
||||
(this.eddn_system.get() and config.OUT_SYS_EDDN) +
|
||||
(this.eddn_delay.get() and config.OUT_SYS_DELAY))
|
||||
(this.eddn_delay.get() and config.OUT_SYS_DELAY)
|
||||
)
|
||||
|
||||
def plugin_stop():
|
||||
|
||||
def plugin_stop() -> None:
|
||||
this.eddn.close()
|
||||
|
||||
def journal_entry(cmdr, is_beta, system, station, entry, state):
|
||||
|
||||
def journal_entry(
|
||||
cmdr: str, is_beta: bool, system: str, station: str, entry: MutableMapping[str, Any], state: Mapping[str, Any]
|
||||
) -> Optional[str]:
|
||||
# Recursively filter '*_Localised' keys from dict
|
||||
def filter_localised(d):
|
||||
filtered = OrderedDict()
|
||||
def filter_localised(d: Mapping[str, Any]) -> OrderedDictT[str, Any]:
|
||||
filtered: OrderedDictT[str, Any] = OrderedDict()
|
||||
for k, v in d.items():
|
||||
if k.endswith('_Localised'):
|
||||
pass
|
||||
|
||||
elif hasattr(v, 'items'): # dict -> recurse
|
||||
filtered[k] = filter_localised(v)
|
||||
|
||||
elif isinstance(v, list): # list of dicts -> recurse
|
||||
filtered[k] = [filter_localised(x) if hasattr(x, 'items') else x for x in v]
|
||||
|
||||
else:
|
||||
filtered[k] = v
|
||||
|
||||
return filtered
|
||||
|
||||
# Track location
|
||||
if entry['event'] in ['Location', 'FSDJump', 'Docked', 'CarrierJump']:
|
||||
if entry['event'] in ('Location', 'FSDJump', 'Docked', 'CarrierJump'):
|
||||
if entry['event'] in ('Location', 'CarrierJump'):
|
||||
this.planet = entry.get('Body') if entry.get('BodyType') == 'Planet' else None
|
||||
this.planet: Optional[str] = entry.get('Body') if entry.get('BodyType') == 'Planet' else None
|
||||
|
||||
elif entry['event'] == 'FSDJump':
|
||||
this.planet = None
|
||||
this.planet: Optional[str] = None
|
||||
|
||||
if 'StarPos' in entry:
|
||||
this.coordinates = tuple(entry['StarPos'])
|
||||
this.coordinates: Optional[Tuple[int, int, int]] = tuple(entry['StarPos'])
|
||||
|
||||
elif this.systemaddress != entry.get('SystemAddress'):
|
||||
this.coordinates = None # Docked event doesn't include coordinates
|
||||
this.systemaddress = entry.get('SystemAddress')
|
||||
this.coordinates: Optional[Tuple[int, int, int]] = None # Docked event doesn't include coordinates
|
||||
|
||||
this.systemaddress: Optional[str] = entry.get('SystemAddress')
|
||||
|
||||
elif entry['event'] == 'ApproachBody':
|
||||
this.planet = entry['Body']
|
||||
elif entry['event'] in ['LeaveBody', 'SupercruiseEntry']:
|
||||
|
||||
elif entry['event'] in ('LeaveBody', 'SupercruiseEntry'):
|
||||
this.planet = None
|
||||
|
||||
# Send interesting events to EDDN, but not when on a crew
|
||||
if (config.getint('output') & config.OUT_SYS_EDDN and not state['Captain'] and
|
||||
(entry['event'] in ('Location', 'FSDJump', 'Docked', 'Scan', 'SAASignalsFound', 'CarrierJump')) and
|
||||
('StarPos' in entry or this.coordinates)):
|
||||
|
||||
# strip out properties disallowed by the schema
|
||||
for thing in ['ActiveFine', 'CockpitBreach', 'BoostUsed', 'FuelLevel', 'FuelUsed', 'JumpDist', 'Latitude', 'Longitude', 'Wanted']:
|
||||
for thing in (
|
||||
'ActiveFine',
|
||||
'CockpitBreach',
|
||||
'BoostUsed',
|
||||
'FuelLevel',
|
||||
'FuelUsed',
|
||||
'JumpDist',
|
||||
'Latitude',
|
||||
'Longitude',
|
||||
'Wanted'
|
||||
):
|
||||
entry.pop(thing, None)
|
||||
|
||||
if 'Factions' in entry:
|
||||
# Filter faction state. `entry` is a shallow copy so replace 'Factions' value rather than modify in-place.
|
||||
entry['Factions'] = [ {k: v for k, v in f.items() if k not in ['HappiestSystem', 'HomeSystem', 'MyReputation', 'SquadronFaction']} for f in entry['Factions']]
|
||||
# Filter faction state to comply with schema restrictions regarding personal data. `entry` is a shallow copy
|
||||
# so replace 'Factions' value rather than modify in-place.
|
||||
entry['Factions'] = [
|
||||
{
|
||||
k: v for k, v in f.items() if k not in (
|
||||
'HappiestSystem', 'HomeSystem', 'MyReputation', 'SquadronFaction'
|
||||
)
|
||||
}
|
||||
for f in entry['Factions']
|
||||
]
|
||||
|
||||
# add planet to Docked event for planetary stations if known
|
||||
if entry['event'] == 'Docked' and this.planet:
|
||||
@ -429,50 +639,66 @@ def journal_entry(cmdr, is_beta, system, station, entry, state):
|
||||
# add mandatory StarSystem, StarPos and SystemAddress properties to Scan events
|
||||
if 'StarSystem' not in entry:
|
||||
if not system:
|
||||
return("system is None, can't add StarSystem")
|
||||
logger.warn("system is None, can't add StarSystem")
|
||||
return "system is None, can't add StarSystem"
|
||||
|
||||
entry['StarSystem'] = system
|
||||
|
||||
if 'StarPos' not in entry:
|
||||
if not this.coordinates:
|
||||
return("this.coordinates is None, can't add StarPos")
|
||||
logger.warn("this.coordinates is None, can't add StarPos")
|
||||
return "this.coordinates is None, can't add StarPos"
|
||||
|
||||
entry['StarPos'] = list(this.coordinates)
|
||||
|
||||
if 'SystemAddress' not in entry:
|
||||
if not this.systemaddress:
|
||||
return("this.systemaddress is None, can't add SystemAddress")
|
||||
logger.warn("this.systemaddress is None, can't add SystemAddress")
|
||||
return "this.systemaddress is None, can't add SystemAddress"
|
||||
|
||||
entry['SystemAddress'] = this.systemaddress
|
||||
|
||||
try:
|
||||
this.eddn.export_journal_entry(cmdr, is_beta, filter_localised(entry))
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
if __debug__: print_exc()
|
||||
logger.debug('Failed in export_journal_entry', exc_info=e)
|
||||
return _("Error: Can't connect to EDDN")
|
||||
|
||||
except Exception as e:
|
||||
if __debug__: print_exc()
|
||||
logger.debug('Failed in export_journal_entry', exc_info=e)
|
||||
return str(e)
|
||||
|
||||
elif (config.getint('output') & config.OUT_MKT_EDDN and not state['Captain'] and
|
||||
entry['event'] in ['Market', 'Outfitting', 'Shipyard']):
|
||||
entry['event'] in ('Market', 'Outfitting', 'Shipyard')):
|
||||
|
||||
try:
|
||||
if this.marketId != entry['MarketID']:
|
||||
this.commodities = this.outfitting = this.shipyard = None
|
||||
this.marketId = entry['MarketID']
|
||||
|
||||
with open(join(config.get('journaldir') or config.default_journal_dir, '%s.json' % entry['event']), 'rb') as h:
|
||||
entry = json.load(h)
|
||||
path = pathlib.Path(str(config.get('journaldir') or config.default_journal_dir)) / f'{entry["event"]}.json'
|
||||
with path.open('rb') as f:
|
||||
entry = json.load(f)
|
||||
if entry['event'] == 'Market':
|
||||
this.eddn.export_journal_commodities(cmdr, is_beta, entry)
|
||||
|
||||
elif entry['event'] == 'Outfitting':
|
||||
this.eddn.export_journal_outfitting(cmdr, is_beta, entry)
|
||||
|
||||
elif entry['event'] == 'Shipyard':
|
||||
this.eddn.export_journal_shipyard(cmdr, is_beta, entry)
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
if __debug__: print_exc()
|
||||
logger.debug(f'Failed exporting {entry["event"]}', exc_info=e)
|
||||
return _("Error: Can't connect to EDDN")
|
||||
|
||||
except Exception as e:
|
||||
if __debug__: print_exc()
|
||||
logger.debug(f'Failed exporting {entry["event"]}', exc_info=e)
|
||||
return str(e)
|
||||
|
||||
def cmdr_data(data, is_beta):
|
||||
|
||||
def cmdr_data(data: Mapping[str, Any], is_beta: bool) -> str:
|
||||
if data['commander'].get('docked') and config.getint('output') & config.OUT_MKT_EDDN:
|
||||
try:
|
||||
if this.marketId != data['lastStarport']['id']:
|
||||
@ -484,6 +710,7 @@ def cmdr_data(data, is_beta):
|
||||
if not old_status:
|
||||
status['text'] = _('Sending data to EDDN...')
|
||||
status.update_idletasks()
|
||||
|
||||
this.eddn.export_commodities(data, is_beta)
|
||||
this.eddn.export_outfitting(data, is_beta)
|
||||
this.eddn.export_shipyard(data, is_beta)
|
||||
@ -492,9 +719,20 @@ def cmdr_data(data, is_beta):
|
||||
status.update_idletasks()
|
||||
|
||||
except requests.RequestException as e:
|
||||
if __debug__: print_exc()
|
||||
logger.debug('Failed exporting data', exc_info=e)
|
||||
return _("Error: Can't connect to EDDN")
|
||||
|
||||
except Exception as e:
|
||||
if __debug__: print_exc()
|
||||
logger.debug('Failed exporting data', exc_info=e)
|
||||
return str(e)
|
||||
|
||||
|
||||
MAP_STR_ANY = Mapping[str, Any]
|
||||
|
||||
|
||||
def is_horizons(economies: MAP_STR_ANY, modules: MAP_STR_ANY, ships: MAP_STR_ANY) -> bool:
|
||||
return (
|
||||
any(economy['name'] == 'Colony' for economy in economies.values()) or
|
||||
any(module.get('sku') == HORIZ_SKU for module in modules.values()) or
|
||||
any(ship.get('sku') == HORIZ_SKU for ship in (ships['shipyard_list'] or {}).values())
|
||||
)
|
||||
|
@ -16,16 +16,16 @@ import requests
|
||||
import sys
|
||||
from queue import Queue
|
||||
from threading import Thread
|
||||
import logging
|
||||
|
||||
import tkinter as tk
|
||||
from ttkHyperlinkLabel import HyperlinkLabel
|
||||
import myNotebook as nb
|
||||
import myNotebook as nb # noqa: N813
|
||||
|
||||
from config import appname, applongname, appversion, config
|
||||
import plug
|
||||
|
||||
if __debug__:
|
||||
from traceback import print_exc
|
||||
logger = logging.getLogger(appname)
|
||||
|
||||
EDSM_POLL = 0.1
|
||||
_TIMEOUT = 20
|
||||
@ -371,23 +371,28 @@ def worker():
|
||||
r.raise_for_status()
|
||||
reply = r.json()
|
||||
(msgnum, msg) = reply['msgnum'], reply['msg']
|
||||
# 1xx = OK, 2xx = fatal error, 3&4xx not generated at top-level, 5xx = error but events saved for later processing
|
||||
# 1xx = OK
|
||||
# 2xx = fatal error
|
||||
# 3&4xx not generated at top-level
|
||||
# 5xx = error but events saved for later processing
|
||||
if msgnum // 100 == 2:
|
||||
print('EDSM\t%s %s\t%s' % (msgnum, msg, json.dumps(pending, separators = (',', ': '))))
|
||||
logger.warning(f'EDSM\t{msgnum} {msg}\t{json.dumps(pending, separators = (",", ": "))}')
|
||||
plug.show_error(_('Error: EDSM {MSG}').format(MSG=msg))
|
||||
else:
|
||||
for e, r in zip(pending, reply['events']):
|
||||
if not closing and e['event'] in ['StartUp', 'Location', 'FSDJump', 'CarrierJump']:
|
||||
# Update main window's system status
|
||||
this.lastlookup = r
|
||||
this.system_link.event_generate('<<EDSMStatus>>', when="tail") # calls update_status in main thread
|
||||
# calls update_status in main thread
|
||||
this.system_link.event_generate('<<EDSMStatus>>', when="tail")
|
||||
elif r['msgnum'] // 100 != 1:
|
||||
print('EDSM\t%s %s\t%s' % (r['msgnum'], r['msg'], json.dumps(e, separators = (',', ': '))))
|
||||
logger.warning(f'EDSM\t{r["msgnum"]} {r["msg"]}\t'
|
||||
f'{json.dumps(e, separators = (",", ": "))}')
|
||||
pending = []
|
||||
|
||||
break
|
||||
except:
|
||||
if __debug__: print_exc()
|
||||
except Exception as e:
|
||||
logger.debug('Sending API events', exc_info=e)
|
||||
retrying += 1
|
||||
else:
|
||||
plug.show_error(_("Error: Can't connect to EDSM"))
|
||||
|
1093
plugins/inara.py
1093
plugins/inara.py
File diff suppressed because it is too large
Load Diff
6
pyproject.toml
Normal file
6
pyproject.toml
Normal file
@ -0,0 +1,6 @@
|
||||
[tool.autopep8]
|
||||
max_line_length = 120
|
||||
|
||||
[tool.isort]
|
||||
multi_line_output = 5
|
||||
line_length = 119
|
@ -6,6 +6,7 @@ flake8-comprehensions==3.2.3
|
||||
flake8-pep3101==1.3.0
|
||||
flake8-polyfill==1.0.2
|
||||
flake8-json
|
||||
flake8-isort==3.0.1
|
||||
pep8-naming==0.11.1
|
||||
|
||||
# Code formatting tools
|
||||
|
40
timeout_session.py
Normal file
40
timeout_session.py
Normal file
@ -0,0 +1,40 @@
|
||||
|
||||
import requests
|
||||
from requests.adapters import HTTPAdapter
|
||||
|
||||
REQUEST_TIMEOUT = 10 # reasonable timeout that all HTTP requests should use
|
||||
|
||||
|
||||
class TimeoutAdapter(HTTPAdapter):
|
||||
"""
|
||||
TimeoutAdapter is an HTTP Adapter that enforces an overridable default timeout on HTTP requests.
|
||||
"""
|
||||
def __init__(self, timeout, *args, **kwargs):
|
||||
self.default_timeout = timeout
|
||||
if kwargs.get("timeout") is not None:
|
||||
del kwargs["timeout"]
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def send(self, *args, **kwargs):
|
||||
if kwargs["timeout"] is None:
|
||||
kwargs["timeout"] = self.default_timeout
|
||||
|
||||
return super().send(*args, **kwargs)
|
||||
|
||||
|
||||
def new_session(timeout: int = REQUEST_TIMEOUT, session: requests.Session = None) -> requests.Session:
|
||||
"""
|
||||
new_session creates a new requests.Session and overrides the default HTTPAdapter with a TimeoutAdapter.
|
||||
|
||||
:param timeout: the timeout to set the TimeoutAdapter to, defaults to REQUEST_TIMEOUT
|
||||
:param session: the Session object to attach the Adapter to, defaults to a new session
|
||||
:return: The created Session
|
||||
"""
|
||||
if session is None:
|
||||
session = requests.Session()
|
||||
|
||||
adapter = TimeoutAdapter(timeout)
|
||||
session.mount("http://", adapter)
|
||||
session.mount("https://", adapter)
|
||||
return session
|
Loading…
x
Reference in New Issue
Block a user