spiderwire 0.1.0a1__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.
@@ -0,0 +1 @@
1
+ """SpiderFarmer GSS peripheral controller over USB-RS485."""
@@ -0,0 +1,481 @@
1
+ """CLI entry point for ``gss-ctrl``.
2
+
3
+ Stand-in for the OEM SpiderFarmer GSS hub on the RS-485 bus: tiered
4
+ polling plus setpoint heartbeat broadcasts so peripherals stay out of
5
+ their master-missing fail-safe state. Shares ``spiderwire`` with the
6
+ Home Assistant integration — same bus logic, different surface.
7
+
8
+ Usage
9
+ -----
10
+ gss-ctrl /dev/ttyUSB0 scan
11
+ gss-ctrl /dev/ttyUSB0 poll [--fast 0x03,0x0A] [--interval 1.0]
12
+ [--actuator-interval 2.5] [--scan-interval 7.0]
13
+ [--heartbeat 3.5]
14
+ gss-ctrl /dev/ttyUSB0 read <addr>[,addr...] [qty]
15
+ gss-ctrl /dev/ttyUSB0 write <addr> <reg> <value>
16
+ gss-ctrl /dev/ttyUSB0 fan <addr> <speed> # 0-25
17
+ gss-ctrl /dev/ttyUSB0 light <percent> # 0-100
18
+ gss-ctrl /dev/ttyUSB0 blower [<addr>] <percent> # 0-100, addr default 0x06
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import logging
25
+ import sys
26
+ import time
27
+
28
+ from spiderwire.bus import (
29
+ DEFAULT_ACTUATOR_INTERVAL,
30
+ DEFAULT_FAST_INTERVAL,
31
+ DEFAULT_HEARTBEAT_INTERVAL,
32
+ DEFAULT_SCAN_INTERVAL,
33
+ BusMaster,
34
+ )
35
+ from spiderwire.protocol import ModbusTimeoutError
36
+ from spiderwire.registers import (
37
+ BlowerData,
38
+ CO2SensorData,
39
+ FanControllerData,
40
+ SensorHubData,
41
+ )
42
+ from spiderwire.transport import RS485Transport
43
+
44
+ log = logging.getLogger(__name__)
45
+
46
+ # Match BusMaster.scan's default inter_poll: USB-RS485 adapters need a
47
+ # beat between slave transactions or later reads often time out.
48
+ _READ_ADDR_GAP_S = 0.05
49
+
50
+ # Default addresses for the light command. The "Light 1" fixture lives
51
+ # on the sensor hub (0x0A, reg 18 enable), brightness on the dimmer at
52
+ # 0x04 reg 10 (blind FC06 — the dimmer never echoes).
53
+ DEFAULT_HUB_ADDR = 0x0A
54
+ DEFAULT_DIMMER_ADDR = 0x04
55
+ HUB_LIGHT_ENABLE_REG = 18
56
+ DIMMER_ENABLE_REG = 16
57
+ DIMMER_BRIGHTNESS_REG = 10
58
+
59
+
60
+ # The OEM ships the same 24-reg SKU as either a fan or a light dimmer,
61
+ # and the same 16-reg SKU as either a light or a blower. The device's
62
+ # self-reported `type_major` byte therefore lies for 0x04 and 0x06 on
63
+ # this rig (see docs/device-map.md). Label by data class — that's
64
+ # already the wiring-aware role we resolved when parsing.
65
+ _ROLE_BY_TYPE = {
66
+ FanControllerData: "light",
67
+ BlowerData: "blower",
68
+ # 0x03 self-IDs as type=FAN but is the CO₂ sensor — see
69
+ # docs/protocol-analysis.md "Tier A — Fast sensors".
70
+ CO2SensorData: "co2_sensor",
71
+ }
72
+
73
+
74
+ def _role_label(data) -> str:
75
+ return _ROLE_BY_TYPE.get(type(data), data.header.type_name)
76
+
77
+
78
+ def _freshness_line(addr: int, bus: BusMaster | None) -> str | None:
79
+ """Diagnostic line: how recently we polled the device, plus tally of
80
+ ok / timeout / crc replies. Lets the user spot a slave that's silently
81
+ falling behind (rising `to=`) vs. one with bus-integrity issues
82
+ (rising `crc=`)."""
83
+ if bus is None:
84
+ return None
85
+ last = bus.last_seen.get(addr)
86
+ stats = bus.poll_stats.get(addr) or {}
87
+ if last is None and not stats:
88
+ return None
89
+ age = f"{time.monotonic() - last:.1f}s ago" if last is not None else "never"
90
+ return (
91
+ f" polled {age} "
92
+ f"(ok={stats.get('ok', 0)} to={stats.get('timeout', 0)} "
93
+ f"crc={stats.get('crc', 0)})"
94
+ )
95
+
96
+
97
+ def _format_device(addr: int, data, bus: BusMaster | None = None) -> str:
98
+ lines = [
99
+ f" [{addr:#04x}] {_role_label(data)} "
100
+ f"model={data.header.model_code:#06x} "
101
+ f"fw={data.header.fw_version} hw={data.header.hw_version:#06x}"
102
+ ]
103
+
104
+ if isinstance(data, SensorHubData):
105
+ soil = f"{data.soil_temp_c:.1f}°C" if data.soil_temp_c is not None else "--"
106
+ lines.append(
107
+ f" temp={data.air_temp_c:.1f}°C rh={data.air_humidity_pct:.1f}% "
108
+ f"vpd={data.vpd_kpa:.2f}kPa soil={soil}"
109
+ )
110
+ lines.append(
111
+ f" light={'ON' if data.light_enabled else 'OFF'} "
112
+ f"val={data.light_value} zone={data.zone}"
113
+ )
114
+ elif isinstance(data, CO2SensorData):
115
+ lines.append(f" co2={data.co2_ppm} ppm")
116
+ elif isinstance(data, FanControllerData):
117
+ lines.append(
118
+ f" brightness={data.value}/100 "
119
+ f"enabled={'ON' if data.enabled else 'OFF'}"
120
+ )
121
+ elif isinstance(data, BlowerData):
122
+ lines.append(
123
+ f" setpoint={data.percent}% "
124
+ f"{'RUNNING' if data.running else 'idle'}"
125
+ )
126
+
127
+ fresh = _freshness_line(addr, bus)
128
+ if fresh is not None:
129
+ lines.append(fresh)
130
+
131
+ return "\n".join(lines)
132
+
133
+
134
+ def _print_devices(devices: dict, bus: BusMaster | None = None) -> None:
135
+ if not devices:
136
+ print(" (no devices responding)")
137
+ return
138
+ for addr in sorted(devices):
139
+ print(_format_device(addr, devices[addr], bus))
140
+
141
+
142
+ def _parse_addr_range(spec: str) -> list[int]:
143
+ """Parse ``START-END`` (inclusive, hex or dec) into a list of addresses."""
144
+ if "-" not in spec:
145
+ raise ValueError(f"Range must be START-END, got {spec!r}")
146
+ start_s, end_s = spec.split("-", 1)
147
+ start, end = int(start_s, 0), int(end_s, 0)
148
+ if not 0 <= start <= end <= 0xFF:
149
+ raise ValueError(f"Range {spec!r} outside 0x00-0xFF or inverted")
150
+ return list(range(start, end + 1))
151
+
152
+
153
+ def cmd_scan(bus: BusMaster, args: argparse.Namespace) -> None:
154
+ addrs = _parse_addr_range(args.range) if args.range else None
155
+ if addrs is not None:
156
+ # Skip broadcast addr 0x00 — reads to it are meaningless and some
157
+ # adapters treat it specially.
158
+ addrs = [a for a in addrs if a != 0]
159
+ print(
160
+ f"Scanning bus (range {args.range}, {len(addrs)} addrs, "
161
+ f"heartbeat {args.heartbeat}s)..."
162
+ )
163
+ else:
164
+ print(f"Scanning bus (heartbeat {args.heartbeat}s)...")
165
+ devices = bus.scan(
166
+ addrs=addrs,
167
+ inter_poll=args.scan_gap,
168
+ heartbeat_interval=args.heartbeat,
169
+ )
170
+ print(f"\n{len(devices)} device(s) found:\n")
171
+ _print_devices(devices, bus)
172
+
173
+
174
+ def cmd_poll(bus: BusMaster, args: argparse.Namespace) -> None:
175
+ if args.fast:
176
+ bus.fast_addrs = [int(a, 0) for a in args.fast.split(",")]
177
+ bus.actuator_interval = args.actuator_interval
178
+ bus.scan_interval = args.scan_interval
179
+ bus.heartbeat_interval = args.heartbeat
180
+
181
+ hb = "off" if args.heartbeat <= 0 else f"{args.heartbeat}s"
182
+ print(
183
+ f"Master mode (tiered, matches OEM GSS hub):\n"
184
+ f" fast {bus.fast_addrs} every {args.interval}s\n"
185
+ f" actuator {bus.actuator_addrs} every {args.actuator_interval}s\n"
186
+ f" scan {len(bus.scan_addrs)} silent slots every {args.scan_interval}s\n"
187
+ f" heartbeat broadcast {hb}\n"
188
+ f"(Ctrl+C to stop)\n"
189
+ )
190
+
191
+ def on_cycle(devices):
192
+ sys.stdout.write("\033[2J\033[H")
193
+ print(f"--- GSS Bus ({len(devices)} online) ---\n")
194
+ _print_devices(devices, bus)
195
+ sys.stdout.flush()
196
+
197
+ bus.poll_loop(interval=args.interval, callback=on_cycle)
198
+
199
+
200
+ def cmd_read(bus: BusMaster, args: argparse.Namespace) -> None:
201
+ addrs = [int(a.strip(), 0) for a in args.device_addr.split(",") if a.strip()]
202
+ qty = int(args.qty) if args.qty else 16
203
+ for j, addr in enumerate(addrs):
204
+ if j:
205
+ time.sleep(_READ_ADDR_GAP_S)
206
+ print()
207
+ try:
208
+ resp = bus.transport.read_holding_registers(addr, start_reg=0, qty=qty)
209
+ except ModbusTimeoutError:
210
+ print(f"Device {addr:#04x}: no response (timeout)")
211
+ continue
212
+ print(f"Device {addr:#04x} ({qty} registers):")
213
+ for i, v in enumerate(resp.registers):
214
+ print(f" reg[{i:2d}] = {v:5d} (0x{v:04X})")
215
+
216
+
217
+ def cmd_write(bus: BusMaster, args: argparse.Namespace) -> None:
218
+ addr = int(args.device_addr, 0)
219
+ reg = int(args.reg, 0)
220
+ value = int(args.value, 0)
221
+ resp = bus.transport.write_register(addr, reg, value)
222
+ print(f"OK: dev={resp.addr:#04x} reg={resp.reg} value={resp.value}")
223
+
224
+
225
+ def cmd_fan(bus: BusMaster, args: argparse.Namespace) -> None:
226
+ addr = int(args.device_addr, 0)
227
+ speed = int(args.speed)
228
+ bus.set_fan_speed(addr, speed)
229
+ print(f"Fan {addr:#04x} speed → {speed}")
230
+
231
+
232
+ _WAKE_ATTEMPTS = 4
233
+ _WAKE_GAP_S = 0.1
234
+
235
+ # Settle window between back-to-back blind FC06s to the dimmer at 0x04.
236
+ # `POST_BLIND_WRITE_QUIET` (5 ms) covers tail TX + adapter quiet, but the
237
+ # dimmer itself needs longer to latch reg 16 before reg 10 takes effect —
238
+ # without this the second write silently no-ops and the light stays at 0
239
+ # after a previous turn-off (observed manually).
240
+ _DIMMER_LATCH_SETTLE_S = 0.05
241
+
242
+
243
+ def _broadcast_wake(bus: BusMaster, count: int = 3) -> None:
244
+ """Broadcast a few heartbeats to silence cold-boot beacons.
245
+
246
+ For all-blind-write commands like `cmd_light` we can't probe the
247
+ target's FC06 echo, so we rely on a short broadcast burst to switch
248
+ slaves out of beacon mode (see `docs/protocol-analysis.md`
249
+ "Cold-Boot Behaviour"). 3 broadcasts spaced by `_WAKE_GAP_S`
250
+ reliably win against the hub's ~1.5 s beacon cadence.
251
+ """
252
+ for _ in range(count):
253
+ try:
254
+ bus.broadcast_setpoints()
255
+ except Exception:
256
+ log.exception("Wake broadcast failed")
257
+ time.sleep(_WAKE_GAP_S)
258
+
259
+
260
+ def _wake_bus(bus: BusMaster, target_addr: int) -> None:
261
+ """Pull `target_addr` out of cold-boot beacon mode.
262
+
263
+ With no master on the bus, slaves emit unsolicited FC03-format
264
+ beacons every ~1–3 s instead of listening (see
265
+ `docs/protocol-analysis.md` "Cold-Boot Behaviour"). A single
266
+ broadcast usually silences them, but our frame race-loses against
267
+ any in-flight beacon, so we loop: broadcast → poll target → bail
268
+ out as soon as the target answers cleanly.
269
+ """
270
+ for attempt in range(1, _WAKE_ATTEMPTS + 1):
271
+ try:
272
+ bus.broadcast_setpoints()
273
+ except Exception:
274
+ log.exception("Wake broadcast failed (attempt %d)", attempt)
275
+ if bus.poll_device(target_addr) is not None:
276
+ return
277
+ time.sleep(_WAKE_GAP_S)
278
+ log.warning(
279
+ "Slave 0x%02X never answered after %d wake attempts; "
280
+ "proceeding with the write anyway",
281
+ target_addr, _WAKE_ATTEMPTS,
282
+ )
283
+
284
+
285
+ def _retry_on_timeout(action, label: str, attempts: int = 3) -> None:
286
+ """Run `action()`, retrying on `ModbusTimeoutError`. Re-raises the
287
+ last timeout if all attempts fail."""
288
+ last: ModbusTimeoutError | None = None
289
+ for attempt in range(1, attempts + 1):
290
+ try:
291
+ action()
292
+ return
293
+ except ModbusTimeoutError as e:
294
+ last = e
295
+ log.warning("%s timed out (attempt %d/%d)", label, attempt, attempts)
296
+ time.sleep(_WAKE_GAP_S)
297
+ assert last is not None
298
+ raise last
299
+
300
+
301
+ def cmd_blower(bus: BusMaster, args: argparse.Namespace) -> None:
302
+ """Set blower / ventilation % (FC06 → reg 14 on 0x06).
303
+
304
+ The OEM app labels this "Light 2" but the hardware is the blower.
305
+ """
306
+ addr = int(args.device_addr, 0)
307
+ pct = int(args.percent)
308
+ _wake_bus(bus, addr)
309
+ _retry_on_timeout(lambda: bus.set_blower(addr, pct), f"blower {addr:#04x}")
310
+ print(f"Blower {addr:#04x} → {pct}%")
311
+
312
+
313
+ def cmd_light(bus: BusMaster, args: argparse.Namespace) -> None:
314
+ """Set Light 1 brightness 0-100 % (hub enable + dimmer brightness).
315
+
316
+ Both the hub at ``0x0A`` and the dimmer at ``0x04`` accept FC06 but
317
+ **neither echoes** on this firmware, so all writes are fired blind
318
+ and the next poll surfaces the new state. To keep the writes from
319
+ landing on a slave that's still in cold-boot beacon mode, we
320
+ broadcast a few heartbeats first.
321
+
322
+ Sequence on turn-on: dimmer enable → settle → dimmer brightness →
323
+ hub gate. Hub is opened *last* so the dimmer is already at the
324
+ right setpoint when light gating goes on (otherwise the light
325
+ flashes the previous brightness for a frame).
326
+ """
327
+ hub_addr = int(args.hub, 0)
328
+ dimmer_addr = int(args.dimmer, 0)
329
+ pct = int(args.percent)
330
+ if not 0 <= pct <= 100:
331
+ raise SystemExit(f"percent must be 0-100 (got {pct})")
332
+
333
+ _broadcast_wake(bus)
334
+
335
+ if pct == 0:
336
+ bus.transport.write_register(
337
+ hub_addr, HUB_LIGHT_ENABLE_REG, 0, wait_for_response=False
338
+ )
339
+ bus.transport.write_register(
340
+ dimmer_addr, DIMMER_BRIGHTNESS_REG, 0, wait_for_response=False
341
+ )
342
+ print(f"Light off (hub {hub_addr:#04x} reg 18 ← 0, dimmer {dimmer_addr:#04x} reg 10 ← 0)")
343
+ return
344
+
345
+ bus.transport.write_register(
346
+ dimmer_addr, DIMMER_ENABLE_REG, 1, wait_for_response=False
347
+ )
348
+ time.sleep(_DIMMER_LATCH_SETTLE_S)
349
+ bus.transport.write_register(
350
+ dimmer_addr, DIMMER_BRIGHTNESS_REG, pct, wait_for_response=False
351
+ )
352
+ bus.transport.write_register(
353
+ hub_addr, HUB_LIGHT_ENABLE_REG, 1, wait_for_response=False
354
+ )
355
+ print(f"Light → {pct}% (hub {hub_addr:#04x} enable=1, dimmer {dimmer_addr:#04x} reg 10={pct})")
356
+
357
+
358
+ def build_parser() -> argparse.ArgumentParser:
359
+ p = argparse.ArgumentParser(
360
+ prog="gss-ctrl",
361
+ description="SpiderFarmer GSS peripheral controller",
362
+ )
363
+ p.add_argument("port", help="Serial port (e.g. /dev/ttyUSB0)")
364
+ p.add_argument("-b", "--baud", type=int, default=115200)
365
+ p.add_argument(
366
+ "-t", "--timeout", type=float, default=0.3,
367
+ help="Per-request serial timeout in seconds (default 0.3)",
368
+ )
369
+ p.add_argument("-v", "--verbose", action="store_true")
370
+
371
+ sub = p.add_subparsers(dest="command", required=True)
372
+
373
+ scan_p = sub.add_parser("scan", help="Scan bus for devices")
374
+ scan_p.add_argument(
375
+ "--scan-gap", type=float, default=0.05,
376
+ help="Seconds between address polls (default 0.05)",
377
+ )
378
+ scan_p.add_argument(
379
+ "--range",
380
+ help="Address range START-END (inclusive, hex ok), e.g. 0x00-0xff. "
381
+ "Default: OEM-polled addresses only.",
382
+ )
383
+ scan_p.add_argument(
384
+ "--heartbeat", type=float, default=2.0,
385
+ help="Seconds between setpoint broadcasts during scan; 0 disables "
386
+ "(default 2.0 — matches the OEM hub)",
387
+ )
388
+
389
+ poll_p = sub.add_parser(
390
+ "poll", help="Act as the GSS master: tiered polling + heartbeat broadcast",
391
+ )
392
+ poll_p.add_argument(
393
+ "--fast",
394
+ help="Comma-separated hex addresses polled every cycle "
395
+ "(default: 0x03,0x0A — the OEM's primary sensors)",
396
+ )
397
+ poll_p.add_argument(
398
+ "--interval", type=float, default=DEFAULT_FAST_INTERVAL,
399
+ help=f"Seconds between fast-tier cycles (default {DEFAULT_FAST_INTERVAL})",
400
+ )
401
+ poll_p.add_argument(
402
+ "--actuator-interval", type=float, default=DEFAULT_ACTUATOR_INTERVAL,
403
+ help=f"Seconds between actuator-tier polls (default {DEFAULT_ACTUATOR_INTERVAL})",
404
+ )
405
+ poll_p.add_argument(
406
+ "--scan-interval", type=float, default=DEFAULT_SCAN_INTERVAL,
407
+ help=f"Seconds between silent-slot discovery scans (default {DEFAULT_SCAN_INTERVAL})",
408
+ )
409
+ poll_p.add_argument(
410
+ "--heartbeat", type=float, default=DEFAULT_HEARTBEAT_INTERVAL,
411
+ help=f"Seconds between setpoint broadcasts; 0 to disable "
412
+ f"(default {DEFAULT_HEARTBEAT_INTERVAL})",
413
+ )
414
+
415
+ read_p = sub.add_parser("read", help="Read registers from one or more devices")
416
+ read_p.add_argument(
417
+ "device_addr",
418
+ help="Device address(es), comma-separated hex (e.g. 0x0A or 0x03,0x0A)",
419
+ )
420
+ read_p.add_argument("qty", nargs="?", help="Number of registers (default 16)")
421
+
422
+ write_p = sub.add_parser("write", help="Write a single register (FC06)")
423
+ write_p.add_argument("device_addr")
424
+ write_p.add_argument("reg", help="Register number")
425
+ write_p.add_argument("value", help="Value to write")
426
+
427
+ fan_p = sub.add_parser("fan", help="Set fan speed (0-25)")
428
+ fan_p.add_argument("device_addr")
429
+ fan_p.add_argument("speed", help="Speed value 0-25")
430
+
431
+ blower_p = sub.add_parser(
432
+ "blower",
433
+ help="Set ventilation blower %% (FC06 → reg 14; default addr 0x06)",
434
+ )
435
+ blower_p.add_argument("device_addr", nargs="?", default="0x06")
436
+ blower_p.add_argument("percent", help="Percent value 0-100")
437
+
438
+ light_p = sub.add_parser(
439
+ "light",
440
+ help="Set Light 1 brightness 0-100 %% (hub enable + dimmer brightness)",
441
+ )
442
+ light_p.add_argument("percent", help="Brightness 0-100 (0 = off)")
443
+ light_p.add_argument(
444
+ "--hub", default=hex(DEFAULT_HUB_ADDR),
445
+ help=f"Sensor hub address (default {hex(DEFAULT_HUB_ADDR)})",
446
+ )
447
+ light_p.add_argument(
448
+ "--dimmer", default=hex(DEFAULT_DIMMER_ADDR),
449
+ help=f"Dimmer address (default {hex(DEFAULT_DIMMER_ADDR)})",
450
+ )
451
+
452
+ return p
453
+
454
+
455
+ def main() -> None:
456
+ parser = build_parser()
457
+ args = parser.parse_args()
458
+
459
+ logging.basicConfig(
460
+ level=logging.DEBUG if args.verbose else logging.INFO,
461
+ format="%(asctime)s %(name)-20s %(levelname)-5s %(message)s",
462
+ datefmt="%H:%M:%S",
463
+ )
464
+
465
+ handlers = {
466
+ "scan": cmd_scan,
467
+ "poll": cmd_poll,
468
+ "read": cmd_read,
469
+ "write": cmd_write,
470
+ "fan": cmd_fan,
471
+ "blower": cmd_blower,
472
+ "light": cmd_light,
473
+ }
474
+
475
+ with RS485Transport(args.port, baudrate=args.baud, timeout=args.timeout) as transport:
476
+ bus = BusMaster(transport=transport)
477
+ handlers[args.command](bus, args)
478
+
479
+
480
+ if __name__ == "__main__":
481
+ main()
spiderwire/__init__.py ADDED
@@ -0,0 +1,60 @@
1
+ """SpiderFarmer GSS Modbus protocol library.
2
+
3
+ Shared between the ``gss-ctrl`` CLI and the SpiderFarmer Home Assistant
4
+ integration. See ``docs/protocol-analysis.md`` and ``docs/device-map.md``
5
+ for the bus reference.
6
+ """
7
+
8
+ from importlib.metadata import PackageNotFoundError, version
9
+
10
+ from .bus import (
11
+ DEFAULT_ACTUATOR_INTERVAL,
12
+ DEFAULT_FAST_INTERVAL,
13
+ DEFAULT_HEARTBEAT_INTERVAL,
14
+ DEFAULT_SCAN_INTERVAL,
15
+ BusMaster,
16
+ )
17
+ from .protocol import (
18
+ CRCError,
19
+ ExceptionResponse,
20
+ ModbusError,
21
+ ModbusTimeoutError,
22
+ )
23
+ from .registers import (
24
+ BlowerData,
25
+ CO2SensorData,
26
+ DeviceData,
27
+ DeviceHeader,
28
+ DeviceType,
29
+ FanControllerData,
30
+ SensorHubData,
31
+ parse_device_data,
32
+ )
33
+ from .transport import RS485Transport
34
+
35
+ try:
36
+ __version__ = version("spiderwire")
37
+ except PackageNotFoundError:
38
+ __version__ = "0.0.0+unknown"
39
+
40
+ __all__ = [
41
+ "BlowerData",
42
+ "BusMaster",
43
+ "CO2SensorData",
44
+ "CRCError",
45
+ "DEFAULT_ACTUATOR_INTERVAL",
46
+ "DEFAULT_FAST_INTERVAL",
47
+ "DEFAULT_HEARTBEAT_INTERVAL",
48
+ "DEFAULT_SCAN_INTERVAL",
49
+ "DeviceData",
50
+ "DeviceHeader",
51
+ "DeviceType",
52
+ "ExceptionResponse",
53
+ "FanControllerData",
54
+ "ModbusError",
55
+ "ModbusTimeoutError",
56
+ "RS485Transport",
57
+ "SensorHubData",
58
+ "__version__",
59
+ "parse_device_data",
60
+ ]