Skip to content

Commit bbc00aa

Browse files
Merge pull request #23 from OpenAstroTech/feature/js/opt-in-user-stats
Feature/js/opt in user stats
2 parents 92801a6 + 5225bf4 commit bbc00aa

File tree

4 files changed

+223
-4
lines changed

4 files changed

+223
-4
lines changed

OATFWGUI/anon_usage_data.py

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import logging
2+
import hashlib
3+
import subprocess
4+
import json
5+
import requests
6+
from pathlib import Path
7+
from typing import Tuple
8+
from functools import lru_cache
9+
10+
from PySide6.QtWidgets import QDialog, QDialogButtonBox, QPlainTextEdit, QVBoxLayout, QLabel, QPushButton, QSizePolicy
11+
from PySide6.QtGui import QFont
12+
import pygments
13+
from pygments.lexers import JsonLexer
14+
from pygments.formatters import HtmlFormatter
15+
16+
from platform_check import get_platform, PlatformEnum
17+
from gui_state import LogicState
18+
19+
log = logging.getLogger('')
20+
21+
22+
class AnonStatsDialog(QDialog):
23+
def __init__(self, logic_state: LogicState, parent=None):
24+
super().__init__(parent)
25+
26+
self.setWindowTitle('What statistics will be uploaded?')
27+
28+
QBtn = QDialogButtonBox.Ok
29+
30+
self.buttonBox = QDialogButtonBox(QBtn)
31+
self.buttonBox.accepted.connect(self.accept)
32+
33+
usage_stats_html = dict_to_html(create_anon_stats(logic_state))
34+
35+
wLbl_1 = QLabel('''
36+
These statistics are invaluable for us developers on figuring out what our users are actually
37+
building, so we can figure out where to put our (limited!) time working towards improving.
38+
After a successful OAT firmware upload the following data will be sent to our statistics server:
39+
'''.replace('\n', ' '))
40+
wLbl_1.setWordWrap(True)
41+
wLbl_1.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
42+
43+
self.wBtn_show_hide = QPushButton('▶Click to expand:')
44+
self.wBtn_show_hide.setStyleSheet('QPushButton { color: #0074cc; background-color: transparent; border: 0px; }')
45+
self.wBtn_show_hide.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
46+
self.wBtn_show_hide.clicked.connect(self.show_hide_html)
47+
48+
self.wTxt_html = QPlainTextEdit()
49+
self.wTxt_html.setReadOnly(True)
50+
self.wTxt_html.appendHtml(f'{usage_stats_html}')
51+
self.wTxt_html.setMinimumSize(500, 250)
52+
self.wTxt_html.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
53+
self.wTxt_html.hide()
54+
55+
wLbl_2 = QLabel('''
56+
(the data might not fully be populated yet, you need to progress through the GUI steps first)
57+
'''.replace('\n', ' '))
58+
italic_font = QFont()
59+
italic_font.setItalic(True)
60+
wLbl_2.setFont(italic_font)
61+
wLbl_2.setWordWrap(True)
62+
wLbl_2.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
63+
64+
self.layout = QVBoxLayout()
65+
self.layout.addWidget(wLbl_1)
66+
self.layout.addWidget(self.wBtn_show_hide)
67+
self.layout.addWidget(self.wTxt_html)
68+
self.layout.addWidget(wLbl_2)
69+
self.layout.addWidget(self.buttonBox)
70+
self.setLayout(self.layout)
71+
72+
def show_hide_html(self):
73+
show_hide_text = self.wBtn_show_hide.text()
74+
if self.wTxt_html.isVisible():
75+
self.wTxt_html.hide()
76+
show_hide_text = show_hide_text.replace('▼', '▶')
77+
else:
78+
self.wTxt_html.show()
79+
show_hide_text = show_hide_text.replace('▶', '▼')
80+
self.wBtn_show_hide.setText(show_hide_text)
81+
82+
83+
def dict_to_html(in_dict: dict) -> str:
84+
json_str = json.dumps(in_dict, indent=4, sort_keys=True)
85+
json_lexer = JsonLexer()
86+
html_formatter = HtmlFormatter(noclasses=True, nobackground=True)
87+
data_html = pygments.highlight(json_str, json_lexer, html_formatter)
88+
return data_html
89+
90+
91+
def create_anon_stats(logic_state: LogicState) -> dict:
92+
if logic_state.release_idx is not None:
93+
release_name = logic_state.release_list[logic_state.release_idx].nice_name
94+
else:
95+
release_name = None
96+
97+
if logic_state.config_file_path is not None:
98+
with open(Path(logic_state.config_file_path).resolve(), 'r') as fp:
99+
config_file = fp.read()
100+
else:
101+
config_file = None
102+
103+
# Catch-all for these so we never crash when getting anon-stats
104+
try:
105+
computer_uuid = get_computer_uuid()
106+
except Exception as e:
107+
log.error(f'get_uuid exception: {e}')
108+
computer_uuid = 'unknown'
109+
try:
110+
approx_lat, approx_lon = get_approx_location()
111+
except Exception as e:
112+
log.error(f'get_approx_location exception: {e}')
113+
approx_lat, approx_lon = None, None
114+
115+
stats = {
116+
'host_uuid': computer_uuid,
117+
'pio_env': logic_state.pio_env,
118+
'release_version': release_name,
119+
'config_file': config_file,
120+
'approx_lat': approx_lat,
121+
'approx_lon': approx_lon,
122+
}
123+
return stats
124+
125+
126+
def upload_anon_stats(anon_stats: dict) -> bool:
127+
analytics_url = 'http://config.cloud.openastrotech.com/api/v1/config/'
128+
log.info(f'Uploading statistics to {analytics_url}')
129+
try:
130+
r = requests.post(analytics_url, json=anon_stats, timeout=2.0)
131+
except Exception as e:
132+
log.error(f'Failed to POST statistics: {e}')
133+
return False
134+
if r.status_code != requests.codes.ok:
135+
log.error(f'Failed to POST statistics: {r.status_code} {r.reason} {r.text}')
136+
return False
137+
return True
138+
139+
140+
@lru_cache(maxsize=1)
141+
def get_computer_uuid() -> str:
142+
machine_id_fn = {
143+
PlatformEnum.WINDOWS: get_uuid_windows,
144+
PlatformEnum.LINUX: get_uuid_linux,
145+
PlatformEnum.UNKNOWN: lambda: 'unknown platform',
146+
}.get(get_platform(), lambda: 'unknown, unhandled platform')
147+
machine_id_str = machine_id_fn()
148+
149+
if 'unknown' in machine_id_str.lower():
150+
uuid_str = machine_id_str # Keep as human-readable, don't hash
151+
else:
152+
uuid_str = hashlib.sha256(machine_id_str.encode()).hexdigest()
153+
log.debug(f'Got UUID {repr(uuid_str)}')
154+
return uuid_str
155+
156+
157+
def get_uuid_windows() -> str:
158+
sub_proc = subprocess.run(
159+
['powershell',
160+
'-Command',
161+
'(Get-CimInstance -Class Win32_ComputerSystemProduct).UUID',
162+
],
163+
capture_output=True)
164+
if sub_proc.returncode != 0:
165+
return 'unknown-windows'
166+
windows_uuid = sub_proc.stdout.decode('UTF-8')
167+
return windows_uuid
168+
169+
170+
def get_uuid_linux() -> str:
171+
id_file = Path('/etc/machine-id')
172+
if not id_file.exists():
173+
return 'unknown-linux'
174+
175+
with open(id_file, 'r') as f:
176+
machine_id_contents = f.read().strip()
177+
return machine_id_contents
178+
179+
180+
def to_nearest_half(num: float) -> float:
181+
return round(num * 2, 0) / 2
182+
183+
184+
@lru_cache(maxsize=1)
185+
def get_approx_location() -> Tuple[float, float]:
186+
geo_ip_url = 'https://ipinfo.io/loc'
187+
response = requests.get(geo_ip_url, timeout=2.0)
188+
resp_str = response.content.decode().strip()
189+
lat_str, lon_str = resp_str.split(',')
190+
lat_approx = to_nearest_half(float(lat_str))
191+
lon_approx = to_nearest_half(float(lon_str))
192+
return lat_approx, lon_approx

OATFWGUI/gui_logic.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,16 @@
77
from pathlib import Path
88

99
from PySide6.QtCore import Slot, QThreadPool, QFile, QProcess, Qt
10-
from PySide6.QtWidgets import QLabel, QComboBox, QWidget, QFileDialog, QPushButton, QPlainTextEdit, QGridLayout, QHBoxLayout
10+
from PySide6.QtWidgets import QLabel, QComboBox, QWidget, QFileDialog, QPushButton, QPlainTextEdit, QGridLayout, \
11+
QHBoxLayout, QCheckBox
1112

1213
import requests
1314

1415
from log_utils import LogObject, LoggedExternalFile
1516
from qt_extensions import Worker, QBusyIndicatorGoodBad, BusyIndicatorState
1617
from external_processes import external_processes, get_install_dir
1718
from gui_state import LogicState, PioEnv, FWVersion
19+
from anon_usage_data import AnonStatsDialog, create_anon_stats, upload_anon_stats
1820

1921
log = logging.getLogger('')
2022

@@ -86,6 +88,7 @@ def __init__(self, main_app: 'MainWidget'):
8688
main_app.wBtn_refresh_ports.clicked.connect(self.spawn_worker_thread(self.refresh_ports))
8789
main_app.wCombo_serial_port.currentIndexChanged.connect(self.serial_port_combo_box_changed)
8890
main_app.wBtn_upload_fw.clicked.connect(self.spawn_worker_thread(self.upload_fw))
91+
main_app.wBtn_what_stats.clicked.connect(self.modal_show_stats)
8992

9093
self.threadpool = QThreadPool()
9194
self.threadpool.setMaxThreadCount(1) # Only one worker
@@ -335,6 +338,18 @@ def pio_upload_finished(self):
335338
log.error('Did not exit normally')
336339
self.main_app.wSpn_upload.setState(BusyIndicatorState.BAD)
337340

341+
if self.main_app.wChk_upload_stats.isChecked():
342+
log.info('Uploading anonymous usage statistics')
343+
anon_stats = create_anon_stats(self.logic_state)
344+
upload_anon_stats(anon_stats)
345+
else:
346+
log.info('NOT uploading anonymous usage statistics')
347+
348+
@Slot()
349+
def modal_show_stats(self):
350+
dlg = AnonStatsDialog(self.logic_state, self.main_app)
351+
dlg.exec_()
352+
338353

339354
class MainWidget(QWidget):
340355
def __init__(self, log_object: LogObject):
@@ -364,6 +379,10 @@ def __init__(self, log_object: LogObject):
364379
self.wBtn_upload_fw.setEnabled(False)
365380
self.wSpn_upload = QBusyIndicatorGoodBad(fixed_size=(50, 50))
366381

382+
self.wChk_upload_stats = QCheckBox('Upload anonymous statistics?',
383+
toolTip='After a successful firmware update, upload anonymous firmware details to the OAT devs')
384+
self.wBtn_what_stats = QPushButton('What will be uploaded?')
385+
367386
self.logText = QPlainTextEdit()
368387
self.logText.setLineWrapMode(QPlainTextEdit.NoWrap)
369388
self.logText.setReadOnly(True)
@@ -375,7 +394,9 @@ def __init__(self, log_object: LogObject):
375394
[self.wMsg_fw_version, self.wCombo_fw_version, self.wBtn_download_fw, self.wSpn_download],
376395
[self.wMsg_pio_env, self.wCombo_pio_env, self.wBtn_select_local_config, self.wBtn_build_fw],
377396
[self.wMsg_config_path, None, None, self.wSpn_build],
378-
[self.wBtn_refresh_ports, self.wCombo_serial_port, self.wBtn_upload_fw, self.wSpn_upload]
397+
[self.wBtn_refresh_ports, self.wCombo_serial_port, self.wBtn_upload_fw, self.wSpn_upload],
398+
[None, None, self.wChk_upload_stats, None],
399+
[None, None, self.wBtn_what_stats, None],
379400
]
380401
for y, row_arr in enumerate(layout_arr):
381402
for x, widget in enumerate(row_arr):

OATFWGUI/main.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import time
88
import tempfile
99
import requests
10+
import json
1011
from pathlib import Path
1112
from typing import Dict, Tuple, Optional
1213

@@ -18,7 +19,8 @@
1819
from _version import __version__
1920
from log_utils import LogObject, setup_logging
2021
from gui_logic import MainWidget
21-
from external_processes import external_processes, add_external_process, get_install_dir
22+
from external_processes import external_processes, add_external_process
23+
from anon_usage_data import create_anon_stats
2224

2325
parser = argparse.ArgumentParser(usage='Graphical way to build and load OAT Firmware')
2426
parser.add_argument('--no-gui', action='store_true',
@@ -206,6 +208,9 @@ def main():
206208
retcode = app.exec()
207209
else:
208210
log.debug('NOT executing app')
211+
log.debug('Testing anonymous statistics creation')
212+
anon_stats = create_anon_stats(widget.main_widget.logic.logic_state)
213+
log.debug(f'Statistics: {json.dumps(anon_stats)}')
209214
# Wait a bit before exiting, prevents Qt complaining about deleted objects
210215
time.sleep(1.0)
211216
retcode = 0

requirements.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
platformio==6.1.4
22
PySide6-Essentials==6.4.2
33
requests~=2.28.1
4-
semver~=2.13.0
4+
semver~=2.13.0
5+
pygments~=2.13.0

0 commit comments

Comments
 (0)