diff --git a/EDMarketConnector.py b/EDMarketConnector.py index d050773f..b335e3ae 100755 --- a/EDMarketConnector.py +++ b/EDMarketConnector.py @@ -23,6 +23,7 @@ import eddn import loadout import flightlog import stats +import chart import prefs from config import appname, applongname, config @@ -202,10 +203,20 @@ class AppWindow: self.status['text'] = "Where are you?!" # Shouldn't happen elif not data.get('ship') or not data['ship'].get('modules') or not data['ship'].get('name','').strip(): self.status['text'] = "What are you flying?!" # Shouldn't happen + elif (config.getint('output') & config.OUT_EDDN) and data['commander'].get('docked') and not data['lastStarport'].get('ships') and not retrying: - # API is flakey about shipyard info - retry if missing (<1s is usually sufficient - 4s for margin). + # API is flakey about shipyard info - retry if missing (<1s is usually sufficient - 5s for margin). self.w.after(shipyard_retry * 1000, lambda:self.getandsend(retrying=True)) + + # Stuff we can do while waiting for retry + if config.getint('output') & config.OUT_STAT: + chart.export(data) + if config.getint('output') & config.OUT_LOG: + flightlog.export(data) + if config.getint('output') & config.OUT_SHIP: + loadout.export(data) return + else: if __debug__ and retrying: print data['lastStarport'].get('ships') and 'Retry for shipyard - Success' or 'Retry for shipyard - Fail' @@ -213,10 +224,14 @@ class AppWindow: if __debug__: # Recording with open('%s%s.%s.json' % (data['lastSystem']['name'], data['commander'].get('docked') and '.'+data['lastStarport']['name'] or '', strftime('%Y-%m-%dT%H.%M.%S', localtime())), 'wt') as h: h.write(json.dumps(data, indent=2, sort_keys=True)) - if config.getint('output') & config.OUT_LOG: - flightlog.export(data) - if config.getint('output') & config.OUT_SHIP: - loadout.export(data) + + if not retrying: + if config.getint('output') & config.OUT_STAT: + chart.export(data) + if config.getint('output') & config.OUT_LOG: + flightlog.export(data) + if config.getint('output') & config.OUT_SHIP: + loadout.export(data) if not (config.getint('output') & (config.OUT_CSV|config.OUT_TD|config.OUT_BPC|config.OUT_EDDN)): # no further output requested diff --git a/EDMarketConnector.wxs b/EDMarketConnector.wxs index c23ad5c6..4461bbdc 100644 --- a/EDMarketConnector.wxs +++ b/EDMarketConnector.wxs @@ -51,6 +51,9 @@ + + + @@ -78,6 +81,9 @@ + + + @@ -318,6 +324,7 @@ + @@ -327,6 +334,7 @@ + diff --git a/README.md b/README.md index e81175d1..3aa1e21a 100644 --- a/README.md +++ b/README.md @@ -5,12 +5,14 @@ This app downloads commodity market and other data from the game [Elite: Dangero * sends the data to the [Elite Dangerous Data Network](http://eddn-gateway.elite-markets.net/) ("EDDN") from where you and others can use it via online trading tools such as [eddb](http://eddb.io/), [Elite Trade Net](http://etn.io/), [Inara](http://inara.cz), etc. * saves the data to files on your computer that you can load into trading tools such as [Slopey's BPC Market Tool](https://forums.frontier.co.uk/showthread.php?t=76081), [Trade Dangerous](https://bitbucket.org/kfsone/tradedangerous/wiki/Home), [Thrudd's Trading Tools](http://www.elitetradingtool.co.uk/), [Inara](http://inara.cz), etc. -* saves a record of your ship loadout and/or flight log. +* saves a record of your ship loadout, flight log and/or statistics. The user-interface is deliberately minimal - when you land at a station just switch to the app and press the "Update" button or press Enter to automatically download and transmit and/or save your choice of data. ![Windows screenshot](img/win.png) ![Mac screenshot](img/mac.png) +![Balance screenshot](img/Balance.png) + Installation -------- @@ -60,6 +62,9 @@ This app can save a variety of data in a variety of formats: * Flight log * Adds a record of your location, ship and cargo to a file that you can open in a text editor or a spreadsheet program such as Excel. Note: Don't edit, rename or move this file - take a copy if you wish to change it. +* Cmdr statstics + * Generates a number of charts of your progress in an Excel file. Note: Don't edit, rename or move this file. + By default these files will be placed in your Documents folder. Since this app will create a lot of files if you use it for a while you may wish to create a separate folder for the files and tell the app to place them there. Statistics @@ -100,6 +105,8 @@ Linux: * Requires the Python "imaging-tk", "iniparse" and "requests" modules. On Debian-based systems install these with `sudo apt-get install python-imaging-tk python-iniparse python-requests` . * Run with `./EDMarketConnector.py` . +On all platforms charting Cmdr statistics additionally requires verison 2.3.0 or later of the [openpyxl](https://pypi.python.org/pypi/openpyxl) module. + Packaging for distribution -------- diff --git a/trends.py b/chart.py old mode 100755 new mode 100644 similarity index 51% rename from trends.py rename to chart.py index 202671a6..128d8cbb --- a/trends.py +++ b/chart.py @@ -1,92 +1,45 @@ #!/usr/bin/python # -# Creates an Excel spreadsheet graphing player stats using data from .json dumps created by -# EDMarketconnector in interactive mode. +# Creates an Excel spreadsheet graphing player stats # -# Requires XlsxWriter +# Requires openpyxl >= 2.3 # -import json -import os -import re +try: + import lxml._elementpath # Explicit dependency for py2exe + import openpyxl + if map(int, openpyxl.__version__.split('.')[:2]) < [2,3]: + raise ImportError() + have_openpyxl = True +except: + have_openpyxl = False + import datetime -import xlsxwriter +import time +import re - -workbook = xlsxwriter.Workbook('trends.xlsx') - -F_TITLE = workbook.add_format({'align': 'center', 'bold':True}) -F_SUB = workbook.add_format({'align': 'right', 'bold':True}) -F_DATE = workbook.add_format({'num_format': 'yy-mm-dd hh:mm:ss'}) - -def makesheet(workbook, name, titles): - worksheet = workbook.add_worksheet(name) - worksheet.write(0, 0, 'Date', F_SUB) - if isinstance(titles[0], tuple): - start = end = 1 - for (head, subtitles) in titles: - for i in range(len(subtitles)): - worksheet.write(1, end, subtitles[i], F_SUB) - end += 1 - worksheet.merge_range(0, start, 0, end-1, head, F_TITLE) - start = end - worksheet.set_column(0, end, 15) - else: - worksheet.set_column(0, len(titles), 15) - for i in range(len(titles)): - worksheet.write(0, i+1, titles[i], F_SUB) - return worksheet - -def addrow(worksheet, row, dt, items): - worksheet.write_datetime(row, 0, dt, F_DATE) - for i in range(len(items)): - worksheet.write(row, i+1, items[i]) - -def makechart(workbook, worksheet, title, axes=None): - chart = workbook.add_chart({'type': 'scatter', 'subtype': 'straight'}) - chart.set_title({'name': title}) - chart.set_size({'width': 2400, 'height': 1600}) - chart.set_x_axis({'date_axis': True, 'num_format': 'yyyy-mm-dd'}) - if axes: - if isinstance(axes, list) or isinstance(axes, tuple): - chart.set_y_axis( {'name': axes[0]}) - chart.set_y2_axis({'name': axes[1]}) - else: - chart.set_y_axis( {'name': axes}) - worksheet.insert_chart('B2', chart) - return chart - - - -inputs = {} -regexp = re.compile('.+\.(\d\d\d\d\-\d\d\-\d\dT\d\d\.\d\d\.\d\d)\.json$') -for f in os.listdir('.'): - match = regexp.match(f) - if match: - inputs[datetime.datetime.strptime(match.group(1), '%Y-%m-%dT%H.%M.%S')] = json.loads(open(f).read()) -if not inputs: - print "No data!" - exit() +from os.path import isfile, join +from config import config dataseries = [ { 'name': 'Combat', - 'axes': ['qty', 'CR'], + 'axes': ['Quantity', 'Profit [CR]'], 'keys': [ ('Bounties', ['stats', 'combat', 'bounty', 'qty']), ('Profit from bounties', ['stats', 'combat', 'bounty', 'value'], True), ('Bonds', ['stats', 'combat', 'bond', 'qty']), ('Profit from bonds', ['stats', 'combat', 'bond', 'value'], True), - ('Assassin', ['stats', 'missions', 'assassin', 'missionsCompleted']), + ('Assassinations', ['stats', 'missions', 'assassin', 'missionsCompleted']), ('Profit from assassin', ['stats', 'missions', 'assassin', 'creditsEarned'], True), ('Hunting', ['stats', 'missions', 'bountyHunter', 'missionsCompleted']), ('Profit from hunting', ['stats', 'missions', 'bountyHunter', 'creditsEarned'], True), - ], + ], }, { 'name': 'Trade', - 'axes': ['qty', 'CR'], + 'axes': ['Quantity', 'Profit [CR]'], 'keys': [ ('Profit from trading', ['stats', 'trade', 'profit'], True), ('Commodities traded', ['stats', 'trade', 'qty']), @@ -98,8 +51,8 @@ dataseries = [ ], }, { - 'name': 'Explore', - 'axes': ['qty', 'CR'], + 'name': 'Explorer', + 'axes': ['Quantity', 'Profit [CR]'], 'keys': [ ('Profits from exploration', ['stats', 'explore', 'creditsEarned'], True), ('Discovery scans', ['stats', 'explore', 'scanSoldLevels', 'lev_0']), @@ -111,7 +64,7 @@ dataseries = [ }, { 'name': 'Crime', - 'axes': ['qty', 'CR'], + 'axes': ['Quantity', 'Profit [CR]'], 'keys': [ ('Fines', ['stats', 'crime', 'fine', 'qty']), ('Lifetime fine value', ['stats', 'crime', 'fine', 'value'], True), @@ -125,17 +78,19 @@ dataseries = [ }, { 'name': 'NPC', + 'axes': 'Quantity', 'prefix': ['stats', 'NPC', 'kills', 'ranks'], 'keys': [('Harmless', 'r0'), ('Mostly Harmless', 'r1'), ('Novice', 'r2'), ('Competent', 'r3'), ('Expert', 'r4'), ('Master', 'r5'), ('Dangerous', 'r6'), ('Deadly', 'r7'), ('Elite', 'r8'), ('Capital', 'rArray')], }, { 'name': 'PVP', + 'axes': 'Quantity', 'prefix': ['stats', 'PVP', 'kills', 'ranks'], 'keys': [('Harmless', 'r0'), ('Mostly Harmless', 'r1'), ('Novice', 'r2'), ('Competent', 'r3'), ('Expert', 'r4'), ('Master', 'r5'), ('Dangerous', 'r6'), ('Deadly', 'r7'), ('Elite', 'r8'), ('Capital', 'rArray')], }, { 'name': 'Balance', - 'axes': ['qty', 'CR'], + 'axes': ['Quantity', '[CR]'], 'keys': [ ('Current balance', ['commander', 'credits'], True), ('Spent on ships', ['stats', 'ship', 'spend', 'ships'], True), @@ -153,63 +108,99 @@ dataseries = [ ] -for thing in dataseries: - if isinstance(thing['keys'][0], tuple): +def export(data, csv=False): + + if not have_openpyxl: return False + + TITLE_F = openpyxl.styles.Font(bold=True) + TITLE_A = openpyxl.styles.Alignment(horizontal='right') + + querytime = config.getint('querytime') or int(time.time()) + + filename = join(config.get('outdir'), 'Cmdr %s.xlsx' % re.sub(r'[\\/:*?"<>|]', '_', data['commander']['name'])) + if not isfile(filename): + wb = openpyxl.Workbook() + try: + wb.active.title = 'Combat' # Workbook is created with one sheet - rename it + except: + pass # except that it isn't under 2.30b1 + else: + wb = openpyxl.load_workbook(filename) + + for thing in dataseries: legends = [x[0] for x in thing['keys']] keys = [x[1] for x in thing['keys']] - if thing.get('axes') and (isinstance(thing['axes'], list) or isinstance(thing['axes'], tuple)): + if thing.get('axes') and isinstance(thing['axes'], (list, tuple)): y2_axis = [len(x)>2 and x[2] for x in thing['keys']] else: - y2_axis = [False] * len(keys) - else: - legends = keys = thing['keys'] - y2_axis = [False] * len(keys) + y2_axis = None - sheet = makesheet(workbook, thing['name'], legends) + if thing['name'] in wb: + ws = wb[thing['name']] + else: + ws = wb.create_sheet(title=thing['name']) - timeseries = sorted(inputs) - for i in range(len(inputs)): - row = i+1 - dt = timeseries[i] - data = inputs[dt] + # Add header row + if ws.max_row <= 1: # Returns 1 for empty sheet + ws.append(['Date'] + legends) + for i in range(ws.max_column): + ws.column_dimensions[openpyxl.utils.get_column_letter(i+1)].width = 17 + for row in ws.get_squared_range(1, 1, ws.max_column, 1): + for cell in row: + cell.font = TITLE_F + cell.alignment = TITLE_A + + # Add data row + vals = [datetime.datetime.fromtimestamp(querytime)] + mydata = data if thing.get('prefix'): for key in thing['prefix']: - data = data[key] - vals = [] + mydata = mydata[key] for key2 in keys: if isinstance(key2, basestring): - vals.append(data.get(key2, 0)) + vals.append(mydata.get(key2, 0)) else: - value = data + value = mydata for key in key2: value = value.get(key, 0) if not value: break vals.append(value) + ws.append(vals) + ws.cell(row=ws.max_row, column=1).number_format = 'yyyy-mm-dd hh:mm:ss' # just a string, not a style - addrow(sheet, row, dt, vals) + dates = openpyxl.chart.Reference(ws, 1, 2, 1, ws.max_row) - chart = makechart(workbook, sheet, thing['name'], thing.get('axes')) - for i in range(len(thing['keys'])): - chart.add_series({'categories': [thing['name'], 1, 0, row, 0], - 'values': [thing['name'], 1, 1+i, row, 1+i], - 'name': legends[i], - 'marker': {'type': 'diamond'}, - 'y2_axis': y2_axis[i], - }) - # Label each line - if y2_axis[i]: - chart.add_series({'categories': [thing['name'], row, 0, row, 0], # last row - 'values': [thing['name'], row, 1+i, row, 1+i], - 'name': legends[i], - 'data_labels': {'series_name': True, 'position': 'right'}, - 'y2_axis': True, - }) - else: - chart.add_series({'categories': [thing['name'], 1, 0, 1, 0], # first row - 'values': [thing['name'], 1, 1+i, 1, 1+i], - 'name': legends[i], - 'data_labels': {'series_name': True, 'position': 'left'}, - }) - chart.set_legend({'delete_series': range(1, 2*len(thing['keys']), 2)}) + chart = openpyxl.chart.ScatterChart() + chart.title = thing['name'] + chart.width, chart.height = 60, 30 # in cm! + chart.scatterStyle = 'lineMarker' + chart.set_categories(dates) + chart.x_axis.number_format = ('yyyy-mm-dd') # date only + chart.x_axis.majorGridlines = None -workbook.close() + if y2_axis: + chart.y_axis.majorGridlines = None # prefer grid lines on secondary axis + chart2 = openpyxl.chart.ScatterChart() + chart2.scatterStyle = 'lineMarker' + # Hack - second chart must have different axis ID + chart2.y_axis = openpyxl.chart.axis.NumericAxis(axId=30, crossAx=10, axPos='r', crosses='max') + + for i in range(len(keys)): + series = openpyxl.chart.Series(openpyxl.chart.Reference(ws, i+2, 1, i+2, ws.max_row), dates, title_from_data=True) + series.marker.symbol = 'diamond' + if y2_axis and y2_axis[i]: + series.dLbls = openpyxl.chart.label.DataLabels([openpyxl.chart.label.DataLabel(idx=ws.max_row-2, dLblPos='r', showSerName=True)]) + chart2.series.append(series) + else: + series.dLbls = openpyxl.chart.label.DataLabels([openpyxl.chart.label.DataLabel(idx=0, dLblPos='l', showSerName=True)]) + chart.series.append(series) + if y2_axis: + chart.y_axis.title = thing['axes'][0] + chart2.y_axis.title = thing['axes'][1] + chart.z_axis = chart2.y_axis + chart += chart2 + elif thing['axes']: + chart.y_axis.title = thing['axes'] + ws.add_chart(chart, 'B2') + + wb.save(filename) diff --git a/config.py b/config.py index 6f31727e..d759164e 100644 --- a/config.py +++ b/config.py @@ -71,6 +71,7 @@ class Config: OUT_CSV = 8 OUT_SHIP = 16 OUT_LOG = 32 + OUT_STAT = 64 if platform=='darwin': diff --git a/img/Balance.png b/img/Balance.png new file mode 100644 index 00000000..8696f44c Binary files /dev/null and b/img/Balance.png differ diff --git a/prefs.py b/prefs.py index 4551db29..467b8073 100644 --- a/prefs.py +++ b/prefs.py @@ -9,6 +9,7 @@ import ttk import tkFileDialog from config import config +from chart import have_openpyxl if platform=='win32': @@ -77,10 +78,10 @@ class PreferencesDialog(tk.Toplevel): outframe.grid(padx=10, pady=10, sticky=tk.NSEW) outframe.columnconfigure(0, weight=1) - output = config.getint('output') or (config.OUT_EDDN | config.OUT_SHIP) + output = config.getint('output') or (config.OUT_EDDN | config.OUT_SHIP | config.OUT_STAT) ttk.Label(outframe, text="Please choose what data to save").grid(row=0, columnspan=2, padx=5, pady=3, sticky=tk.W) self.out_eddn= tk.IntVar(value = (output & config.OUT_EDDN) and 1 or 0) - ttk.Checkbutton(outframe, text="Send market data to the Elite Dangerous Data Network", variable=self.out_eddn).grid(row=1, columnspan=2, padx=5, sticky=tk.W) + ttk.Checkbutton(outframe, text="Send station data to the Elite Dangerous Data Network", variable=self.out_eddn).grid(row=1, columnspan=2, padx=5, sticky=tk.W) self.out_bpc = tk.IntVar(value = (output & config.OUT_BPC ) and 1 or 0) ttk.Checkbutton(outframe, text="Market data in Slopey's BPC format", variable=self.out_bpc, command=self.outvarchanged).grid(row=2, columnspan=2, padx=5, sticky=tk.W) self.out_td = tk.IntVar(value = (output & config.OUT_TD ) and 1 or 0) @@ -89,14 +90,17 @@ class PreferencesDialog(tk.Toplevel): ttk.Checkbutton(outframe, text="Market data in CSV format", variable=self.out_csv, command=self.outvarchanged).grid(row=4, columnspan=2, padx=5, sticky=tk.W) self.out_ship= tk.IntVar(value = (output & config.OUT_SHIP) and 1 or 0) ttk.Checkbutton(outframe, text="Ship loadout in E:D Shipyard format", variable=self.out_ship, command=self.outvarchanged).grid(row=5, columnspan=2, padx=5, sticky=tk.W) - self.out_log = tk.IntVar(value = (output & config.OUT_LOG) and 1 or 0) + self.out_log = tk.IntVar(value = (output & config.OUT_LOG ) and 1 or 0) ttk.Checkbutton(outframe, text="Flight log", variable=self.out_log, command=self.outvarchanged).grid(row=6, columnspan=2, padx=5, sticky=tk.W) - ttk.Label(outframe, text=(platform=='darwin' and 'Where:' or 'File location:')).grid(row=7, padx=5, pady=(5,0), sticky=tk.NSEW) + self.out_stat= tk.IntVar(value = have_openpyxl and (output & config.OUT_STAT) and 1 or 0) + ttk.Checkbutton(outframe, text="Cmdr statistics", variable=self.out_stat, command=self.outvarchanged, state=have_openpyxl and tk.NORMAL or tk.DISABLED).grid(row=7, columnspan=2, padx=5, sticky=tk.W) + + ttk.Label(outframe, text=(platform=='darwin' and 'Where:' or 'File location:')).grid(row=8, padx=5, pady=(5,0), sticky=tk.NSEW) self.outbutton = ttk.Button(outframe, text=(platform=='darwin' and 'Change...' or 'Browse...'), command=self.outbrowse) - self.outbutton.grid(row=7, column=1, padx=5, pady=(5,0), sticky=tk.NSEW) + self.outbutton.grid(row=8, column=1, padx=5, pady=(5,0), sticky=tk.NSEW) self.outdir = ttk.Entry(outframe) self.outdir.insert(0, config.get('outdir')) - self.outdir.grid(row=8, columnspan=2, padx=5, pady=5, sticky=tk.EW) + self.outdir.grid(row=9, columnspan=2, padx=5, pady=5, sticky=tk.EW) self.outvarchanged() privacyframe = ttk.LabelFrame(frame, text='Privacy') @@ -123,7 +127,7 @@ class PreferencesDialog(tk.Toplevel): #self.wait_window(self) # causes duplicate events on OSX def outvarchanged(self): - local = self.out_bpc.get() or self.out_td.get() or self.out_csv.get() or self.out_ship.get() or self.out_log.get() + local = self.out_bpc.get() or self.out_td.get() or self.out_csv.get() or self.out_ship.get() or self.out_log.get() or self.out_stat.get() self.outbutton['state'] = local and tk.NORMAL or tk.DISABLED self.outdir['state'] = local and 'readonly' or tk.DISABLED @@ -162,7 +166,7 @@ class PreferencesDialog(tk.Toplevel): credentials = (config.get('username'), config.get('password')) config.set('username', self.username.get().strip()) config.set('password', self.password.get().strip()) - config.set('output', (self.out_eddn.get() and config.OUT_EDDN or 0) + (self.out_bpc.get() and config.OUT_BPC or 0) + (self.out_td.get() and config.OUT_TD or 0) + (self.out_csv.get() and config.OUT_CSV or 0) + (self.out_ship.get() and config.OUT_SHIP or 0) + (self.out_log.get() and config.OUT_LOG or 0)) + config.set('output', (self.out_eddn.get() and config.OUT_EDDN or 0) + (self.out_bpc.get() and config.OUT_BPC or 0) + (self.out_td.get() and config.OUT_TD or 0) + (self.out_csv.get() and config.OUT_CSV or 0) + (self.out_ship.get() and config.OUT_SHIP or 0) + (self.out_log.get() and config.OUT_LOG or 0) + (self.out_stat.get() and config.OUT_STAT or 0)) config.set('outdir', self.outdir.get().strip()) config.set('anonymous', self.out_anon.get()) self.destroy()