spiderwire 0.1.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.
- gss_ctrl_pc/__init__.py +1 -0
- gss_ctrl_pc/__main__.py +481 -0
- spiderwire/__init__.py +60 -0
- spiderwire/bus.py +288 -0
- spiderwire/protocol.py +149 -0
- spiderwire/py.typed +0 -0
- spiderwire/registers.py +288 -0
- spiderwire/transport.py +234 -0
- spiderwire-0.1.0.dist-info/METADATA +184 -0
- spiderwire-0.1.0.dist-info/RECORD +13 -0
- spiderwire-0.1.0.dist-info/WHEEL +4 -0
- spiderwire-0.1.0.dist-info/entry_points.txt +2 -0
- spiderwire-0.1.0.dist-info/licenses/LICENSE +21 -0
gss_ctrl_pc/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""SpiderFarmer GSS peripheral controller over USB-RS485."""
|
gss_ctrl_pc/__main__.py
ADDED
|
@@ -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
|
+
]
|