benchlab-pytools 3.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- benchlab/__init__.py +14 -0
- benchlab/__main__.py +21 -0
- benchlab/config/README.md +325 -0
- benchlab/config/__init__.py +9 -0
- benchlab/config/config_client.py +631 -0
- benchlab/config/config_manager.py +495 -0
- benchlab/config/config_tool.py +358 -0
- benchlab/config/diff.py +163 -0
- benchlab/config/examples/complete_config.json +56 -0
- benchlab/config/examples/rgb_lighting.json +24 -0
- benchlab/config/examples/simple_fan.json +32 -0
- benchlab/config/schema.py +138 -0
- benchlab/core/README.md +154 -0
- benchlab/core/__init__.py +78 -0
- benchlab/core/config.py +145 -0
- benchlab/core/datasource.py +1209 -0
- benchlab/core/datasource_manager.py +418 -0
- benchlab/core/device_registry.py +206 -0
- benchlab/core/discovery.py +92 -0
- benchlab/core/infrastructure.py +309 -0
- benchlab/core/process_manager.py +356 -0
- benchlab/core/retry.py +85 -0
- benchlab/core/shared_serial.py +235 -0
- benchlab/core/statistics.py +224 -0
- benchlab/csv_log/README.md +206 -0
- benchlab/csv_log/__init__.py +5 -0
- benchlab/csv_log/csv_logger_enhanced.py +416 -0
- benchlab/csv_log/message_batcher.py +361 -0
- benchlab/csv_log/requirements.txt +3 -0
- benchlab/csv_log/smart_retry.py +466 -0
- benchlab/graph/README.md +117 -0
- benchlab/graph/__init__.py +5 -0
- benchlab/graph/app.py +353 -0
- benchlab/graph/requirements.txt +2 -0
- benchlab/graph/runner.py +42 -0
- benchlab/graph/ui.py +288 -0
- benchlab/hwinfo/README.md +135 -0
- benchlab/hwinfo/__init__.py +5 -0
- benchlab/hwinfo/hwinfo_export.py +386 -0
- benchlab/hwinfo/requirements.txt +1 -0
- benchlab/launcher.py +387 -0
- benchlab/link/.env.EXAMPLE +10 -0
- benchlab/link/README.md +172 -0
- benchlab/link/__init__.py +1 -0
- benchlab/link/link_main.py +479 -0
- benchlab/main.py +462 -0
- benchlab/menu.py +729 -0
- benchlab/menu_classic.py +410 -0
- benchlab/mqtt/README.md +201 -0
- benchlab/mqtt/__init__.py +5 -0
- benchlab/mqtt/mqtt.config_template +2 -0
- benchlab/mqtt/mqtt_publisher.py +540 -0
- benchlab/mqtt/requirements.txt +3 -0
- benchlab/prefs.py +67 -0
- benchlab/restapi/.env.example +17 -0
- benchlab/restapi/__init__.py +5 -0
- benchlab/restapi/favicon.ico +0 -0
- benchlab/restapi/readme.md +202 -0
- benchlab/restapi/requirements.txt +5 -0
- benchlab/restapi/telemetry_api.py +808 -0
- benchlab/sources.py +515 -0
- benchlab/tools.py +147 -0
- benchlab/tui/README.md +266 -0
- benchlab/tui/__init__.py +5 -0
- benchlab/tui/config.py +477 -0
- benchlab/tui/requirements.txt +2 -0
- benchlab/tui/tui_core.py +1298 -0
- benchlab/tui/tui_main.py +418 -0
- benchlab/vu/README.md +64 -0
- benchlab/vu/VU-Server/NOTICE.md +13 -0
- benchlab/vu/VU-Server/README.md +80 -0
- benchlab/vu/VU-Server/Running_from_source_on_linux.md +97 -0
- benchlab/vu/VU-Server/__init__.py +0 -0
- benchlab/vu/VU-Server/assets/vu1_hello_world.png +0 -0
- benchlab/vu/VU-Server/community_applications.md +13 -0
- benchlab/vu/VU-Server/config.yaml +9 -0
- benchlab/vu/VU-Server/database.py +256 -0
- benchlab/vu/VU-Server/dial_driver.py +534 -0
- benchlab/vu/VU-Server/dials/Comms_Hub_Gauge.py +78 -0
- benchlab/vu/VU-Server/dials/Comms_Hub_Server.py +100 -0
- benchlab/vu/VU-Server/dials/__init__.py +0 -0
- benchlab/vu/VU-Server/dials/base_logger.py +100 -0
- benchlab/vu/VU-Server/installer/inc/icon.ico +0 -0
- benchlab/vu/VU-Server/installer/install.nsi +148 -0
- benchlab/vu/VU-Server/make_version.py +40 -0
- benchlab/vu/VU-Server/pylintrc +628 -0
- benchlab/vu/VU-Server/requirements.txt +9 -0
- benchlab/vu/VU-Server/serial_driver.py +244 -0
- benchlab/vu/VU-Server/server.py +670 -0
- benchlab/vu/VU-Server/server_config.py +255 -0
- benchlab/vu/VU-Server/server_dial_handler.py +334 -0
- benchlab/vu/VU-Server/vu_notifications.py +24 -0
- benchlab/vu/VU-Server/www/android-chrome-192x192.png +0 -0
- benchlab/vu/VU-Server/www/android-chrome-512x512.png +0 -0
- benchlab/vu/VU-Server/www/apple-touch-icon.png +0 -0
- benchlab/vu/VU-Server/www/assets/css/demo.css +291 -0
- benchlab/vu/VU-Server/www/assets/css/demo.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/demo.rtl.css +276 -0
- benchlab/vu/VU-Server/www/assets/css/demo.rtl.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-flags.css +1084 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-flags.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-flags.rtl.css +1084 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-flags.rtl.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-payments.css +528 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-payments.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-payments.rtl.css +528 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-payments.rtl.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-social.css +10 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-social.min.css +8 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-social.rtl.css +10 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-social.rtl.min.css +8 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-vendors.css +1504 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-vendors.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-vendors.rtl.css +1504 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-vendors.rtl.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/tabler.css +25813 -0
- benchlab/vu/VU-Server/www/assets/css/tabler.min.css +13 -0
- benchlab/vu/VU-Server/www/assets/css/tabler.rtl.css +25777 -0
- benchlab/vu/VU-Server/www/assets/css/tabler.rtl.min.css +13 -0
- benchlab/vu/VU-Server/www/assets/css/theme.min.css +261 -0
- benchlab/vu/VU-Server/www/assets/img/header.png +0 -0
- benchlab/vu/VU-Server/www/assets/img/logo.png +0 -0
- benchlab/vu/VU-Server/www/assets/js/dark-theme.js +35 -0
- benchlab/vu/VU-Server/www/assets/js/dark-theme.min.js +9 -0
- benchlab/vu/VU-Server/www/assets/js/demo.js +132 -0
- benchlab/vu/VU-Server/www/assets/js/demo.min.js +9 -0
- benchlab/vu/VU-Server/www/assets/js/jquery.min.js +2 -0
- benchlab/vu/VU-Server/www/assets/js/tabler.esm.js +7482 -0
- benchlab/vu/VU-Server/www/assets/js/tabler.esm.min.js +15 -0
- benchlab/vu/VU-Server/www/assets/js/tabler.js +7543 -0
- benchlab/vu/VU-Server/www/assets/js/tabler.min.js +15 -0
- benchlab/vu/VU-Server/www/assets/js/vu1_api.js +68 -0
- benchlab/vu/VU-Server/www/assets/js/vu1_gui_api_key_settings.js +167 -0
- benchlab/vu/VU-Server/www/assets/js/vu1_gui_api_keys.js +138 -0
- benchlab/vu/VU-Server/www/assets/js/vu1_gui_dial.js +179 -0
- benchlab/vu/VU-Server/www/assets/js/vu1_gui_home.js +60 -0
- benchlab/vu/VU-Server/www/assets/js/vu1_gui_root.js +125 -0
- benchlab/vu/VU-Server/www/favicon-16x16.png +0 -0
- benchlab/vu/VU-Server/www/favicon-32x32.png +0 -0
- benchlab/vu/VU-Server/www/favicon.ico +0 -0
- benchlab/vu/VU-Server/www/favicon.png +0 -0
- benchlab/vu/VU-Server/www/index.html +47 -0
- benchlab/vu/VU-Server/www/views/api_keys.html +151 -0
- benchlab/vu/VU-Server/www/views/dial.html +286 -0
- benchlab/vu/VU-Server/www/views/footer.html +27 -0
- benchlab/vu/VU-Server/www/views/header.html +86 -0
- benchlab/vu/VU-Server/www/views/key_settings.html +132 -0
- benchlab/vu/VU-Server/www/views/start.html +124 -0
- benchlab/vu/__init__.py +5 -0
- benchlab/vu/assets/Barlow-Bold.ttf +0 -0
- benchlab/vu/assets/bl_dial_144x200.png +0 -0
- benchlab/vu/assets/bl_logo_144x200.png +0 -0
- benchlab/vu/devices.py +166 -0
- benchlab/vu/requirements.txt +24 -0
- benchlab/vu/sensors.py +18 -0
- benchlab/vu/vu_dial.config_template +90 -0
- benchlab/vu/vu_logo_gen.py +97 -0
- benchlab/vu/vu_server.config_template +5 -0
- benchlab/vu/vu_server_config.py +40 -0
- benchlab/vu/vu_server_manager.py +192 -0
- benchlab/vu/vu_tui.py +919 -0
- benchlab/vu/vu_updater.py +540 -0
- benchlab/wigidash/README.md +182 -0
- benchlab/wigidash/__init__.py +5 -0
- benchlab/wigidash/assets/Inter-Bold.ttf +0 -0
- benchlab/wigidash/assets/Inter.ttf +0 -0
- benchlab/wigidash/assets/benchlab.png +0 -0
- benchlab/wigidash/benchlab_fleet.py +150 -0
- benchlab/wigidash/benchlab_graph.py +517 -0
- benchlab/wigidash/benchlab_overview.py +426 -0
- benchlab/wigidash/benchlab_telemetry.py +105 -0
- benchlab/wigidash/benchlab_ui.py +190 -0
- benchlab/wigidash/benchlab_utils.py +182 -0
- benchlab/wigidash/requirements.txt +9 -0
- benchlab/wigidash/wigidash_device.py +541 -0
- benchlab/wigidash/wigidash_manager.py +342 -0
- benchlab/wigidash/wigidash_session.py +355 -0
- benchlab/wigidash/wigidash_usb.py +247 -0
- benchlab/wigidash/wigidash_widget.py +88 -0
- benchlab_pytools-3.0.0.dist-info/METADATA +239 -0
- benchlab_pytools-3.0.0.dist-info/RECORD +184 -0
- benchlab_pytools-3.0.0.dist-info/WHEEL +4 -0
- benchlab_pytools-3.0.0.dist-info/entry_points.txt +2 -0
- benchlab_pytools-3.0.0.dist-info/licenses/LICENSE +21 -0
benchlab/tui/tui_main.py
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Refactored TUI Main Entry Point
|
|
3
|
+
Clean main entry point using the refactored architecture with separated
|
|
4
|
+
concerns:
|
|
5
|
+
- DataSourceManager handles all data source operations
|
|
6
|
+
- ChannelStats manages statistics
|
|
7
|
+
- TUICore handles all UI rendering
|
|
8
|
+
- Config centralizes configuration
|
|
9
|
+
This replaces the original monolithic tui_main.py with a much cleaner
|
|
10
|
+
implementation.
|
|
11
|
+
"""
|
|
12
|
+
import curses
|
|
13
|
+
import logging
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
from typing import List, Dict, Any
|
|
17
|
+
|
|
18
|
+
from benchlab.tui.__init__ import __version__
|
|
19
|
+
from benchlab.core.datasource_manager import DataSourceManager
|
|
20
|
+
from benchlab.core.statistics import ChannelStats, create_stats_callback
|
|
21
|
+
from .tui_core import TUICore
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger("benchlab.tui.main")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _DevNullWriter:
|
|
27
|
+
"""A file-like object that discards all output."""
|
|
28
|
+
|
|
29
|
+
def write(self, s):
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
def flush(self):
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
def isatty(self):
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _TUIStdoutRedirect:
|
|
40
|
+
"""Redirect stdout/stderr to prevent interference with curses display.
|
|
41
|
+
|
|
42
|
+
The pycore library writes INFO messages directly to stdout which messes
|
|
43
|
+
up the curses layout. This class redirects stdout/stderr to a file or
|
|
44
|
+
/dev/null
|
|
45
|
+
during TUI operation.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, log_file: str = None):
|
|
49
|
+
self.log_file = log_file
|
|
50
|
+
self._original_stdout = None
|
|
51
|
+
self._original_stderr = None
|
|
52
|
+
self._file_handle = None
|
|
53
|
+
|
|
54
|
+
def __enter__(self):
|
|
55
|
+
self._original_stdout = sys.stdout
|
|
56
|
+
self._original_stderr = sys.stderr
|
|
57
|
+
|
|
58
|
+
if self.log_file:
|
|
59
|
+
self._file_handle = open(self.log_file, 'a')
|
|
60
|
+
sys.stdout = self._file_handle
|
|
61
|
+
sys.stderr = self._file_handle
|
|
62
|
+
else:
|
|
63
|
+
# Discard all output
|
|
64
|
+
sys.stdout = _DevNullWriter()
|
|
65
|
+
sys.stderr = _DevNullWriter()
|
|
66
|
+
|
|
67
|
+
return self
|
|
68
|
+
|
|
69
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
70
|
+
if self._file_handle:
|
|
71
|
+
self._file_handle.flush()
|
|
72
|
+
self._file_handle.close()
|
|
73
|
+
|
|
74
|
+
sys.stdout = self._original_stdout
|
|
75
|
+
sys.stderr = self._original_stderr
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def get_default_datasource(args) -> str:
|
|
80
|
+
"""Get default data source based on command-line arguments."""
|
|
81
|
+
if hasattr(args, 'source') and args.source:
|
|
82
|
+
return args.source
|
|
83
|
+
return 'direct'
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def convert_fleet_format(
|
|
87
|
+
devices_dict: Dict[str, Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
88
|
+
"""Convert DataSourceManager device dict to fleet list format."""
|
|
89
|
+
fleet = []
|
|
90
|
+
for uid, device_info in devices_dict.items():
|
|
91
|
+
fleet.append({
|
|
92
|
+
'uid': uid,
|
|
93
|
+
'port': device_info.get('port', 'unknown'),
|
|
94
|
+
'firmware': device_info.get(
|
|
95
|
+
'firmware', device_info.get('FwVersion', 0)),
|
|
96
|
+
'variant': device_info.get('variant'),
|
|
97
|
+
'ProductId': device_info.get('ProductId'),
|
|
98
|
+
})
|
|
99
|
+
return sorted(fleet, key=lambda d: d["port"])
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class TUIApplication:
|
|
103
|
+
"""
|
|
104
|
+
Main TUI application class that coordinates between DataSourceManager,
|
|
105
|
+
statistics tracking, and UI rendering.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
def __init__(self, args):
|
|
109
|
+
self.args = args
|
|
110
|
+
self.source_type = get_default_datasource(args)
|
|
111
|
+
|
|
112
|
+
self.stats = ChannelStats()
|
|
113
|
+
stats_callback = create_stats_callback(self.stats)
|
|
114
|
+
|
|
115
|
+
datasource_kwargs = {
|
|
116
|
+
'poll_interval': getattr(args, 'interval', 1.0),
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if self.source_type == 'fastapi':
|
|
120
|
+
if hasattr(args, 'api_port'):
|
|
121
|
+
datasource_kwargs['base_url'] = f"http://127.0.0.1:{
|
|
122
|
+
args.api_port}"
|
|
123
|
+
else:
|
|
124
|
+
datasource_kwargs['base_url'] = "http://127.0.0.1:8000"
|
|
125
|
+
datasource_kwargs['timeout'] = 5.0
|
|
126
|
+
|
|
127
|
+
elif self.source_type == 'fastapi_custom':
|
|
128
|
+
# Use the custom URL from args.api_url (set from BENCHLAB_API_URL
|
|
129
|
+
# env var)
|
|
130
|
+
datasource_kwargs['base_url'] = getattr(
|
|
131
|
+
args, 'api_url', 'http://127.0.0.1:8000')
|
|
132
|
+
datasource_kwargs['timeout'] = 5.0
|
|
133
|
+
|
|
134
|
+
elif self.source_type == 'mqtt':
|
|
135
|
+
datasource_kwargs['broker'] = getattr(
|
|
136
|
+
args, 'mqtt_broker', 'localhost')
|
|
137
|
+
datasource_kwargs['port'] = getattr(args, 'mqtt_port', 1883)
|
|
138
|
+
datasource_kwargs['timeout'] = 5.0
|
|
139
|
+
|
|
140
|
+
elif self.source_type == 'named_pipe':
|
|
141
|
+
datasource_kwargs['timeout'] = 5.0
|
|
142
|
+
|
|
143
|
+
elif self.source_type == 'service_http':
|
|
144
|
+
service_url = getattr(
|
|
145
|
+
args, 'service_url', None) or 'http://localhost:8585'
|
|
146
|
+
datasource_kwargs['base_url'] = service_url
|
|
147
|
+
datasource_kwargs['timeout'] = 5.0
|
|
148
|
+
|
|
149
|
+
self.datasource_manager = DataSourceManager(
|
|
150
|
+
source_type=self.source_type,
|
|
151
|
+
stats_callback=stats_callback,
|
|
152
|
+
**datasource_kwargs
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
self.tui_core = None
|
|
156
|
+
self.fleet_cache = []
|
|
157
|
+
self.last_fleet_refresh = 0.0
|
|
158
|
+
|
|
159
|
+
def run(self, stdscr):
|
|
160
|
+
self.tui_core = TUICore(stdscr, __version__)
|
|
161
|
+
|
|
162
|
+
# Force full screen redraw to overwrite any stray console output
|
|
163
|
+
stdscr.clearok(True)
|
|
164
|
+
|
|
165
|
+
# Auto-connect for all non-direct sources
|
|
166
|
+
if self.source_type != 'direct':
|
|
167
|
+
self._connect_datasource()
|
|
168
|
+
|
|
169
|
+
self._refresh_fleet_cache()
|
|
170
|
+
|
|
171
|
+
while True:
|
|
172
|
+
snapshot = self.datasource_manager.snapshot()
|
|
173
|
+
|
|
174
|
+
rendered = self.tui_core.render(
|
|
175
|
+
snapshot=snapshot,
|
|
176
|
+
stats=self.stats,
|
|
177
|
+
fleet_devices=self.fleet_cache,
|
|
178
|
+
refresh_interval=getattr(self.args, 'interval', 1.0)
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
if not rendered:
|
|
182
|
+
time.sleep(0.2)
|
|
183
|
+
continue
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
key = stdscr.getkey()
|
|
187
|
+
action = self.tui_core.handle_key(key)
|
|
188
|
+
|
|
189
|
+
if not self._handle_action(action):
|
|
190
|
+
break
|
|
191
|
+
|
|
192
|
+
except curses.error:
|
|
193
|
+
pass
|
|
194
|
+
|
|
195
|
+
def _handle_action(self, action: Dict[str, Any]) -> bool:
|
|
196
|
+
action_type = action.get('type', 'none')
|
|
197
|
+
|
|
198
|
+
if action_type == 'quit':
|
|
199
|
+
return False
|
|
200
|
+
|
|
201
|
+
elif action_type == 'reset_stats':
|
|
202
|
+
uid = self.datasource_manager.get_selected_uid()
|
|
203
|
+
if uid:
|
|
204
|
+
self.stats.reset(uid)
|
|
205
|
+
self.tui_core.set_status("Stats reset.")
|
|
206
|
+
else:
|
|
207
|
+
self.tui_core.set_status("No device selected.")
|
|
208
|
+
|
|
209
|
+
elif action_type == 'rescan_fleet':
|
|
210
|
+
self._refresh_fleet_cache()
|
|
211
|
+
count = len(self.fleet_cache)
|
|
212
|
+
source_label = (
|
|
213
|
+
self.source_type.upper()
|
|
214
|
+
if self.source_type != 'direct' else 'serial')
|
|
215
|
+
self.tui_core.set_status(
|
|
216
|
+
f"Fleet rescanned ({source_label}) — {count} device(s) found.")
|
|
217
|
+
|
|
218
|
+
elif action_type == 'fleet_nav':
|
|
219
|
+
direction = action.get('direction')
|
|
220
|
+
self.tui_core.update_fleet_index(len(self.fleet_cache), direction)
|
|
221
|
+
|
|
222
|
+
elif action_type == 'fleet_select':
|
|
223
|
+
index = action.get('index', 0)
|
|
224
|
+
if 0 <= index < len(self.fleet_cache):
|
|
225
|
+
selected_device = self.fleet_cache[index]
|
|
226
|
+
self._connect_to_device(selected_device)
|
|
227
|
+
|
|
228
|
+
return True
|
|
229
|
+
|
|
230
|
+
def _connect_datasource(self):
|
|
231
|
+
"""Connect via non-direct datasource
|
|
232
|
+
(FastAPI/MQTT/named_pipe/service_http)."""
|
|
233
|
+
try:
|
|
234
|
+
if self.datasource_manager.connect():
|
|
235
|
+
uid = self.datasource_manager.get_selected_uid()
|
|
236
|
+
self.tui_core.set_status(
|
|
237
|
+
f"Connected via {
|
|
238
|
+
self.source_type.upper()}")
|
|
239
|
+
logger.info(
|
|
240
|
+
f"Connected to {
|
|
241
|
+
self.source_type} datasource, selected: {uid}")
|
|
242
|
+
else:
|
|
243
|
+
snapshot = self.datasource_manager.snapshot()
|
|
244
|
+
error_msg = snapshot.get(
|
|
245
|
+
'last_error', f"Failed to connect to {
|
|
246
|
+
self.source_type}")
|
|
247
|
+
self.tui_core.set_status(
|
|
248
|
+
f"Connection failed: {error_msg}", 5.0)
|
|
249
|
+
logger.error(
|
|
250
|
+
f"Failed to connect to {
|
|
251
|
+
self.source_type}: {error_msg}")
|
|
252
|
+
except Exception as e:
|
|
253
|
+
self.tui_core.set_status(f"Connection error: {str(e)}", 5.0)
|
|
254
|
+
logger.error(
|
|
255
|
+
f"Exception during {
|
|
256
|
+
self.source_type} connection: {e}")
|
|
257
|
+
|
|
258
|
+
def _connect_to_device(self, device: Dict[str, Any]):
|
|
259
|
+
port = device.get('port')
|
|
260
|
+
uid = device.get('uid')
|
|
261
|
+
|
|
262
|
+
if uid == "BUSY":
|
|
263
|
+
self.tui_core.set_status(
|
|
264
|
+
"Device is busy (already connected elsewhere)", 3.0)
|
|
265
|
+
return
|
|
266
|
+
|
|
267
|
+
try:
|
|
268
|
+
if self.source_type == 'direct':
|
|
269
|
+
if self.datasource_manager.connect(port=port):
|
|
270
|
+
self.tui_core.set_status(f"Connected to {port}")
|
|
271
|
+
logger.info(f"Connected to device on {port}")
|
|
272
|
+
else:
|
|
273
|
+
snapshot = self.datasource_manager.snapshot()
|
|
274
|
+
error_msg = snapshot.get('last_error', 'Connection failed')
|
|
275
|
+
self.tui_core.set_status(
|
|
276
|
+
f"Connection failed: {error_msg}", 4.0)
|
|
277
|
+
logger.error(f"Failed to connect to {port}: {error_msg}")
|
|
278
|
+
else:
|
|
279
|
+
# Network/pipe datasource — select different device
|
|
280
|
+
if self.datasource_manager.select_device(uid):
|
|
281
|
+
self.tui_core.set_status(f"Selected device {uid}")
|
|
282
|
+
logger.info(
|
|
283
|
+
f"Selected device {uid} via {
|
|
284
|
+
self.source_type}")
|
|
285
|
+
else:
|
|
286
|
+
self.tui_core.set_status("Failed to select device", 3.0)
|
|
287
|
+
logger.warning(f"Failed to select device {uid}")
|
|
288
|
+
|
|
289
|
+
except Exception as e:
|
|
290
|
+
self.tui_core.set_status(f"Connection error: {str(e)}", 4.0)
|
|
291
|
+
logger.error(f"Exception during device connection: {e}")
|
|
292
|
+
|
|
293
|
+
def _refresh_fleet_cache(self):
|
|
294
|
+
try:
|
|
295
|
+
current_time = time.time()
|
|
296
|
+
if current_time - self.last_fleet_refresh < 1.0:
|
|
297
|
+
return
|
|
298
|
+
self.last_fleet_refresh = current_time
|
|
299
|
+
|
|
300
|
+
if (self.source_type != 'direct'
|
|
301
|
+
and self.datasource_manager.is_connected()):
|
|
302
|
+
devices_dict = self.datasource_manager.list_devices()
|
|
303
|
+
self.fleet_cache = convert_fleet_format(devices_dict)
|
|
304
|
+
else:
|
|
305
|
+
self.fleet_cache = self._scan_local_fleet()
|
|
306
|
+
|
|
307
|
+
if self.tui_core and self.tui_core.fleet_index >= len(
|
|
308
|
+
self.fleet_cache):
|
|
309
|
+
self.tui_core.fleet_index = 0
|
|
310
|
+
|
|
311
|
+
logger.debug(
|
|
312
|
+
f"Fleet cache refreshed: {len(self.fleet_cache)} devices")
|
|
313
|
+
|
|
314
|
+
except Exception as e:
|
|
315
|
+
logger.warning(f"Error refreshing fleet cache: {e}")
|
|
316
|
+
self.fleet_cache = []
|
|
317
|
+
|
|
318
|
+
def _scan_local_fleet(self) -> List[Dict[str, Any]]:
|
|
319
|
+
fleet = []
|
|
320
|
+
try:
|
|
321
|
+
devices = self.datasource_manager.discover_devices()
|
|
322
|
+
for device in devices:
|
|
323
|
+
fleet.append({
|
|
324
|
+
"port": device.get("port", "Unknown"),
|
|
325
|
+
"firmware": device.get("fw", "?"),
|
|
326
|
+
"uid": device.get("uid", "Unknown"),
|
|
327
|
+
"variant": device.get("variant", "ORIGINAL"),
|
|
328
|
+
})
|
|
329
|
+
except Exception as e:
|
|
330
|
+
logger.error(f"Error during local fleet scan: {e}")
|
|
331
|
+
|
|
332
|
+
# discover_devices() probes each port with a fresh serial connection,
|
|
333
|
+
# which fails (and is silently dropped) for the port our own active
|
|
334
|
+
# connection already holds exclusively. Fall back to the connected
|
|
335
|
+
# device's own info so it doesn't vanish from the fleet list while
|
|
336
|
+
# we're connected to it.
|
|
337
|
+
if self.datasource_manager.is_connected():
|
|
338
|
+
uid = self.datasource_manager.get_selected_uid()
|
|
339
|
+
if uid and not any(d["uid"] == uid for d in fleet):
|
|
340
|
+
snapshot = self.datasource_manager.snapshot()
|
|
341
|
+
device_info = snapshot.get('device_info') or {}
|
|
342
|
+
fleet.append({
|
|
343
|
+
"port": snapshot.get("port") or "Unknown",
|
|
344
|
+
"firmware": device_info.get("FwVersion", "?"),
|
|
345
|
+
"uid": uid,
|
|
346
|
+
"variant": device_info.get("variant", "ORIGINAL"),
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
return sorted(fleet, key=lambda d: d["port"])
|
|
350
|
+
|
|
351
|
+
def cleanup(self):
|
|
352
|
+
if self.datasource_manager:
|
|
353
|
+
self.datasource_manager.disconnect()
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def tui_main(stdscr, _unused, args):
|
|
357
|
+
"""Main TUI entry point."""
|
|
358
|
+
# Redirect stdout/stderr to prevent pycore INFO messages from messing up
|
|
359
|
+
# curses layout
|
|
360
|
+
with _TUIStdoutRedirect():
|
|
361
|
+
app = TUIApplication(args)
|
|
362
|
+
|
|
363
|
+
try:
|
|
364
|
+
app.run(stdscr)
|
|
365
|
+
except Exception as e:
|
|
366
|
+
logger.error(f"TUI application error: {e}")
|
|
367
|
+
raise
|
|
368
|
+
finally:
|
|
369
|
+
app.cleanup()
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
if __name__ == "__main__":
|
|
373
|
+
import argparse
|
|
374
|
+
|
|
375
|
+
parser = argparse.ArgumentParser(description="BenchLab TUI")
|
|
376
|
+
parser.add_argument(
|
|
377
|
+
'--interval',
|
|
378
|
+
type=float,
|
|
379
|
+
default=1.0,
|
|
380
|
+
help='Telemetry refresh interval')
|
|
381
|
+
parser.add_argument(
|
|
382
|
+
'--source',
|
|
383
|
+
choices=[
|
|
384
|
+
'direct',
|
|
385
|
+
'fastapi',
|
|
386
|
+
'fastapi_custom',
|
|
387
|
+
'mqtt',
|
|
388
|
+
'named_pipe',
|
|
389
|
+
'service_http'],
|
|
390
|
+
default='direct',
|
|
391
|
+
help='Data source type')
|
|
392
|
+
parser.add_argument('--api-port', type=int, default=8000, dest='api_port',
|
|
393
|
+
help='FastAPI server port')
|
|
394
|
+
parser.add_argument(
|
|
395
|
+
'--mqtt-broker',
|
|
396
|
+
default='localhost',
|
|
397
|
+
dest='mqtt_broker',
|
|
398
|
+
help='MQTT broker hostname')
|
|
399
|
+
parser.add_argument(
|
|
400
|
+
'--mqtt-port',
|
|
401
|
+
type=int,
|
|
402
|
+
default=1883,
|
|
403
|
+
dest='mqtt_port',
|
|
404
|
+
help='MQTT broker port')
|
|
405
|
+
parser.add_argument(
|
|
406
|
+
'--service-url',
|
|
407
|
+
default='http://localhost:8585',
|
|
408
|
+
dest='service_url',
|
|
409
|
+
help='C# BenchLab service HTTP API URL')
|
|
410
|
+
args = parser.parse_args()
|
|
411
|
+
|
|
412
|
+
try:
|
|
413
|
+
curses.wrapper(tui_main, None, args)
|
|
414
|
+
except KeyboardInterrupt:
|
|
415
|
+
print("\nTUI interrupted by user")
|
|
416
|
+
except Exception as e:
|
|
417
|
+
print(f"TUI error: {e}")
|
|
418
|
+
sys.exit(1)
|
benchlab/vu/README.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# BENCHLAB VU Dials
|
|
2
|
+
|
|
3
|
+
Drives physical analog-style USB "VU dial" gauges (via the bundled [VU-Server](VU-Server/)) using live BENCHLAB telemetry, and provides a terminal UI for mapping dials to sensors.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
This tool has two pieces:
|
|
8
|
+
|
|
9
|
+
- **VU Updater** (`-vu`) — polls BENCHLAB telemetry via the shared data-source layer, resolves each configured dial's mapped sensor value, and pushes it to the VU-Server API so the physical dial needle moves. Also renders and uploads a sensor logo image to each dial's screen.
|
|
10
|
+
- **VU Config** (`-vuconfig`) — a curses TUI (`vu_tui.py`) for discovering connected VU dials and BENCHLAB devices, and mapping each dial to a specific device + sensor.
|
|
11
|
+
|
|
12
|
+
Both talk to a local **VU-Server** process (bundled in [`VU-Server/`](VU-Server/), a fork of [ThePi910FC's VU1 server](VU-Server/README.md)), which the tool starts and manages automatically if it isn't already running.
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install -r benchlab/vu/requirements.txt
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Key dependencies: `requests`, `PyYAML`, `Pillow` (logo rendering), `pyserial`, `tornado` (VU-Server), plus `blessed` for the legacy curses-adjacent terminal helpers.
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
### Configure dial-to-sensor mappings
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
python -m benchlab -vuconfig
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Lists detected VU dials and BENCHLAB devices/sensors, and lets you assign a sensor to each dial. Mappings are written to `benchlab/vu/vu_dial.config`.
|
|
31
|
+
|
|
32
|
+
### Run the updater
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
python -m benchlab -vu
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Starts (or connects to) VU-Server, then continuously polls telemetry and updates each mapped dial. Supports the standard data-source flags (`--source`, `--api-url`, `--mqtt-broker`, etc. — see the [top-level README](../../README.md#data-sources)) and `-i/--interval` for poll frequency.
|
|
39
|
+
|
|
40
|
+
The updater watches `vu_dial.config` for changes and hot-reloads mappings without restarting.
|
|
41
|
+
|
|
42
|
+
## Configuration
|
|
43
|
+
|
|
44
|
+
- **`vu_server.config`** — VU-Server connection settings:
|
|
45
|
+
```json
|
|
46
|
+
{
|
|
47
|
+
"vu_server_url": "http://localhost:5340",
|
|
48
|
+
"api_key": "...",
|
|
49
|
+
"logo_file": "assets/bl_logo_144x200.png"
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
- **`vu_dial.config`** — list of dial-to-sensor mappings (`dial_uid`, `dial_name`, `benchlab_uid`, `benchlab_port`, `sensor`), managed by `-vuconfig` or by editing directly.
|
|
53
|
+
- **`VU-Server/config.yaml`** — the underlying VU-Server's own device/serial configuration.
|
|
54
|
+
|
|
55
|
+
## Troubleshooting
|
|
56
|
+
|
|
57
|
+
- **VU-Server won't start** — check `vu_updater.log` in this directory; the updater forwards VU-Server's stdout/stderr there.
|
|
58
|
+
- **No dials detected** — VU dials enumerate as USB serial devices; verify they're connected and recognized by the OS before running `-vuconfig`.
|
|
59
|
+
- **Dial not updating** — confirm the mapped sensor name still exists in the current telemetry snapshot (sensor keys can change if a device's config changes).
|
|
60
|
+
|
|
61
|
+
## See Also
|
|
62
|
+
|
|
63
|
+
- [BENCHLAB PyTools Documentation](../../README.md)
|
|
64
|
+
- [VU-Server README](VU-Server/README.md) — the bundled dial server itself
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Notice
|
|
2
|
+
|
|
3
|
+
This directory vendors code and assets from the VU Server project by Sasa
|
|
4
|
+
Karanovic: https://github.com/SasaKaranovic/VU-Server
|
|
5
|
+
|
|
6
|
+
The upstream repository does not publish a `LICENSE` file. BENCHLAB.io has
|
|
7
|
+
obtained permission directly from the author to include and redistribute
|
|
8
|
+
this code as part of BENCHLAB PyTools, including in the packaged PyPI
|
|
9
|
+
distribution (`benchlab-pytools[vu]`).
|
|
10
|
+
|
|
11
|
+
This notice documents that redistribution grant. It does not itself
|
|
12
|
+
constitute a license; questions about terms for other uses should be
|
|
13
|
+
directed to the upstream author.
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# VU Dials - VU Server
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+

|
|
5
|
+
|
|
6
|
+
This is the official VU dials repository for the VU Server application and it's source code.
|
|
7
|
+
|
|
8
|
+
You can learn more about VU dials at [VUDials.com](https://vudials.com)
|
|
9
|
+
|
|
10
|
+
The VU Server is a key component of the VU dials.
|
|
11
|
+
|
|
12
|
+
It provides a Web API that can be interfaced by any third party application/script/service, allowing it to easily interact with and control the VU dials.
|
|
13
|
+
|
|
14
|
+
For example, in order to update a dial, any application can make a simple [API request](https://docs.vudials.com/api/dial_UID_set/) to the VU server and that's it.
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# Demo application
|
|
18
|
+
|
|
19
|
+
Right now, we have a very simple [VU1 demo application](https://github.com/SasaKaranovic/VU-Demo-App) that runs on Windows and demonstrates how dials can be used for resource usage monitoring.
|
|
20
|
+
|
|
21
|
+
You can download it from [VUDials Download page](https://vudials.com/download/demo_app) (or look at the [source code](https://github.com/SasaKaranovic/VU-Demo-App)).
|
|
22
|
+
|
|
23
|
+
Demo application uses VU Server API to demonstrate how a third party application can interact with VU dials.
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# Why not build apps for VU dials?
|
|
27
|
+
|
|
28
|
+
You might ask why build an API server? Why not just build more stand-alone applications for VU dials? It's not that simple, but we believe that we have a good reason for this decision...
|
|
29
|
+
|
|
30
|
+
VU dials can display virtually any information, and we all have our favourite/preferred applications for temperature monitoring, CPU/GPU/MEM load monitoring, weather app, stock monitoring, and the list can go on for days.
|
|
31
|
+
|
|
32
|
+
If we started building new applications specifically for VU dials, we would be forcing our users to choose between their existing favourite application and the new one that works with VU dials.
|
|
33
|
+
|
|
34
|
+
In the long run, this would result in "splitting the community" or forcing users to choose between abandoning perfectly fine software packages or abandoning the option to show some information on the VU dials.
|
|
35
|
+
|
|
36
|
+
Instead, our approach is that we have completely opened the VU dials to the community and any third-party application/script/service running on your PC (or even network).
|
|
37
|
+
|
|
38
|
+
Any application can make a straightforward API request to the VU server and say, "Hey, update dial X to show the value of Y", and that's it.
|
|
39
|
+
|
|
40
|
+
Making a web API request is a very simple thing to do in almost any software package.
|
|
41
|
+
|
|
42
|
+
So how does this benefit the community?
|
|
43
|
+
|
|
44
|
+
This approach should allow existing applications to integrate/support VU dials natively or through extensions/addons/plugins. This means you still get to use your favourite application, but as a bonus, it now works seamlessly with VU dials.
|
|
45
|
+
|
|
46
|
+
We are aware of the big caveat, which is that VU dial adoption won't happen over night and that it will require a lot of love and support from the community and developers.
|
|
47
|
+
|
|
48
|
+
But we strongly believe that in the long run this is a much better approach than creating another closed-source, black-box product that forces you to use prescribed app store if you want to use the product.
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# How to run/install VU Server
|
|
53
|
+
|
|
54
|
+
For Windows we have an installer that can be download from [https://vudials.com/download/server](https://vudials.com/download/server)
|
|
55
|
+
|
|
56
|
+
For Linux and Mac users, we don't have the installer (yet), but you can run the VU Server from the source code.
|
|
57
|
+
|
|
58
|
+
We have a simple example that describes how to run VU Server from source code on a Linux machine.
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
[Jump to Linux Running From Source Code read me](Running_from_source_on_linux.md)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# Want to contribute?
|
|
65
|
+
|
|
66
|
+
As mentioned, right now, VU Dials adoption is the main hurdle for everyone enjoying VU dials in combination with their favourite game/applications/service.
|
|
67
|
+
|
|
68
|
+
For developers interested in supporting VU dials natively or through extension/addon/plugin, please take a look at [VU Dials API](https://docs.vudials.com/api_messaging/) documentation to learn how you can communicate with the dials through VU server.
|
|
69
|
+
|
|
70
|
+
For non-developers interested in supporting VU dials; you can reach out to your favorite developers and let them know that you would appreciate if they could integrate VU dials in their software.
|
|
71
|
+
|
|
72
|
+
We could use any and all the love that the community has to offer. :)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
[VU Dials Home page](https://vudials.com)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# Running VU Server from source code on Linux
|
|
2
|
+
|
|
3
|
+
You can run VU Server from source code.
|
|
4
|
+
|
|
5
|
+
In order to do this you will need to install Python (programming language used by VU Server) and optionally Git.
|
|
6
|
+
|
|
7
|
+
To do this you can run the following command
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
sudo apt install git python3 python3-pip
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Then you can clone the VU Server repository and install project dependencies
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
git clone https://github.com/SasaKaranovic/VU-Server.git
|
|
17
|
+
cd VU-Server
|
|
18
|
+
pip3 install -r requirements.txt
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
You will need to run the above steps only once.
|
|
23
|
+
|
|
24
|
+
After that you can start VU server by running `python3 server.py`
|
|
25
|
+
|
|
26
|
+
Now you can access VU server web GUI by navigating to http://localhost:5340 in your browser.
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
## How to fix "OSError: [Errno 13] Permission denied: '/dev/ttyUSBx'" error
|
|
30
|
+
|
|
31
|
+
On some distributions you will need to explicitly allow access to `/dev/ttyUSBx` where `x` is usually a number (ie `/dev/ttyUSB0`).
|
|
32
|
+
|
|
33
|
+
There are multiple ways to do this.
|
|
34
|
+
|
|
35
|
+
You can temporarily grant access by running `sudo chmod 666 /dev/ttyUSB0`.
|
|
36
|
+
|
|
37
|
+
But you will have to run this command every time you re-plug in the hub, which is not very practical.
|
|
38
|
+
|
|
39
|
+
More permanent solution involves creating a udev rule
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
# navigate to rules.d directory
|
|
43
|
+
cd /etc/udev/rules.d
|
|
44
|
+
#create a new rule file
|
|
45
|
+
sudo touch vu-rule.rules
|
|
46
|
+
# open the file
|
|
47
|
+
sudo nano vu-rule.rules
|
|
48
|
+
# add the following
|
|
49
|
+
KERNEL=="ttyUSB0", MODE="0666"
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Please note that you will have to reboot your machine in order for new udev rules to take effect.
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# Example command line usage
|
|
56
|
+
|
|
57
|
+
Ideally you would have an application/service/script that updates VU dials for you.
|
|
58
|
+
|
|
59
|
+
But in some instances you might want to update the VU dials from the command line.
|
|
60
|
+
|
|
61
|
+
This example shows how to do this.
|
|
62
|
+
|
|
63
|
+
Requirements
|
|
64
|
+
|
|
65
|
+
- You have to have VU server running.
|
|
66
|
+
- From the VU Server GUI; Get the UID of the dial you want to update.
|
|
67
|
+
- From the VU Server GUI; Get the API key you want to use for this purpose (create a new one or use existing one).
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
In order to update the dial, we need to make a [HTTP request](https://docs.vudials.com/api/dial_UID_set/) to the VU server.
|
|
71
|
+
|
|
72
|
+
Let's say we want to set the dial with the UID `3E0075000650564139323920` to `50%` using API key `cTpAWYuRpA2zx75Yh961Cg`
|
|
73
|
+
|
|
74
|
+
We can make a simple GET request using `wget` (or `curl` if you prefer):
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
wget -O- -q "http://localhost:5340/api/v0/dial/3E0075000650564139323920/set?value=50&key=cTpAWYuRpA2zx75Yh961C" ; echo
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
You can use pipe redirects in your terminal to redirect output of applications to VU dials.
|
|
81
|
+
|
|
82
|
+
Let's say we use following command to retrieve CPU usage on our Ubuntu machine
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
echo $[100-$(vmstat 1 2|tail -1|awk '{print $15}')]
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
This command will output an integer that represents CPU usage in percentage. We can then forward this information to VU server using pipe redirects
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
echo $[100-$(vmstat 1 2|tail -1|awk '{print $15}')] | xargs -I{} wget -O- -q "http://localhost:5340/api/v0/dial/3E0075000650564139323920/set?value={}&key=cTpAWYuRpA2zx75Yh961C"; echo
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Please keep in mind that this is a very "simple" example (and yet looks somewhat complicated), but the main idea is that you can easily redirect output of any application on your system and send it to VU Server to have it displayed on your VU1 dials.
|
|
95
|
+
|
|
96
|
+
Ideally you would not run these commands manually but instead have a script/service/cron that updates your dials periodically.
|
|
97
|
+
|
|
File without changes
|
|
Binary file
|