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
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration Client Abstraction
|
|
3
|
+
|
|
4
|
+
Provides unified interface for reading/writing device configuration
|
|
5
|
+
via both direct serial (pycore) and named pipe (Windows service) sources.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
from abc import ABC, abstractmethod
|
|
11
|
+
from typing import Optional, Dict, Any
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("benchlab.config.client")
|
|
14
|
+
|
|
15
|
+
DISCOVERY_PIPE_NAME = "BenchlabDiscovery"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def query_named_pipe(
|
|
19
|
+
pipe_name: str,
|
|
20
|
+
command: str,
|
|
21
|
+
payload=None,
|
|
22
|
+
timeout_ms: int = 5000):
|
|
23
|
+
"""Send a single command to a named pipe and return the parsed JSON
|
|
24
|
+
response.
|
|
25
|
+
|
|
26
|
+
Standalone helper (not tied to a device-scoped NamedPipeConfigClient) so
|
|
27
|
+
the BenchlabDiscovery pipe -- which isn't associated with any one device
|
|
28
|
+
-- can be queried the same way, e.g. for ListDevices. Mirrors
|
|
29
|
+
NamedPipeConfigClient._send_command's open/write/read/close sequence.
|
|
30
|
+
|
|
31
|
+
Returns the parsed JSON response (dict or list), or None on failure.
|
|
32
|
+
"""
|
|
33
|
+
import win32file
|
|
34
|
+
import win32pipe
|
|
35
|
+
import pywintypes
|
|
36
|
+
|
|
37
|
+
path = f"\\\\.\\pipe\\{pipe_name}"
|
|
38
|
+
handle = None
|
|
39
|
+
try:
|
|
40
|
+
win32pipe.WaitNamedPipe(path, timeout_ms)
|
|
41
|
+
handle = win32file.CreateFile(
|
|
42
|
+
path,
|
|
43
|
+
win32file.GENERIC_READ | win32file.GENERIC_WRITE,
|
|
44
|
+
0, None,
|
|
45
|
+
win32file.OPEN_EXISTING,
|
|
46
|
+
0, None
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
win32file.WriteFile(handle, (command + "\n").encode("utf-8"))
|
|
50
|
+
if payload is not None:
|
|
51
|
+
if isinstance(payload, str):
|
|
52
|
+
win32file.WriteFile(handle, (payload + "\n").encode("utf-8"))
|
|
53
|
+
else:
|
|
54
|
+
win32file.WriteFile(
|
|
55
|
+
handle, (json.dumps(payload) + "\n").encode("utf-8"))
|
|
56
|
+
|
|
57
|
+
chunks = []
|
|
58
|
+
while True:
|
|
59
|
+
try:
|
|
60
|
+
_, data = win32file.ReadFile(handle, 4096)
|
|
61
|
+
if not data:
|
|
62
|
+
break
|
|
63
|
+
chunks.append(data)
|
|
64
|
+
except pywintypes.error as e:
|
|
65
|
+
if e.winerror == 109: # Pipe closed
|
|
66
|
+
break
|
|
67
|
+
raise
|
|
68
|
+
|
|
69
|
+
response_text = b"".join(chunks).decode(
|
|
70
|
+
"utf-8", errors="replace").strip()
|
|
71
|
+
if not response_text:
|
|
72
|
+
return None
|
|
73
|
+
return json.loads(response_text)
|
|
74
|
+
|
|
75
|
+
except (pywintypes.error, json.JSONDecodeError) as e:
|
|
76
|
+
logger.error(f"Pipe command failed on {pipe_name}: {e}")
|
|
77
|
+
return None
|
|
78
|
+
finally:
|
|
79
|
+
if handle is not None:
|
|
80
|
+
try:
|
|
81
|
+
win32file.CloseHandle(handle)
|
|
82
|
+
except Exception:
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class ConfigClient(ABC):
|
|
87
|
+
"""Abstract base class for device configuration clients."""
|
|
88
|
+
|
|
89
|
+
@abstractmethod
|
|
90
|
+
def get_device_info(self) -> Optional[Dict[str, Any]]:
|
|
91
|
+
"""Get device information."""
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
@abstractmethod
|
|
95
|
+
def read_device_name(self) -> Optional[str]:
|
|
96
|
+
"""Read device friendly name."""
|
|
97
|
+
pass
|
|
98
|
+
|
|
99
|
+
@abstractmethod
|
|
100
|
+
def write_device_name(self, name: str) -> bool:
|
|
101
|
+
"""Write device friendly name."""
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
@abstractmethod
|
|
105
|
+
def read_fan_config(self, profile_id: int,
|
|
106
|
+
fan_id: int) -> Optional[Dict[str, Any]]:
|
|
107
|
+
"""Read fan configuration."""
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
@abstractmethod
|
|
111
|
+
def write_fan_config(self, profile_id: int, fan_id: int,
|
|
112
|
+
config: Dict[str, Any]) -> bool:
|
|
113
|
+
"""Write fan configuration."""
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
@abstractmethod
|
|
117
|
+
def read_rgb_config(self, profile_id: int) -> Optional[Dict[str, Any]]:
|
|
118
|
+
"""Read RGB configuration."""
|
|
119
|
+
pass
|
|
120
|
+
|
|
121
|
+
@abstractmethod
|
|
122
|
+
def write_rgb_config(self, profile_id: int,
|
|
123
|
+
config: Dict[str, Any]) -> bool:
|
|
124
|
+
"""Write RGB configuration."""
|
|
125
|
+
pass
|
|
126
|
+
|
|
127
|
+
@abstractmethod
|
|
128
|
+
def read_calibration(self) -> Optional[Dict[str, Any]]:
|
|
129
|
+
"""Read calibration data."""
|
|
130
|
+
pass
|
|
131
|
+
|
|
132
|
+
@abstractmethod
|
|
133
|
+
def write_calibration(self, calibration: Dict[str, Any]) -> bool:
|
|
134
|
+
"""Write calibration data."""
|
|
135
|
+
pass
|
|
136
|
+
|
|
137
|
+
@abstractmethod
|
|
138
|
+
def save_config(self) -> bool:
|
|
139
|
+
"""Save configuration to device flash."""
|
|
140
|
+
pass
|
|
141
|
+
|
|
142
|
+
@abstractmethod
|
|
143
|
+
def load_config(self) -> bool:
|
|
144
|
+
"""Load configuration from device flash."""
|
|
145
|
+
pass
|
|
146
|
+
|
|
147
|
+
@abstractmethod
|
|
148
|
+
def reset_config(self) -> bool:
|
|
149
|
+
"""Reset configuration to factory defaults."""
|
|
150
|
+
pass
|
|
151
|
+
|
|
152
|
+
@abstractmethod
|
|
153
|
+
def close(self):
|
|
154
|
+
"""Close connection and cleanup resources."""
|
|
155
|
+
pass
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class DirectConfigClient(ConfigClient):
|
|
159
|
+
"""Configuration client using direct serial connection via pycore."""
|
|
160
|
+
|
|
161
|
+
def __init__(self, port: str):
|
|
162
|
+
"""Initialize direct serial client.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
port: Serial port name (e.g., 'COM4')
|
|
166
|
+
"""
|
|
167
|
+
import serial
|
|
168
|
+
from benchlab_pycore.core import read_device
|
|
169
|
+
|
|
170
|
+
self.port = port
|
|
171
|
+
self.ser = serial.Serial(port, 115200, timeout=1)
|
|
172
|
+
|
|
173
|
+
# Get device info to determine variant
|
|
174
|
+
info = read_device(self.ser)
|
|
175
|
+
if not info:
|
|
176
|
+
raise ConnectionError(f"Failed to read device info from {port}")
|
|
177
|
+
|
|
178
|
+
self.product_id = info.get('ProductId')
|
|
179
|
+
logger.info(
|
|
180
|
+
f"Connected to device on {port}, Product ID: 0x{
|
|
181
|
+
self.product_id:02X}")
|
|
182
|
+
|
|
183
|
+
def get_device_info(self) -> Optional[Dict[str, Any]]:
|
|
184
|
+
"""Get device information."""
|
|
185
|
+
from benchlab_pycore.core import read_device, read_uid
|
|
186
|
+
|
|
187
|
+
info = read_device(self.ser)
|
|
188
|
+
if not info:
|
|
189
|
+
return None
|
|
190
|
+
|
|
191
|
+
uid = read_uid(self.ser)
|
|
192
|
+
return {
|
|
193
|
+
'VendorId': info.get('VendorId'),
|
|
194
|
+
'ProductId': info.get('ProductId'),
|
|
195
|
+
'FwVersion': info.get('FwVersion'),
|
|
196
|
+
'uid': uid,
|
|
197
|
+
'port': self.port,
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
def read_device_name(self) -> Optional[str]:
|
|
201
|
+
"""Read device friendly name."""
|
|
202
|
+
from benchlab_pycore.core import read_name
|
|
203
|
+
return read_name(self.ser)
|
|
204
|
+
|
|
205
|
+
def write_device_name(self, name: str) -> bool:
|
|
206
|
+
"""Write device friendly name."""
|
|
207
|
+
from benchlab_pycore.core import write_name
|
|
208
|
+
return write_name(self.ser, name)
|
|
209
|
+
|
|
210
|
+
def read_fan_config(self, profile_id: int,
|
|
211
|
+
fan_id: int) -> Optional[Dict[str, Any]]:
|
|
212
|
+
"""Read fan configuration."""
|
|
213
|
+
from benchlab_pycore.core import read_fan_profile
|
|
214
|
+
|
|
215
|
+
config = read_fan_profile(
|
|
216
|
+
self.ser,
|
|
217
|
+
fan_profile=profile_id,
|
|
218
|
+
fan_id=fan_id)
|
|
219
|
+
if not config:
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
# Convert struct to dict
|
|
223
|
+
return {
|
|
224
|
+
'FanMode': config.FanMode,
|
|
225
|
+
'TempSource': config.TempSource,
|
|
226
|
+
'Temp': list(config.Temp),
|
|
227
|
+
'Duty': list(config.Duty),
|
|
228
|
+
'RampStep': config.RampStep,
|
|
229
|
+
'FixedDuty': config.FixedDuty,
|
|
230
|
+
'MinDuty': config.MinDuty,
|
|
231
|
+
'MaxDuty': config.MaxDuty,
|
|
232
|
+
'FanStop': config.FanStop,
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
def write_fan_config(self, profile_id: int, fan_id: int,
|
|
236
|
+
config: Dict[str, Any]) -> bool:
|
|
237
|
+
"""Write fan configuration."""
|
|
238
|
+
from benchlab_pycore.core import write_fan_profile, FanConfigStruct
|
|
239
|
+
|
|
240
|
+
# Convert dict to struct
|
|
241
|
+
cfg = FanConfigStruct()
|
|
242
|
+
cfg.FanMode = config.get('FanMode', 0)
|
|
243
|
+
cfg.TempSource = config.get('TempSource', 0)
|
|
244
|
+
|
|
245
|
+
# Handle array fields properly
|
|
246
|
+
temp_list = config.get('Temp', [300, 600])
|
|
247
|
+
duty_list = config.get('Duty', [30, 80])
|
|
248
|
+
|
|
249
|
+
# Get array types from struct
|
|
250
|
+
for field_name, field_type in FanConfigStruct._fields_:
|
|
251
|
+
if field_name == 'Temp':
|
|
252
|
+
cfg.Temp = field_type(*temp_list)
|
|
253
|
+
elif field_name == 'Duty':
|
|
254
|
+
cfg.Duty = field_type(*duty_list)
|
|
255
|
+
|
|
256
|
+
cfg.RampStep = config.get('RampStep', 5)
|
|
257
|
+
cfg.FixedDuty = config.get('FixedDuty', 50)
|
|
258
|
+
cfg.MinDuty = config.get('MinDuty', 20)
|
|
259
|
+
cfg.MaxDuty = config.get('MaxDuty', 100)
|
|
260
|
+
cfg.FanStop = config.get('FanStop', 0)
|
|
261
|
+
|
|
262
|
+
return write_fan_profile(
|
|
263
|
+
self.ser,
|
|
264
|
+
fan_profile=profile_id,
|
|
265
|
+
fan_id=fan_id,
|
|
266
|
+
config=cfg)
|
|
267
|
+
|
|
268
|
+
def read_rgb_config(self, profile_id: int) -> Optional[Dict[str, Any]]:
|
|
269
|
+
"""Read RGB configuration."""
|
|
270
|
+
from benchlab_pycore.core import read_rgb_profile
|
|
271
|
+
|
|
272
|
+
config = read_rgb_profile(self.ser, rgb_profile=profile_id)
|
|
273
|
+
if not config:
|
|
274
|
+
return None
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
'Mode': config.Mode,
|
|
278
|
+
'Red': config.Red,
|
|
279
|
+
'Green': config.Green,
|
|
280
|
+
'Blue': config.Blue,
|
|
281
|
+
'Direction': config.Direction,
|
|
282
|
+
'Speed': config.Speed,
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
def write_rgb_config(self, profile_id: int,
|
|
286
|
+
config: Dict[str, Any]) -> bool:
|
|
287
|
+
"""Write RGB configuration."""
|
|
288
|
+
from benchlab_pycore.core import write_rgb_profile, RGBConfigStruct
|
|
289
|
+
|
|
290
|
+
cfg = RGBConfigStruct()
|
|
291
|
+
cfg.Mode = config.get('Mode', 0)
|
|
292
|
+
cfg.Red = config.get('Red', 255)
|
|
293
|
+
cfg.Green = config.get('Green', 255)
|
|
294
|
+
cfg.Blue = config.get('Blue', 255)
|
|
295
|
+
cfg.Direction = config.get('Direction', 0)
|
|
296
|
+
cfg.Speed = config.get('Speed', 50)
|
|
297
|
+
|
|
298
|
+
return write_rgb_profile(self.ser, rgb_profile=profile_id, config=cfg)
|
|
299
|
+
|
|
300
|
+
def _struct_to_dict(self, obj) -> Any:
|
|
301
|
+
"""Recursively convert ctypes structures to dictionaries."""
|
|
302
|
+
import ctypes
|
|
303
|
+
|
|
304
|
+
# Handle ctypes Structure
|
|
305
|
+
if isinstance(obj, ctypes.Structure):
|
|
306
|
+
result = {}
|
|
307
|
+
for field_name, field_type in obj._fields_:
|
|
308
|
+
value = getattr(obj, field_name)
|
|
309
|
+
result[field_name] = self._struct_to_dict(value)
|
|
310
|
+
return result
|
|
311
|
+
# Handle arrays/lists
|
|
312
|
+
elif hasattr(obj, '__len__') and not isinstance(obj, (str, bytes)):
|
|
313
|
+
return [self._struct_to_dict(item) for item in obj]
|
|
314
|
+
# Handle primitives
|
|
315
|
+
else:
|
|
316
|
+
return obj
|
|
317
|
+
|
|
318
|
+
def read_calibration(self) -> Optional[Dict[str, Any]]:
|
|
319
|
+
"""Read calibration data."""
|
|
320
|
+
from benchlab_pycore.core import read_calibration
|
|
321
|
+
|
|
322
|
+
cal = read_calibration(self.ser, product_id=self.product_id)
|
|
323
|
+
if not cal:
|
|
324
|
+
return None
|
|
325
|
+
|
|
326
|
+
# Convert calibration struct to dict for JSON serialization
|
|
327
|
+
try:
|
|
328
|
+
return self._struct_to_dict(cal)
|
|
329
|
+
except Exception as e:
|
|
330
|
+
logger.warning(f"Could not convert calibration to dict: {e}")
|
|
331
|
+
return None
|
|
332
|
+
|
|
333
|
+
def _dict_to_struct(self, data: Any, struct_type):
|
|
334
|
+
"""Recursively convert dictionaries back to ctypes structures."""
|
|
335
|
+
|
|
336
|
+
if isinstance(data, dict):
|
|
337
|
+
# Create struct instance
|
|
338
|
+
struct_obj = struct_type()
|
|
339
|
+
# Set each field
|
|
340
|
+
for field_name, field_type in struct_type._fields_:
|
|
341
|
+
if field_name in data:
|
|
342
|
+
value = data[field_name]
|
|
343
|
+
# Get the actual field type (unwrap if it's an array)
|
|
344
|
+
if hasattr(
|
|
345
|
+
field_type,
|
|
346
|
+
'_type_') and hasattr(
|
|
347
|
+
field_type,
|
|
348
|
+
'_length_'):
|
|
349
|
+
# It's an array - convert list to array
|
|
350
|
+
if isinstance(value, list):
|
|
351
|
+
array_type = field_type
|
|
352
|
+
# Check if array elements are structures
|
|
353
|
+
elem_type = field_type._type_
|
|
354
|
+
if hasattr(elem_type, '_fields_'):
|
|
355
|
+
# Array of structures
|
|
356
|
+
converted = [
|
|
357
|
+
self._dict_to_struct(
|
|
358
|
+
item, elem_type) for item in value]
|
|
359
|
+
setattr(
|
|
360
|
+
struct_obj, field_name, array_type(
|
|
361
|
+
*converted))
|
|
362
|
+
else:
|
|
363
|
+
# Array of primitives
|
|
364
|
+
setattr(
|
|
365
|
+
struct_obj,
|
|
366
|
+
field_name,
|
|
367
|
+
array_type(
|
|
368
|
+
*value))
|
|
369
|
+
else:
|
|
370
|
+
setattr(struct_obj, field_name, value)
|
|
371
|
+
elif hasattr(field_type, '_fields_'):
|
|
372
|
+
# It's a nested structure
|
|
373
|
+
setattr(
|
|
374
|
+
struct_obj,
|
|
375
|
+
field_name,
|
|
376
|
+
self._dict_to_struct(
|
|
377
|
+
value,
|
|
378
|
+
field_type))
|
|
379
|
+
else:
|
|
380
|
+
# Primitive type
|
|
381
|
+
setattr(struct_obj, field_name, value)
|
|
382
|
+
return struct_obj
|
|
383
|
+
else:
|
|
384
|
+
return data
|
|
385
|
+
|
|
386
|
+
def write_calibration(self, calibration: Dict[str, Any]) -> bool:
|
|
387
|
+
"""Write calibration data."""
|
|
388
|
+
from benchlab_pycore.core import (
|
|
389
|
+
write_calibration,
|
|
390
|
+
CalibrationStruct,
|
|
391
|
+
CalibrationStructBL2,
|
|
392
|
+
BENCHLAB_BL2_PRODUCT_ID,
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
# Select the struct type matching the connected device's variant --
|
|
396
|
+
# BL2 has 8 temp sensors vs Original's 4, so the struct sizes and
|
|
397
|
+
# array lengths differ. Mirrors benchlab_pycore.core.read_calibration's
|
|
398
|
+
# own product_id -> struct_type selection.
|
|
399
|
+
struct_type = (
|
|
400
|
+
CalibrationStructBL2
|
|
401
|
+
if self.product_id == BENCHLAB_BL2_PRODUCT_ID
|
|
402
|
+
else CalibrationStruct
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
# Reconstruct calibration struct from dict
|
|
406
|
+
try:
|
|
407
|
+
cal = self._dict_to_struct(calibration, struct_type)
|
|
408
|
+
except Exception as e:
|
|
409
|
+
logger.error(f"Failed to reconstruct calibration struct: {e}")
|
|
410
|
+
return False
|
|
411
|
+
|
|
412
|
+
return write_calibration(
|
|
413
|
+
self.ser,
|
|
414
|
+
calibration=cal,
|
|
415
|
+
product_id=self.product_id)
|
|
416
|
+
|
|
417
|
+
def save_config(self) -> bool:
|
|
418
|
+
"""Save configuration to device flash."""
|
|
419
|
+
from benchlab_pycore.core.config_io import save_config
|
|
420
|
+
return save_config(self.ser)
|
|
421
|
+
|
|
422
|
+
def load_config(self) -> bool:
|
|
423
|
+
"""Load configuration from device flash."""
|
|
424
|
+
from benchlab_pycore.core.config_io import load_config
|
|
425
|
+
return load_config(self.ser)
|
|
426
|
+
|
|
427
|
+
def reset_config(self) -> bool:
|
|
428
|
+
"""Reset configuration to factory defaults."""
|
|
429
|
+
from benchlab_pycore.core.config_io import reset_config
|
|
430
|
+
return reset_config(self.ser)
|
|
431
|
+
|
|
432
|
+
def close(self):
|
|
433
|
+
"""Close serial connection."""
|
|
434
|
+
if self.ser and self.ser.is_open:
|
|
435
|
+
self.ser.close()
|
|
436
|
+
logger.info(f"Closed connection to {self.port}")
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
class NamedPipeConfigClient(ConfigClient):
|
|
440
|
+
"""Configuration client using Windows named pipes."""
|
|
441
|
+
|
|
442
|
+
def __init__(self, pipe_name: str):
|
|
443
|
+
"""Initialize named pipe client.
|
|
444
|
+
|
|
445
|
+
Args:
|
|
446
|
+
pipe_name: Named pipe name (e.g., 'BenchlabSensorPipe_10_ABC123')
|
|
447
|
+
"""
|
|
448
|
+
import sys
|
|
449
|
+
if not sys.platform.startswith("win"):
|
|
450
|
+
raise RuntimeError(
|
|
451
|
+
"NamedPipeConfigClient is only supported on Windows")
|
|
452
|
+
|
|
453
|
+
self.pipe_name = pipe_name
|
|
454
|
+
self.handle = None
|
|
455
|
+
logger.info(f"Initialized named pipe client for {pipe_name}")
|
|
456
|
+
|
|
457
|
+
def _open_pipe(self):
|
|
458
|
+
"""Open named pipe connection."""
|
|
459
|
+
import win32file
|
|
460
|
+
import win32pipe
|
|
461
|
+
import pywintypes
|
|
462
|
+
|
|
463
|
+
path = f"\\\\.\\pipe\\{self.pipe_name}"
|
|
464
|
+
try:
|
|
465
|
+
win32pipe.WaitNamedPipe(path, 5000)
|
|
466
|
+
except pywintypes.error as e:
|
|
467
|
+
raise ConnectionError(
|
|
468
|
+
f"Pipe '{
|
|
469
|
+
self.pipe_name}' not available: {e}") from e
|
|
470
|
+
|
|
471
|
+
self.handle = win32file.CreateFile(
|
|
472
|
+
path,
|
|
473
|
+
win32file.GENERIC_READ | win32file.GENERIC_WRITE,
|
|
474
|
+
0, None,
|
|
475
|
+
win32file.OPEN_EXISTING,
|
|
476
|
+
0, None
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
def _close_pipe(self):
|
|
480
|
+
"""Close named pipe connection."""
|
|
481
|
+
if self.handle:
|
|
482
|
+
import win32file
|
|
483
|
+
try:
|
|
484
|
+
win32file.CloseHandle(self.handle)
|
|
485
|
+
except Exception:
|
|
486
|
+
pass
|
|
487
|
+
self.handle = None
|
|
488
|
+
|
|
489
|
+
def _read_response(self) -> str:
|
|
490
|
+
"""Read full response from pipe."""
|
|
491
|
+
import win32file
|
|
492
|
+
import pywintypes
|
|
493
|
+
|
|
494
|
+
chunks = []
|
|
495
|
+
while True:
|
|
496
|
+
try:
|
|
497
|
+
result, data = win32file.ReadFile(self.handle, 4096)
|
|
498
|
+
if not data:
|
|
499
|
+
break
|
|
500
|
+
chunks.append(data)
|
|
501
|
+
except pywintypes.error as e:
|
|
502
|
+
if e.winerror == 109: # Pipe closed
|
|
503
|
+
break
|
|
504
|
+
raise
|
|
505
|
+
|
|
506
|
+
return b"".join(chunks).decode("utf-8", errors="replace").strip()
|
|
507
|
+
|
|
508
|
+
def _send_command(self, command: str,
|
|
509
|
+
payload=None) -> Optional[Dict[str, Any]]:
|
|
510
|
+
"""Send command to pipe and get response."""
|
|
511
|
+
import win32file
|
|
512
|
+
import pywintypes
|
|
513
|
+
|
|
514
|
+
try:
|
|
515
|
+
self._close_pipe()
|
|
516
|
+
self._open_pipe()
|
|
517
|
+
|
|
518
|
+
# Write command
|
|
519
|
+
win32file.WriteFile(self.handle, (command + "\n").encode("utf-8"))
|
|
520
|
+
|
|
521
|
+
# Write payload if provided
|
|
522
|
+
if payload is not None:
|
|
523
|
+
if isinstance(payload, str):
|
|
524
|
+
win32file.WriteFile(
|
|
525
|
+
self.handle, (payload + "\n").encode("utf-8"))
|
|
526
|
+
else:
|
|
527
|
+
win32file.WriteFile(
|
|
528
|
+
self.handle,
|
|
529
|
+
(json.dumps(payload) + "\n").encode("utf-8"))
|
|
530
|
+
|
|
531
|
+
# Read response
|
|
532
|
+
response_text = self._read_response()
|
|
533
|
+
if not response_text:
|
|
534
|
+
return None
|
|
535
|
+
|
|
536
|
+
return json.loads(response_text)
|
|
537
|
+
|
|
538
|
+
except (pywintypes.error, json.JSONDecodeError) as e:
|
|
539
|
+
logger.error(f"Pipe command failed: {e}")
|
|
540
|
+
return None
|
|
541
|
+
finally:
|
|
542
|
+
self._close_pipe()
|
|
543
|
+
|
|
544
|
+
def get_device_info(self) -> Optional[Dict[str, Any]]:
|
|
545
|
+
"""Get device information."""
|
|
546
|
+
return self._send_command("GetDeviceInfo")
|
|
547
|
+
|
|
548
|
+
def read_device_name(self) -> Optional[str]:
|
|
549
|
+
"""Read device friendly name."""
|
|
550
|
+
info = self.get_device_info()
|
|
551
|
+
return info.get('deviceName') if info else None
|
|
552
|
+
|
|
553
|
+
def write_device_name(self, name: str) -> bool:
|
|
554
|
+
"""Write device friendly name."""
|
|
555
|
+
result = self._send_command("SetDeviceName", payload=name)
|
|
556
|
+
return result and result.get('success', False)
|
|
557
|
+
|
|
558
|
+
def read_fan_config(self, profile_id: int,
|
|
559
|
+
fan_id: int) -> Optional[Dict[str, Any]]:
|
|
560
|
+
"""Read fan configuration."""
|
|
561
|
+
return self._send_command(f"ReadFanConfig:{profile_id}:{fan_id}")
|
|
562
|
+
|
|
563
|
+
def write_fan_config(self, profile_id: int, fan_id: int,
|
|
564
|
+
config: Dict[str, Any]) -> bool:
|
|
565
|
+
"""Write fan configuration."""
|
|
566
|
+
result = self._send_command(
|
|
567
|
+
f"WriteFanConfig:{profile_id}:{fan_id}",
|
|
568
|
+
payload=config)
|
|
569
|
+
return result and result.get('success', False)
|
|
570
|
+
|
|
571
|
+
def read_rgb_config(self, profile_id: int) -> Optional[Dict[str, Any]]:
|
|
572
|
+
"""Read RGB configuration."""
|
|
573
|
+
return self._send_command(f"ReadRgbConfig:{profile_id}")
|
|
574
|
+
|
|
575
|
+
def write_rgb_config(self, profile_id: int,
|
|
576
|
+
config: Dict[str, Any]) -> bool:
|
|
577
|
+
"""Write RGB configuration."""
|
|
578
|
+
result = self._send_command(
|
|
579
|
+
f"WriteRgbConfig:{profile_id}", payload=config)
|
|
580
|
+
return result and result.get('success', False)
|
|
581
|
+
|
|
582
|
+
def read_calibration(self) -> Optional[Dict[str, Any]]:
|
|
583
|
+
"""Read calibration data."""
|
|
584
|
+
return self._send_command("ReadCalibration")
|
|
585
|
+
|
|
586
|
+
def write_calibration(self, calibration: Dict[str, Any]) -> bool:
|
|
587
|
+
"""Write calibration data."""
|
|
588
|
+
result = self._send_command("WriteCalibration", payload=calibration)
|
|
589
|
+
return result and result.get('success', False)
|
|
590
|
+
|
|
591
|
+
def save_config(self) -> bool:
|
|
592
|
+
"""Save configuration to device flash."""
|
|
593
|
+
result = self._send_command("SaveConfig")
|
|
594
|
+
return result and result.get('success', False)
|
|
595
|
+
|
|
596
|
+
def load_config(self) -> bool:
|
|
597
|
+
"""Load configuration from device flash."""
|
|
598
|
+
result = self._send_command("LoadConfig")
|
|
599
|
+
return result and result.get('success', False)
|
|
600
|
+
|
|
601
|
+
def reset_config(self) -> bool:
|
|
602
|
+
"""Reset configuration to factory defaults."""
|
|
603
|
+
result = self._send_command("ResetConfig")
|
|
604
|
+
return result and result.get('success', False)
|
|
605
|
+
|
|
606
|
+
def close(self):
|
|
607
|
+
"""Close pipe connection."""
|
|
608
|
+
self._close_pipe()
|
|
609
|
+
logger.info(f"Closed named pipe connection to {self.pipe_name}")
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def create_config_client(source: str, identifier: str) -> ConfigClient:
|
|
613
|
+
"""Factory function to create appropriate config client.
|
|
614
|
+
|
|
615
|
+
Args:
|
|
616
|
+
source: 'direct' or 'named_pipe'
|
|
617
|
+
identifier: Port name for direct, pipe name for named_pipe
|
|
618
|
+
|
|
619
|
+
Returns:
|
|
620
|
+
ConfigClient instance
|
|
621
|
+
|
|
622
|
+
Raises:
|
|
623
|
+
ValueError: If source type is invalid
|
|
624
|
+
ConnectionError: If connection fails
|
|
625
|
+
"""
|
|
626
|
+
if source == 'direct':
|
|
627
|
+
return DirectConfigClient(identifier)
|
|
628
|
+
elif source == 'named_pipe':
|
|
629
|
+
return NamedPipeConfigClient(identifier)
|
|
630
|
+
else:
|
|
631
|
+
raise ValueError(f"Invalid source type: {source}")
|