├── data └── .gitignore ├── utils ├── __init__.py ├── converter.py ├── version.py ├── usb.py ├── config.py ├── formatting.py └── storage.py ├── interfaces ├── __init__.py ├── interface.py ├── wrapper.py ├── um.py └── tc.py ├── docker-run.cmd ├── screenshots ├── setup.png ├── tables.png └── graphs-v2.png ├── static ├── img │ ├── icon.ico │ └── favicon.ico ├── css │ ├── themes │ │ ├── light.styles.css │ │ ├── dark.styles.css │ │ └── midnight.styles.css │ └── styles.css ├── fonts │ ├── glyphicons-halflings-regular.eot │ ├── glyphicons-halflings-regular.ttf │ ├── glyphicons-halflings-regular.woff │ └── glyphicons-halflings-regular.woff2 └── js │ ├── js-cookie.min.js │ └── application.js ├── .gitignore ├── docker ├── entrypoint.sh └── docker-ble ├── bin └── MicrosoftEdgeWebview2Setup.exe ├── .dockerignore ├── pyinstaller ├── clean.py ├── rename-binaries.py ├── package-source-code.py └── gui-only │ └── hook-cefpython3.py ├── webapp ├── templates │ ├── default.html │ ├── ble.html │ ├── rfcomm.html │ ├── serial.html │ ├── tc66c-import.html │ ├── csv-import.html │ ├── setup.html │ ├── data.html │ ├── graph.html │ └── layout.html ├── backend.py └── index.py ├── on-receive-python-example.cmd ├── docker-build.cmd ├── Dockerfile ├── requirements_headless.txt ├── requirements_headless_new.txt ├── on-receive.sh ├── on-receive.cmd ├── docker-build-all-push.cmd ├── requirements.txt ├── pyinstaller-cli.spec ├── on-receive-python-example.py ├── pyinstaller.spec ├── installer.nsi ├── web.py ├── app.py └── README.md /data/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /interfaces/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docker-run.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | call rd-usb-docker-copy.cmd 3 | call rd-usb-docker-run.cmd 4 | -------------------------------------------------------------------------------- /screenshots/setup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kolinger/rd-usb/HEAD/screenshots/setup.png -------------------------------------------------------------------------------- /static/img/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kolinger/rd-usb/HEAD/static/img/icon.ico -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /build/ 2 | /dist/ 3 | /data/* 4 | *.pyc 5 | 6 | /version.txt 7 | 8 | !.gitignore 9 | -------------------------------------------------------------------------------- /docker/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | python /opt/rd-usb/web.py --data-dir /opt/rd-usb/data $ARGS 3 | -------------------------------------------------------------------------------- /screenshots/tables.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kolinger/rd-usb/HEAD/screenshots/tables.png -------------------------------------------------------------------------------- /static/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kolinger/rd-usb/HEAD/static/img/favicon.ico -------------------------------------------------------------------------------- /screenshots/graphs-v2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kolinger/rd-usb/HEAD/screenshots/graphs-v2.png -------------------------------------------------------------------------------- /bin/MicrosoftEdgeWebview2Setup.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kolinger/rd-usb/HEAD/bin/MicrosoftEdgeWebview2Setup.exe -------------------------------------------------------------------------------- /static/css/themes/light.styles.css: -------------------------------------------------------------------------------- 1 | pre { 2 | background: #f5f5f5; 3 | border-color: #ccc; 4 | color: #333; 5 | } 6 | -------------------------------------------------------------------------------- /static/css/themes/dark.styles.css: -------------------------------------------------------------------------------- 1 | pre { 2 | background: #4e5660; 3 | border-color: #272b30; 4 | color: #c8c8c8; 5 | } 6 | -------------------------------------------------------------------------------- /static/css/themes/midnight.styles.css: -------------------------------------------------------------------------------- 1 | pre { 2 | background: #2e3338; 3 | border-color: #666666; 4 | color: #c8c8c8; 5 | } 6 | -------------------------------------------------------------------------------- /static/fonts/glyphicons-halflings-regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kolinger/rd-usb/HEAD/static/fonts/glyphicons-halflings-regular.eot -------------------------------------------------------------------------------- /static/fonts/glyphicons-halflings-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kolinger/rd-usb/HEAD/static/fonts/glyphicons-halflings-regular.ttf -------------------------------------------------------------------------------- /static/fonts/glyphicons-halflings-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kolinger/rd-usb/HEAD/static/fonts/glyphicons-halflings-regular.woff -------------------------------------------------------------------------------- /static/fonts/glyphicons-halflings-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kolinger/rd-usb/HEAD/static/fonts/glyphicons-halflings-regular.woff2 -------------------------------------------------------------------------------- /utils/converter.py: -------------------------------------------------------------------------------- 1 | class Converter: 2 | def convert(self, item): 3 | item["current-m"] = round(item["current"] * 1000) 4 | return item 5 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | /build/ 2 | /dist/ 3 | /data/* 4 | *.pyc 5 | 6 | /.git/ 7 | /.idea/ 8 | **/__pycache__/ 9 | /bin/ 10 | /screenshots/ 11 | 12 | !.gitignore 13 | -------------------------------------------------------------------------------- /pyinstaller/clean.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | 4 | for entry in os.listdir("dist/"): 5 | if re.search(r"^rd-usb.*\.(exe|zip)$", entry): 6 | os.remove("dist/%s" % entry) 7 | -------------------------------------------------------------------------------- /webapp/templates/default.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block content %} 3 |
4 |
{{ log }}
5 |
6 | {% endblock %} 7 | -------------------------------------------------------------------------------- /webapp/templates/ble.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block content %} 3 |
4 |
5 | 6 |
7 |
8 |
9 | {% endblock %} 10 | -------------------------------------------------------------------------------- /webapp/templates/rfcomm.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block content %} 3 |
4 |
5 | 6 |
7 |
8 |
9 | {% endblock %} 10 | -------------------------------------------------------------------------------- /interfaces/interface.py: -------------------------------------------------------------------------------- 1 | class Interface: 2 | def connect(self): 3 | raise NotImplementedError() 4 | 5 | def disconnect(self): 6 | raise NotImplementedError() 7 | 8 | def read(self): 9 | raise NotImplementedError() 10 | 11 | 12 | class FatalErrorException(Exception): 13 | pass 14 | -------------------------------------------------------------------------------- /on-receive-python-example.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | rem Example how to call python or other language 4 | rem Replace python.exe with your python.exe 5 | rem Replace on-receive-python-example.py with your .py script 6 | rem The %* part is passing all arguments to .py script, required in order to make it work 7 | 8 | call C:\Python311\python.exe on-receive-python-example.py %* 9 | -------------------------------------------------------------------------------- /docker-build.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | python utils/version.py version.txt || goto :error 3 | set /p VERSION==3.0.0 is required with newer python 13 | pendulum~=2.1.2 14 | -------------------------------------------------------------------------------- /requirements_headless_new.txt: -------------------------------------------------------------------------------- 1 | flask~=3.1.2 2 | pyserial~=3.5 3 | python-socketio~=5.15.0 4 | python-engineio~=4.12.3 5 | appdirs~=1.4.4 6 | 7 | bleak~=2.0.0 8 | pycryptodome~=3.23.0 9 | git+https://github.com/pybluez/pybluez.git#egg=pybluez 10 | 11 | # pendulum <3.0.0 is used by default since it has wider compatibility for now 12 | # but pendulum >=3.0.0 is required with newer python 13 | pendulum~=3.1.0 14 | -------------------------------------------------------------------------------- /on-receive.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # first argument $1 is path to JSON file containing measurements 4 | 5 | # read and process temporary file 6 | # in this example JSON will be appended to on-receive.output file, for testing purpose 7 | # here we can parse/format this JSON and send data somewhere else 8 | cat "$1" >> on-receive.output 9 | echo "" >> on-receive.output 10 | 11 | # delete temporary file 12 | rm "$1" 13 | -------------------------------------------------------------------------------- /on-receive.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | rem first argument %1 is path to JSON file containing measurements 4 | 5 | rem read and process temporary file 6 | rem in this example JSON will be appended to on-receive.output file, for testing purpose 7 | rem here we can parse/format this JSON and send data somewhere else 8 | type "%1" >> on-receive.output 9 | echo "" >> on-receive.output 10 | 11 | rem delete temporary file 12 | del "%1" 13 | -------------------------------------------------------------------------------- /docker-build-all-push.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | python utils/version.py version.txt || goto :error 3 | set /p VERSION= 4 |
5 |
6 | 7 |
8 | 9 |
10 | 11 |
12 | 13 |
14 | 15 |
16 | 17 | {% endblock %} 18 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | flask~=3.1.2 2 | pyserial~=3.5 3 | python-socketio~=5.15.0 4 | python-engineio~=4.12.3 5 | appdirs~=1.4.4 6 | 7 | bleak~=2.0.0 8 | pycryptodome~=3.23.0 9 | git+https://github.com/pybluez/pybluez.git#egg=pybluez 10 | 11 | # pendulum <3.0.0 is used by default since it has wider compatibility for now 12 | # but pendulum >=3.0.0 is required with newer python 13 | pendulum~=2.1.2 14 | 15 | # GUI-only depenencies 16 | pywebview~=6.1.0 17 | pythonnet~=3.0.5 18 | screeninfo~=0.8.1 19 | 20 | # don't forget pip install -U pyinstaller on upgrade 21 | # and also update requirements_headless.txt if applicable 22 | # and also update requirements_headless_new.txt if applicable 23 | -------------------------------------------------------------------------------- /pyinstaller/package-source-code.py: -------------------------------------------------------------------------------- 1 | import os 2 | from runpy import run_path 3 | import subprocess 4 | import sys 5 | 6 | sys.path.append(".") 7 | from utils.version import version 8 | 9 | version_script = os.path.realpath("utils/version.py") 10 | run_path(version_script, run_name="write") 11 | 12 | exclude = [ 13 | ".git/", 14 | ".idea/", 15 | "dist/", 16 | "build/", 17 | "venv/", 18 | "__pycache__/", 19 | "*.output", 20 | ] 21 | 22 | for index, pattern in enumerate(exclude): 23 | exclude[index] = " -x!" + pattern 24 | 25 | exclude = " ".join(exclude) 26 | 27 | archive = "dist/rd-usb-source-%s.zip" % version 28 | 29 | subprocess.check_call("7z a -r %s %s ." % (exclude, archive)) 30 | 31 | run_path(version_script, run_name="clean") 32 | -------------------------------------------------------------------------------- /pyinstaller-cli.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python -*- 2 | 3 | from runpy import run_path 4 | version_script = os.path.realpath(workpath + "/../../utils/version.py") 5 | run_path(version_script, run_name="write") 6 | 7 | from PyInstaller.utils.hooks import collect_dynamic_libs 8 | 9 | block_cipher = None 10 | 11 | a = Analysis(['web.py'], 12 | binaries=collect_dynamic_libs('bleak'), 13 | datas=[ 14 | ('webapp/templates', 'webapp/templates'), 15 | ('static', 'static'), 16 | ], 17 | hiddenimports=['engineio.async_drivers.threading', 'pytzdata'], 18 | hookspath=['pyinstaller/shared'], 19 | runtime_hooks=[], 20 | excludes=[], 21 | win_no_prefer_redirects=False, 22 | win_private_assemblies=False, 23 | cipher=block_cipher, 24 | noarchive=False) 25 | 26 | pyz = PYZ(a.pure, a.zipped_data, 27 | cipher=block_cipher) 28 | 29 | exe = EXE(pyz, 30 | a.scripts, 31 | a.binaries, 32 | a.zipfiles, 33 | a.datas, 34 | [], 35 | name='rd-usb', 36 | icon='static/img/icon.ico', 37 | debug=False, 38 | bootloader_ignore_signals=False, 39 | strip=False, 40 | upx=False, 41 | runtime_tmpdir=None, 42 | console=True ) 43 | 44 | run_path(version_script, run_name="clean") 45 | -------------------------------------------------------------------------------- /static/js/js-cookie.min.js: -------------------------------------------------------------------------------- 1 | /*! js-cookie v3.0.5 | MIT */ 2 | !function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self,function(){var n=e.Cookies,o=e.Cookies=t();o.noConflict=function(){return e.Cookies=n,o}}())}(this,(function(){"use strict";function e(e){for(var t=1;t 1 else None 53 | else: 54 | command = __name__ 55 | 56 | if command == "write": 57 | write(detect()) 58 | 59 | elif command == "version.txt": 60 | with open(version_txt, "w") as file: 61 | file.write(detect(force=True)) 62 | 63 | elif command == "clean": 64 | write(None) 65 | 66 | elif command == "detect": 67 | print(detect()) 68 | -------------------------------------------------------------------------------- /docker/docker-ble: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | profile docker-ble flags=(attach_disconnected,mediate_deleted) { 4 | 5 | #include 6 | #include 7 | 8 | dbus (send, receive) bus=system peer=(name=org.bluez, label=unconfined), 9 | dbus (send, receive) bus=system interface=org.bluez.GattCharacteristic1 peer=(label=unconfined), 10 | dbus (send, receive) bus=system interface=org.bluez.GattDescriptor1 peer=(label=unconfined), 11 | dbus (send, receive) bus=system interface=org.freedesktop.DBus.ObjectManager peer=(label=unconfined), 12 | dbus (send, receive) bus=system interface=org.freedesktop.DBus.Properties peer=(label=unconfined), 13 | 14 | network, 15 | capability, 16 | file, 17 | umount, 18 | 19 | # Host (privileged) processes may send signals to container processes. 20 | signal (receive) peer=unconfined, 21 | # Container processes may send signals amongst themselves. 22 | signal (send,receive) peer=docker-ble, 23 | 24 | deny @{PROC}/* w, # deny write for all files directly in /proc (not in a subdir) 25 | # deny write to files not in /proc//** or /proc/sys/** 26 | deny @{PROC}/{[^1-9],[^1-9][^0-9],[^1-9s][^0-9y][^0-9s],[^1-9][^0-9][^0-9][^0-9]*}/** w, 27 | deny @{PROC}/sys/[^k]** w, # deny /proc/sys except /proc/sys/k* (effectively /proc/sys/kernel) 28 | deny @{PROC}/sys/kernel/{?,??,[^s][^h][^m]**} w, # deny everything except shm* in /proc/sys/kernel/ 29 | deny @{PROC}/sysrq-trigger rwklx, 30 | deny @{PROC}/kcore rwklx, 31 | deny mount, 32 | deny /sys/[^f]*/** wklx, 33 | deny /sys/f[^s]*/** wklx, 34 | deny /sys/fs/[^c]*/** wklx, 35 | deny /sys/fs/c[^g]*/** wklx, 36 | deny /sys/fs/cg[^r]*/** wklx, 37 | deny /sys/firmware/** rwklx, 38 | deny /sys/kernel/security/** rwklx, 39 | 40 | # suppress ptrace denials when using 'docker ps' or using 'ps' inside a container 41 | ptrace (trace,read,tracedby,readby) peer=docker-ble, 42 | } 43 | -------------------------------------------------------------------------------- /utils/usb.py: -------------------------------------------------------------------------------- 1 | data_line_voltages = [ 2 | { 3 | "name": "Apple 0.5A", 4 | "positive": 2, 5 | "negative": 2, 6 | }, 7 | { 8 | "name": "Apple 1.0A", 9 | "positive": 2, 10 | "negative": 2.7, 11 | }, 12 | { 13 | "name": "Apple 2.1A", 14 | "positive": 2.7, 15 | "negative": 2, 16 | }, 17 | { 18 | "name": "Apple 2.4A", 19 | "positive": 2.7, 20 | "negative": 2.7, 21 | }, 22 | { 23 | "name": "Samsung 0.9A", 24 | "positive": 1.7, 25 | "negative": 1.7, 26 | }, 27 | { 28 | "name": "Quick Charge", 29 | "values": [ 30 | {"positive": 0.6, "negative": 0}, # 5V 31 | {"positive": 3.3, "negative": 0.6}, # 9V 32 | {"positive": 0.6, "negative": 0.6}, # 12V 33 | {"positive": 3.3, "negative": 3.3}, # 20V 34 | ], 35 | }, 36 | { 37 | "name": "DCP 1.5A", 38 | "equal": True, 39 | } 40 | ] 41 | 42 | 43 | def decode_usb_data_lines(positive, negative): 44 | for data in data_line_voltages: 45 | if "equal" in data: 46 | if compare_voltage(positive, negative): 47 | return data["name"] 48 | 49 | elif "values" in data: 50 | for pair in list(data["values"]): 51 | if compare_voltage(positive, pair["positive"]) and compare_voltage(negative, pair["negative"]): 52 | return data["name"] 53 | 54 | elif compare_voltage(positive, data["positive"]) and compare_voltage(negative, data["negative"]): 55 | return data["name"] 56 | 57 | return "Unknown" 58 | 59 | 60 | def compare_voltage(value, reference, tolerance=5): 61 | if value > 2.7: 62 | tolerance = 15 63 | minimum = reference * (100 - tolerance) / 100 64 | maximum = reference * (100 + tolerance) / 100 65 | return value >= minimum and value <= maximum 66 | -------------------------------------------------------------------------------- /pyinstaller.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python -*- 2 | 3 | from runpy import run_path 4 | version_script = os.path.realpath(workpath + "/../../utils/version.py") 5 | run_path(version_script, run_name="write") 6 | 7 | from PyInstaller.utils.hooks import collect_dynamic_libs 8 | 9 | block_cipher = None 10 | 11 | a = Analysis(['app.py'], 12 | binaries=collect_dynamic_libs('bleak'), 13 | datas=[ 14 | ('webapp/templates', 'webapp/templates'), 15 | ('static', 'static'), 16 | ], 17 | hiddenimports=['engineio.async_drivers.threading', 'pytzdata'], 18 | hookspath=['pyinstaller/shared', 'pyinstaller/gui-only'], 19 | runtime_hooks=[], 20 | excludes=[], 21 | win_no_prefer_redirects=False, 22 | win_private_assemblies=False, 23 | cipher=block_cipher, 24 | noarchive=False) 25 | 26 | pyz = PYZ(a.pure, a.zipped_data, 27 | cipher=block_cipher) 28 | 29 | #exe = EXE(pyz, 30 | # a.scripts, 31 | # a.binaries, 32 | # a.zipfiles, 33 | # a.datas, 34 | # [], 35 | # name='rd-usb', 36 | # icon='static/img/icon.ico', 37 | # debug=False, 38 | # bootloader_ignore_signals=False, 39 | # strip=False, 40 | # upx=False, 41 | # runtime_tmpdir=None, 42 | # console=False) 43 | 44 | exe = EXE(pyz, 45 | a.scripts, 46 | [], 47 | exclude_binaries=True, 48 | name='rd-usb', 49 | icon='static/img/icon.ico', 50 | debug=False, 51 | bootloader_ignore_signals=False, 52 | strip=False, 53 | upx=False, 54 | console=False) 55 | 56 | coll = COLLECT(exe, 57 | a.binaries, 58 | a.zipfiles, 59 | a.datas, 60 | strip=False, 61 | upx=False, 62 | upx_exclude=[], 63 | name='rd-usb') 64 | 65 | run_path(version_script, run_name="clean") 66 | -------------------------------------------------------------------------------- /utils/config.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import sys 4 | 5 | from appdirs import user_data_dir, user_cache_dir 6 | 7 | _data_path = None 8 | _cache_path = None 9 | _args = None 10 | 11 | if getattr(sys, "frozen", False): 12 | static_path = sys._MEIPASS + "/static" 13 | else: 14 | static_path = os.path.realpath(os.path.dirname(__file__) + "/../static") 15 | 16 | 17 | def initialize_paths_from_args(args): 18 | global _data_path 19 | global _cache_path 20 | global _args 21 | _args = args 22 | if args.data_dir: 23 | _data_path = args.data_dir 24 | _cache_path = os.path.join(_data_path, "cache") 25 | 26 | 27 | def get_args(): 28 | return _args 29 | 30 | 31 | def get_data_path(): 32 | global _data_path 33 | 34 | if _data_path is None: 35 | _data_path = user_data_dir("rd-usb", False) 36 | 37 | if not os.path.exists(_data_path): 38 | os.makedirs(_data_path) 39 | 40 | return _data_path 41 | 42 | 43 | def get_cache_path(): 44 | global _cache_path 45 | 46 | if _cache_path is None: 47 | _cache_path = user_cache_dir("rd-usb", False) 48 | 49 | if not os.path.exists(_cache_path): 50 | os.makedirs(_cache_path) 51 | 52 | return _cache_path 53 | 54 | 55 | class Config: 56 | data = {} 57 | 58 | def __init__(self): 59 | self.config_file = os.path.join(get_data_path(), "config.json") 60 | 61 | if os.path.exists(self.config_file): 62 | with open(self.config_file, "r") as file: 63 | try: 64 | self.data = json.load(file) 65 | except ValueError: 66 | pass 67 | 68 | def read(self, name, fallback=None): 69 | if name in self.data: 70 | return self.data[name] 71 | return fallback 72 | 73 | def write(self, name, value, flush=True): 74 | self.data[name] = value 75 | if flush: 76 | self.flush() 77 | 78 | def flush(self): 79 | with open(self.config_file, "w") as file: 80 | json.dump(self.data, file, indent=True) 81 | -------------------------------------------------------------------------------- /webapp/templates/tc66c-import.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block content %} 3 |

TC66C recording import

4 | 5 |
6 | First try to establish live connection with Connect button. After successful connection is made you can 7 | disconnect and use import functionality. 8 |
9 | 10 | {% for message in messages %} 11 |
{{ message }}
12 | {% endfor %} 13 | 14 |
15 |
16 |
17 | 18 | 19 |
20 |
21 | 22 | 24 |
25 |
26 | 27 | 28 |
29 |
30 | 33 |
34 |
35 |
36 | 37 |
38 | * 'Calculate W/mAh/mWh/Ω' option will calculate remaining values from voltage and current 39 | since TC66C doesn't record anything else. Calculated values are best estimates, 40 | and they may not be accurate.
41 | WARNING: Incorrect period value will make bogus mAh/mWh values. 42 |
43 | {% endblock %} 44 | -------------------------------------------------------------------------------- /webapp/templates/csv-import.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block content %} 3 |

CSV import

4 | 5 |
6 | This functionality can import CSV files created by this application (via Export CSV feature). 7 | You can also create your own CSV files, but they are required to follow header/column naming and data format 8 | according to what Export CSV feature creates. 9 |
10 | 11 | {% for message in messages %} 12 |
{{ message }}
13 | {% endfor %} 14 | 15 |
16 |
17 |
18 | 19 | 20 |
21 |
22 | 23 | 29 |
30 |
31 | 32 |
33 | 34 |
35 |
36 |
37 | 40 |
41 |
42 |
43 | {% endblock %} 44 | -------------------------------------------------------------------------------- /webapp/templates/setup.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block content %} 3 |
4 |
5 | 6 | 11 |
12 |
13 | 14 | 18 |
19 | Automatically connect to previously connected device when this application launches. 20 |
21 |
22 |
23 | 24 | 25 |
26 | How long to wait before giving up. Set to 0 to wait indefinitely. 27 | Default {{ default_timeout }} seconds. 28 |
29 |
30 |
31 | 32 | 33 |
34 | How many times to retry operation before giving up. Set to 0 to retry indefinitely. 35 | Default: {{ default_retry_count }} times. 36 |
37 |
38 |
39 | Note: Both timeout and number of retries work together. 40 | If one run-outs before the other, the connection is terminated. 41 |
42 |
43 | 46 |
47 |
48 | {% endblock %} 49 | -------------------------------------------------------------------------------- /installer.nsi: -------------------------------------------------------------------------------- 1 | !include MUI2.nsh 2 | 3 | Name "RD-USB" 4 | 5 | !insertmacro MUI_PAGE_DIRECTORY 6 | !insertmacro MUI_PAGE_INSTFILES 7 | 8 | !define MUI_FINISHPAGE_RUN 9 | !define MUI_FINISHPAGE_RUN_NOTCHECKED 10 | !define MUI_FINISHPAGE_RUN_TEXT "Start application" 11 | !define MUI_FINISHPAGE_RUN_FUNCTION "LaunchLink" 12 | !insertmacro MUI_PAGE_FINISH 13 | 14 | !insertmacro MUI_LANGUAGE "English" 15 | 16 | OutFile "dist/rd-usb-install.exe" 17 | 18 | InstallDir "$PROGRAMFILES64\rd-usb" 19 | InstallDirRegKey HKLM "Software\rd-usb" "Install_Dir" 20 | RequestExecutionLevel admin 21 | 22 | Section "Dummy Section" SecDummy 23 | ; program files 24 | SetOutPath $INSTDIR 25 | RMDir /r $INSTDIR 26 | File /r dist\rd-usb\*.* 27 | 28 | ; shortcuts 29 | CreateDirectory "$SMPROGRAMS\rd-usb" 30 | CreateShortcut "$SMPROGRAMS\RD-USB\Uninstall.lnk" "$INSTDIR\uninstall.exe" 31 | CreateShortcut "$SMPROGRAMS\RD-USB\RD-USB.lnk" "$INSTDIR\rd-usb.exe" 32 | 33 | ; reinstall helper 34 | WriteRegStr HKLM Software\rd-usb "Install_Dir" "$INSTDIR" 35 | 36 | ; uninstaller 37 | WriteUninstaller "$INSTDIR\uninstall.exe" 38 | WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\rd-usb" "DisplayName" "RD-USB" 39 | WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\rd-usb" "DisplayIcon" "$INSTDIR\static\img\icon.ico" 40 | WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\rd-usb" "UninstallString" '"$INSTDIR\uninstall.exe"' 41 | WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\rd-usb" "NoModify" 1 42 | WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\rd-usb" "NoRepair" 1 43 | 44 | ; edge webview2 runtime 45 | Call installEdgeWebView2 46 | SectionEnd 47 | 48 | Section "Uninstall" 49 | ; program files 50 | RMDir /r $INSTDIR 51 | 52 | ; shortcuts 53 | RMDir /r "$SMPROGRAMS\rd-usb" 54 | 55 | ; registry 56 | DeleteRegKey HKLM "Software\rd-usb" 57 | DeleteRegKey HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\rd-usb" 58 | SectionEnd 59 | 60 | Function LaunchLink 61 | ExecShell "" "$SMPROGRAMS\rd-usb\RD-USB.lnk" 62 | FunctionEnd 63 | 64 | # Install edge webview2 by launching the bootstrapper 65 | # See https://docs.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution#online-only-deployment 66 | # MicrosoftEdgeWebview2Setup.exe download here https://go.microsoft.com/fwlink/p/?LinkId=2124703 67 | Function installEdgeWebView2 68 | # If this key exists and is not empty then webview2 is already installed 69 | ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv" 70 | ReadRegStr $1 HKCU "Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv" 71 | DetailPrint "WebView2 machine version: $0" 72 | DetailPrint "WebView2 user version: $1" 73 | 74 | ${If} $0 == "" 75 | ${AndIf} $1 == "" 76 | SetDetailsPrint both 77 | DetailPrint "Installing: WebView2 Runtime, this may take a while, please wait..." 78 | SetDetailsPrint listonly 79 | 80 | InitPluginsDir 81 | CreateDirectory "$pluginsdir\webview2bootstrapper" 82 | SetOutPath "$pluginsdir\webview2bootstrapper" 83 | File "bin\MicrosoftEdgeWebview2Setup.exe" 84 | ExecWait '"$pluginsdir\webview2bootstrapper\MicrosoftEdgeWebview2Setup.exe" /silent /install' 85 | 86 | SetDetailsPrint both 87 | ${Else} 88 | DetailPrint "WebView2 is already installed" 89 | ${EndIf} 90 | FunctionEnd 91 | -------------------------------------------------------------------------------- /static/css/styles.css: -------------------------------------------------------------------------------- 1 | .btn-default:active, 2 | .btn-default:focus { 3 | outline: none; 4 | background-color: #fff; 5 | border-color: #ccc; 6 | } 7 | 8 | .navbar-placeholder { 9 | height: 71px; 10 | } 11 | 12 | .navbar .navbar-header .navbar-brand { 13 | padding-left: 12px; 14 | padding-right: 12px; 15 | margin-left: -12px; 16 | } 17 | 18 | .navbar-nav > li > a { 19 | padding-left: 12px; 20 | padding-right: 12px; 21 | } 22 | 23 | .navbar .top { 24 | float: left; 25 | } 26 | 27 | .navbar .top .right { 28 | display: none; 29 | float: right; 30 | } 31 | 32 | .navbar .navbar-form.setup { 33 | margin-right: -15px; 34 | } 35 | 36 | .navbar .navbar-form .form-control { 37 | padding-left: 7px; 38 | padding-right: 7px; 39 | } 40 | 41 | .navbar .form-group { 42 | margin: 0 7px 0 0; 43 | } 44 | 45 | .navbar .form-group input { 46 | width: 165px; 47 | } 48 | 49 | .navbar .form-group input[name="port"] { 50 | width: 90px; 51 | } 52 | 53 | .content { 54 | padding: 0 0 10px 0; 55 | } 56 | 57 | .content .log { 58 | overflow: auto; 59 | } 60 | 61 | .content .log pre { 62 | white-space: pre-wrap; 63 | word-wrap: break-word; 64 | margin-bottom: 0; 65 | 66 | -webkit-border-radius: 0; 67 | -moz-border-radius: 0; 68 | border-radius: 0; 69 | } 70 | 71 | .content .data { 72 | margin: 20px 0 0 0; 73 | } 74 | 75 | .content .graph-data { 76 | margin: 40px 0 0 0; 77 | } 78 | 79 | .content .graph-data .graph { 80 | margin: 20px 0 0 0; 81 | } 82 | 83 | .content .graph-data .graph .loading { 84 | font-size: 24px; 85 | font-weight: 700; 86 | } 87 | 88 | .form-inline .form-group { 89 | margin: 0 10px 0 0; 90 | } 91 | 92 | .form-inline .form-group label { 93 | margin: 0 10px 0 0; 94 | } 95 | 96 | #graph { 97 | height: 700px; 98 | } 99 | 100 | @media (max-width: 1199px) { 101 | .navbar-header { 102 | display: none; 103 | } 104 | } 105 | 106 | @media (max-width: 991px) { 107 | .navbar .top { 108 | float: none; 109 | } 110 | 111 | .navbar .top .navbar-header { 112 | float: left !important; 113 | } 114 | 115 | .navbar .top .right { 116 | display: block; 117 | } 118 | 119 | .navbar .top .right .nav li { 120 | float: left !important; 121 | } 122 | 123 | .navbar > .container > .nav, 124 | .navbar > .container > .navbar-form { 125 | float: none !important; 126 | display: none; 127 | padding-left: 0 !important; 128 | } 129 | } 130 | 131 | @media (max-width: 767px) { 132 | .container { 133 | padding: 0 15px; 134 | } 135 | 136 | .navbar > .container > .navbar-form { 137 | padding: 0 15px !important; 138 | border: none; 139 | } 140 | 141 | .navbar .form-group input { 142 | width: 100% !important; 143 | } 144 | 145 | .navbar .form-group { 146 | margin: 0 0 10px 0; 147 | } 148 | } 149 | 150 | @media (min-width: 787px) { 151 | .container { 152 | padding-left: 0; 153 | padding-right: 0; 154 | } 155 | } 156 | 157 | .rfcomm .scan-result, 158 | .ble .scan-result, 159 | .serial .scan-result { 160 | margin: 20px 0 0 0; 161 | } 162 | 163 | .serial input[name="port"] { 164 | width: 150px; 165 | } 166 | 167 | .serial .scan { 168 | margin: 20px 0 0 0; 169 | } 170 | 171 | .paginator { 172 | margin: -10px 0 20px 0; 173 | } 174 | -------------------------------------------------------------------------------- /webapp/templates/data.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block content %} 3 |
4 |
5 | 6 | 13 |
14 | 15 | 16 | Import CSV 17 | 18 | Import TC66C recording 19 |
20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 41 | 42 | 43 | 44 | 45 | {% for item in measurements %} 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 62 | 63 | 64 | {% endfor %} 65 | 66 |
TimeVoltageCurrentPowerTemperatureData +/-Mode 33 | Accumulated 34 | 35 | show actual 36 | 37 | 38 | show zeroed 39 | 40 | Resistance
{{ format.time(item) }}{{ format.voltage(item) }}{{ format.current(item) }}{{ format.power(item) }}{{ format.temperature(item) }}{{ format.data(item) }}{{ format.mode(item) }} 55 | 56 | {{ format.accumulated(item) }} 57 | 58 | 59 | {{ format.zeroed_accumulated(item) }} 60 | 61 | {{ format.resistance(item) }}
67 |
68 | 69 | {% if pages|length > 1 %} 70 |
71 | 78 |
79 | {% endif %} 80 | {% endblock %} 81 | -------------------------------------------------------------------------------- /interfaces/wrapper.py: -------------------------------------------------------------------------------- 1 | from multiprocessing import Queue, Process 2 | import queue 3 | from queue import Empty 4 | from time import time 5 | import traceback 6 | 7 | from interfaces.interface import Interface, FatalErrorException 8 | from interfaces.tc import TcBleInterface, TcSerialInterface 9 | from interfaces.um import UmInterface, UmRfcommInterface 10 | from utils.config import Config, get_args, initialize_paths_from_args 11 | 12 | 13 | class Wrapper(Interface): 14 | listener = None 15 | process = None 16 | 17 | def __init__(self): 18 | self.command = Queue() 19 | self.result = Queue() 20 | 21 | def run(self): 22 | if self.process is None or not self.process.is_alive(): 23 | self.process = Process(target=self._run, args=(self.command, self.result, get_args())) 24 | self.process.daemon = True 25 | self.process.start() 26 | 27 | def _run(self, command, result, args): 28 | receiver = Receiver(command, result, args) 29 | receiver.run() 30 | 31 | def connect(self): 32 | self.run() 33 | self.command.put("connect") 34 | self.get_result(60) 35 | 36 | def disconnect(self): 37 | if not self.process.is_alive(): 38 | return 39 | 40 | self.command.put("disconnect") 41 | try: 42 | self.get_result(10) 43 | except TimeoutError: 44 | self.process.terminate() 45 | 46 | def read(self): 47 | self.run() 48 | self.command.put("read") 49 | return self.get_result(60) 50 | 51 | def get_result(self, timeout): 52 | timeout = time() + timeout 53 | result = None 54 | while timeout > time(): 55 | try: 56 | result = self.result.get(block=True, timeout=1) 57 | break 58 | except Empty: 59 | pass 60 | 61 | if result is None: 62 | raise TimeoutError 63 | 64 | if "FatalErrorException" in result: 65 | raise FatalErrorException(result) 66 | if "Traceback" in result: 67 | raise ErrorException(result) 68 | 69 | return result 70 | 71 | 72 | class Receiver: 73 | def __init__(self, command, result, args): 74 | self.command = command 75 | self.result = result 76 | initialize_paths_from_args(args) 77 | 78 | def run(self): 79 | config = Config() 80 | 81 | version = config.read("version") 82 | serialTimeout = int(config.read("serial_timeout", 10)) 83 | if version.startswith("TC"): 84 | if version.endswith("USB"): 85 | interface = TcSerialInterface(config.read("port"), serialTimeout) 86 | else: 87 | interface = TcBleInterface(config.read("ble_address")) 88 | else: 89 | if version.endswith("Serial"): 90 | interface = UmInterface(config.read("port"), serialTimeout) 91 | else: 92 | interface = UmRfcommInterface(config.read("rfcomm_address")) 93 | 94 | if version.startswith("UM25C"): 95 | interface.enable_higher_resolution() 96 | 97 | while True: 98 | try: 99 | message = self.command.get(block=True, timeout=1) 100 | except queue.Empty: 101 | continue 102 | 103 | if message == "connect": 104 | self.result.put(self.call(interface.connect, "connected")) 105 | if message == "disconnect": 106 | self.result.put(self.call(interface.disconnect, "disconnected")) 107 | if message == "read": 108 | self.result.put(self.call(interface.read)) 109 | 110 | def call(self, callback, default=None): 111 | try: 112 | result = callback() 113 | if result is None: 114 | return default 115 | return result 116 | except (KeyboardInterrupt, SystemExit): 117 | raise 118 | except: 119 | return traceback.format_exc() 120 | 121 | 122 | class ErrorException(Exception): 123 | pass 124 | -------------------------------------------------------------------------------- /webapp/templates/graph.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block content %} 3 |
4 |
5 | 6 | 13 |
14 | 15 | 16 | 17 |
18 | 19 | {% if item %} 20 |
21 |

Current value

22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 |
TimeVoltageCurrentPowerTemperatureData +/-ModeAccumulatedResistance
{{ format.time(item) }}{{ format.voltage(item) }}{{ format.current(item) }}{{ format.power(item) }}{{ format.temperature(item) }}{{ format.data(item) }}{{ format.mode(item) }}{{ format.accumulated(item) }}{{ format.resistance(item) }}
50 |
51 | {% endif %} 52 | 53 |
54 |
55 | {% macro render_select(name, current) %} 56 | 61 | {% endmacro %} 62 |
63 | 64 | {{ render_select("left_axis", left_axis) }} 65 |
66 |
67 | 68 | {{ render_select("right_axis", right_axis) }} 69 |
70 |
71 | 72 | 77 |
78 | 79 |
80 | 81 |
82 |
Loading... Please wait...
83 |
84 |
85 |
86 | {% endblock %} 87 | -------------------------------------------------------------------------------- /interfaces/um.py: -------------------------------------------------------------------------------- 1 | import codecs 2 | from collections import OrderedDict 3 | from time import time 4 | 5 | import bluetooth 6 | import serial 7 | 8 | from interfaces.interface import Interface, FatalErrorException 9 | 10 | 11 | class UmInterface(Interface): 12 | serial = None 13 | higher_resolution = False 14 | modes = { 15 | 0: "Unknown", 16 | 1: "QC2.0", 17 | 2: "QC3.0", 18 | 3: "APP2.4A", 19 | 4: "APP2.1A", 20 | 5: "APP1.0A", 21 | 6: "APP0.5A", 22 | 7: "DCP1.5A", 23 | 8: "SAMSUNG", 24 | 65535: "Unknown" 25 | } 26 | 27 | def __init__(self, port, timeout): 28 | self.port = port 29 | self.timeout = timeout 30 | 31 | def enable_higher_resolution(self): 32 | self.higher_resolution = True 33 | 34 | def connect(self): 35 | if self.serial is None: 36 | self.serial = serial.Serial(port=self.port, baudrate=9600, timeout=self.timeout, write_timeout=0) 37 | 38 | def read(self): 39 | self.open() 40 | self.send("f0") 41 | data = self.serial.read(130) 42 | return self.parse(data) 43 | 44 | def send(self, value): 45 | self.open() 46 | self.serial.write(bytes.fromhex(value)) 47 | 48 | def parse(self, data): 49 | if len(data) < 130: 50 | return None 51 | 52 | data = codecs.encode(data, "hex").decode("utf-8") 53 | 54 | result = OrderedDict() 55 | 56 | multiplier = 10 if self.higher_resolution else 1 57 | 58 | result["timestamp"] = time() 59 | result["voltage"] = int("0x" + data[4] + data[5] + data[6] + data[7], 0) / (100 * multiplier) 60 | result["current"] = int("0x" + data[8] + data[9] + data[10] + data[11], 0) / (1000 * multiplier) 61 | result["power"] = int("0x" + data[12] + data[13] + data[14] + data[15] + data[16] + 62 | data[17] + data[18] + data[19], 0) / 1000 63 | result["temperature"] = int("0x" + data[20] + data[21] + data[22] + data[23], 0) 64 | result["data_plus"] = int("0x" + data[192] + data[193] + data[194] + data[195], 0) / 100 65 | result["data_minus"] = int("0x" + data[196] + data[197] + data[198] + data[199], 0) / 100 66 | result["mode_id"] = int("0x" + data[200] + data[201] + data[202] + data[203], 0) 67 | result["mode_name"] = None 68 | result["accumulated_current"] = int("0x" + data[204] + data[205] + data[206] + data[207] + data[208] + 69 | data[209] + data[210] + data[211], 0) 70 | result["accumulated_power"] = int("0x" + data[212] + data[213] + data[214] + data[215] + data[216] + 71 | data[217] + data[218] + data[219], 0) 72 | result["accumulated_time"] = int("0x" + data[224] + data[225] + data[226] + data[227] + data[228] + 73 | data[229] + data[230] + data[231], 0) 74 | result["resistance"] = int("0x" + data[244] + data[245] + data[246] + data[247] + data[248] + 75 | data[249] + data[250] + data[251], 0) / 10 76 | 77 | if result["mode_id"] in self.modes: 78 | result["mode_name"] = self.modes[result["mode_id"]] 79 | 80 | return result 81 | 82 | def open(self): 83 | if not self.serial.isOpen(): 84 | self.serial.open() 85 | 86 | def disconnect(self): 87 | if self.serial: 88 | self.serial.close() 89 | 90 | 91 | class UmRfcommInterface(UmInterface): 92 | socket = None 93 | 94 | def __init__(self, address): 95 | super().__init__(None, None) 96 | self.address = address 97 | 98 | def connect(self): 99 | if self.socket is None: 100 | services = bluetooth.find_service(address=self.address) 101 | service = None 102 | for item in services: 103 | if item["protocol"] == "RFCOMM": 104 | service = item 105 | break 106 | 107 | if service is None: 108 | raise FatalErrorException("Bluetooth service not found, try to initiate Setup again") 109 | 110 | self.socket = bluetooth.BluetoothSocket(bluetooth.RFCOMM) 111 | self.socket.connect((service["host"], service["port"])) 112 | 113 | def read(self): 114 | self.connect() 115 | self.send("f0") 116 | buffer = bytearray() 117 | deadline = time() + 30 118 | while len(buffer) < 130 and time() < deadline: 119 | data = self.socket.recv(130) 120 | buffer.extend(data) 121 | return self.parse(buffer) 122 | 123 | def send(self, value): 124 | self.connect() 125 | self.socket.send(bytes.fromhex(value)) 126 | 127 | def disconnect(self): 128 | if self.socket: 129 | self.socket.close() 130 | self.socket = None 131 | -------------------------------------------------------------------------------- /web.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import logging 3 | from logging import StreamHandler 4 | from logging.handlers import TimedRotatingFileHandler 5 | import multiprocessing 6 | import random 7 | import string 8 | import sys 9 | from threading import Thread 10 | from time import sleep 11 | from urllib import request 12 | import webbrowser 13 | 14 | from flask import Flask 15 | import socketio 16 | from werkzeug.middleware.dispatcher import DispatcherMiddleware 17 | 18 | from utils.config import Config, static_path, get_data_path, initialize_paths_from_args 19 | from utils.storage import Storage 20 | from webapp.backend import Backend 21 | from webapp.index import Index 22 | 23 | 24 | def url_ok(url): 25 | try: 26 | request.urlopen(url=url) 27 | return True 28 | except Exception: 29 | return False 30 | 31 | 32 | def parse_cli(open_browser=True, webview=False): 33 | parser = argparse.ArgumentParser() 34 | parser.add_argument("port", nargs="?", type=int, default=5000, help="Port for web server to listen on") 35 | parser.add_argument("--listen", type=str, default="0.0.0.0", 36 | help="Listen on address of specific interface (defaults to all interfaces)") 37 | parser.add_argument("--on-receive", help="Call this program/script when new measurements are received") 38 | parser.add_argument("--on-receive-interval", type=int, default=60, help="Interval for --on-receive (in seconds)") 39 | parser.add_argument("--data-dir", type=str, help="Where to store configuration and user data files") 40 | if webview: 41 | parser.add_argument("--disable-gpu", action="store_true", default=False, help="Disable GPU rendering") 42 | else: 43 | parser.add_argument("--daemon", action="store_true", default=not open_browser, help="Do not launch web browser") 44 | parser.add_argument("--prefix", default="/", help="If you want to reverse-proxy from path, like /rd-usb") 45 | 46 | return parser.parse_args() 47 | 48 | 49 | def run(args=None, embedded=False): 50 | if not args: 51 | args = parse_cli() 52 | 53 | if not embedded: 54 | initialize_paths_from_args(args) 55 | 56 | port = args.port 57 | listen = args.listen 58 | daemon = "daemon" in args and args.daemon 59 | 60 | if "prefix" in args: 61 | prefix = args.prefix 62 | else: 63 | prefix = "/" 64 | if not prefix.startswith("/"): 65 | prefix = "/" + prefix 66 | if len(prefix) > 1 and prefix.endswith("/"): 67 | prefix = prefix[0:-1] 68 | 69 | app = Flask(__name__, static_folder=static_path) 70 | app.config["embedded"] = embedded 71 | app.config["app_prefix"] = prefix 72 | app.register_blueprint(Index().register()) 73 | 74 | if prefix != "/": 75 | def fallback(env, resp): 76 | resp(b"200 OK", [(b"Content-Type", b"text/plain; charset=UTF-8")]) 77 | return [b"use '%s' instead" % prefix.encode("utf-8")] 78 | 79 | app.config["APPLICATION_ROOT"] = prefix 80 | app.wsgi_app = DispatcherMiddleware(fallback, {prefix: app.wsgi_app}) 81 | 82 | logger = logging.getLogger() 83 | logger.setLevel(logging.INFO) 84 | formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s") 85 | 86 | console = StreamHandler() 87 | console.setLevel(logging.DEBUG) 88 | console.setFormatter(formatter) 89 | logger.addHandler(console) 90 | 91 | if not app.debug: 92 | file = TimedRotatingFileHandler(get_data_path() + "/error.log", when="w0", backupCount=14) 93 | file.setLevel(logging.ERROR) 94 | file.setFormatter(formatter) 95 | logger.addHandler(file) 96 | 97 | try: 98 | config = Config() 99 | secret_key = config.read("secret_key") 100 | if not secret_key: 101 | secret_key = "".join(random.choice(string.ascii_uppercase + string.digits) for _ in range(16)) 102 | config.write("secret_key", secret_key) 103 | app.secret_key = secret_key 104 | 105 | Storage().init() 106 | 107 | sockets = socketio.Server(async_mode="threading", cors_allowed_origins="*") 108 | socketio_path = "socket.io" 109 | if len(prefix) > 1: 110 | socketio_path = prefix[1:] + "/" + socketio_path 111 | app.wsgi_app = socketio.Middleware(sockets, app.wsgi_app, socketio_path=socketio_path) 112 | sockets.register_namespace(Backend(args.on_receive, args.on_receive_interval)) 113 | 114 | if not embedded: 115 | def open_in_browser(): 116 | logging.info("Application is starting...") 117 | 118 | url = "http://127.0.0.1:%s" % port 119 | while not url_ok(url): 120 | sleep(0.5) 121 | 122 | logging.info("Application is available at " + url) 123 | 124 | if not app.debug and not daemon: 125 | webbrowser.open(url) 126 | 127 | Thread(target=open_in_browser, daemon=True).start() 128 | 129 | app.run(host=listen, port=port, threaded=True, use_reloader=False) 130 | 131 | except (KeyboardInterrupt, SystemExit): 132 | raise 133 | except: 134 | logging.exception(sys.exc_info()[0]) 135 | 136 | 137 | if __name__ == "__main__": 138 | if len(sys.argv) > 1 and "fork" in sys.argv[1]: 139 | multiprocessing.freeze_support() 140 | exit(0) 141 | 142 | if sys.platform.startswith("win"): 143 | multiprocessing.freeze_support() 144 | 145 | run() 146 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | from contextlib import redirect_stdout 2 | import io 3 | import multiprocessing 4 | import os 5 | import sys 6 | from threading import Thread 7 | from time import sleep 8 | from urllib import request 9 | 10 | from screeninfo import screeninfo 11 | import webview 12 | 13 | from utils.config import Config, get_data_path, get_cache_path, initialize_paths_from_args 14 | from utils.version import version 15 | from web import run, parse_cli 16 | 17 | debug = "FLASK_DEBUG" in os.environ 18 | 19 | 20 | class Webview: 21 | title = None 22 | width = None 23 | height = None 24 | x = None 25 | y = None 26 | 27 | callback = None 28 | window = None 29 | loaded = False 30 | sleep = 0.5 31 | 32 | loading_html = """ 33 | 39 | 40 |
41 |

Loading...

42 |
43 | 44 | """ 45 | 46 | def __init__(self, url): 47 | self.url = url 48 | self.window_parameters = { 49 | "text_select": True, 50 | } 51 | 52 | def start(self): 53 | Thread(target=self.handle_callback, daemon=True).start() 54 | 55 | parameters = self.window_parameters 56 | parameters["title"] = self.title 57 | parameters["width"] = self.width 58 | parameters["height"] = self.height 59 | parameters["x"] = self.x 60 | parameters["y"] = self.y 61 | 62 | self.clamp_coordinates(parameters) 63 | 64 | self.window = webview.create_window(html=self.loading_html, **parameters) 65 | self.window.events.loaded += self.on_loaded 66 | self.window.events.closing += self.on_close 67 | webview.start(debug=debug) 68 | 69 | def on_loaded(self): 70 | self.window.events.loaded -= self.on_loaded 71 | self.loaded = True 72 | 73 | def on_close(self): 74 | config = Config() 75 | config.write("window_width", self.window.width, False) 76 | config.write("window_height", self.window.height, False) 77 | config.write("window_x", self.window.x, flush=False) 78 | config.write("window_y", self.window.y, flush=False) 79 | config.flush() 80 | 81 | def handle_callback(self): 82 | if self.callback: 83 | Thread(target=self.callback, daemon=True).start() 84 | 85 | while not self.url_ok(self.url) or not self.loaded: 86 | sleep(self.sleep) 87 | 88 | self.window.load_url(self.url) 89 | 90 | def url_ok(self, url): 91 | try: 92 | request.urlopen(url=url) 93 | return True 94 | except Exception: 95 | return False 96 | 97 | def clamp_coordinates(self, parameters): 98 | # not ideal, this doesn't consider monitors with different resolutions, but it's better than nothing 99 | extreme = { 100 | "left": 0, 101 | "right": 0, 102 | "top": 0, 103 | "bottom": 0, 104 | } 105 | 106 | for monitor in screeninfo.get_monitors(): 107 | if monitor.x < extreme["left"]: 108 | extreme["left"] = monitor.x 109 | 110 | right = monitor.x + monitor.width 111 | if right > extreme["right"]: 112 | extreme["right"] = right 113 | 114 | if monitor.y < extreme["top"]: 115 | extreme["top"] = monitor.y 116 | 117 | bottom = monitor.y + monitor.height 118 | if bottom > extreme["bottom"]: 119 | extreme["bottom"] = bottom 120 | 121 | if parameters["x"] is None or parameters["y"] is None: 122 | out_of_bounds = True 123 | else: 124 | out_of_bounds = False 125 | if parameters["x"] < extreme["left"]: 126 | out_of_bounds = True 127 | elif parameters["x"] + parameters["width"] > extreme["right"]: 128 | out_of_bounds = True 129 | elif parameters["y"] < extreme["top"]: 130 | out_of_bounds = True 131 | elif parameters["y"] + parameters["height"] > extreme["bottom"]: 132 | out_of_bounds = True 133 | 134 | if out_of_bounds: 135 | parameters["x"] = None 136 | parameters["y"] = None 137 | 138 | 139 | if __name__ == "__main__": 140 | def run_view(): 141 | if len(sys.argv) > 1 and "fork" in sys.argv[1]: 142 | multiprocessing.freeze_support() 143 | exit(0) 144 | 145 | args = parse_cli(webview=True) 146 | initialize_paths_from_args(args) 147 | 148 | try: 149 | from webview.platforms.cef import settings, command_line_switches 150 | 151 | if args.disable_gpu: 152 | command_line_switches["disable-gpu"] = "" 153 | 154 | settings.update({ 155 | "log_file": os.path.join(get_data_path(), "cef.log"), 156 | "cache_path": get_cache_path(), 157 | }) 158 | except ImportError: 159 | pass # cef only 160 | 161 | def callback(): 162 | run(args, embedded=True) 163 | 164 | url = "http://%s:%s" % ("127.0.0.1", args.port) 165 | view = Webview(url) 166 | view.callback = callback 167 | view.title = "RD-USB " + version 168 | config = Config() 169 | view.width = config.read("window_width", 1250) 170 | view.height = config.read("window_height", 800) 171 | view.x = config.read("window_x", None) 172 | view.y = config.read("window_y", None) 173 | view.start() 174 | 175 | 176 | if getattr(sys, "frozen", False): 177 | with io.StringIO() as buffer, redirect_stdout(buffer): 178 | run_view() 179 | else: 180 | run_view() 181 | -------------------------------------------------------------------------------- /utils/formatting.py: -------------------------------------------------------------------------------- 1 | import decimal 2 | 3 | import pendulum 4 | 5 | from utils.usb import decode_usb_data_lines 6 | 7 | 8 | class Format: 9 | time_format = "YYYY-MM-DD HH:mm:ss" 10 | table_fields = ["time", "voltage", "current", "power", "temperature", "data", "mode", "accumulated", "resistance"] 11 | graph_fields = [ 12 | "timestamp", "voltage", "current", "power", "temperature", 13 | "resistance", "accumulated_current", "accumulated_power", 14 | ] 15 | export_fields = [ 16 | "time", 17 | "voltage", 18 | "current", 19 | "power", 20 | "temperature", 21 | "data_plus", 22 | "data_minus", 23 | "resistance", 24 | "accumulated_current", 25 | "accumulated_power", 26 | "accumulated_time", 27 | "run_time", 28 | "run_time_seconds", 29 | "timestamp", 30 | "zeroed_accumulated_current", 31 | "zeroed_accumulated_power", 32 | "zeroed_accumulated_time", 33 | ] 34 | field_names = { 35 | "time": "Time", 36 | "voltage": "Voltage (V)", 37 | "current": "Current (A)", 38 | "power": "Power (W)", 39 | "temperature": "Temperature (°C)", 40 | "data_plus": "Data+ (V)", 41 | "data_minus": "Data- (V)", 42 | "resistance": "Resistance (Ω)", 43 | "accumulated_current": "Accumulated current (mAh)", 44 | "accumulated_power": "Accumulated power (mWh)", 45 | "accumulated_time": "Accumulated time (seconds)", 46 | "run_time": "Run time", 47 | "run_time_seconds": "Run time (seconds)", 48 | "timestamp": "Unix time", 49 | "zeroed_accumulated_current": "Zerod accumulated current (mAh)", 50 | "zeroed_accumulated_power": "Zerod accumulated power (mWh)", 51 | "zeroed_accumulated_time": "Zerod accumulated time (seconds)", 52 | } 53 | 54 | def __init__(self, version=None): 55 | self.version = version 56 | self.um25c = self.version == "UM25C" 57 | self.tc66c = self.version and self.version.startswith("TC66C") 58 | self.decimal_context = decimal.Context(20) 59 | 60 | def time(self, data): 61 | time = pendulum.from_timestamp(data["timestamp"]) 62 | time = time.in_tz("local") 63 | return time.format(self.time_format) 64 | 65 | def timestamp(self, data): 66 | return data["timestamp"] * 1000 67 | 68 | def voltage(self, data): 69 | return self.format_value(data, "voltage") + " V" 70 | 71 | def current(self, data): 72 | return self.format_value(data, "current") + " A" 73 | 74 | def power(self, data): 75 | return self.format_value(data, "power") + " W" 76 | 77 | def temperature(self, data): 78 | return self.format_value(data, "temperature") + " °C" 79 | 80 | def data(self, data): 81 | return "+" + self.format_value(data, "data_plus") + " / -" + self.format_value(data, "data_minus") + " V" 82 | 83 | def mode(self, data): 84 | if data["mode_id"] is None and data["data_plus"] is not None: 85 | return decode_usb_data_lines(data["data_plus"], data["data_minus"]) 86 | if data["mode_name"]: 87 | return data["mode_name"] 88 | return "id: " + str(data["mode_id"]) 89 | 90 | def accumulated_current(self, data): 91 | return self.format_value(data, "accumulated_current") + " mAh" 92 | 93 | def accumulated_power(self, data): 94 | return self.format_value(data, "accumulated_power") + " mWh" 95 | 96 | def accumulated_time(self, data): 97 | if data["accumulated_time"] is None: 98 | return "" 99 | return str(data["accumulated_time"]) + " seconds" 100 | 101 | def accumulated(self, data): 102 | parts = [ 103 | self.accumulated_current(data), 104 | self.accumulated_power(data), 105 | ] 106 | if data["accumulated_time"] is not None: 107 | parts.append(self.accumulated_time(data)) 108 | return " / ".join(parts) 109 | 110 | def zeroed_accumulated_current(self, data): 111 | return self.format_value(data, "zeroed_accumulated_current") + " mAh" 112 | 113 | def zeroed_accumulated_power(self, data): 114 | return self.format_value(data, "zeroed_accumulated_power") + " mWh" 115 | 116 | def zeroed_accumulated_time(self, data): 117 | if data["zeroed_accumulated_time"] is None: 118 | return "" 119 | return str(data["zeroed_accumulated_time"]) + " seconds" 120 | 121 | def zeroed_accumulated(self, data): 122 | parts = [ 123 | self.zeroed_accumulated_current(data), 124 | self.zeroed_accumulated_power(data), 125 | ] 126 | if data["zeroed_accumulated_time"] is not None: 127 | parts.append(self.zeroed_accumulated_time(data)) 128 | return " / ".join(parts) 129 | 130 | def resistance(self, data): 131 | return str(data["resistance"]) + " Ω" 132 | 133 | def field_name(self, field): 134 | if field in self.field_names: 135 | return self.field_names[field] 136 | return field 137 | 138 | def field_name_reverse(self, alias): 139 | for field_name, field_alias in self.field_names.items(): 140 | if field_alias == alias: 141 | return field_name 142 | print(alias) 143 | return None 144 | 145 | def format_value(self, data, name): 146 | value = data[name] 147 | precision = None 148 | variable = False 149 | if name == "voltage": 150 | if self.tc66c: 151 | precision = 4 152 | elif self.um25c: 153 | precision = 3 154 | else: 155 | precision = 2 156 | variable = True 157 | elif name == "current": 158 | if self.tc66c: 159 | precision = 5 160 | elif self.um25c: 161 | precision = 4 162 | else: 163 | precision = 3 164 | variable = True 165 | elif name == "power": 166 | precision = 4 if self.tc66c else 3 167 | variable = True 168 | elif name == "temperature": 169 | precision = 0 170 | elif name in ["data_plus", "data_minus"]: 171 | precision = 2 172 | elif name in ["accumulated_current", "accumulated_power"]: 173 | precision = 0 174 | elif name in ["zeroed_accumulated_current", "zeroed_accumulated_power"]: 175 | precision = 0 176 | elif name == "resistance": 177 | precision = 1 178 | if precision is not None: 179 | if not self.version and variable: 180 | # backward compatibility: 181 | # since we don't know what meter was used, then don't round to fixed number of places 182 | value = format(self.decimal_context.create_decimal(repr(value)), "f") 183 | else: 184 | value = self.format_number(value, precision) 185 | return value 186 | 187 | def format_number(self, value, precision): 188 | if precision > 0: 189 | return ("{:.%sf}" % precision).format(value) 190 | return "{:d}".format(int(round(value))) 191 | 192 | def selected(self, current, reference): 193 | if current == reference: 194 | return " selected" 195 | return "" 196 | -------------------------------------------------------------------------------- /webapp/templates/layout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | RD-USB {{ rd_user_version }} 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 124 | 125 |
126 | {% with messages = get_flashed_messages(with_categories=true) %} 127 | {% if messages %} 128 |
129 | {% for category, message in messages %} 130 |
{{ message }}
131 | {% endfor %} 132 |
133 | {% endif %} 134 | {% endwith %} 135 | 136 | {% block content %}{% endblock %} 137 |
138 | 139 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | -------------------------------------------------------------------------------- /interfaces/tc.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import struct 4 | from time import time, sleep 5 | 6 | from Crypto.Cipher import AES 7 | 8 | try: 9 | from bleak import BleakClient, BleakError, BleakScanner 10 | supported = True 11 | except Exception as e: 12 | message = str(e) 13 | if "Only Windows 10 is supported" in message or "Requires at least Windows 10" in message: 14 | unsupported_reason = message 15 | supported = False 16 | else: 17 | raise 18 | 19 | import serial 20 | 21 | from interfaces.interface import Interface 22 | 23 | SERVER_RX_DATA = ["0000ffe9-0000-1000-8000-00805f9b34fb", "0000ffe2-0000-1000-8000-00805f9b34fb"] 24 | SERVER_TX_DATA = ["0000ffe4-0000-1000-8000-00805f9b34fb", "0000ffe1-0000-1000-8000-00805f9b34fb"] 25 | ASK_FOR_VALUES_COMMAND = "bgetva" 26 | 27 | 28 | class TcBleInterface(Interface): 29 | timeout = 30 30 | client = None 31 | loop = None 32 | bound = False 33 | addresses_index = 0 34 | 35 | def __init__(self, address): 36 | self.address = address 37 | self.response = Response() 38 | 39 | def scan(self): 40 | async def run(): 41 | devices = await BleakScanner().discover() 42 | formatted = [] 43 | for device in devices: 44 | formatted.append({ 45 | "address": device.address, 46 | "name": device.name, 47 | }) 48 | return formatted 49 | 50 | return self.get_loop().run_until_complete(run()) 51 | 52 | def connect(self): 53 | self.get_loop().run_until_complete(self._connect_run(self.address)) 54 | 55 | async def _connect_run(self, address): 56 | if not supported: 57 | raise NotSupportedException("TC66C over BLE is NOT SUPPORTED, reason: %s" % unsupported_reason) 58 | self.client = BleakClient(address, loop=self.get_loop()) 59 | self.addresses_index = 0 60 | await self.client.connect() 61 | 62 | def disconnect(self): 63 | expiration = time() + self.timeout 64 | while self.loop and self.loop.is_running() and time() <= expiration: 65 | sleep(0.1) 66 | 67 | sleep(1) 68 | 69 | try: 70 | self.get_loop().run_until_complete(self._close_run()) 71 | except RuntimeError as e: 72 | if "loop is already running" not in str(e): 73 | raise e 74 | 75 | self.bound = False 76 | 77 | async def _close_run(self): 78 | try: 79 | await self.client.stop_notify(SERVER_TX_DATA[self.addresses_index]) 80 | except Exception: 81 | pass 82 | 83 | try: 84 | await self.client.disconnect() 85 | except Exception: 86 | pass 87 | 88 | def read(self): 89 | return self.get_loop().run_until_complete(self._read_run()) 90 | 91 | async def _read_run(self): 92 | self.response.reset() 93 | 94 | for retry in range(0, 3): 95 | address = SERVER_RX_DATA[self.addresses_index] 96 | try: 97 | await self.client.write_gatt_char(address, self.encode_command(ASK_FOR_VALUES_COMMAND), True) 98 | 99 | if not self.bound: 100 | self.bound = True 101 | await self.client.start_notify(SERVER_TX_DATA[self.addresses_index], self.response.callback) 102 | 103 | except BleakError as e: 104 | message = str(e).lower() 105 | if "not found" in message and "characteristic" in message: 106 | self.addresses_index += 1 107 | if self.addresses_index >= len(SERVER_RX_DATA): 108 | raise 109 | else: 110 | raise 111 | 112 | expiration = time() + 5 113 | while not self.response.is_complete() and time() <= expiration: 114 | await asyncio.sleep(0.1) 115 | 116 | if not self.response.is_complete(): 117 | continue 118 | 119 | try: 120 | return self.response.decode() 121 | except CorruptedResponseException as e: 122 | logging.exception(e) 123 | continue 124 | 125 | if not self.response.is_complete(): 126 | raise NoResponseException 127 | 128 | return self.response.decode() 129 | 130 | def encode_command(self, command): 131 | string = command + "\r\n" 132 | encoded = string.encode("ascii") 133 | encoded = bytearray(encoded) 134 | return encoded 135 | 136 | def get_loop(self): 137 | if not self.loop: 138 | self.loop = asyncio.new_event_loop() 139 | return self.loop 140 | 141 | 142 | class TcSerialInterface(Interface): 143 | serial = None 144 | 145 | def __init__(self, port, timeout): 146 | self.port = port 147 | self.response = Response() 148 | self.timeout = timeout 149 | 150 | def connect(self): 151 | if self.serial is None: 152 | self.serial = serial.Serial(port=self.port, baudrate=115200, timeout=self.timeout, write_timeout=0) 153 | 154 | def read(self): 155 | self.open() 156 | self.send("getva") 157 | data = self.serial.read(192) 158 | self.response.reset() 159 | self.response.callback(None, data) 160 | return self.response.decode() 161 | 162 | def read_records(self): 163 | self.send("gtrec") 164 | 165 | results = [] 166 | buffer = bytearray() 167 | while True: 168 | chunk = self.serial.read(8) 169 | if len(chunk) == 0: 170 | break 171 | 172 | buffer.extend(chunk) 173 | if len(buffer) >= 8: 174 | record = struct.unpack("<2I", buffer[0:8]) 175 | buffer = buffer[8:] 176 | 177 | results.append({ 178 | "voltage": float(record[0]) / 1000 / 10, 179 | "current": float(record[1]) / 1000 / 100, 180 | }) 181 | 182 | return results 183 | 184 | def send(self, value): 185 | self.open() 186 | self.serial.write(value.encode("ascii")) 187 | 188 | def open(self): 189 | if not self.serial.isOpen(): 190 | self.serial.open() 191 | 192 | def disconnect(self): 193 | if self.serial: 194 | self.serial.close() 195 | 196 | 197 | class Response: 198 | key = [ 199 | 88, 33, -6, 86, 1, -78, -16, 38, 200 | -121, -1, 18, 4, 98, 42, 79, -80, 201 | -122, -12, 2, 96, -127, 111, -102, 11, 202 | -89, -15, 6, 97, -102, -72, 114, -120 203 | ] 204 | buffer = bytearray() 205 | index = 0 206 | 207 | def append(self, data): 208 | try: 209 | self.buffer.extend(data) 210 | self.index += len(data) 211 | except BufferError: 212 | pass 213 | 214 | def callback(self, sender, data): 215 | self.append(data) 216 | 217 | def is_complete(self): 218 | return self.index >= 192 219 | 220 | def decrypt(self): 221 | key = [] 222 | for index, value in enumerate(self.key): 223 | key.append(value & 255) 224 | 225 | aes = AES.new(bytes(key), AES.MODE_ECB) 226 | try: 227 | return aes.decrypt(self.buffer) 228 | except ValueError: 229 | raise CorruptedResponseException 230 | 231 | def decode(self, data=None): 232 | if data is not None: 233 | self.append(data) 234 | 235 | data = self.decrypt() 236 | 237 | if self.decode_integer(data, 88) == 1: 238 | temperature_multiplier = -1 239 | else: 240 | temperature_multiplier = 1 241 | 242 | return { 243 | "timestamp": time(), 244 | "voltage": self.decode_integer(data, 48, 10000), 245 | "current": self.decode_integer(data, 52, 100000), 246 | "power": self.decode_integer(data, 56, 10000), 247 | "resistance": self.decode_integer(data, 68, 10), 248 | "accumulated_current": self.decode_integer(data, 72), 249 | "accumulated_power": self.decode_integer(data, 76), 250 | "accumulated_time": None, 251 | "temperature": self.decode_integer(data, 92) * temperature_multiplier, 252 | "data_plus": self.decode_integer(data, 96, 100), 253 | "data_minus": self.decode_integer(data, 100, 100), 254 | "mode_id": None, 255 | "mode_name": None 256 | } 257 | 258 | def decode_integer(self, data, first_byte, divider=1): 259 | temp4 = data[first_byte] & 255 260 | temp3 = data[first_byte + 1] & 255 261 | temp2 = data[first_byte + 2] & 255 262 | temp1 = data[first_byte + 3] & 255 263 | return ((((temp1 << 24) | (temp2 << 16)) | (temp3 << 8)) | temp4) / float(divider) 264 | 265 | def reset(self): 266 | self.buffer = bytearray() 267 | self.index = 0 268 | 269 | 270 | class NoResponseException(Exception): 271 | pass 272 | 273 | 274 | class CorruptedResponseException(Exception): 275 | pass 276 | 277 | 278 | class NotSupportedException(Exception): 279 | pass 280 | -------------------------------------------------------------------------------- /pyinstaller/gui-only/hook-cefpython3.py: -------------------------------------------------------------------------------- 1 | """ 2 | This is PyInstaller hook file for CEF Python. This file 3 | helps PyInstaller find CEF Python dependencies that are 4 | required to run final executable. 5 | 6 | See PyInstaller docs for hooks: 7 | https://pyinstaller.readthedocs.io/en/stable/hooks.html 8 | """ 9 | 10 | import glob 11 | import os 12 | import platform 13 | import re 14 | import sys 15 | 16 | import PyInstaller 17 | from PyInstaller import log as logging 18 | from PyInstaller.compat import is_win, is_darwin, is_linux 19 | from PyInstaller.utils.hooks import is_module_satisfies, get_package_paths 20 | try: 21 | # PyInstaller >= 4.0 doesn't support Python 2.7 22 | from PyInstaller.compat import is_py2 23 | except ImportError: 24 | is_py2 = None 25 | 26 | # Constants 27 | CEFPYTHON_MIN_VERSION = "57.0" 28 | PYINSTALLER_MIN_VERSION = "3.2.1" 29 | 30 | # Makes assumption that using "python.exe" and not "pyinstaller.exe" 31 | # TODO: use this code to work cross-platform: 32 | # > from PyInstaller.utils.hooks import get_package_paths 33 | # > get_package_paths("cefpython3") 34 | 35 | CEFPYTHON3_DIR = get_package_paths("cefpython3")[1] 36 | 37 | CYTHON_MODULE_EXT = ".pyd" if is_win else ".so" 38 | 39 | # Globals 40 | logger = logging.getLogger(__name__) 41 | 42 | 43 | # Functions 44 | def check_platforms(): 45 | if not is_win and not is_darwin and not is_linux: 46 | raise SystemExit("Error: Currently only Windows, Linux and Darwin " 47 | "platforms are supported, see Issue #135.") 48 | 49 | 50 | def check_pyinstaller_version(): 51 | """Using is_module_satisfies() for pyinstaller fails when 52 | installed using 'pip install develop.zip' command 53 | (PyInstaller Issue #2802).""" 54 | # Example version string for dev version of pyinstaller: 55 | # > 3.3.dev0+g5dc9557c 56 | version = PyInstaller.__version__ 57 | match = re.search(r"^\d+\.\d+(\.\d+)?", version) 58 | if not (match.group(0) >= PYINSTALLER_MIN_VERSION): 59 | raise SystemExit("Error: pyinstaller %s or higher is required" 60 | % PYINSTALLER_MIN_VERSION) 61 | 62 | 63 | def check_cefpython3_version(): 64 | if not is_module_satisfies("cefpython3 >= %s" % CEFPYTHON_MIN_VERSION): 65 | raise SystemExit("Error: cefpython3 %s or higher is required" 66 | % CEFPYTHON_MIN_VERSION) 67 | 68 | 69 | def get_cefpython_modules(): 70 | """Get all cefpython Cython modules in the cefpython3 package. 71 | It returns a list of names without file extension. Eg. 72 | 'cefpython_py27'. """ 73 | pyds = glob.glob(os.path.join(CEFPYTHON3_DIR, 74 | "cefpython_py*" + CYTHON_MODULE_EXT)) 75 | assert len(pyds) > 1, "Missing cefpython3 Cython modules" 76 | modules = [] 77 | for path in pyds: 78 | filename = os.path.basename(path) 79 | mod = filename.replace(CYTHON_MODULE_EXT, "") 80 | modules.append(mod) 81 | return modules 82 | 83 | 84 | def get_excluded_cefpython_modules(): 85 | """CEF Python package includes Cython modules for various Python 86 | versions. When using Python 2.7 pyinstaller should not 87 | bundle modules for eg. Python 3.6, otherwise it will 88 | cause to include Python 3 dll dependencies. Returns a list 89 | of fully qualified names eg. 'cefpython3.cefpython_py27'.""" 90 | pyver = "".join(map(str, sys.version_info[:2])) 91 | pyver_string = "py%s" % pyver 92 | modules = get_cefpython_modules() 93 | excluded = [] 94 | for mod in modules: 95 | if pyver_string in mod: 96 | continue 97 | excluded.append("cefpython3.%s" % mod) 98 | logger.info("Exclude cefpython3 module: %s" % excluded[-1]) 99 | return excluded 100 | 101 | 102 | def get_cefpython3_datas(): 103 | """Returning almost all of cefpython binaries as DATAS (see exception 104 | below), because pyinstaller does strange things and fails if these are 105 | returned as BINARIES. It first updates manifest in .dll files: 106 | >> Updating manifest in chrome_elf.dll 107 | 108 | And then because of that it fails to load the library: 109 | >> hsrc = win32api.LoadLibraryEx(filename, 0, LOAD_LIBRARY_AS_DATAFILE) 110 | >> pywintypes.error: (5, 'LoadLibraryEx', 'Access is denied.') 111 | 112 | It is not required for pyinstaller to modify in any way 113 | CEF binaries or to look for its dependencies. CEF binaries 114 | does not have any external dependencies like MSVCR or similar. 115 | 116 | The .pak .dat and .bin files cannot be marked as BINARIES 117 | as pyinstaller would fail to find binary depdendencies on 118 | these files. 119 | 120 | One exception is subprocess (subprocess.exe on Windows) executable 121 | file, which is passed to pyinstaller as BINARIES in order to collect 122 | its dependecies. 123 | 124 | DATAS are in format: tuple(full_path, dest_subdir). 125 | """ 126 | ret = list() 127 | 128 | if is_win: 129 | cefdatadir = "." 130 | elif is_darwin or is_linux: 131 | cefdatadir = "." 132 | else: 133 | assert False, "Unsupported system {}".format(platform.system()) 134 | 135 | # Binaries, licenses and readmes in the cefpython3/ directory 136 | for filename in os.listdir(CEFPYTHON3_DIR): 137 | # Ignore Cython modules which are already handled by 138 | # pyinstaller automatically. 139 | if filename[:-len(CYTHON_MODULE_EXT)] in get_cefpython_modules(): 140 | continue 141 | 142 | # CEF binaries and datas 143 | extension = os.path.splitext(filename)[1] 144 | if extension in \ 145 | [".exe", ".dll", ".pak", ".dat", ".bin", ".txt", ".so", ".plist"] \ 146 | or filename.lower().startswith("license"): 147 | logger.info("Include cefpython3 data: {}".format(filename)) 148 | ret.append((os.path.join(CEFPYTHON3_DIR, filename), cefdatadir)) 149 | 150 | if is_darwin: 151 | # "Chromium Embedded Framework.framework/Resources" with subdirectories 152 | # is required. Contain .pak files and locales (each locale in separate 153 | # subdirectory). 154 | resources_subdir = \ 155 | os.path.join("Chromium Embedded Framework.framework", "Resources") 156 | base_path = os.path.join(CEFPYTHON3_DIR, resources_subdir) 157 | assert os.path.exists(base_path), \ 158 | "{} dir not found in cefpython3".format(resources_subdir) 159 | for path, dirs, files in os.walk(base_path): 160 | for file in files: 161 | absolute_file_path = os.path.join(path, file) 162 | dest_path = os.path.relpath(path, CEFPYTHON3_DIR) 163 | ret.append((absolute_file_path, dest_path)) 164 | logger.info("Include cefpython3 data: {}".format(dest_path)) 165 | elif is_win or is_linux: 166 | # The .pak files in cefpython3/locales/ directory 167 | locales_dir = os.path.join(CEFPYTHON3_DIR, "locales") 168 | assert os.path.exists(locales_dir), \ 169 | "locales/ dir not found in cefpython3" 170 | for filename in os.listdir(locales_dir): 171 | logger.info("Include cefpython3 data: {}/{}".format( 172 | os.path.basename(locales_dir), filename)) 173 | ret.append((os.path.join(locales_dir, filename), 174 | os.path.join(cefdatadir, "locales"))) 175 | 176 | # Optional .so/.dll files in cefpython3/swiftshader/ directory 177 | swiftshader_dir = os.path.join(CEFPYTHON3_DIR, "swiftshader") 178 | if os.path.isdir(swiftshader_dir): 179 | for filename in os.listdir(swiftshader_dir): 180 | logger.info("Include cefpython3 data: {}/{}".format( 181 | os.path.basename(swiftshader_dir), filename)) 182 | ret.append((os.path.join(swiftshader_dir, filename), 183 | os.path.join(cefdatadir, "swiftshader"))) 184 | return ret 185 | 186 | 187 | # ---------------------------------------------------------------------------- 188 | # Main 189 | # ---------------------------------------------------------------------------- 190 | 191 | # Checks 192 | check_platforms() 193 | check_pyinstaller_version() 194 | check_cefpython3_version() 195 | 196 | # Info 197 | logger.info("CEF Python package directory: %s" % CEFPYTHON3_DIR) 198 | 199 | # Hidden imports. 200 | # PyInstaller has no way on detecting imports made by Cython 201 | # modules, so all pure Python imports made in cefpython .pyx 202 | # files need to be manually entered here. 203 | # TODO: Write a tool script that would find such imports in 204 | # .pyx files automatically. 205 | hiddenimports = [ 206 | "codecs", 207 | "copy", 208 | "datetime", 209 | "inspect", 210 | "json", 211 | "os", 212 | "platform", 213 | "random", 214 | "re", 215 | "sys", 216 | "time", 217 | "traceback", 218 | "types", 219 | "urllib", 220 | "weakref", 221 | ] 222 | if is_py2: 223 | hiddenimports += [ 224 | "urlparse", 225 | ] 226 | 227 | # Excluded modules 228 | excludedimports = get_excluded_cefpython_modules() 229 | 230 | # Include binaries requiring to collect its dependencies 231 | if is_darwin or is_linux: 232 | binaries = [(os.path.join(CEFPYTHON3_DIR, "subprocess"), ".")] 233 | elif is_win: 234 | binaries = [(os.path.join(CEFPYTHON3_DIR, "subprocess.exe"), ".")] 235 | else: 236 | binaries = [] 237 | 238 | # Include datas 239 | datas = get_cefpython3_datas() 240 | 241 | # Notify pyinstaller.spec code that this hook was executed 242 | # and that it succeeded. 243 | os.environ["PYINSTALLER_CEFPYTHON3_HOOK_SUCCEEDED"] = "1" 244 | -------------------------------------------------------------------------------- /utils/storage.py: -------------------------------------------------------------------------------- 1 | from contextlib import closing 2 | import logging 3 | import os 4 | import shutil 5 | import sqlite3 6 | from time import time 7 | 8 | import pendulum 9 | 10 | from utils.config import get_data_path 11 | from utils.converter import Converter 12 | 13 | 14 | class Storage: 15 | sqlite = None 16 | schema_version = 2 17 | 18 | def __init__(self): 19 | self.parameters = { 20 | "database": os.path.join(get_data_path(), "data.db"), 21 | "isolation_level": None, 22 | } 23 | self.converter = Converter() 24 | 25 | def connect(self, extra_parameters=None): 26 | parameters = self.parameters 27 | if extra_parameters: 28 | parameters.update(extra_parameters) 29 | connection = sqlite3.connect(**parameters) 30 | connection.row_factory = self.row_factory 31 | return connection 32 | 33 | def row_factory(self, cursor, row): 34 | dictionary = {} 35 | for index, column in enumerate(cursor.description): 36 | dictionary[column[0]] = row[index] 37 | return dictionary 38 | 39 | def init(self): 40 | with closing(self.connect()) as sqlite: 41 | cursor = sqlite.cursor() 42 | cursor.execute("SELECT name FROM sqlite_master WHERE type = 'table'") 43 | tables = [] 44 | for row in cursor.fetchall(): 45 | tables.append(row["name"]) 46 | 47 | schema_version = self.schema_version 48 | if "version" not in tables: 49 | cursor.execute("CREATE TABLE version (version INTEGER)") 50 | cursor.execute("INSERT INTO version VALUES (%s)" % self.schema_version) 51 | else: 52 | schema_version = int(cursor.execute("SELECT version FROM version").fetchone()["version"]) 53 | 54 | if "status" not in tables: 55 | cursor.execute("CREATE TABLE status (status TEXT)") 56 | cursor.execute("INSERT INTO status VALUES ('disconnected')") 57 | 58 | if "logs" not in tables: 59 | cursor.execute(( 60 | "CREATE TABLE logs (" 61 | "id INTEGER PRIMARY KEY," 62 | "message TEXT" 63 | ")" 64 | )) 65 | 66 | if "measurements" not in tables: 67 | cursor.execute(( 68 | "CREATE TABLE measurements (" 69 | "id INTEGER PRIMARY KEY," 70 | "name TEXT," 71 | "timestamp INTEGER," 72 | "voltage REAL," 73 | "current REAL," 74 | "power REAL," 75 | "temperature REAL," 76 | "data_plus REAL," 77 | "data_minus REAL," 78 | "mode_id INTEGER," 79 | "mode_name TEXT," 80 | "accumulated_current INTEGER," 81 | "accumulated_power INTEGER," 82 | "accumulated_time INTEGER," 83 | "resistance REAL," 84 | "session_id INTEGER" 85 | ")" 86 | )) 87 | 88 | if "sessions" not in tables: 89 | cursor.execute(( 90 | "CREATE TABLE sessions (" 91 | "id INTEGER PRIMARY KEY," 92 | "version TEXT," 93 | "name TEXT," 94 | "timestamp INTEGER" 95 | ")" 96 | )) 97 | 98 | if schema_version == 1: 99 | logging.info("migrating database to new version, this may take a while...") 100 | 101 | self.backup() 102 | 103 | cursor.execute(( 104 | "ALTER TABLE measurements ADD session_id INTEGER" 105 | )) 106 | 107 | cursor.execute("DELETE FROM measurements WHERE name = '' OR name IS NULL") 108 | 109 | query = cursor.execute( 110 | "SELECT name, MIN(timestamp) AS timestamp FROM measurements WHERE session_id IS NULL GROUP BY name ORDER BY MIN(id)") 111 | rows = query.fetchall() 112 | for row in rows: 113 | session_name = row["name"] 114 | cursor.execute("INSERT INTO sessions (name, timestamp) VALUES (:name, :timestamp)", ( 115 | session_name, row["timestamp"] 116 | )) 117 | session_id = cursor.lastrowid 118 | cursor.execute("UPDATE measurements SET session_id = :session_id WHERE name = :name", ( 119 | session_id, session_name 120 | )) 121 | 122 | cursor.execute("UPDATE version SET version = 2") 123 | 124 | def store_measurement(self, data): 125 | with closing(self.connect()) as sqlite: 126 | self._insert_measurement(sqlite, data) 127 | 128 | def store_measurements(self, items): 129 | with closing(self.connect({"isolation_level": "DEFERRED"})) as sqlite: 130 | for data in items: 131 | self._insert_measurement(sqlite, data) 132 | sqlite.commit() 133 | 134 | def _insert_measurement(self, sqlite, data): 135 | if data is None: 136 | return 137 | 138 | columns = [] 139 | placeholders = [] 140 | values = [] 141 | for name, value in data.items(): 142 | columns.append(name) 143 | placeholders.append(":" + name) 144 | values.append(value) 145 | 146 | columns = ", ".join(columns) 147 | placeholders = ", ".join(placeholders) 148 | values = tuple(values) 149 | 150 | cursor = sqlite.cursor() 151 | cursor.execute("INSERT INTO measurements (" + columns + ") VALUES (" + placeholders + ")", values) 152 | 153 | def destroy_measurements(self, session): 154 | with closing(self.connect()) as sqlite: 155 | cursor = sqlite.cursor() 156 | cursor.execute("DELETE FROM measurements WHERE session_id = ?", (session,)) 157 | cursor.execute("DELETE FROM sessions WHERE id = ?", (session,)) 158 | 159 | def fetch_sessions(self): 160 | with closing(self.connect()) as sqlite: 161 | cursor = sqlite.cursor() 162 | return cursor.execute("SELECT * FROM sessions ORDER BY timestamp DESC").fetchall() 163 | 164 | def fetch_measurements_count(self, session): 165 | with closing(self.connect()) as sqlite: 166 | cursor = sqlite.cursor() 167 | cursor.execute("SELECT COUNT(id) AS count FROM measurements WHERE session_id = ?", (session,)) 168 | return int(cursor.fetchone()["count"]) 169 | 170 | def fetch_measurements(self, session, limit=None, offset=None, zeroed=False): 171 | with closing(self.connect()) as sqlite: 172 | cursor = sqlite.cursor() 173 | sql = "SELECT * FROM measurements WHERE session_id = ? ORDER BY timestamp ASC" 174 | if limit is None or offset is None: 175 | cursor.execute(sql, (session,)) 176 | else: 177 | cursor.execute(sql + " LIMIT ?, ?", (session, offset, limit)) 178 | items = cursor.fetchall() 179 | 180 | for index, item in enumerate(items): 181 | items[index] = self.converter.convert(item) 182 | 183 | if zeroed: 184 | self.fill_zeroed_accumulated_fields_for_measurements(session, items) 185 | 186 | return items 187 | 188 | def fill_zeroed_accumulated_fields_for_measurements(self, session, measurements): 189 | first_measurements = self.fetch_measurements(session, 1, 0) 190 | first_measurement = first_measurements[0] if len(first_measurements) else None 191 | for item in measurements: 192 | for name, value in list(item.items()): 193 | if name.startswith("accumulated_"): 194 | zeroed_name = "zeroed_%s" % name 195 | if name not in first_measurement or first_measurement[name] is None: 196 | item[zeroed_name] = None 197 | else: 198 | item[zeroed_name] = value - first_measurement[name] 199 | 200 | def fetch_last_measurement_by_name(self, name): 201 | with closing(self.connect()) as sqlite: 202 | cursor = sqlite.cursor() 203 | cursor.execute("SELECT * FROM measurements WHERE name = ? ORDER BY timestamp DESC LIMIT 1", (name,)) 204 | return cursor.fetchone() 205 | 206 | def fetch_last_measurement(self): 207 | with closing(self.connect()) as sqlite: 208 | cursor = sqlite.cursor() 209 | cursor.execute("SELECT * FROM measurements ORDER BY timestamp DESC LIMIT 1") 210 | return cursor.fetchone() 211 | 212 | def get_selected_session(self, selected): 213 | with closing(self.connect()) as sqlite: 214 | cursor = sqlite.cursor() 215 | if selected == "": 216 | session = cursor.execute("SELECT * FROM sessions ORDER BY timestamp DESC LIMIT 1").fetchone() 217 | else: 218 | session = cursor.execute("SELECT * FROM sessions WHERE id = ?", (selected,)).fetchone() 219 | 220 | return session 221 | 222 | def log(self, message): 223 | with closing(self.connect()) as sqlite: 224 | cursor = sqlite.cursor() 225 | cursor.execute("INSERT INTO logs (message) VALUES (?)", (message,)) 226 | 227 | def fetch_log(self): 228 | with closing(self.connect()) as sqlite: 229 | cursor = sqlite.cursor() 230 | cursor.execute("SELECT message FROM logs") 231 | 232 | log = "" 233 | for row in cursor.fetchall(): 234 | log += row["message"] 235 | 236 | return log 237 | 238 | def clear_log(self): 239 | with closing(self.connect()) as sqlite: 240 | cursor = sqlite.cursor() 241 | cursor.execute("DELETE FROM logs WHERE id NOT IN (SELECT id FROM logs ORDER BY id DESC LIMIT 250)") 242 | 243 | def update_status(self, status): 244 | with closing(self.connect()) as sqlite: 245 | cursor = sqlite.cursor() 246 | cursor.execute("UPDATE status SET status = ?", (status,)) 247 | 248 | def fetch_status(self): 249 | with closing(self.connect()) as sqlite: 250 | cursor = sqlite.cursor() 251 | cursor.execute("SELECT status FROM status") 252 | return cursor.fetchone()["status"] 253 | 254 | def create_session(self, name, version): 255 | with closing(self.connect()) as sqlite: 256 | cursor = sqlite.cursor() 257 | cursor.execute("INSERT INTO sessions (name, version, timestamp) VALUES (?, ?, ?)", (name, version, time())) 258 | return cursor.lastrowid 259 | 260 | def backup(self): 261 | path = self.parameters["database"] 262 | backup_path = "%s.backup-%s" % (path, pendulum.now().format("YYYY-MM-DD_HH-mm-ss")) 263 | if os.path.exists(path): 264 | shutil.copy(path, backup_path) 265 | -------------------------------------------------------------------------------- /webapp/backend.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import logging 4 | import os 5 | import re 6 | import subprocess 7 | import sys 8 | from threading import Thread 9 | from time import time, sleep 10 | from timeit import default_timer as timer 11 | import traceback 12 | 13 | import bluetooth 14 | import pendulum 15 | from serial.tools.list_ports import comports 16 | from socketio import Namespace 17 | 18 | from interfaces.interface import FatalErrorException 19 | from interfaces.tc import TcBleInterface 20 | from interfaces.wrapper import Wrapper 21 | from utils.config import Config 22 | from utils.converter import Converter 23 | from utils.formatting import Format 24 | from utils.storage import Storage 25 | 26 | 27 | class Backend(Namespace): 28 | config = None 29 | 30 | def __init__(self, on_receive, on_receive_interval): 31 | super().__init__() 32 | self.daemon = Daemon(self, on_receive, on_receive_interval) 33 | self.handle_auto_connect() 34 | 35 | def handle_auto_connect(self): 36 | config = Config() 37 | setup = config.read("setup") 38 | auto_connect = self.daemon.parse_setup_option(setup, "auto_connect", str, "no") 39 | if auto_connect == "yes": 40 | self.on_open(None, config.data) 41 | 42 | def init(self): 43 | self.config = Config() 44 | 45 | def on_open(self, sid, data): 46 | self.init() 47 | 48 | if isinstance(data, str): 49 | data = json.loads(data) 50 | 51 | self.config.write("version", data["version"]) 52 | 53 | if "port" in data: 54 | self.config.write("port", data["port"]) 55 | 56 | if "rfcomm_address" in data: 57 | self.config.write("rfcomm_address", data["rfcomm_address"]) 58 | else: 59 | data["rfcomm_address"] = self.config.read("rfcomm_address") 60 | 61 | if "ble_address" in data: 62 | self.config.write("ble_address", data["ble_address"]) 63 | else: 64 | data["ble_address"] = self.config.read("ble_address") 65 | 66 | storage = Storage() 67 | last = storage.fetch_last_measurement_by_name(data["name"]) 68 | if last: 69 | if time() - int(last["timestamp"]) > 3600: 70 | match = re.match(".+( [0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2})$", data["name"]) 71 | if match: 72 | data["name"] = data["name"][:-len(match.group(1))] 73 | data["name"] += " " + pendulum.now().format("YYYY-MM-DD HH:mm") 74 | 75 | if not data["name"]: 76 | data["name"] = "My measurement" 77 | 78 | self.config.write("name", data["name"]) 79 | 80 | try: 81 | self.config.write("rate", float(data["rate"])) 82 | except ValueError: 83 | pass 84 | 85 | rfcomm = data["version"].startswith("UM") and not data["version"].endswith("Serial") 86 | if rfcomm and ("rfcomm_address" not in data or not data["rfcomm_address"]): 87 | self.daemon.log("Bluetooth address is missing. Select address in Setup") 88 | return 89 | 90 | tc_ble = data["version"].startswith("TC") and not data["version"].endswith("USB") 91 | if tc_ble and ("ble_address" not in data or not data["ble_address"]): 92 | self.daemon.log("BLE address is missing. Select address in Setup") 93 | return 94 | 95 | self.emit("connecting") 96 | self.daemon.start() 97 | 98 | def on_scan_rfcomm(self, sid): 99 | self.init() 100 | try: 101 | result = ["Results:"] 102 | 103 | devices = bluetooth.discover_devices(lookup_names=True) 104 | if len(devices) == 0: 105 | result.append("no device found, try again") 106 | 107 | for address, name in devices: 108 | name = "%s (%s)" % (address, name) 109 | result.append("%s" % (address, name)) 110 | 111 | self.emit("scan-result", "\n".join(result)) 112 | 113 | except (KeyboardInterrupt, SystemExit): 114 | raise 115 | except: 116 | logging.exception(sys.exc_info()[0]) 117 | self.emit("scan-result", traceback.format_exc()) 118 | 119 | def on_scan_ble(self, sid): 120 | self.init() 121 | try: 122 | result = ["Results:"] 123 | 124 | data = TcBleInterface(self.config.read("ble_address")).scan() 125 | if len(data) == 0: 126 | result.append("no device found, try again") 127 | 128 | for device in data: 129 | name = "%s (%s)" % (device["address"], device["name"]) 130 | result.append("%s" % (device["address"], name)) 131 | 132 | self.emit("scan-result", "\n".join(result)) 133 | 134 | except (KeyboardInterrupt, SystemExit): 135 | raise 136 | except: 137 | logging.exception(sys.exc_info()[0]) 138 | self.emit("scan-result", traceback.format_exc()) 139 | 140 | def on_scan_serial(self, sid): 141 | self.init() 142 | try: 143 | result = ["Results:"] 144 | 145 | data = comports() 146 | if len(data) == 0: 147 | result.append("no device found, try again") 148 | else: 149 | additional = [ 150 | "description", 151 | "manufacturer", 152 | "product", 153 | "serial_number", 154 | "vid", 155 | "pid", 156 | ] 157 | 158 | for device in data: 159 | description = [] 160 | 161 | for property in additional: 162 | value = getattr(device, property) 163 | if value is not None: 164 | value = str(value).strip() 165 | content = property + ": " + value 166 | if property == "description": 167 | content = value 168 | elif property == "vid": 169 | content = "VID_" + hex(int(value))[2:].upper().zfill(4) 170 | elif property == "pid": 171 | content = "PID_" + hex(int(value))[2:].upper().zfill(4) 172 | description.append(content) 173 | 174 | name = device.device 175 | if len(description) > 0: 176 | name += " (" + ", ".join(description) + ")" 177 | 178 | result.append("" + name + "") 179 | 180 | self.emit("scan-result", "\n".join(result)) 181 | 182 | except (KeyboardInterrupt, SystemExit): 183 | raise 184 | except: 185 | logging.exception(sys.exc_info()[0]) 186 | self.emit("scan-result", traceback.format_exc()) 187 | 188 | def on_close(self, sid): 189 | self.init() 190 | self.emit("disconnecting") 191 | self.daemon.stop() 192 | 193 | def emit(self, event, data=None, to=None, room=None, skip_sid=None, namespace=None, callback=None): 194 | if self.server is None: 195 | return 196 | super().emit(event, data=data, to=to, room=room, skip_sid=skip_sid, namespace=namespace, callback=callback) 197 | 198 | 199 | class Daemon: 200 | DEFAULT_TIMEOUT = 60 201 | DEFAULT_RETRY_COUNT = 10 202 | 203 | running = None 204 | thread = None 205 | storage = None 206 | config = None 207 | interface = None 208 | buffer = None 209 | buffer_expiration = None 210 | timeout = None 211 | retry_count = None 212 | 213 | def __init__(self, backend, on_receive, on_receive_interval): 214 | self.backed = backend 215 | self.on_receive = on_receive 216 | self.on_receive_interval = on_receive_interval 217 | self.storage = Storage() 218 | if self.storage.fetch_status() != "disconnected": 219 | self.storage.update_status("disconnected") 220 | self.loop = asyncio.new_event_loop() 221 | self.converter = Converter() 222 | 223 | def start(self): 224 | self.running = True 225 | if self.thread is None: 226 | self.thread = Thread(target=self.run) 227 | if not self.thread.is_alive(): 228 | self.thread.start() 229 | 230 | def stop(self): 231 | self.log("Disconnecting") 232 | self.running = False 233 | if self.interface: 234 | self.interface.disconnect() 235 | while self.thread and self.thread.is_alive(): 236 | sleep(0.1) 237 | self.emit("disconnected") 238 | self.thread = None 239 | 240 | def run(self): 241 | self.storage = Storage() 242 | self.config = Config() 243 | 244 | self.interface = Wrapper() 245 | 246 | setup = self.config.read("setup") 247 | self.timeout = self.parse_setup_option(setup, "timeout", int, self.DEFAULT_TIMEOUT) 248 | self.retry_count = self.parse_setup_option(setup, "retry_count", int, self.DEFAULT_RETRY_COUNT) 249 | 250 | try: 251 | self.log("Connecting") 252 | self.retry(self.interface.connect) 253 | self.emit("connected") 254 | self.log("Connected") 255 | 256 | name = self.config.read("name") 257 | interval = float(self.config.read("rate")) 258 | version = self.config.read("version") 259 | session_id = self.storage.create_session(name, version) 260 | while self.running: 261 | begin = timer() 262 | data = self.retry(self.interface.read) 263 | 264 | if isinstance(data, str): 265 | if data in ["disconnected", "connected"]: 266 | self.disconnect() 267 | return 268 | raise Exception(data) 269 | else: 270 | self.log(json.dumps(data)) 271 | if data: 272 | data["session_id"] = session_id 273 | self.update(data, version) 274 | self.storage.store_measurement(data) 275 | 276 | measurement_runtime = timer() - begin 277 | sleep_time = interval - measurement_runtime 278 | if sleep_time > 0: 279 | sleep(sleep_time) 280 | 281 | except (KeyboardInterrupt, SystemExit): 282 | raise 283 | except: 284 | logging.exception(sys.exc_info()[0]) 285 | self.emit("log", traceback.format_exc()) 286 | self.emit("log-error") 287 | finally: 288 | self.disconnect() 289 | 290 | def disconnect(self): 291 | self.interface.disconnect() 292 | self.emit("disconnected") 293 | self.log("Disconnected") 294 | self.thread = None 295 | 296 | def update(self, data, version): 297 | format = Format(version) 298 | 299 | table = [] 300 | for name in format.table_fields: 301 | callback = getattr(format, name) 302 | table.append(callback(data)) 303 | 304 | graph = {} 305 | for name in format.graph_fields: 306 | if name == "timestamp": 307 | callback = getattr(format, name) 308 | value = callback(data) 309 | else: 310 | value = data[name] 311 | graph[name] = value 312 | 313 | graph = self.converter.convert(graph) 314 | 315 | if self.on_receive: 316 | if not self.buffer: 317 | self.buffer = [] 318 | 319 | data["timestamp"] = int(data["timestamp"]) 320 | self.buffer.append(data) 321 | 322 | execute = True 323 | if self.on_receive_interval: 324 | execute = False 325 | if not self.buffer_expiration or self.buffer_expiration <= time(): 326 | execute = True 327 | self.buffer_expiration = time() + self.on_receive_interval 328 | 329 | if execute: 330 | payload = json.dumps(self.buffer) 331 | self.buffer = None 332 | payload_file = os.path.join(os.getcwd(), "on-receive-payload-%s.json") % time() 333 | with open(payload_file, "w") as file: 334 | file.write(payload) 335 | 336 | logging.info("executing --on-receive command '%s' with payload file '%s'" % ( 337 | self.on_receive, payload_file 338 | )) 339 | command = self.on_receive + " \"" + payload_file + "\"" 340 | env = os.environ.copy() 341 | env["PYTHONPATH"] = "" 342 | subprocess.Popen(command, shell=True, env=env) 343 | 344 | self.emit("update", json.dumps({ 345 | "table": table, 346 | "graph": graph, 347 | })) 348 | 349 | def retry(self, callback): 350 | timeout = self.timeout 351 | deadline = time() + timeout 352 | count = self.retry_count 353 | 354 | reconnect = False 355 | while self.running: 356 | try: 357 | if reconnect: 358 | self.interface.disconnect() 359 | self.interface.connect() 360 | # noinspection PyUnusedLocal 361 | reconnect = False 362 | 363 | return callback() 364 | except (KeyboardInterrupt, SystemExit): 365 | raise 366 | except FatalErrorException: 367 | raise 368 | except: 369 | count -= 1 370 | logging.exception(sys.exc_info()[0]) 371 | 372 | if self.timeout > 0 and deadline <= time(): 373 | raise 374 | 375 | if self.retry_count > 0 and count <= 0: 376 | raise 377 | 378 | condition = [] 379 | if self.retry_count > 0: 380 | condition.append("%s of %s" % (self.retry_count - count, self.retry_count)) 381 | if self.timeout > 0: 382 | timestamp = pendulum.from_timestamp(deadline).in_tz("local").format("YYYY-MM-DD HH:mm:ss") 383 | condition.append("until %s" % timestamp) 384 | 385 | if len(condition): 386 | condition = " or ".join(condition) 387 | else: 388 | condition = "indefinitely" 389 | 390 | self.log("operation failed, retrying %s" % condition) 391 | self.emit("log", traceback.format_exc()) 392 | reconnect = True 393 | 394 | return None # this will never happen, it's just to satisfy the IDE 395 | 396 | def emit(self, event, data=None): 397 | if event == "log": 398 | self.storage.log(data) 399 | elif event in ["connecting", "connected", "disconnecting", "disconnected"]: 400 | self.storage.update_status(event) 401 | self.backed.emit(event, data) 402 | 403 | def log(self, message): 404 | prefix = pendulum.now().format("YYYY-MM-DD HH:mm:ss") + " - " 405 | self.emit("log", prefix + message + "\n") 406 | 407 | def parse_setup_option(self, setup, name, data_type, default=None): 408 | if isinstance(setup, dict) and name in setup: 409 | try: 410 | return data_type(setup[name]) 411 | except (ValueError, TypeError): 412 | pass 413 | return default 414 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Web GUI for RuiDeng USB testers (UM34C, UM24C, UM25C, TC66C) 2 | == 3 | 4 | Simple web GUI written in Python 3. Measurements are stored in sqlite database. Tables and graphs are supported. 5 | Live preview and graphing is also available. 6 | 7 | Tested on UM34C/UM24C/UM25C/TC66C. 8 | 9 | Based on https://github.com/sebastianha/um34c 10 | 11 | 12 | Requirements 13 | -- 14 | 15 | - UM34C/UM24C/UM25C - meter connected via regular Bluetooth 16 | - TC66C - meter is using BLE instead of regular bluetooth so pairing will not work nor RFCOMM will 17 | - BLE has limited support on desktop devices, see used library for supported platforms and versions: 18 | https://github.com/hbldh/bleak#features 19 | - TC66C USB - meter connected with USB 20 | - Meter connected with USB exposes itself as serial port 21 | - UM34C/UM24C/UM25C Serial/COM port method - meter can be connected as serial port 22 | - Pairing with Windows Settings works fine. Pin is 1234. After successful pairing some serial ports are 23 | installed. In my case two. One of them works. 24 | - On Linux `rfcomm` and `hcitool` can be used (both provided by bluez package) 25 | - Retrieve bluetooth address with `hcitool scan` 26 | - Bind retrieved address to serial port with `rfcomm bind 0 aa:bb:cc:dd:ee:ff`. 27 | This step is not persistent. Serial port will disappear after reboot. Also, rd-usb needs to 28 | have permissions to use /dev/rfcommX. 29 | 30 | Installation 31 | -- 32 | 33 | ### Binaries (Win x64 only) 34 | 35 | - Download from [releases](https://github.com/kolinger/rd-usb/releases) 36 | - **rd-usb-x.exe** is CLI web server application. GUI is provided by web browser. 37 | Run executable and web server will be shortly spawned on address http://127.0.0.1:5000. 38 | - **rd-usb-install-x.exe** is installer of standalone GUI application. Works without web browser. 39 | Embedded browser is used instead. External web browser still can be used with address (see above). 40 | - Application will be probably blocked by Microsoft SmartScreen. For unblock click `More info` 41 | and `Run anyway`. I don't have certificate for signing and application does not have any 42 | reputation so Microsoft will block by default. 43 | 44 | ### Source code 45 | 46 | 1. Python 3.11 or newer is required 47 | 2. Download `rd-usb-source-x.zip` from [releases](https://github.com/kolinger/rd-usb/releases) 48 | or `git clone https://github.com/kolinger/rd-usb.git` 49 | 3. Install requirements 50 | - For GUI: `pip install -r requirements.txt` 51 | - For headless `pip install -r requirements_headless.txt` this version doesn't 52 | contain dependencies for embedded browser GUI. This is useful if you plan to use CLI/webserver only. 53 | It's also useful on ARM SBCs. 54 | - If you encounter failing installing of `pendulum` library then you may need different set of requirements. 55 | [See bellow for details](#pendulum-issue).\ 56 | In this case use `pip install -r requirements_headless_new.txt` as workaround. 57 | - If you encounter failing installation of `pybluez` then make sure you have `libbluetooth` dependency installed.\ 58 | For Debian based distribution this means `apt-get install libbluetooth-dev`.\ 59 | For Windows you need Microsoft Visual C++ 14.0 or greater distributed via 60 | [Microsoft C++ Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/). 61 | - There is also currently issue with `pybluez` installation where it fails due to pip version. 62 | Make sure you are not using pip 24.x or newer, pip 23.x works fine, if you have newer pip version you can 63 | downgrade to latest 23.x for example `C:\Python\python.exe -m pip install --upgrade pip==23.3.2` 64 | 4. Run with `python web.py` - this will spawn web server on http://127.0.0.1:5000, port can be changed 65 | with first argument: `python web.py 5555` 66 | 67 | For additional arguments/options use --help: `python web.py --help`. 68 | 69 | On Windows `python` can be found in Python's installation folder as `python.exe`. 70 | For example replace `python web.py` with `C:\Python\python.exe web.py` 71 | and `pip install -r requirements.txt` with `C:\Python\python.exe -m pip install -r requirements.txt`. 72 | 73 | On Linux use `python3` and `pip` inside [virtual environment](https://docs.python.org/3/library/venv.html) (venv). 74 | 75 | Usage 76 | -- 77 | 78 | **UM34C/UM24C/UM25C Bluetooth** 79 | 80 | 1. Select your device version. 81 | 2. Name your session. For example 'testing some power bank'. This is 82 | used to separate multiple measurements from each other. 83 | 3. Select sample rate. Faster sample rate will result in more accurate data but also 84 | will create a lot more data. For short measurements use faster sample rate. For longer 85 | use slower rate. Choose carefully. 86 | 4. Select UM34C/UM24C/UM25C from devices and follow with Setup link. 87 | 1. If previously connected you can initiate Setup again by clicking on current Bluetooth address 88 | 5. Scan for devices and select your device from list by clicking on it 89 | 6. After this you can connect simply by using Connect button. Setup is required only for new/different device. 90 | 7. Connection will be hopefully successful, and you will see live measurements in graph. 91 | Otherwise, read log for error messages. 92 | 93 | The 100ms and 250ms polling period (‘sample rate’) is misleading since only some meters support polling this quick, 94 | and it seems that all meters have estimated internal sample rate of 2 Hz (500ms) thus even if the meter supports 95 | polling at 10 Hz / 100ms (like the TC66C over USB, while the UM24/25/34 has estimated polling rate up to 3 Hz) this 96 | isn't useful since the extra samples are just duplicates of previous sample. In the original implementation the 97 | polling period wasn't polling period but the gap between samples, that's why in past the 100ms/250ms value did have 98 | meaningful purpose, but currently these options are essentially without purpose and left as legacy. 99 | 100 | **TC66C Bluetooth** 101 | 102 | 1. Make sure your OS is supported and has bluetooth with BLE support (Bluetooth Low Energy) 103 | 2. Rest is same as other devices. See above. 104 | 105 | **Bluetooth issues** 106 | 107 | If you have Bluetooth issues then try to close rd-usb, unplug/plug USB meter and start rd-usb again. Bluetooth can be 108 | sometimes funny and just refuses to work without any reason. If this doesn't help check proximity of your PC and USB 109 | meter, sometimes moving USB meter real close can resolve Bluetooth issues. 110 | 111 | Also make sure no other connection to the meter exists, like no other application is connected to the meter, 112 | the RFCOMM isn't bound, if any other connection exists this may break the ability to use or even see the meter 113 | from rd-usb. 114 | 115 | If you find trouble connecting to the UM34C/UM24C/UM25C via the direct Bluetooth method then try 116 | to use the UMxxC Serial method, where you need to first pair (in Windows) or use rfcomm bind (Linux), 117 | see [Requirements](#Requirements) above for details. 118 | 119 | **TC66C USB or UM34C/UM24C/UM25C Serial/COM port method** 120 | 121 | 1. Select UM34C/UM24C/UM25C with Serial suffix. 122 | 2. Follow Setup link to find your serial port or click Connect if you already have port selected. 123 | 3. Rest is same as other devices. See above. 124 | 125 | ![setup](screenshots/setup.png) 126 | 127 | Graphs 128 | -- 129 | 130 | ![tables](screenshots/graphs-v2.png) 131 | 132 | 133 | Tables 134 | -- 135 | 136 | ![tables](screenshots/tables.png) 137 | 138 | Pendulum issue 139 | -------------- 140 | 141 | There is known issue with library named `pendulum` (used for date and time) when used together with 142 | newer Python versions. This issue is triggered when installing requirements via pip and looks 143 | something like this: 144 | 145 | ``` 146 | ... 147 | Building wheel for pendulum (pyproject.toml) did not run successfully. 148 | ... 149 | ModuleNotFoundError: No module named 'distutils' 150 | ... 151 | ``` 152 | 153 | If your installation via pip fails due to this then the solution is to install different version of dependencies. 154 | Specifically `pip install -r requirements_headless_new.txt` should fix this issue. 155 | 156 | This issue is solved with version `>=3.0.0` of this library. Unfortunately as of now 157 | version `3.0.0` has other compatibility issues (specifically failing Rust compilation 158 | on systems that don't have prebuilt wheels) thus that's why `<3.0.0` is used by default. 159 | 160 | Multiple instances 161 | ------------------ 162 | 163 | If you want to use multiple instances for multiple USB meters then you need to do some adjustments to separate these 164 | instance from each other. As default all instances share the same port thus you can't use multiple USB meters. 165 | 166 | This can be easily fixed by changing the port, the port is first argument, without any argument `5000` is used. 167 | We can use default `5000` for first instance and then increment port for each additional instance. 168 | 169 | Examples: 170 | - `"C:\Program Files\rd-usb\rd-usb.exe" 5001` for Windows GUI 171 | - `"C:\somewhere\rd-usb.exe" 5001` for Windows standalone binary 172 | - `"C:\Python\python.exe" web.py 5001` for Windows Python 173 | - `/some/python web.py 5001` for Linux 174 | 175 | This way you can use multiple instances with multiple USB meters but all data and settings are shared together. 176 | 177 | It may be beneficial to separate instances completely including all data and settings. For this extra parameter 178 | `--data-dir` is required, for details see **Custom data directory** section bellow. This parameter expects directory 179 | where all data and settings will be placed. If you combined `--data-dir` and port then you can have independent 180 | instances. 181 | 182 | Examples: 183 | - `"C:\Program Files\rd-usb\rd-usb.exe" 5001 --data-dir C:\some\place\for\data` for Windows GUI 184 | - `"C:\somewhere\rd-usb.exe" 5001 --data-dir C:\some\place\for\data` for Windows standalone binary 185 | - `"C:\Python\python.exe web.py" 5001 --data-dir C:\some\place\for\data` for Windows Python 186 | - `/some/python web.py 5001 --data-dir /some/place/for/data` for Linux 187 | 188 | On Windows it may be handy to make shortcut for this - just copy existing shortcut and add the parameters at the end of 189 | Target field separated by space like the first examples. Then you have easy access to your second instance. 190 | 191 | Docker 192 | -- 193 | 194 | Experimental Docker container exists. Bluetooth isn't officially supported in Docker so there is need to pass special 195 | arguments to Docker to make it work - there are also caveats that different system may need extra system changes to 196 | make Bluetooth in Docker work. 197 | 198 | **Ubuntu/Debian/Raspbian caveats** - you need to load custom Docker AppArmor policy and add extra argument for Docker 199 | to use this policy instead of the default one. 200 | 201 | In terminal: 202 | ``` 203 | sudo wget https://raw.githubusercontent.com/kolinger/rd-usb/master/docker/docker-ble -O /etc/apparmor.d/docker-ble 204 | sudo apparmor_parser -r -W /etc/apparmor.d/docker-ble 205 | ``` 206 | 207 | This may be sometimes unnecessary, but it won't hurt. 208 | Thanks to @thijsputman for his work on [docker-ble](https://github.com/thijsputman/tc66c-mqtt/blob/main/docker/docker-ble). 209 | 210 | **Other Linuxes caveats** - unknown - if you know, let me know. The principe is the same but details may differ. 211 | If you use AppArmor then you may need custom policy otherwise Bluetooth won't work. 212 | 213 | **Don't forget** to mount `/opt/rd-usb/data` via volume otherwise you will lose all your data when container gets 214 | recreated/updated. 215 | 216 | **docker-compose.yml** 217 | 218 | ``` 219 | version: '3.7' 220 | services: 221 | rd-usb: 222 | container_name: rd-usb 223 | image: kolinger/rd-usb:latest 224 | restart: unless-stopped 225 | security_opt: 226 | - apparmor=docker-ble 227 | environment: 228 | - TZ=Etc/UTC # change your timeozne (TZ identifier here), for example Europe/London 229 | - ARGS= 230 | volumes: 231 | - /var/run/dbus/system_bus_socket:/var/run/dbus/system_bus_socket 232 | - /some/place/to/store/data:/opt/rd-usb/data 233 | # you need something like these examples if you want to use serial port based communication 234 | #devices: 235 | # - /dev/ttyUSB0 236 | # - /dev/rfcomm0 237 | network_mode: host 238 | ``` 239 | 240 | Change `/some/place/to/store/data` to your liking. 241 | 242 | If you want to use TC66C USB or UMxxC Serial then you also need to pass specific serial device to docker. For TC66C USB 243 | it would be something like /dev/ttyUSB0 and for UMxxC Serial something like /dev/rfcomm0. 244 | 245 | Optional arguments for `web.py` can be passed to `ARGS` environment variables - for example `ARGS=1888` will 246 | change listening port to 1888, `ARGS="1888 --prefix /rd-usb"` will set path prefix for proxy purposes. 247 | 248 | Custom data directory 249 | -- 250 | Custom data directory can be specified with `--data-dir` option. 251 | 252 | For example `rd-usb.exe --data-dir C:/rd-usb` will place all configuration and data files in `C:/rd-usb` directory. 253 | This can be used together with shortcut, just make sure you escape path with quotes properly, for example: 254 | `"C:\your\path\rd-usb.exe" --data-dir "C:\your\path\data"`. 255 | 256 | Custom export 257 | -- 258 | 259 | Application has basic CSV export built-in. For more advanced use-cases external script can be used. 260 | 261 | External program/script can be specified with `--on-receive` option. 262 | This script will be executed when new data is received. 263 | New measurements are provided as JSON file. Path of this file is provided as first argument. 264 | 265 | For managing overhead another option `--on-receive-interval` is available (default value is 60). 266 | This number specified how often is external script called (in seconds, default value is every minute). 267 | Value 0 means script will be called for every new measurement as they happen. 268 | Script is not called when no new measurements are available. 269 | 270 | CLI example: `python web.py --on-receive on-receive.sh` 271 | 272 | See `on-receive.sh` or `on-receive.cmd` files for more information how implement this program/script. 273 | Also `on-receive-python-example.cmd` and `on-receive-python-example.py` show more extended example how to call python. 274 | 275 | Example structure of JSON file: 276 | 277 | ``` 278 | [ 279 | { 280 | "timestamp": 1599556295, 281 | "voltage": 5.12, 282 | "current": 0.0, 283 | "power": 0.0, 284 | "temperature": 25, 285 | "data_plus": 0.0, 286 | "data_minus": 0.0, 287 | "mode_id": 7, 288 | "mode_name": "DCP1.5A", 289 | "accumulated_current": 0, 290 | "accumulated_power": 0, 291 | "accumulated_time": 0, 292 | "resistance": 9999.9, 293 | "name": "measurement name" 294 | }, 295 | ... 296 | ] 297 | ``` 298 | 299 | Reverse proxy 300 | -- 301 | 302 | If you like to have HTTPS (or use reverse proxy for other reasons) then simple reverse proxy can be used. 303 | All common webservers can do this. Here are examples for nginx. 304 | 305 | You can also modify on what address/interface is rd-usb listening by providing `--listen` CLI option 306 | like `--listen 192.168.1.100` where `192.168.1.100` is address of interface you want to listen on. 307 | 308 | At root: 309 | 310 | ```` 311 | server { 312 | listen 443 ssl; 313 | listen [::]:443 ssl; 314 | 315 | ssl_certificate /your/certificate.crt; 316 | ssl_certificate_key /your/certificate.key; 317 | 318 | server_name rd-usb; 319 | 320 | location / { 321 | proxy_pass http://127.0.0.1:5000; 322 | } 323 | } 324 | ```` 325 | 326 | In path: 327 | 328 | ```` 329 | server { 330 | listen 443 ssl; 331 | listen [::]:443 ssl; 332 | 333 | ssl_certificate /your/certificate.crt; 334 | ssl_certificate_key /your/certificate.key; 335 | 336 | server_name domain.tld; 337 | 338 | # your other things 339 | 340 | location /rd-usb { 341 | proxy_pass http://127.0.0.1:5000; 342 | } 343 | } 344 | ```` 345 | 346 | When some prefix/path is used then it needs to be specified as argument `--prefix` when launching rd-usb. 347 | In this example `--prefix /rd-usb` is required resulting in something like `python3 web.py --prefix /rd-usb`. 348 | 349 | Note: `rd-usb` should not be exposed on untrusted network or to untrusted users. 350 | Use [HTTP basic auth](https://docs.nginx.com/nginx/admin-guide/security-controls/configuring-http-basic-authentication/) 351 | for example as authentication mechanism when running rd-usb at untrusted network. 352 | 353 | Systemd service 354 | -- 355 | 356 | If you would like to have rd-usb start automatically on unix systems, you can grab the example service files, 357 | drop them in `/etc/systemd/system/` respectively: 358 | 359 | /etc/systemd/system/rfcomm0.service 360 | 361 | ``` 362 | [Unit] 363 | Description=rfcomm0 364 | After=bluetooth.target 365 | 366 | [Service] 367 | Type=oneshot 368 | User=root 369 | ExecStart=/usr/bin/rfcomm bind 0 370 | ExecStart=/usr/bin/chown root:dialout /dev/rfcomm0 371 | ExecStop=/usr/bin/rfcomm release 0 372 | RemainAfterExit=yes 373 | Restart=no 374 | 375 | [Install] 376 | WantedBy=multi-user.target 377 | ``` 378 | 379 | ``` 380 | systemctl enable rfcomm0.service 381 | systemctl start rfcomm0.service 382 | ``` 383 | 384 | /etc/systemd/system/rd-usb.service 385 | 386 | ``` 387 | [Unit] 388 | Description=rd-usb 389 | After=network.target 390 | After=rfcomm0.service 391 | 392 | [Service] 393 | Type=simple 394 | WorkingDirectory= 395 | ExecStart=/usr/bin/python3 web.py --daemon 396 | User=www-data 397 | Group=www-data 398 | Restart=always 399 | RestartSec=30 400 | 401 | [Install] 402 | WantedBy=multi-user.target 403 | ``` 404 | 405 | ``` 406 | systemctl enable rd-usb.service 407 | systemctl start rd-usb.service 408 | ``` 409 | 410 | Don't forget to add user of `rd-usb.service` to `dialout` group to enable access to `/dev/rfcomm0` for communication. 411 | In this example you need to add `www-data` to `dialout` like this `usermod -aG dialout www-data`. 412 | Webserver as reverse proxy in front of `rd-usb.service` is suggested (like nginx, apache2, ...). 413 | 414 | Development 415 | -- 416 | 417 | ### Building binaries 418 | 419 | 1. Install pyinstaller: `pip install pyinstaller` (4.x) and [NSIS](https://nsis.sourceforge.io/Download) installer 420 | 2. Generate binaries: 421 | - `pyinstaller pyinstaller-cli.spec` 422 | - `pyinstaller pyinstaller.spec` 423 | - `makensis.exe installer.nsi` 424 | - or use `build.cmd` 425 | 3. Binaries will be saved in `dist` directory 426 | -------------------------------------------------------------------------------- /webapp/index.py: -------------------------------------------------------------------------------- 1 | import csv 2 | import io 3 | from math import ceil, floor 4 | import os 5 | import pathlib 6 | from time import time 7 | import traceback 8 | 9 | from flask import url_for, request, jsonify, redirect, flash, make_response, current_app 10 | from flask.blueprints import Blueprint 11 | from flask.templating import render_template 12 | import pendulum 13 | from werkzeug.utils import secure_filename 14 | 15 | from interfaces.tc import TcSerialInterface 16 | from utils.config import Config, static_path, get_data_path 17 | from utils.formatting import Format 18 | from utils.storage import Storage 19 | from utils.version import version 20 | from webapp.backend import Daemon 21 | 22 | 23 | class Index: 24 | config = None 25 | storage = None 26 | import_in_progress = False 27 | 28 | def register(self): 29 | blueprint = Blueprint("index", __name__, template_folder="templates") 30 | blueprint.add_url_rule("/", "default", self.render_default) 31 | blueprint.add_url_rule("/data", "data", self.render_data) 32 | blueprint.add_url_rule("/graph", "graph", self.render_graph) 33 | blueprint.add_url_rule("/graph.json", "graph_data", self.render_graph_data) 34 | blueprint.add_url_rule("/setup", "setup", self.render_setup, methods=["GET", "POST"]) 35 | blueprint.add_url_rule("/rfcomm", "rfcomm", self.render_rfcomm) 36 | blueprint.add_url_rule("/ble", "ble", self.render_ble) 37 | blueprint.add_url_rule("/serial", "serial", self.render_serial) 38 | blueprint.add_url_rule("/tc66c-import", "tc66c_import", self.render_tc66c_import, methods=["GET", "POST"]) 39 | blueprint.add_url_rule("/csv-import", "csv_import", self.render_csv_import, methods=["GET", "POST"]) 40 | blueprint.context_processor(self.fill) 41 | return blueprint 42 | 43 | def init(self): 44 | self.config = Config() 45 | self.storage = Storage() 46 | 47 | def fill(self): 48 | variables = { 49 | "rd_user_version": version, 50 | "url_for": self.url_for, 51 | "version": self.config.read("version", "UM34C"), 52 | "port": self.config.read("port", ""), 53 | "rate": str(self.config.read("rate", 1.0)), 54 | "name": self.config.read("name", pendulum.now().format("YYYY-MM-DD")), 55 | "rfcomm_address": self.config.read("rfcomm_address"), 56 | "ble_address": self.config.read("ble_address"), 57 | "format_datetime": self.format_date, 58 | "app_prefix": current_app.config["app_prefix"] 59 | } 60 | 61 | status = self.storage.fetch_status() 62 | variables["status"] = status.title() 63 | variables["connect_disabled"] = status != "disconnected" 64 | variables["connect_button"] = "Connect" if status == "disconnected" else "Disconnect" 65 | 66 | setup = self.config.read("setup") 67 | variables["theme"] = setup["theme"] if setup and "theme" in setup else "light" 68 | 69 | variables["embedded"] = current_app.config["embedded"] 70 | 71 | return variables 72 | 73 | def render_default(self): 74 | self.init() 75 | self.storage.clear_log() 76 | log = self.storage.fetch_log() 77 | return render_template("default.html", log=log, page="default") 78 | 79 | def render_data(self): 80 | self.init() 81 | 82 | sessions, selected = self.prepare_selection() 83 | session = self.storage.get_selected_session(selected) 84 | 85 | format = None 86 | measurements = [] 87 | pages = [] 88 | if session: 89 | format = Format(session["version"]) 90 | 91 | if request.args.get("export") == "": 92 | string = io.StringIO() 93 | writer = csv.writer(string) 94 | 95 | names = [] 96 | for field in format.export_fields: 97 | names.append(format.field_name(field)) 98 | writer.writerow(names) 99 | 100 | run_time_offset = None 101 | for item in self.storage.fetch_measurements(session["id"], zeroed=True): 102 | if run_time_offset is None and item["resistance"] < 9999.9: 103 | run_time_offset = item["timestamp"] 104 | 105 | rune_time = 0 106 | if run_time_offset is not None: 107 | rune_time = round(item["timestamp"] - run_time_offset) 108 | 109 | values = [] 110 | for field in format.export_fields: 111 | if field == "time": 112 | values.append(format.time(item)) 113 | elif field == "run_time": 114 | remaining = rune_time 115 | hours = floor(remaining / 3600) 116 | remaining -= hours * 3600 117 | minutes = floor(remaining / 60) 118 | remaining -= minutes * 60 119 | seconds = remaining 120 | parts = [ 121 | hours, 122 | minutes, 123 | seconds, 124 | ] 125 | for index, value in enumerate(parts): 126 | parts[index] = str(value).zfill(2) 127 | values.append(":".join(parts)) 128 | elif field == "run_time_seconds": 129 | values.append(rune_time) 130 | else: 131 | values.append(item[field]) 132 | writer.writerow(values) 133 | 134 | output = make_response(string.getvalue()) 135 | output.headers["Content-Disposition"] = "attachment; filename=" + session["name"] + ".csv" 136 | output.headers["Content-type"] = "text/csv" 137 | return output 138 | 139 | elif request.args.get("destroy") == "": 140 | if selected == "": 141 | flash("Please select session first", "info") 142 | return redirect(request.base_url) 143 | self.storage.destroy_measurements(session["id"]) 144 | flash("Measurements with session name '" + session["name"] + "' were deleted", "danger") 145 | return redirect(request.base_url) 146 | 147 | page = request.args.get("page", 1, int) 148 | limit = 100 149 | offset = limit * (page - 1) 150 | count = self.storage.fetch_measurements_count(session["id"]) 151 | pages = self.prepare_pages(session["id"], page, limit, count) 152 | 153 | measurements = self.storage.fetch_measurements(session["id"], limit, offset, zeroed=True) 154 | 155 | return render_template( 156 | "data.html", 157 | format=format, 158 | sessions=sessions, 159 | selected=selected, 160 | measurements=measurements, 161 | pages=pages, 162 | page="data", 163 | accumulated="zeroed" if request.cookies.get("accumulated") == "zeroed" else "actual" 164 | ) 165 | 166 | def prepare_pages(self, session_id, page, limit, count, blocks=10): 167 | first_page = 1 168 | related = 3 169 | last_page = int(ceil(count / limit)) 170 | steps = set(range(max((first_page, page - related)), min((last_page, page + related)) + 1)) 171 | quotient = (last_page - 1) / blocks 172 | if len(steps) > 1: 173 | for index in range(0, blocks): 174 | steps.add(round(quotient * index) + first_page) 175 | steps.add(last_page) 176 | steps = sorted(steps) 177 | 178 | pages = [] 179 | for number in steps: 180 | pages.append({ 181 | "number": number, 182 | "link": url_for("index.data", page=number, session=session_id), 183 | "current": number == page, 184 | }) 185 | 186 | return pages 187 | 188 | def render_graph(self): 189 | self.init() 190 | 191 | sessions, selected = self.prepare_selection() 192 | session = self.storage.get_selected_session(selected) 193 | 194 | format = Format(session["version"] if session else None) 195 | 196 | last_measurement = None 197 | if selected == "": 198 | last_measurement = self.storage.fetch_last_measurement() 199 | 200 | metrics = { 201 | "voltage": "Voltage (V)", 202 | "current": "Current (A)", 203 | "current-m": "Current (mA)", 204 | "power": "Power (W)", 205 | "temperature": "Temperature (°C)", 206 | "accumulated_current": "Accumulated current (mAh)", 207 | "accumulated_power": "Accumulated power (mWh)", 208 | "zeroed_accumulated_current": "Accumulated current (mAh) [zeroed]", 209 | "zeroed_accumulated_power": "Accumulated power (mWh) [zeroed]", 210 | "resistance": "Resistance (Ω)", 211 | } 212 | 213 | left_axis = request.args.get("left_axis", default="voltage") 214 | right_axis = request.args.get("right_axis", default="current") 215 | 216 | return render_template( 217 | "graph.html", 218 | format=format, 219 | sessions=sessions, 220 | selected=selected, 221 | item=last_measurement, 222 | metrics=metrics, 223 | left_axis=left_axis, 224 | right_axis=right_axis, 225 | colors=self.config.read("colors", "colorful"), 226 | page="graph" 227 | ) 228 | 229 | def render_graph_data(self): 230 | self.init() 231 | 232 | selected = request.args.get("session") 233 | session = self.storage.get_selected_session(selected) 234 | 235 | left_axis = request.args.get("left_axis") 236 | right_axis = request.args.get("right_axis") 237 | colors = request.args.get("colors") 238 | if self.config.read("colors") != colors: 239 | self.config.write("colors", colors, flush=True) 240 | 241 | format = Format(session["version"] if session else None) 242 | 243 | data = [] 244 | if session: 245 | for item in self.storage.fetch_measurements(session["id"], zeroed=True): 246 | if left_axis in item and right_axis in item: 247 | data.append({ 248 | "date": format.timestamp(item), 249 | "left": item[left_axis], 250 | "right": item[right_axis], 251 | }) 252 | 253 | return jsonify(data) 254 | 255 | def prepare_selection(self): 256 | sessions = self.storage.fetch_sessions() 257 | selected = request.args.get("session") 258 | try: 259 | selected = int(selected) 260 | except (ValueError, TypeError): 261 | selected = None 262 | 263 | if selected is None: 264 | selected = "" 265 | 266 | return sessions, selected 267 | 268 | def fill_config_from_parameters(self): 269 | value = request.args.get("version") 270 | if value is not None: 271 | self.config.write("version", value) 272 | 273 | value = request.args.get("rate") 274 | if value is not None: 275 | self.config.write("rate", float(value)) 276 | 277 | def render_setup(self): 278 | self.init() 279 | setup: dict = self.config.read("setup") 280 | if not isinstance(setup, dict): 281 | setup = {} 282 | 283 | defaults = { 284 | "theme": "light", 285 | "auto_connect": "no", 286 | "timeout": Daemon.DEFAULT_TIMEOUT, 287 | "retry_count": Daemon.DEFAULT_RETRY_COUNT, 288 | } 289 | 290 | for name, default_value in defaults.items(): 291 | if name not in setup or setup[name] is None or setup[name] == "": 292 | setup[name] = default_value 293 | 294 | if "do" in request.form: 295 | for name in defaults.keys(): 296 | setup[name] = request.form[name] 297 | self.config.write("setup", setup) 298 | flash("Settings were successfully saved", "success") 299 | return redirect(url_for("index.setup")) 300 | 301 | return render_template( 302 | "setup.html", 303 | setup=setup, 304 | page="setup", 305 | default_timeout=Daemon.DEFAULT_TIMEOUT, 306 | default_retry_count=Daemon.DEFAULT_RETRY_COUNT, 307 | ) 308 | 309 | def render_rfcomm(self): 310 | self.init() 311 | self.fill_config_from_parameters() 312 | return render_template( 313 | "rfcomm.html" 314 | ) 315 | 316 | def render_ble(self): 317 | self.init() 318 | self.fill_config_from_parameters() 319 | return render_template( 320 | "ble.html" 321 | ) 322 | 323 | def render_serial(self): 324 | self.init() 325 | self.fill_config_from_parameters() 326 | return render_template( 327 | "serial.html" 328 | ) 329 | 330 | def render_tc66c_import(self): 331 | self.init() 332 | self.fill_config_from_parameters() 333 | 334 | messages = [] 335 | if self.config.read("version") not in ["TC66C-USB"]: 336 | messages.append("Available only for TC66C USB") 337 | elif self.storage.fetch_status() != "disconnected": 338 | messages.append("Disconnect first") 339 | 340 | session_name = "My recording" 341 | period = 1 342 | calculate = False 343 | if "do" in request.form: 344 | if len(messages) == 0: 345 | session_name = request.form.get("session_name") 346 | if not session_name: 347 | messages.append("Please provide session name") 348 | 349 | try: 350 | period = int(request.form.get("period")) 351 | if period < 1 or period > 60: 352 | raise ValueError 353 | except ValueError: 354 | messages.append("Period has invalid value, please enter number between 1 and 60") 355 | 356 | calculate = request.form.get("calculate") is not None 357 | 358 | if len(messages) == 0: 359 | messages.extend(self.do_tc66c_import(session_name, period, calculate)) 360 | if len(messages) == 0: 361 | return redirect(url_for("index.graph")) 362 | 363 | return render_template( 364 | "tc66c-import.html", 365 | messages=messages, 366 | session_name=session_name, 367 | period=period, 368 | calculate=calculate, 369 | page="data" 370 | ) 371 | 372 | def do_tc66c_import(self, name, period=1, calculate=False): 373 | messages = [] 374 | if self.import_in_progress: 375 | messages.append("Import is already running") 376 | return messages 377 | self.import_in_progress = True 378 | serial_timeout = int(self.config.read("serial_timeout", 10)) 379 | interface = TcSerialInterface(self.config.read("port"), serial_timeout) 380 | self.storage.fetch_status() 381 | try: 382 | interface.connect() 383 | begin = time() 384 | previous_timestamp = None 385 | accumulated_current = 0 386 | accumulated_power = 0 387 | session_id = None 388 | for index, record in enumerate(interface.read_records()): 389 | timestamp = begin + (index * period) 390 | 391 | if session_id is None: 392 | session_id = self.storage.create_session(name, "TC66C recording") 393 | 394 | data = { 395 | "timestamp": timestamp, 396 | "voltage": round(record["voltage"] * 10000) / 10000, 397 | "current": round(record["current"] * 100000) / 100000, 398 | "power": 0, 399 | "temperature": 0, 400 | "data_plus": 0, 401 | "data_minus": 0, 402 | "mode_id": 0, 403 | "mode_name": None, 404 | "accumulated_current": round(accumulated_current), 405 | "accumulated_power": round(accumulated_power), 406 | "accumulated_time": 0, 407 | "resistance": 0, 408 | "session_id": session_id, 409 | } 410 | 411 | if calculate: 412 | data["power"] = round(record["voltage"] * record["current"] * 1000) / 1000 413 | if record["current"] <= 0 and record["current"] >= 0: 414 | data["resistance"] = 9999.9 415 | else: 416 | data["resistance"] = round(record["voltage"] / record["current"] * 10) / 10 417 | if data["resistance"] > 9999.9: 418 | data["resistance"] = 9999.9 419 | 420 | if previous_timestamp is not None: 421 | delta = (timestamp - previous_timestamp) / 3600 422 | accumulated_current += (data["current"] * 1000) * delta 423 | accumulated_power += (data["power"] * 1000) * delta 424 | 425 | self.storage.store_measurement(data) 426 | 427 | previous_timestamp = timestamp 428 | 429 | except (KeyboardInterrupt, SystemExit): 430 | raise 431 | except: 432 | message = "Failed to connect:" 433 | exception = traceback.format_exc() 434 | self.storage.log(exception) 435 | message += "\n%s" % exception 436 | messages.append(message) 437 | finally: 438 | interface.disconnect() 439 | self.import_in_progress = False 440 | 441 | return messages 442 | 443 | def render_csv_import(self): 444 | self.init() 445 | self.fill_config_from_parameters() 446 | 447 | messages = [] 448 | 449 | session_name = "My CSV import" 450 | version = self.config.read("version") 451 | if "do" in request.form: 452 | session_name = request.form.get("session_name") 453 | if not session_name: 454 | messages.append("Please provide session name") 455 | 456 | version = request.form.get("version") 457 | if not version: 458 | messages.append("Please provide version") 459 | 460 | uploaded_file = request.files.get("file") 461 | if not uploaded_file or uploaded_file.filename == "": 462 | messages.append("Please select a CSV file") 463 | else: 464 | extension = pathlib.Path(uploaded_file.filename).suffix[1:].lower() 465 | if extension != "csv": 466 | messages.append("Selected file '%s' is not a CSV file" % uploaded_file.filename) 467 | 468 | if len(messages) == 0: 469 | messages.extend(self.do_csv_import(session_name, version, uploaded_file)) 470 | if len(messages) == 0: 471 | return redirect(url_for("index.graph")) 472 | 473 | return render_template( 474 | "csv-import.html", 475 | messages=messages, 476 | session_name=session_name, 477 | import_version=version, 478 | page="data" 479 | ) 480 | 481 | def do_csv_import(self, session_name, version, uploaded_file): 482 | temp_dir = os.path.join(get_data_path(), "temp") 483 | if not os.path.exists(temp_dir): 484 | os.makedirs(temp_dir) 485 | 486 | filename = secure_filename(uploaded_file.filename) 487 | file_path = os.path.join(temp_dir, filename) 488 | uploaded_file.save(file_path) 489 | 490 | messages = [] 491 | if self.import_in_progress: 492 | messages.append("Import is already running") 493 | return messages 494 | self.import_in_progress = True 495 | 496 | self.storage.fetch_status() 497 | try: 498 | with open(file_path, "r", encoding="utf-8") as file: 499 | csv_reader = csv.reader(file, delimiter=",") 500 | 501 | header_expected = True 502 | mapping: dict = {} 503 | format = Format(version) 504 | session_id = None 505 | row_number = 0 506 | chunk = [] 507 | for row in csv_reader: 508 | row_number += 1 509 | if header_expected: 510 | header_expected = False 511 | index = 0 512 | for name in row: 513 | field = format.field_name_reverse(name) 514 | if field is not None: 515 | mapping[field] = index 516 | index += 1 517 | 518 | if "time" not in mapping: 519 | messages.append( 520 | "Invalid CSV layout: time column not found, make sure your column names are correct" 521 | ) 522 | return messages 523 | else: 524 | if session_id is None: 525 | session_id = self.storage.create_session(session_name, version) 526 | 527 | time_index = mapping["time"] 528 | try: 529 | time = pendulum.from_format(row[time_index], format.time_format) 530 | except ValueError: 531 | messages.append("Warning: unable to parse time: %s, skipping row: %s" % ( 532 | row[time_index], row_number 533 | )) 534 | continue 535 | 536 | data = { 537 | "timestamp": time.timestamp(), 538 | "voltage": 0, 539 | "current": 0, 540 | "power": 0, 541 | "temperature": 0, 542 | "data_plus": 0, 543 | "data_minus": 0, 544 | "mode_id": 0, 545 | "mode_name": None, 546 | "accumulated_current": 0, 547 | "accumulated_power": 0, 548 | "accumulated_time": 0, 549 | "resistance": 0, 550 | "session_id": session_id, 551 | } 552 | for field in data.keys(): 553 | if field in mapping: 554 | column_index = mapping[field] 555 | data[field] = float(row[column_index]) 556 | 557 | chunk.append(data) 558 | 559 | if len(chunk) >= 1000: 560 | self.storage.store_measurements(chunk) 561 | chunk = [] 562 | 563 | if len(chunk): 564 | self.storage.store_measurements(chunk) 565 | 566 | os.remove(file_path) 567 | finally: 568 | self.import_in_progress = False 569 | 570 | return messages 571 | 572 | def url_for(self, endpoint, **values): 573 | if endpoint == "static": 574 | filename = values.get("filename", None) 575 | if filename: 576 | file_path = static_path + "/" + filename 577 | values["v"] = int(os.stat(file_path).st_mtime) 578 | return url_for(endpoint, **values) 579 | 580 | def format_date(self, timestamp): 581 | date = pendulum.from_timestamp(timestamp) 582 | return date.in_timezone("local").format("YYYY-MM-DD HH:mm:ss") 583 | -------------------------------------------------------------------------------- /static/js/application.js: -------------------------------------------------------------------------------- 1 | var ntdrt = ntdrt || {}; 2 | 3 | ntdrt.application = { 4 | 5 | logScrollEnabled: true, 6 | 7 | init: function () { 8 | var self = ntdrt.application; 9 | self.relativeAppPrefix = ntdrt.appPrefix; 10 | if (self.relativeAppPrefix === '/') { 11 | self.relativeAppPrefix = ''; 12 | } 13 | 14 | $(document).on('click', '[data-confirm]', function (e) { 15 | return confirm($(this).attr('data-confirm')); 16 | }); 17 | 18 | var adjustNavigationPlaceholder = function () { 19 | var navigation = $('.navbar'); 20 | var placeholder = $('.navbar-placeholder'); 21 | var height = navigation.outerHeight() + 20; 22 | placeholder.css('height', height + 'px'); 23 | }; 24 | $(window).on('resize', adjustNavigationPlaceholder); 25 | adjustNavigationPlaceholder(); 26 | 27 | $(document).on('click', '.toggle-navigation', function (e) { 28 | e.preventDefault(); 29 | var target = $('.navbar > .container > .nav'); 30 | if (target.is(':visible')) { 31 | target.slideUp(250); 32 | } else { 33 | target.slideDown(250); 34 | } 35 | }); 36 | 37 | $(document).on('click', '.toggle-form', function (e) { 38 | e.preventDefault(); 39 | var target = $('.navbar > .container > .navbar-form'); 40 | if (target.is(':visible')) { 41 | target.slideUp(250); 42 | } else { 43 | target.slideDown(250); 44 | } 45 | }); 46 | 47 | $(window).on('resize', function () { 48 | if ($(window).width() >= 992) { 49 | var targets = $('.navbar > .container > .nav, .navbar > .container > .navbar-form'); 50 | targets.each(function () { 51 | var target = $(this); 52 | if (!target.is(':visible')) { 53 | target.css('display', ''); 54 | } 55 | }); 56 | } 57 | }); 58 | 59 | $(document).on('change', '.setup select[name="version"]', function (e) { 60 | var control = $(this); 61 | var version = control.val(); 62 | 63 | var serial = $('.setup [data-serial]').hide(); 64 | var rfcomm = $('.setup [data-rfcomm]').hide(); 65 | var ble = $('.setup [data-ble]').hide(); 66 | if (version.indexOf('UM') === 0 && version.indexOf('Serial') === -1) { 67 | rfcomm.show(); 68 | } else if (version.indexOf('TC') === 0 && version.indexOf('USB') === -1) { 69 | ble.show(); 70 | } else { 71 | serial.show(); 72 | } 73 | }); 74 | 75 | $(document).on('click', '.setup-link', function (e) { 76 | e.preventDefault(); 77 | var link = $(this).attr('href'); 78 | var parts = []; 79 | var data = self.collect_connection_data(); 80 | for (var name in data) { 81 | parts.push(name + '=' + encodeURIComponent(data[name])); 82 | } 83 | var sep = link.indexOf('?') === -1 ? '?' : '&'; 84 | window.location.href = link + sep + parts.join('&'); 85 | }); 86 | 87 | $(document).on('click', 'button[data-import]', function () { 88 | var control = $(this); 89 | setTimeout(function () { 90 | control.prop('disabled', true); 91 | control.text('Importing...'); 92 | }, 0); 93 | }); 94 | 95 | $(document).on('click', '.data .table [data-accumulated]', function (e) { 96 | e.preventDefault(); 97 | var control = $(this); 98 | var accumulated = control.attr('data-accumulated'); 99 | var table = control.closest('.table'); 100 | table.find('[data-accumulated]').show(); 101 | table.find('[data-accumulated="' + accumulated + '"]').hide(); 102 | table.find('[data-accumulated-value]').hide(); 103 | table.find('[data-accumulated-value="' + accumulated + '"]').show(); 104 | Cookies.set('accumulated', accumulated, {expires: 365}); 105 | }); 106 | 107 | $(document).on('change', '[data-submit-on-change]', function () { 108 | var control = $(this); 109 | var form = control.closest('form'); 110 | var selector = control.attr('data-submit-on-change'); 111 | if (selector) { 112 | form.find(selector).trigger('click'); 113 | } else { 114 | form.submit(); 115 | } 116 | }); 117 | 118 | $('[data-focus-me]').focus(); 119 | 120 | var logWrapper = $('#log'); 121 | var previousLogPosition = 0; 122 | logWrapper.on('scroll', function () { 123 | var position = logWrapper.scrollTop(); 124 | if (previousLogPosition > position) { 125 | self.logScrollEnabled = false; 126 | } else if (!self.logScrollEnabled) { 127 | var mostBottomPosition = logWrapper.find('pre').outerHeight(true) - logWrapper.height(); 128 | if (position === mostBottomPosition) { 129 | self.logScrollEnabled = true; 130 | } 131 | } 132 | previousLogPosition = position; 133 | }); 134 | 135 | self.connection(); 136 | self.log(); 137 | self.current(); 138 | 139 | self.graph(); 140 | }, 141 | 142 | socket: null, 143 | connection: function () { 144 | var self = this; 145 | 146 | var url = location.protocol + '//' + location.host; 147 | var socket = self.socket = io.connect(url, {path: self.relativeAppPrefix + '/socket.io'}); 148 | 149 | var newConnection = false; 150 | socket.on('connecting', function () { 151 | newConnection = false; 152 | $('#status').text('Connecting'); 153 | self.disable(true); 154 | $('#connect button').text('Disconnect'); 155 | }); 156 | 157 | socket.on('connected', function () { 158 | newConnection = true; 159 | $('#status').text('Connected'); 160 | }); 161 | 162 | socket.on('disconnecting', function () { 163 | $('#status').text('Disconnecting'); 164 | }); 165 | 166 | socket.on('disconnected', function () { 167 | $('#status').text('Disconnected'); 168 | self.disable(false); 169 | $('#connect button').text('Connect'); 170 | }); 171 | 172 | socket.on('update', function () { 173 | if (newConnection) { 174 | window.location.href = self.relativeAppPrefix + '/graph?session='; 175 | } 176 | }); 177 | 178 | socket.on('log-error', function () { 179 | window.location.href = self.relativeAppPrefix + '/'; 180 | }); 181 | 182 | $(document).on('submit', '#connect', function (e) { 183 | var form = $(e.target); 184 | var input = form.find('[name="version"]'); 185 | if (input.is(':disabled')) { 186 | socket.emit('close'); 187 | } else { 188 | self.connect(); 189 | } 190 | return false; 191 | }); 192 | 193 | $(document).on('click', '.serial [data-connect]', function (e) { 194 | e.preventDefault(); 195 | var port = $('.serial input[name="port"]').val(); 196 | self.connect({port: port}); 197 | }); 198 | 199 | var serial = function () { 200 | socket.emit('scan_serial'); 201 | $('.scan-result').text('Scanning... This can take a while...'); 202 | }; 203 | $(document).on('click', '.serial .scan button', serial); 204 | if ($('.serial .scan').length) { 205 | serial(); 206 | } 207 | 208 | var rfcomm = function () { 209 | socket.emit('scan_rfcomm'); 210 | $('.scan-result').text('Scanning... This can take a while...'); 211 | }; 212 | $(document).on('click', '.rfcomm .scan button', rfcomm); 213 | if ($('.rfcomm .scan').length) { 214 | rfcomm(); 215 | } 216 | 217 | var ble = function () { 218 | socket.emit('scan_ble'); 219 | $('.scan-result').text('Scanning... This can take a while...'); 220 | }; 221 | $(document).on('click', '.ble .scan button', ble); 222 | if ($('.ble .scan').length) { 223 | ble(); 224 | } 225 | 226 | socket.on('scan-result', function (result) { 227 | $('.scan-result').html("
" + result + "
"); 228 | }); 229 | 230 | $(document).on('click', '.serial .scan-result [data-address]', function (e) { 231 | e.preventDefault(); 232 | $('.scan-result').empty(); 233 | self.connect({port: $(this).attr('data-address')}); 234 | }); 235 | 236 | $(document).on('click', '.rfcomm .scan-result [data-address]', function (e) { 237 | e.preventDefault(); 238 | $('.scan-result').empty(); 239 | self.connect({rfcomm_address: $(this).attr('data-address')}); 240 | }); 241 | 242 | $(document).on('click', '.ble .scan-result [data-address]', function (e) { 243 | e.preventDefault(); 244 | $('.scan-result').empty(); 245 | self.connect({ble_address: $(this).attr('data-address')}); 246 | }); 247 | }, 248 | 249 | collect_connection_data: function () { 250 | var form = $('#connect'); 251 | return { 252 | version: form.find('[name="version"]').val(), 253 | port: form.find('[name="port"]').val(), 254 | rate: form.find('[name="rate"]').val(), 255 | name: form.find('[name="name"]').val() 256 | }; 257 | }, 258 | 259 | connect: function (override) { 260 | var self = this; 261 | 262 | var data = self.collect_connection_data(); 263 | if (override) { 264 | for (var name in override) { 265 | if (override.hasOwnProperty(name)) { 266 | data[name] = override[name]; 267 | } 268 | } 269 | 270 | var form = $('#connect'); 271 | form.find('[data-serial]').hide(); 272 | form.find('[data-rfcomm]').hide(); 273 | form.find('[data-ble]').hide(); 274 | 275 | if (override.hasOwnProperty('port')) { 276 | var serial = form.find('[data-serial]'); 277 | serial.show(); 278 | serial.find('.setup-link').text(data['port']); 279 | 280 | } else if (override.hasOwnProperty('rfcomm_address')) { 281 | var rfcomm = form.find('[data-rfcomm]'); 282 | rfcomm.show(); 283 | rfcomm.find('.setup-link').text(data['rfcomm_address']); 284 | 285 | } else if (override.hasOwnProperty('ble_address')) { 286 | var ble = form.find('[data-ble]'); 287 | ble.show(); 288 | ble.find('.setup-link').text(data['ble_address']); 289 | } 290 | } 291 | 292 | data = JSON.stringify(data); 293 | self.socket.emit('open', data); 294 | return data; 295 | }, 296 | 297 | log: function () { 298 | var self = this; 299 | if ($('#log').length) { 300 | self.socket.on('log', function (message) { 301 | $('#log pre').append(message); 302 | if (self.logScrollEnabled) { 303 | self.logScroll(500); 304 | } 305 | }); 306 | 307 | $(window).on('resize', self.logResize); 308 | self.logResize(); 309 | self.logScroll(0); 310 | } 311 | }, 312 | 313 | chartLeft: null, 314 | chartRight: null, 315 | left_axis: null, 316 | right_axis: null, 317 | chart_buffer: [], 318 | current: function () { 319 | var self = this; 320 | var current = $('#current'); 321 | if (current.length) { 322 | self.socket.on('update', function (message) { 323 | var data = JSON.parse(message); 324 | var counter = 0; 325 | current.find('td').each(function () { 326 | $(this).text(data['table'][counter]); 327 | counter++; 328 | }); 329 | 330 | if (self.chartLeft && self.chartRight) { 331 | self.chart_buffer.push(data); 332 | 333 | // flush less often for huge datasets to minimize lag 334 | var chart_size = self.chartLeft.data.length; 335 | var buffer_size = self.chart_buffer.length; 336 | if (chart_size > 1000 && buffer_size < 5) { 337 | return; 338 | } 339 | if (chart_size > 10000 && buffer_size < 10) { 340 | return; 341 | } 342 | if (chart_size > 100000 && buffer_size < 60) { 343 | return; 344 | } 345 | 346 | try { 347 | var items = []; 348 | for (var index in self.chart_buffer) { 349 | data = self.chart_buffer[index]; 350 | var item = { 351 | date: data['graph']['timestamp'], 352 | }; 353 | var push = false; 354 | if (self.left_axis && data['graph'].hasOwnProperty(self.left_axis)) { 355 | item['left'] = data['graph'][self.left_axis]; 356 | push = true; 357 | } 358 | if (self.right_axis && data['graph'].hasOwnProperty(self.right_axis)) { 359 | item['right'] = data['graph'][self.right_axis]; 360 | push = true; 361 | } 362 | if (push) { 363 | items.push(item); 364 | } 365 | } 366 | if (items.length) { 367 | self.chartLeft.data.pushAll(items); 368 | self.chartRight.data.pushAll(items); 369 | } 370 | } catch (e) { 371 | // ignore 372 | } 373 | self.chart_buffer = []; 374 | } 375 | }); 376 | } 377 | }, 378 | 379 | graph: function () { 380 | var self = this; 381 | var chartRoot = null; 382 | var graph = $('#graph'); 383 | if (graph.length) { 384 | var create = function () { 385 | if (chartRoot) { 386 | chartRoot.dispose(); 387 | } 388 | 389 | graph.parent().find('.loading').show(); 390 | 391 | var session = $('select[name="session"]').val(); 392 | 393 | var left_axis = self.left_axis = $('select[name="left_axis"]').val(); 394 | var left_name = $('#graph-settings option[value="' + left_axis + '"]').first().text(); 395 | var right_axis = self.right_axis = $('select[name="right_axis"]').val(); 396 | var right_name = $('#graph-settings option[value="' + right_axis + '"]').first().text(); 397 | 398 | var colorsMode = $('select[name="colors"]').val(); 399 | 400 | var left_color; 401 | var right_color; 402 | 403 | var colors; 404 | switch (colorsMode) { 405 | case 'colorful': 406 | colors = { 407 | 'voltage': '#0080ff', 408 | 'current': '#e50000', 409 | 'current-m': '#e50000', 410 | 'power': '#eabe24', 411 | 'temperature': '#417200', 412 | 'accumulated_current': '#a824ea', 413 | 'accumulated_power': '#014d98', 414 | 'zeroed_accumulated_current': '#a824ea', 415 | 'zeroed_accumulated_power': '#014d98', 416 | 'resistance': '#6cc972', 417 | 'fallback': '#373737' 418 | }; 419 | left_color = colors.hasOwnProperty(left_axis) ? colors[left_axis] : colors['fallback']; 420 | right_color = colors.hasOwnProperty(right_axis) ? colors[right_axis] : colors['fallback']; 421 | break; 422 | 423 | case 'midnight': 424 | colors = { 425 | 'voltage': '#5489bf', 426 | 'current': '#c83c3c', 427 | 'current-m': '#c83c3c', 428 | 'power': '#eabe24', 429 | 'temperature': '#549100', 430 | 'accumulated_current': '#9c78bc', 431 | 'accumulated_power': '#997b18', 432 | 'zeroed_accumulated_current': '#9c78bc', 433 | 'zeroed_accumulated_power': '#997b18', 434 | 'resistance': '#56a05a', 435 | 'fallback': '#373737' 436 | }; 437 | left_color = colors.hasOwnProperty(left_axis) ? colors[left_axis] : colors['fallback']; 438 | right_color = colors.hasOwnProperty(right_axis) ? colors[right_axis] : colors['fallback']; 439 | break; 440 | 441 | default: 442 | left_color = '#0080ff'; 443 | right_color = '#e50000'; 444 | } 445 | 446 | var unit = function (name) { 447 | var matches = name.match(/\(([^)]+)\)/i); 448 | if (matches) { 449 | return matches[1]; 450 | } 451 | return null; 452 | }; 453 | var left_unit = unit(left_name); 454 | var right_unit = unit(right_name); 455 | 456 | var url = graph.attr('data-url'); 457 | url += '?session=' + session; 458 | url += '&left_axis=' + left_axis; 459 | url += '&right_axis=' + right_axis; 460 | url += '&colors=' + colorsMode; 461 | 462 | var pageUrl = new URL(window.location.href); 463 | var changed = false; 464 | if (pageUrl.searchParams.get('left_axis') !== left_axis) { 465 | pageUrl.searchParams.set('left_axis', left_axis); 466 | $('input[type="hidden"][name="left_axis"]').val(left_axis); 467 | changed = true; 468 | } 469 | if (pageUrl.searchParams.get('right_axis') !== right_axis) { 470 | pageUrl.searchParams.set('right_axis', right_axis); 471 | $('input[type="hidden"][name="right_axis"]').val(right_axis); 472 | changed = true; 473 | } 474 | if (changed) { 475 | window.history.pushState(null, null, pageUrl.toString()); 476 | } 477 | 478 | var loadingDots = $('.graph .loading [data-dots]'); 479 | var loadingTimer = setInterval(function () { 480 | var count = loadingDots.text().length; 481 | count++; 482 | if (count > 3) { 483 | count = 0; 484 | } 485 | loadingDots.text('.'.repeat(count)); 486 | }, 500); 487 | 488 | $.get(url, function (data) { 489 | var xAxisTextColor = null; 490 | var axisLineColor = null; 491 | var cursorColor = null; 492 | switch (ntdrt.theme) { 493 | case 'midnight': 494 | xAxisTextColor = '#c8c8c8'; 495 | axisLineColor = '#464646'; 496 | cursorColor = '#65ff00'; 497 | break; 498 | 499 | case 'dark': 500 | xAxisTextColor = '#c8c8c8'; 501 | axisLineColor = '#464646'; 502 | cursorColor = '#c8c8c8'; 503 | break; 504 | } 505 | 506 | var root = chartRoot = am5.Root.new(graph[0]); 507 | var chart = am5xy.XYChart.new(root, {}); 508 | root.container.children.push(chart); 509 | root.numberFormatter.set('numberFormat', "####.####"); 510 | 511 | // X axis 512 | var xAxis = chart.xAxes.push( 513 | am5xy.DateAxis.new(root, { 514 | renderer: am5xy.AxisRendererX.new(root, {}), 515 | groupData: true, 516 | groupCount: 10000, 517 | baseInterval: { 518 | timeUnit: 'second', 519 | count: 1 520 | }, 521 | gridIntervals: [ 522 | {timeUnit: 'second', count: 1}, 523 | {timeUnit: 'second', count: 2}, 524 | {timeUnit: 'second', count: 5}, 525 | {timeUnit: 'second', count: 10}, 526 | {timeUnit: 'second', count: 30}, 527 | {timeUnit: 'minute', count: 1}, 528 | {timeUnit: 'minute', count: 2}, 529 | {timeUnit: 'minute', count: 5}, 530 | {timeUnit: 'minute', count: 10}, 531 | {timeUnit: 'minute', count: 15}, 532 | {timeUnit: 'minute', count: 30}, 533 | {timeUnit: 'hour', count: 1}, 534 | {timeUnit: 'hour', count: 2}, 535 | {timeUnit: 'hour', count: 3}, 536 | {timeUnit: 'hour', count: 6}, 537 | {timeUnit: 'hour', count: 12}, 538 | {timeUnit: 'day', count: 1}, 539 | {timeUnit: 'day', count: 2}, 540 | {timeUnit: 'day', count: 3}, 541 | {timeUnit: 'day', count: 4}, 542 | {timeUnit: 'day', count: 5}, 543 | {timeUnit: 'day', count: 6}, 544 | {timeUnit: 'week', count: 1}, 545 | {timeUnit: 'month', count: 1}, 546 | {timeUnit: 'year', count: 1}, 547 | ], 548 | tooltip: am5.Tooltip.new(root, {}), 549 | tooltipDateFormats: { 550 | millisecond: 'HH:mm:ss.SSS', 551 | second: 'HH:mm:ss', 552 | minute: 'HH:mm', 553 | hour: 'HH:mm', 554 | day: 'yyyy-MM-dd', 555 | week: 'yyyy-MM-dd', 556 | month: 'yyyy-MM', 557 | year: 'yyyy' 558 | } 559 | }) 560 | ); 561 | if (xAxisTextColor) { 562 | xAxis.get('renderer').labels.template.setAll({ 563 | fill: xAxisTextColor 564 | }); 565 | } 566 | if (axisLineColor) { 567 | xAxis.get('renderer').grid.template.setAll({ 568 | stroke: axisLineColor 569 | }); 570 | } 571 | var xLabel = xAxis.children.push( 572 | am5.Label.new(root, { 573 | text: 'Time', 574 | x: am5.p50, 575 | centerX: am5.p50 576 | }) 577 | ); 578 | if (xAxisTextColor) { 579 | xLabel.setAll({ 580 | fill: xAxisTextColor 581 | }); 582 | } 583 | 584 | // left Y axis 585 | var yLeftAxis = chart.yAxes.push( 586 | am5xy.ValueAxis.new(root, { 587 | renderer: am5xy.AxisRendererY.new(root, {}), 588 | numberFormat: '####.## \' ' + left_unit + '\'', 589 | min: 0, 590 | tooltip: am5.Tooltip.new(root, {}) 591 | }) 592 | ); 593 | yLeftAxis.get('renderer').labels.template.setAll({ 594 | fill: left_color, 595 | fontWeight: 700 596 | }); 597 | if (axisLineColor) { 598 | yLeftAxis.get('renderer').grid.template.setAll({ 599 | stroke: axisLineColor 600 | }); 601 | } 602 | yLeftAxis.children.unshift( 603 | am5.Label.new(root, { 604 | text: left_name, 605 | rotation: -90, 606 | y: am5.p50, 607 | centerX: am5.p50, 608 | fill: left_color, 609 | fontWeight: 700 610 | }) 611 | ); 612 | 613 | // left Y axis tooltip 614 | var leftTooltip = am5.Tooltip.new(root, { 615 | getFillFromSprite: false, 616 | autoTextColor: false, 617 | labelText: '{valueY} ' + left_unit 618 | }); 619 | leftTooltip.get('background').setAll({ 620 | fill: left_color, 621 | }); 622 | leftTooltip.label.setAll({ 623 | fill: '#fff' 624 | }); 625 | 626 | // left Y axis series 627 | var leftSeries = self.chartLeft = chart.series.push( 628 | am5xy.LineSeries.new(root, { 629 | xAxis: xAxis, 630 | yAxis: yLeftAxis, 631 | valueXField: 'date', 632 | valueYField: 'left', 633 | stroke: left_color, 634 | tooltip: leftTooltip 635 | }) 636 | ); 637 | leftSeries.strokes.template.setAll({ 638 | strokeWidth: 2 639 | }); 640 | leftSeries.data.setAll(data); 641 | 642 | // right Y axis 643 | var yRightAxis = chart.yAxes.push( 644 | am5xy.ValueAxis.new(root, { 645 | renderer: am5xy.AxisRendererY.new(root, { 646 | opposite: true 647 | }), 648 | numberFormat: '####.## \' ' + right_unit + '\'', 649 | min: 0, 650 | tooltip: am5.Tooltip.new(root, {}) 651 | }) 652 | ); 653 | yRightAxis.get('renderer').labels.template.setAll({ 654 | fill: right_color, 655 | fontWeight: 700 656 | }); 657 | if (axisLineColor) { 658 | yRightAxis.get('renderer').grid.template.setAll({ 659 | stroke: axisLineColor 660 | }); 661 | } 662 | yRightAxis.children.push( 663 | am5.Label.new(root, { 664 | text: right_name, 665 | rotation: 90, 666 | y: am5.p50, 667 | centerX: am5.p50, 668 | fill: right_color, 669 | fontWeight: 700 670 | }) 671 | ); 672 | 673 | // right Y axis tooltip 674 | var rightTooltip = am5.Tooltip.new(root, { 675 | getFillFromSprite: false, 676 | autoTextColor: false, 677 | labelText: '{valueY} ' + right_unit 678 | }); 679 | rightTooltip.get('background').setAll({ 680 | fill: right_color, 681 | }); 682 | rightTooltip.label.setAll({ 683 | fill: '#fff' 684 | }); 685 | 686 | // right Y axis series 687 | var rightSeries = self.chartRight = chart.series.push( 688 | am5xy.LineSeries.new(root, { 689 | xAxis: xAxis, 690 | yAxis: yRightAxis, 691 | valueXField: 'date', 692 | valueYField: 'right', 693 | stroke: right_color, 694 | tooltip: rightTooltip 695 | }) 696 | ); 697 | rightSeries.strokes.template.setAll({ 698 | strokeWidth: 2 699 | }); 700 | rightSeries.data.setAll(data); 701 | 702 | // rest 703 | var cursor = am5xy.XYCursor.new(root, { 704 | behavior: 'zoomX' 705 | }); 706 | if (cursorColor) { 707 | cursor.lineX.setAll({ 708 | stroke: cursorColor 709 | }); 710 | cursor.lineY.setAll({ 711 | stroke: cursorColor 712 | }); 713 | } 714 | chart.set('cursor', cursor); 715 | 716 | var timeout = null; 717 | var onFrameEnded = function () { 718 | if (timeout) { 719 | clearTimeout(timeout); 720 | } 721 | timeout = setTimeout(function () { 722 | root.events.off('frameended', onFrameEnded); 723 | graph.parent().find('.loading').hide(); 724 | clearInterval(loadingTimer); 725 | }, 100) 726 | }; 727 | root.events.on('frameended', onFrameEnded); 728 | }); 729 | }; 730 | 731 | create(); 732 | 733 | $(document).on('submit', '#graph-settings', function () { 734 | create(); 735 | return false; 736 | }); 737 | } 738 | }, 739 | 740 | logScroll: function (delay) { 741 | var target = $('#log'); 742 | target.animate({scrollTop: target.prop("scrollHeight")}, delay); 743 | }, 744 | 745 | logResize: function () { 746 | var target = $('#log'); 747 | var height = $(window).height(); 748 | height -= $('body').height(); 749 | height += target.height(); 750 | target.css('height', height + 'px') 751 | }, 752 | 753 | disable: function (value) { 754 | $('#connect select').prop('disabled', value); 755 | $('#connect input').prop('disabled', value); 756 | }, 757 | 758 | register: function () { 759 | $(function () { 760 | ntdrt.application.init(); 761 | }); 762 | } 763 | }; 764 | 765 | ntdrt.application.register(); 766 | --------------------------------------------------------------------------------