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.
Files changed (184) hide show
  1. benchlab/__init__.py +14 -0
  2. benchlab/__main__.py +21 -0
  3. benchlab/config/README.md +325 -0
  4. benchlab/config/__init__.py +9 -0
  5. benchlab/config/config_client.py +631 -0
  6. benchlab/config/config_manager.py +495 -0
  7. benchlab/config/config_tool.py +358 -0
  8. benchlab/config/diff.py +163 -0
  9. benchlab/config/examples/complete_config.json +56 -0
  10. benchlab/config/examples/rgb_lighting.json +24 -0
  11. benchlab/config/examples/simple_fan.json +32 -0
  12. benchlab/config/schema.py +138 -0
  13. benchlab/core/README.md +154 -0
  14. benchlab/core/__init__.py +78 -0
  15. benchlab/core/config.py +145 -0
  16. benchlab/core/datasource.py +1209 -0
  17. benchlab/core/datasource_manager.py +418 -0
  18. benchlab/core/device_registry.py +206 -0
  19. benchlab/core/discovery.py +92 -0
  20. benchlab/core/infrastructure.py +309 -0
  21. benchlab/core/process_manager.py +356 -0
  22. benchlab/core/retry.py +85 -0
  23. benchlab/core/shared_serial.py +235 -0
  24. benchlab/core/statistics.py +224 -0
  25. benchlab/csv_log/README.md +206 -0
  26. benchlab/csv_log/__init__.py +5 -0
  27. benchlab/csv_log/csv_logger_enhanced.py +416 -0
  28. benchlab/csv_log/message_batcher.py +361 -0
  29. benchlab/csv_log/requirements.txt +3 -0
  30. benchlab/csv_log/smart_retry.py +466 -0
  31. benchlab/graph/README.md +117 -0
  32. benchlab/graph/__init__.py +5 -0
  33. benchlab/graph/app.py +353 -0
  34. benchlab/graph/requirements.txt +2 -0
  35. benchlab/graph/runner.py +42 -0
  36. benchlab/graph/ui.py +288 -0
  37. benchlab/hwinfo/README.md +135 -0
  38. benchlab/hwinfo/__init__.py +5 -0
  39. benchlab/hwinfo/hwinfo_export.py +386 -0
  40. benchlab/hwinfo/requirements.txt +1 -0
  41. benchlab/launcher.py +387 -0
  42. benchlab/link/.env.EXAMPLE +10 -0
  43. benchlab/link/README.md +172 -0
  44. benchlab/link/__init__.py +1 -0
  45. benchlab/link/link_main.py +479 -0
  46. benchlab/main.py +462 -0
  47. benchlab/menu.py +729 -0
  48. benchlab/menu_classic.py +410 -0
  49. benchlab/mqtt/README.md +201 -0
  50. benchlab/mqtt/__init__.py +5 -0
  51. benchlab/mqtt/mqtt.config_template +2 -0
  52. benchlab/mqtt/mqtt_publisher.py +540 -0
  53. benchlab/mqtt/requirements.txt +3 -0
  54. benchlab/prefs.py +67 -0
  55. benchlab/restapi/.env.example +17 -0
  56. benchlab/restapi/__init__.py +5 -0
  57. benchlab/restapi/favicon.ico +0 -0
  58. benchlab/restapi/readme.md +202 -0
  59. benchlab/restapi/requirements.txt +5 -0
  60. benchlab/restapi/telemetry_api.py +808 -0
  61. benchlab/sources.py +515 -0
  62. benchlab/tools.py +147 -0
  63. benchlab/tui/README.md +266 -0
  64. benchlab/tui/__init__.py +5 -0
  65. benchlab/tui/config.py +477 -0
  66. benchlab/tui/requirements.txt +2 -0
  67. benchlab/tui/tui_core.py +1298 -0
  68. benchlab/tui/tui_main.py +418 -0
  69. benchlab/vu/README.md +64 -0
  70. benchlab/vu/VU-Server/NOTICE.md +13 -0
  71. benchlab/vu/VU-Server/README.md +80 -0
  72. benchlab/vu/VU-Server/Running_from_source_on_linux.md +97 -0
  73. benchlab/vu/VU-Server/__init__.py +0 -0
  74. benchlab/vu/VU-Server/assets/vu1_hello_world.png +0 -0
  75. benchlab/vu/VU-Server/community_applications.md +13 -0
  76. benchlab/vu/VU-Server/config.yaml +9 -0
  77. benchlab/vu/VU-Server/database.py +256 -0
  78. benchlab/vu/VU-Server/dial_driver.py +534 -0
  79. benchlab/vu/VU-Server/dials/Comms_Hub_Gauge.py +78 -0
  80. benchlab/vu/VU-Server/dials/Comms_Hub_Server.py +100 -0
  81. benchlab/vu/VU-Server/dials/__init__.py +0 -0
  82. benchlab/vu/VU-Server/dials/base_logger.py +100 -0
  83. benchlab/vu/VU-Server/installer/inc/icon.ico +0 -0
  84. benchlab/vu/VU-Server/installer/install.nsi +148 -0
  85. benchlab/vu/VU-Server/make_version.py +40 -0
  86. benchlab/vu/VU-Server/pylintrc +628 -0
  87. benchlab/vu/VU-Server/requirements.txt +9 -0
  88. benchlab/vu/VU-Server/serial_driver.py +244 -0
  89. benchlab/vu/VU-Server/server.py +670 -0
  90. benchlab/vu/VU-Server/server_config.py +255 -0
  91. benchlab/vu/VU-Server/server_dial_handler.py +334 -0
  92. benchlab/vu/VU-Server/vu_notifications.py +24 -0
  93. benchlab/vu/VU-Server/www/android-chrome-192x192.png +0 -0
  94. benchlab/vu/VU-Server/www/android-chrome-512x512.png +0 -0
  95. benchlab/vu/VU-Server/www/apple-touch-icon.png +0 -0
  96. benchlab/vu/VU-Server/www/assets/css/demo.css +291 -0
  97. benchlab/vu/VU-Server/www/assets/css/demo.min.css +9 -0
  98. benchlab/vu/VU-Server/www/assets/css/demo.rtl.css +276 -0
  99. benchlab/vu/VU-Server/www/assets/css/demo.rtl.min.css +9 -0
  100. benchlab/vu/VU-Server/www/assets/css/tabler-flags.css +1084 -0
  101. benchlab/vu/VU-Server/www/assets/css/tabler-flags.min.css +9 -0
  102. benchlab/vu/VU-Server/www/assets/css/tabler-flags.rtl.css +1084 -0
  103. benchlab/vu/VU-Server/www/assets/css/tabler-flags.rtl.min.css +9 -0
  104. benchlab/vu/VU-Server/www/assets/css/tabler-payments.css +528 -0
  105. benchlab/vu/VU-Server/www/assets/css/tabler-payments.min.css +9 -0
  106. benchlab/vu/VU-Server/www/assets/css/tabler-payments.rtl.css +528 -0
  107. benchlab/vu/VU-Server/www/assets/css/tabler-payments.rtl.min.css +9 -0
  108. benchlab/vu/VU-Server/www/assets/css/tabler-social.css +10 -0
  109. benchlab/vu/VU-Server/www/assets/css/tabler-social.min.css +8 -0
  110. benchlab/vu/VU-Server/www/assets/css/tabler-social.rtl.css +10 -0
  111. benchlab/vu/VU-Server/www/assets/css/tabler-social.rtl.min.css +8 -0
  112. benchlab/vu/VU-Server/www/assets/css/tabler-vendors.css +1504 -0
  113. benchlab/vu/VU-Server/www/assets/css/tabler-vendors.min.css +9 -0
  114. benchlab/vu/VU-Server/www/assets/css/tabler-vendors.rtl.css +1504 -0
  115. benchlab/vu/VU-Server/www/assets/css/tabler-vendors.rtl.min.css +9 -0
  116. benchlab/vu/VU-Server/www/assets/css/tabler.css +25813 -0
  117. benchlab/vu/VU-Server/www/assets/css/tabler.min.css +13 -0
  118. benchlab/vu/VU-Server/www/assets/css/tabler.rtl.css +25777 -0
  119. benchlab/vu/VU-Server/www/assets/css/tabler.rtl.min.css +13 -0
  120. benchlab/vu/VU-Server/www/assets/css/theme.min.css +261 -0
  121. benchlab/vu/VU-Server/www/assets/img/header.png +0 -0
  122. benchlab/vu/VU-Server/www/assets/img/logo.png +0 -0
  123. benchlab/vu/VU-Server/www/assets/js/dark-theme.js +35 -0
  124. benchlab/vu/VU-Server/www/assets/js/dark-theme.min.js +9 -0
  125. benchlab/vu/VU-Server/www/assets/js/demo.js +132 -0
  126. benchlab/vu/VU-Server/www/assets/js/demo.min.js +9 -0
  127. benchlab/vu/VU-Server/www/assets/js/jquery.min.js +2 -0
  128. benchlab/vu/VU-Server/www/assets/js/tabler.esm.js +7482 -0
  129. benchlab/vu/VU-Server/www/assets/js/tabler.esm.min.js +15 -0
  130. benchlab/vu/VU-Server/www/assets/js/tabler.js +7543 -0
  131. benchlab/vu/VU-Server/www/assets/js/tabler.min.js +15 -0
  132. benchlab/vu/VU-Server/www/assets/js/vu1_api.js +68 -0
  133. benchlab/vu/VU-Server/www/assets/js/vu1_gui_api_key_settings.js +167 -0
  134. benchlab/vu/VU-Server/www/assets/js/vu1_gui_api_keys.js +138 -0
  135. benchlab/vu/VU-Server/www/assets/js/vu1_gui_dial.js +179 -0
  136. benchlab/vu/VU-Server/www/assets/js/vu1_gui_home.js +60 -0
  137. benchlab/vu/VU-Server/www/assets/js/vu1_gui_root.js +125 -0
  138. benchlab/vu/VU-Server/www/favicon-16x16.png +0 -0
  139. benchlab/vu/VU-Server/www/favicon-32x32.png +0 -0
  140. benchlab/vu/VU-Server/www/favicon.ico +0 -0
  141. benchlab/vu/VU-Server/www/favicon.png +0 -0
  142. benchlab/vu/VU-Server/www/index.html +47 -0
  143. benchlab/vu/VU-Server/www/views/api_keys.html +151 -0
  144. benchlab/vu/VU-Server/www/views/dial.html +286 -0
  145. benchlab/vu/VU-Server/www/views/footer.html +27 -0
  146. benchlab/vu/VU-Server/www/views/header.html +86 -0
  147. benchlab/vu/VU-Server/www/views/key_settings.html +132 -0
  148. benchlab/vu/VU-Server/www/views/start.html +124 -0
  149. benchlab/vu/__init__.py +5 -0
  150. benchlab/vu/assets/Barlow-Bold.ttf +0 -0
  151. benchlab/vu/assets/bl_dial_144x200.png +0 -0
  152. benchlab/vu/assets/bl_logo_144x200.png +0 -0
  153. benchlab/vu/devices.py +166 -0
  154. benchlab/vu/requirements.txt +24 -0
  155. benchlab/vu/sensors.py +18 -0
  156. benchlab/vu/vu_dial.config_template +90 -0
  157. benchlab/vu/vu_logo_gen.py +97 -0
  158. benchlab/vu/vu_server.config_template +5 -0
  159. benchlab/vu/vu_server_config.py +40 -0
  160. benchlab/vu/vu_server_manager.py +192 -0
  161. benchlab/vu/vu_tui.py +919 -0
  162. benchlab/vu/vu_updater.py +540 -0
  163. benchlab/wigidash/README.md +182 -0
  164. benchlab/wigidash/__init__.py +5 -0
  165. benchlab/wigidash/assets/Inter-Bold.ttf +0 -0
  166. benchlab/wigidash/assets/Inter.ttf +0 -0
  167. benchlab/wigidash/assets/benchlab.png +0 -0
  168. benchlab/wigidash/benchlab_fleet.py +150 -0
  169. benchlab/wigidash/benchlab_graph.py +517 -0
  170. benchlab/wigidash/benchlab_overview.py +426 -0
  171. benchlab/wigidash/benchlab_telemetry.py +105 -0
  172. benchlab/wigidash/benchlab_ui.py +190 -0
  173. benchlab/wigidash/benchlab_utils.py +182 -0
  174. benchlab/wigidash/requirements.txt +9 -0
  175. benchlab/wigidash/wigidash_device.py +541 -0
  176. benchlab/wigidash/wigidash_manager.py +342 -0
  177. benchlab/wigidash/wigidash_session.py +355 -0
  178. benchlab/wigidash/wigidash_usb.py +247 -0
  179. benchlab/wigidash/wigidash_widget.py +88 -0
  180. benchlab_pytools-3.0.0.dist-info/METADATA +239 -0
  181. benchlab_pytools-3.0.0.dist-info/RECORD +184 -0
  182. benchlab_pytools-3.0.0.dist-info/WHEEL +4 -0
  183. benchlab_pytools-3.0.0.dist-info/entry_points.txt +2 -0
  184. benchlab_pytools-3.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,138 @@
1
+ """
2
+ JSON Schema and Validation for BENCHLAB Device Configuration
3
+
4
+ Defines Pydantic models for type-safe configuration handling.
5
+ """
6
+
7
+ from typing import List, Optional, Any, Dict, Literal
8
+ from pydantic import BaseModel, Field, field_validator
9
+
10
+
11
+ class DeviceSelector(BaseModel):
12
+ """Device selection criteria."""
13
+ type: Literal["guid", "productId", "pipeName", "port", "any"] = Field(
14
+ description="How to identify the device"
15
+ )
16
+ value: Optional[Any] = Field(
17
+ default=None,
18
+ description="Selector value (null for 'any')"
19
+ )
20
+
21
+
22
+ class FanConfig(BaseModel):
23
+ """Fan configuration for a single fan channel."""
24
+ fanId: int = Field(ge=0, le=8, description="Fan ID (0-8)")
25
+ FanMode: int = Field(ge=0, le=2, description="0=Auto, 1=Fixed, 2=Manual")
26
+ TempSource: int = Field(ge=0, le=5, description="Temperature sensor index")
27
+ Temp: List[int] = Field(
28
+ default=[300, 600],
29
+ min_length=2,
30
+ max_length=2,
31
+ description="Temperature curve points (0.1°C units)"
32
+ )
33
+ Duty: List[int] = Field(
34
+ default=[30, 80],
35
+ min_length=2,
36
+ max_length=2,
37
+ description="Duty cycle at each temp point (0-100%)"
38
+ )
39
+ RampStep: int = Field(
40
+ ge=0,
41
+ le=255,
42
+ default=5,
43
+ description="Rate of change")
44
+ FixedDuty: int = Field(
45
+ ge=0,
46
+ le=100,
47
+ default=50,
48
+ description="Fixed duty cycle")
49
+ MinDuty: int = Field(ge=0, le=100, default=20, description="Minimum duty")
50
+ MaxDuty: int = Field(ge=0, le=100, default=100, description="Maximum duty")
51
+ FanStop: int = Field(
52
+ ge=0,
53
+ le=1,
54
+ default=0,
55
+ description="0=disabled, 1=enabled")
56
+
57
+ @field_validator('Duty')
58
+ @classmethod
59
+ def validate_duty(cls, v):
60
+ for duty in v:
61
+ if not 0 <= duty <= 100:
62
+ raise ValueError(f"Duty values must be 0-100, got {duty}")
63
+ return v
64
+
65
+
66
+ class FanProfile(BaseModel):
67
+ """Fan profile containing multiple fan configurations."""
68
+ profileId: int = Field(ge=0, le=2, description="Profile ID (0-2)")
69
+ fans: List[FanConfig] = Field(description="Fan configurations")
70
+
71
+
72
+ class RGBConfig(BaseModel):
73
+ """RGB profile configuration."""
74
+ profileId: int = Field(ge=0, le=1, description="Profile ID (0-1)")
75
+ Mode: int = Field(ge=0, le=9, description="RGB mode")
76
+ Red: int = Field(ge=0, le=255, description="Red component")
77
+ Green: int = Field(ge=0, le=255, description="Green component")
78
+ Blue: int = Field(ge=0, le=255, description="Blue component")
79
+ Direction: int = Field(ge=0, le=1, description="Animation direction")
80
+ Speed: int = Field(ge=0, le=100, description="Animation speed")
81
+
82
+
83
+ class DeviceConfig(BaseModel):
84
+ """Configuration for a single device."""
85
+ selector: DeviceSelector = Field(description="Device selection criteria")
86
+ deviceName: Optional[str] = Field(
87
+ default=None,
88
+ max_length=31,
89
+ description="Device friendly name"
90
+ )
91
+ fanProfiles: Optional[List[FanProfile]] = Field(
92
+ default=None,
93
+ description="Fan profile configurations"
94
+ )
95
+ rgbProfiles: Optional[List[RGBConfig]] = Field(
96
+ default=None,
97
+ description="RGB profile configurations"
98
+ )
99
+ calibration: Optional[Dict[str, Any]] = Field(
100
+ default=None,
101
+ description="Calibration data"
102
+ )
103
+ saveToFlash: bool = Field(
104
+ default=False,
105
+ description="Save config to flash after applying"
106
+ )
107
+
108
+
109
+ class ConfigFile(BaseModel):
110
+ """Root configuration file schema."""
111
+ version: str = Field(default="1.0", description="Schema version")
112
+ description: str = Field(
113
+ default="BENCHLAB Configuration",
114
+ description="Human-readable description"
115
+ )
116
+ devices: List[DeviceConfig] = Field(description="Device configurations")
117
+
118
+ @field_validator('devices')
119
+ @classmethod
120
+ def validate_devices(cls, v):
121
+ if not v:
122
+ raise ValueError("Configuration must contain at least one device")
123
+ return v
124
+
125
+
126
+ def validate_config_file(config_dict: dict) -> ConfigFile:
127
+ """Validate a configuration dictionary against the schema.
128
+
129
+ Args:
130
+ config_dict: Dictionary loaded from JSON
131
+
132
+ Returns:
133
+ Validated ConfigFile object
134
+
135
+ Raises:
136
+ ValidationError: If validation fails
137
+ """
138
+ return ConfigFile(**config_dict)
@@ -0,0 +1,154 @@
1
+ # BENCHLAB Core - Shared Internals
2
+
3
+ `benchlab/core/` is the internal library used by every BENCHLAB PyTools tool (TUI, FastAPI server, MQTT publisher, Link, HWiNFO export, VU dials, WigiDash, config tool). It provides the data-source abstraction that lets multiple tools consume telemetry without fighting over a serial port, plus supporting infrastructure: device lifecycle tracking, subprocess management, retry helpers, and statistics.
4
+
5
+ This is a developer-facing internals doc. For CLI usage of individual tools, see their own READMEs (`benchlab/restapi/readme.md`, `benchlab/mqtt/README.md`, `benchlab/config/README.md`, `benchlab/link/README.md`).
6
+
7
+ ## Architecture Overview
8
+
9
+ ### The Problem
10
+
11
+ Only one process can own a serial port at a time. If every tool connected directly via `benchlab_pycore`, only one tool could ever run at once.
12
+
13
+ ### The Solution
14
+
15
+ A **DataSource abstraction layer** (`datasource.py`) with multiple implementations, all exposing the same interface. Tools pick a source type via `--source` and don't need to know how the data actually gets to them.
16
+
17
+ ## Components
18
+
19
+ ### 1. DataSource classes (`datasource.py`)
20
+
21
+ `DataSource` is an abstract base class (`connect`, `disconnect`, `is_connected`, `list_devices`, `get_telemetry(uid)`, `get_device_info(uid)`, `source_type`). Concrete implementations:
22
+
23
+ | Class | `source_type` | Description |
24
+ |---|---|---|
25
+ | `DirectDataSource` | `direct` | Connects directly to the serial port via `benchlab_pycore`. Runs its own background polling thread. Exclusive port access — only one tool. |
26
+ | `FastAPIDataSource` | `fastapi` / `fastapi_custom` | HTTP client for the `benchlab.restapi` server. Polls `/health`, `/devices`, `/device/{uid}/telemetry`, `/device/{uid}/info` via `requests`. |
27
+ | `MQTTDataSource` | `mqtt` / `mqtt_custom` | Subscribes to `<topic_prefix>/+/telemetry` and `<topic_prefix>/+/info` on an MQTT broker via `paho-mqtt`. Resolves `MQTT_PROTOCOL` (v3.1 / v3.1.1 / v5) via the module-level `resolve_mqtt_protocol()` helper. |
28
+ | `NamedPipeDataSource` | `named_pipe` | Windows-only. Talks to the C# BENCHLAB Windows service over named pipes (`BenchlabDiscovery` for device listing, per-device `BenchlabSensorPipe_XX_YYY` pipes for telemetry). Normalizes the C# service's `ShortName`-keyed sensor payloads into the same flat key names Python tools expect, via the module-level `_normalise_cs_telemetry()` helper. |
29
+ | `ServiceHttpDataSource` | `service_http` | REST client for the C# BENCHLAB service's HTTP API (default `http://localhost:8585`). Thin client only — does not start/manage the service. Same telemetry normalization as `NamedPipeDataSource`. |
30
+
31
+ Each implementation validates its constructor kwargs through a Pydantic model in `config.py` (`SerialConfig`, `FastAPIConfig`, `MQTTConfig`, `NamedPipeConfig`, `ServiceHttpConfig`).
32
+
33
+ The `create_datasource(source_type, **kwargs)` factory function builds the right class:
34
+
35
+ ```python
36
+ from benchlab.core import create_datasource
37
+
38
+ datasource = create_datasource('fastapi', base_url='http://127.0.0.1:8000')
39
+ datasource = create_datasource('direct', port='COM3')
40
+ datasource = create_datasource('mqtt', broker='localhost', port=1883)
41
+
42
+ datasource.connect()
43
+ devices = datasource.list_devices()
44
+ telemetry = datasource.get_telemetry(uid)
45
+ device_info = datasource.get_device_info(uid)
46
+ datasource.disconnect()
47
+ ```
48
+
49
+ ### 2. DataSourceManager (`datasource_manager.py`)
50
+
51
+ Wraps any `DataSource` with a consistent, higher-level API used by tools (TUI, Link, etc.): connection management, a background polling thread, device selection, and a single `snapshot()` call for UI/consumer code.
52
+
53
+ ```python
54
+ from benchlab.core.datasource_manager import DataSourceManager
55
+
56
+ mgr = DataSourceManager(source_type='fastapi', base_url='http://127.0.0.1:8000')
57
+ mgr.connect()
58
+ mgr.select_device(uid)
59
+ snap = mgr.snapshot()
60
+ # snap: connected, source_type, source_desc, port, uid, device_info,
61
+ # sensor_data, connection_time, last_error, all_devices, all_telemetry
62
+ ```
63
+
64
+ Also accepts an optional `stats_callback(uid, channel, value)` invoked on every changed numeric telemetry value, typically wired to a `ChannelStats` instance (see below).
65
+
66
+ ### 3. DeviceRegistry (`device_registry.py`)
67
+
68
+ Thread-safe singleton (`DeviceRegistry.get_instance()`) that is the single source of truth for "which devices exist right now." Exactly one component per process should own registration — e.g. the FastAPI server registers/unregisters devices as its reader threads start and stop. Other tools only observe via `get_devices()`, `get_device(uid)`, `has_device(uid)`, or subscribe to `on_connect(callback)` / `on_disconnect(callback)` events. `update_telemetry(uid)` stamps a device's `last_telemetry` time.
69
+
70
+ ### 4. ProcessManager (`process_manager.py`)
71
+
72
+ Thread-safe singleton (`ProcessManager.get_instance()`) for starting/stopping infrastructure subprocesses (e.g. a spawned FastAPI server or MQTT publisher process) with health checks:
73
+
74
+ ```python
75
+ pm = ProcessManager.get_instance()
76
+ pm.start_service(
77
+ name="fastapi",
78
+ cmd=["python", "-m", "benchlab", "-fastapi"],
79
+ health_check=lambda: ping_http("http://127.0.0.1:8000/health"),
80
+ timeout=20,
81
+ )
82
+ pm.stop_service("fastapi")
83
+ pm.shutdown_all()
84
+ ```
85
+
86
+ Redirects each service's stdout/stderr to `logs/svc_<name>_stdout.log` / `_stderr.log`. Stopping tries graceful termination (SIGTERM / `taskkill`) first, then force-kills after a timeout.
87
+
88
+ ### 5. InfrastructureManager (`infrastructure.py`)
89
+
90
+ Higher-level, non-singleton orchestrator used by the multi-tool launcher path: starts a FastAPI server as a `uvicorn` subprocess (`start_fastapi`) or an MQTT publisher as a background thread (`start_mqtt_publisher`), detecting if a compatible service is already listening on the target port before starting a new one. `start_all(tools)` / `stop_all()` take a list of tool configs and start only what's needed. Usable as a context manager.
91
+
92
+ ### 6. Discovery (`discovery.py`)
93
+
94
+ `discover_devices()` — the canonical way to enumerate connected BENCHLAB devices over direct serial. Uses `benchlab_pycore.core.get_benchlab_ports()` to find candidate ports, opens each, reads UID/firmware/product ID, and returns `{"uid", "port", "fw", "variant"}` dicts (`variant` is `"ORIGINAL"` or `"BL2"`). Wrapped in the `retry` decorator (3 attempts, exponential backoff).
95
+
96
+ ### 7. Retry (`retry.py`)
97
+
98
+ `RetryPolicy` (dataclass: `max_retries`, `backoff_factor`, `base_delay`, `allowed_exceptions`) plus a `retry(policy)` decorator that retries a wrapped callable with exponential backoff, logging each attempt. Used throughout `datasource.py` and `discovery.py` for connection attempts.
99
+
100
+ ### 8. Shared serial (`shared_serial.py`)
101
+
102
+ `open_serial_connection(port)` — the one place that actually calls `serial.Serial(port, 115200, timeout=1)`; returns `None` instead of raising on failure. All direct-serial code paths (`DirectDataSource`, `discovery.py`, `telemetry_api.py`) use this instead of talking to `pyserial` directly, so the "None on failure" contract is consistent everywhere.
103
+
104
+ Also provides `SharedSerialManager`, a singleton connection pool keyed by port with reference counting (`acquire_connection` / `release_connection`), so multiple in-process consumers can share one open port instead of each opening their own handle. `SharedConnection` is the context-manager wrapper returned by `acquire_connection`.
105
+
106
+ ### 9. Statistics (`statistics.py`)
107
+
108
+ `ChannelStats` — thread-safe per-device, per-channel running min/max/average tracker (`update`, `get`, `get_all`, `reset`, `get_devices`, `get_channels`, `has_data`). `StatsFormatter` provides display helpers (`format_stat_string`, `format_compact_range`). `create_stats_callback(stats)` builds a `(uid, channel, value) -> None` callback suitable for `DataSourceManager(stats_callback=...)`.
109
+
110
+ ### 10. Config models (`config.py`)
111
+
112
+ Pydantic models validating each DataSource's constructor kwargs: `SerialConfig`, `FastAPIConfig` (normalizes `base_url` to include a scheme and strips trailing slash), `MQTTConfig`, `NamedPipeConfig`, `ServiceHttpConfig`.
113
+
114
+ ## Package exports (`benchlab/core/__init__.py`)
115
+
116
+ ```python
117
+ from benchlab.core import (
118
+ DataSource, DirectDataSource, FastAPIDataSource, MQTTDataSource, create_datasource,
119
+ DataSourceManager,
120
+ ChannelStats, StatsFormatter, create_stats_callback,
121
+ DeviceRegistry, DeviceInfo,
122
+ ProcessManager, ManagedProcess,
123
+ BENCHLAB_ORIGINAL_PRODUCT_ID, BENCHLAB_BL2_PRODUCT_ID,
124
+ )
125
+ ```
126
+
127
+ `NamedPipeDataSource` and `ServiceHttpDataSource` live in `datasource.py` but aren't re-exported from `__init__.py`; import them directly from `benchlab.core.datasource` if needed. `BENCHLAB_ORIGINAL_PRODUCT_ID` / `BENCHLAB_BL2_PRODUCT_ID` are re-exported from `benchlab_pycore` (with a fallback to hardcoded `0x10`/`0x11` if pycore isn't installed) so tools can detect device variant without importing pycore directly.
128
+
129
+ ## How tools use this
130
+
131
+ - **`benchlab/main.py`** resolves `--source` into environment variables via `_setup_source_from_args()` and calls `check_and_setup_source()` (in `benchlab/sources.py`) to start/verify the chosen source before launching a tool.
132
+ - **`benchlab/restapi/telemetry_api.py`** is a *producer*: it owns the serial port directly, registers devices in `DeviceRegistry`, and serves `DirectDataSource`-equivalent data to `FastAPIDataSource` clients.
133
+ - **`benchlab/mqtt/mqtt_publisher.py`** is also a producer: reads sensors directly and publishes to an MQTT broker for `MQTTDataSource` clients.
134
+ - **`benchlab/link/link_main.py`** is a consumer: uses `DataSourceManager` against any local source (direct/fastapi/mqtt) and republishes telemetry to a remote/cloud MQTT broker.
135
+ - **`benchlab/tui/`**, **VU dials**, **WigiDash**, **HWiNFO export**, **CSV logger** are consumers via `DataSourceManager` or `create_datasource` directly.
136
+ - The **config tool** (`benchlab/config/`) does not use the DataSource abstraction — it has its own direct/named-pipe client layer (`config_client.py`) for read/write configuration operations, since DataSource is read-only telemetry.
137
+
138
+ ## Data Source Comparison
139
+
140
+ | Source | Use case | Pros | Cons |
141
+ |---|---|---|---|
142
+ | `direct` | Single tool, lowest latency | No overhead, no extra process | Exclusive port access, only one tool |
143
+ | `fastapi` / `fastapi_custom` | Multiple tools, remote access | HTTP-based, firewall-friendly, multi-client | Small latency overhead, requires a running server |
144
+ | `mqtt` / `mqtt_custom` | Distributed/IoT integration | Pub/sub, works with existing MQTT infra | Requires a broker, more moving parts |
145
+ | `named_pipe` | Windows, alongside the C# BENCHLAB service | Multiple tools, no direct serial management | Windows only, requires the service + pywin32 |
146
+ | `service_http` | Windows, alongside the C# BENCHLAB service | HTTP-based, multi-client | Windows-oriented, requires the service running |
147
+
148
+ ## See Also
149
+
150
+ - [BENCHLAB PyTools Documentation](../../README.md) - Main PyTools documentation
151
+ - [FastAPI Telemetry Server README](../restapi/readme.md)
152
+ - [MQTT Publisher README](../mqtt/README.md)
153
+ - [Config Tool README](../config/README.md)
154
+ - [Link README](../link/README.md)
@@ -0,0 +1,78 @@
1
+ """
2
+ BenchLab Core - Infrastructure layer
3
+
4
+ Provides:
5
+ - DataSource abstraction for telemetry consumption (direct / FastAPI / MQTT)
6
+ - DataSourceManager for unified datasource management with statistics
7
+ - DeviceRegistry singleton for device lifecycle tracking
8
+ - ProcessManager singleton for infrastructure service management
9
+ - ChannelStats for thread-safe telemetry statistics tracking
10
+ - InfrastructureManager for higher-level orchestration
11
+
12
+ PyCore v0.3.0 Multi-Variant Support:
13
+ - ORIGINAL (0x10): 11 power sensors, 4 temperature sensors
14
+ - BL2 / BENCHLAB 2 (0x11): 23 power sensors, 8 temperature sensors
15
+ """
16
+
17
+ import logging
18
+
19
+ from benchlab.core.datasource import (
20
+ DataSource,
21
+ DirectDataSource,
22
+ FastAPIDataSource,
23
+ MQTTDataSource,
24
+ create_datasource,
25
+ )
26
+ from benchlab.core.datasource_manager import DataSourceManager
27
+ from benchlab.core.device_registry import DeviceRegistry, DeviceInfo
28
+ from benchlab.core.process_manager import ProcessManager, ManagedProcess
29
+ from benchlab.core.statistics import (
30
+ ChannelStats,
31
+ StatsFormatter,
32
+ create_stats_callback,
33
+ )
34
+
35
+ logger = logging.getLogger("benchlab.core")
36
+
37
+ # PyCore variant constants for tools that need to detect device capabilities
38
+ # Import from pycore to make them easily accessible to tools.
39
+ # PyCore >=0.4.1 renamed BENCHLAB_CFE_PRODUCT_ID to BENCHLAB_BL2_PRODUCT_ID
40
+ # (the "CFE" SKU is now marketed as BENCHLAB 2); fall back to the old name
41
+ # for older pycore installs.
42
+ try:
43
+ from benchlab_pycore.core import BENCHLAB_ORIGINAL_PRODUCT_ID
44
+ try:
45
+ from benchlab_pycore.core import BENCHLAB_BL2_PRODUCT_ID
46
+ except ImportError:
47
+ from benchlab_pycore.core import (
48
+ BENCHLAB_CFE_PRODUCT_ID as BENCHLAB_BL2_PRODUCT_ID)
49
+ except ImportError:
50
+ logger.warning(
51
+ "benchlab_pycore not installed; using fallback product ID constants")
52
+ BENCHLAB_ORIGINAL_PRODUCT_ID = 0x10
53
+ BENCHLAB_BL2_PRODUCT_ID = 0x11
54
+
55
+ __version__ = "2.0.0"
56
+
57
+ __all__ = [
58
+ # DataSource layer
59
+ "DataSource",
60
+ "DirectDataSource",
61
+ "FastAPIDataSource",
62
+ "MQTTDataSource",
63
+ "create_datasource",
64
+ # Unified datasource management
65
+ "DataSourceManager",
66
+ # Statistics tracking
67
+ "ChannelStats",
68
+ "StatsFormatter",
69
+ "create_stats_callback",
70
+ # Device and process management
71
+ "DeviceRegistry",
72
+ "DeviceInfo",
73
+ "ProcessManager",
74
+ "ManagedProcess",
75
+ # PyCore v0.3.0 variant constants
76
+ "BENCHLAB_ORIGINAL_PRODUCT_ID",
77
+ "BENCHLAB_BL2_PRODUCT_ID",
78
+ ]
@@ -0,0 +1,145 @@
1
+ """Configuration models for Benchlab data sources.
2
+
3
+ This module defines Pydantic ``BaseModel`` classes that validate the
4
+ configuration parameters required by each concrete ``DataSource``
5
+ implementation. Using a strict schema helps catch mis-configurations
6
+ early (e.g. missing serial port, invalid MQTT broker address) and makes
7
+ the library easier to use from external tools.
8
+ """
9
+
10
+ from __future__ import annotations
11
+ from typing import Optional
12
+ from pydantic import BaseModel, Field, field_validator
13
+
14
+
15
+ class SerialConfig(BaseModel):
16
+ """Configuration for :class:`DirectDataSource`.
17
+
18
+ Attributes
19
+ ----------
20
+ port: Optional[str]
21
+ The serial port to connect to. ``None`` triggers auto-detection.
22
+ poll_interval: float
23
+ Seconds between successive sensor reads. Must be positive.
24
+ """
25
+
26
+ port: Optional[str] = None
27
+ poll_interval: float = Field(
28
+ default=1.0,
29
+ gt=0,
30
+ description="Polling interval in seconds")
31
+
32
+
33
+ class FastAPIConfig(BaseModel):
34
+ """Configuration for :class:`FastAPIDataSource`.
35
+
36
+ Attributes
37
+ ----------
38
+ base_url: str
39
+ Base URL of the FastAPI server (e.g. ``http://127.0.0.1:8000``).
40
+ timeout: float
41
+ HTTP request timeout in seconds. Must be positive.
42
+ """
43
+
44
+ base_url: str = Field(..., description="Base URL of the FastAPI server")
45
+ timeout: float = Field(
46
+ default=5.0,
47
+ gt=0,
48
+ description="Request timeout in seconds")
49
+
50
+ @field_validator("base_url")
51
+ @classmethod
52
+ def _validate_base_url(cls, v: str) -> str:
53
+ """Ensure base_url has a protocol prefix and no trailing slash."""
54
+ v = v.strip()
55
+ if not v.startswith(('http://', 'https://')):
56
+ v = f"http://{v}"
57
+ return v.rstrip('/')
58
+
59
+
60
+ class MQTTConfig(BaseModel):
61
+ """Configuration for :class:`MQTTDataSource`.
62
+
63
+ Attributes
64
+ ----------
65
+ broker: str
66
+ Hostname of the MQTT broker.
67
+ port: int
68
+ Network port of the broker (default 1883).
69
+ topic_prefix: str
70
+ Prefix used for all Benchlab topics.
71
+ timeout: float
72
+ Connection timeout in seconds.
73
+ """
74
+
75
+ broker: str = Field(
76
+ default="localhost",
77
+ description="MQTT broker hostname")
78
+ port: int = Field(default=1883, ge=1, le=65535, description="Broker port")
79
+ topic_prefix: str = Field(
80
+ default="benchlab",
81
+ description="Base topic prefix")
82
+ timeout: float = Field(
83
+ default=5.0,
84
+ gt=0,
85
+ description="Connection timeout in seconds")
86
+
87
+
88
+ class NamedPipeConfig(BaseModel):
89
+ """Configuration for :class:`NamedPipeDataSource`.
90
+
91
+ Windows-only. Connects to the C# BenchLab Windows service via
92
+ named pipes (BenchlabDiscovery + per-device BenchlabSensorPipe_XX_YYY).
93
+
94
+ Attributes
95
+ ----------
96
+ timeout: float
97
+ Seconds to wait for a pipe connection before giving up.
98
+ poll_interval: float
99
+ Seconds between successive sensor reads.
100
+ """
101
+
102
+ timeout: float = Field(
103
+ default=5.0,
104
+ gt=0,
105
+ description="Pipe connection timeout in seconds")
106
+ poll_interval: float = Field(
107
+ default=1.0,
108
+ gt=0,
109
+ description="Polling interval in seconds")
110
+
111
+
112
+ class ServiceHttpConfig(BaseModel):
113
+ """Configuration for :class:`ServiceHttpDataSource`.
114
+
115
+ Connects to the C# BenchLab service REST HTTP API.
116
+ The service is auto-detected at port 8585 by default.
117
+
118
+ Attributes
119
+ ----------
120
+ base_url: str
121
+ Base URL of the C# BenchLab service
122
+ (e.g. ``http://localhost:8585``).
123
+ timeout: float
124
+ HTTP request timeout in seconds. Must be positive.
125
+ poll_interval: float
126
+ Seconds between successive telemetry polls.
127
+ """
128
+
129
+ base_url: str = Field(
130
+ default="http://localhost:8585",
131
+ description="Base URL of the C# BenchLab service HTTP API",
132
+ )
133
+ timeout: float = Field(
134
+ default=5.0,
135
+ gt=0,
136
+ description="Request timeout in seconds")
137
+ poll_interval: float = Field(
138
+ default=1.0,
139
+ gt=0,
140
+ description="Polling interval in seconds")
141
+
142
+ @field_validator("base_url")
143
+ @classmethod
144
+ def _strip_trailing_slash(cls, v: str) -> str:
145
+ return v.rstrip('/')