diff --git a/CHANGELOG.md b/CHANGELOG.md index b31133a..2402969 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,287 @@ ## [Unreleased] +### Fixed + +- **The APDU head is a length, not an opcode.** Byte 5 of a GENI frame is + `0booLLLLLL`: an operation (GET/SET/INFO) or an acknowledgement in the + top two bits, and the payload's byte count in the low six. There is no + "OpSpec". `byte5 == len(frame) - 8` held for every reply measured + against the pump. + + Two mistakes followed from reading it as an opcode. The set `{0x30, + 0x2B, 0x14, 0x2E, 0x2D, 0x09}`, carried as "register-read operation + specifiers" and used to select a payload offset, is really the payload + lengths 48, 43, 20, 46, 45 and 9 — it worked because 48, 43 and 20 are + exactly the three telemetry replies, and mis-sliced anything else that + size. And `0x81` was read as an acknowledgement carrying an error code: + it is Unknown Data Item with one payload byte, and that byte names the + item the pump did not recognise, so a refused write read as accepted + whenever the item was `0x00` — the case this pump produces. + +- **A response carries no Object ID and no Sub-ID.** Bytes 6-9 are + `[00][TypeH][TypeL][Version]`, the type of the object answered. Matching + therefore discriminates types, not instances: Object 86 sub-ids 13, 15, + 17 and 39 all answer `00 01 2d 01`, and alarms and warnings answer + identically to each other. + + The rule that accepted the two fields in either order is gone — they are + one type field, every measured reply matches in wire order, and + accepting a transpose let unrelated objects answer each other's reads. + +- **Telemetry routed on the address it asked for.** The decoder's table, + the frame parser's telemetry set and the stream-detection flags all + compared against `(87, 69)`, `(93, 290)` and `(93, 300)`. No reply + carries those, so every case fell through to a raw-frame fallback, and + `_has_motor_state_stream` could never be set by a notification — so the + polling it exists to suppress ran whether or not the pump was streaming. + +- **An out-of-range setpoint is no longer refused before the wire.** This + pump does not reject a setpoint it dislikes - it takes it and clamps it, + and reports what it stored - so the write now goes out and settles + `clamped` with the pump's value. Measured: 4000 RPM stores 3671, 600 RPM + stores 1650, against a published range of 1650-3671. + + The published range becomes the explanation rather than the gate. It is + quoted in the settle detail when a clamp happens, and only when it came + from the pump rather than from a fallback constant. + + There is a second reason the client must not pre-refuse, and it is not + recoverable from the range: **with a flow limiter enabled there is no + maximum speed.** The pump accepts the setpoint and manages actual speed + to hold the flow bound, and where it settles is a property of the + installation's hydraulics - one reported loop delivered 1885 RPM for a + 3000 RPM request. Any check that looked authoritative would be wrong in + a way the client cannot detect. Raised by @jfriend00 on + esphome-alpha-hwr #276. + + A value that is not a number is still refused: there is nothing for the + pump to clamp to, and the all-ones float doubles as the `SETPOINT_KEEP` + sentinel, so a NaN would read as "leave the setpoint alone". + +- **One time base, in `alpha_hwr.pump_time`.** The pump keeps local wall + clock and has no notion of UTC: its `DateTimeActual` carries a + `dst_status` that reads `SummerTime`, its `DaylightSavingTime` object is + enabled with the US rule and a 60-minute offset, and **no timezone or + UTC-offset field exists anywhere in its GENI profile**. So every + timestamp it stores is in its clock's base, which is local. + + `set_clock` and the single-event encoding were already right. The event + log and the trend history were not: they decoded with + `datetime.fromtimestamp(ts, tz=UTC)`, which yields the correct digits + attached to the wrong instant - calling `.astimezone()` on one shifted it + by the local offset. Those surfaces now return naive datetimes carrying + the pump's wall clock, like the rest. + + This is an interoperability rule rather than a preference: the GO app, + the ESPHome component and this library all write the same clock, and the + pump cannot say which base a value arrived in. Two clients disagreeing + sets the clock wrong by the local offset and misfires every stored + schedule. + +- **Every reason a frame is thrown away is now counted** + (`transport.frame_drops`): bad CRC, an abandoned partial, bytes that + start no frame, an impossible declared length, a reassembly overflow, + and a full response queue. They are separate counters because they mean + different things - a bad CRC is a corrupted link, a runt length is a + peer talking nonsense, and unsolicited fragments usually mean sync was + lost rather than that the radio is bad. A dropped frame is the system + working; what was missing was any way to know it had happened. + +- **Inbound CRC is now enforced.** It was computed and never read: + `validate_frame_integrity()` was its only consumer and had no call site, + so every write verdict was decided by reading unverified bytes back. + Frames are trimmed to their declared length first, since the completion + test is `>=` and trailing bytes sit outside what the CRC covers. Bad + frames are dropped and counted (`transport.crc_failures`). + +- **Reassembly.** `0x27` was accepted as an inbound start byte; the pump + never sends it. A frame start now begins a new packet only when + reassembly is not already under way — a mid-frame fragment can perfectly + well begin `0x24`, and treating it as a start discarded the frame in + progress. A partial frame is abandoned after a second rather than + wedging the buffer. The declared length is bounded at both ends: there + was no minimum, so a length byte of `0x00` completed a four-byte + "frame" instantly, and the maximum was 256 against a real 257. A second + frame arriving in the same notification is now delivered instead of + being swallowed into the first one's payload. + +- **Every device-info string was a character short.** The Class 7 header + is six bytes, not seven: byte 5 is the string's byte count and the text + starts at offset 6, with no echoed string ID. Two strings were patched + up afterwards and so looked right — an `"A"` prepended to `LPHA HWR`, + and a `"1"` prepended to a serial reading `0000479`, which was correct + for this unit only by coincidence. The versions had no such patch: + + software 2601618V04.02.01.02539 -> 92601618V04.02.01.02539 + hardware 2601617V01.03.00.00469 -> 92601617V01.03.00.00469 + BLE 2811431V06.00.01.00001 -> 92811431V06.00.01.00001 + + Both rewrites are removed rather than retuned. + +- **Single-event writes declared a payload length they did not carry.** + The APDU head was `0xB3` - SET with 51 bytes - borrowed from the schedule + layer write, whose 53-byte APDU really does carry 51. A single event + carries 19, so the head is `0x93`. Every one of the 29 single-event + writes in the capture corpus uses `0x93`; the 8 layer writes use `0xB3`. + The pump accepts either, so nothing was visibly failing, but a firmware + that checked the field would have refused ours with no diagnostic. + +- **A single-event write never checked what the pump kept.** It now reads + the slot back and compares the window, the enabled flag and - the point + of the exercise - the ACTION byte. ACTION is half the meaning of a + single event: `0x01` holds the pump off across the window, which is what + a vacation *is*, and `0x02` runs it once. A confirm without it would + settle a vacation as written while the pump was scheduled to run. + +- **`clear_vacation()` ignored the clock.** It cleared the first enabled + Stop event in slot order, so a finished vacation in an early slot + shadowed a live one later: the call reported success and the pump stayed + off. It now prefers the vacation that is running, then the next one due, + and says so when it falls back to an expired one. `find_free_slot()` one + method up had always been clocked; the asymmetry was the bug. + +- **A wholly-past window is refused**, rather than spending one of five + slots on an event that can never run. A window already *underway* is + still accepted - starting part-way through is legitimate, so only the + end is compared. + +- **Slot bounds are checked in two stages, in that order.** The protocol + envelope first and without touching the pump - sub-id is `900 + slot` + and the schedule layers start at 1000, so slot 100 addresses layer 0 + whatever the pump is doing. The pump's own count second, from the + overview. Deferring the first check made an impossible slot on a broken + link report "the overview could not be read", blaming the link for an + argument that could never have been right. + +- **Single-event timestamps are bounded to what the wire can hold** + (uint32, 1970 to 2106). `build_apdu` previously raised `OverflowError` + from inside a `try` that caught only read errors, so it escaped + uncaught. + +- **A read chain cut short by a disconnect reported itself as success.** + `get_all_entries()` skipped entries it could not read - which is right, + since a log with twelve entries reports the other eight as unreadable - + and a dropped link went down the same path. The result was a short list + and `Retrieved 5/20`, which is exactly what a five-entry log looks like. + `get_trend_data()` had the same shape: three of its four series are + legitimately `None` on some pumps, so a half-built collection did not + look wrong. + + Both now raise `ConnectionError` and say how far they got, rather than + handing back something indistinguishable from less data. Measured on the + pump: dropping the link 0.35 s into a full event-log read now raises + *"disconnected while reading the event log after 4 of 20 entries"* + instead of returning four entries. + +- **A waiter sat out its own timeout after the link had gone.** Nothing + woke a pending read when the BLE link dropped, so each one waited its + full three seconds for a pump that was no longer there. `read_response` + now races the reply against the disconnect, and a dropped link is + reported as such rather than as a timeout - 0.1 s instead of 3.0 s in + the unit test that pins it. + +- **`alpha_hwr.exceptions.ConnectionError` now subclasses the builtin.** + The package shadows the builtin name, and which one a module raised came + down to whether that file happened to import this one - `base.py` and + `client.py` raised the package's, `session.py` and `time.py` the + builtin - so no single `except` clause caught both. It now inherits from + both, which is what anyone writing `except ConnectionError` expects. + +- **A GENI frame must be split into 20-byte GATT writes.** The transport + has always chunked at `BLE_MTU_LIMIT = 20`, and it turns out that is a + pump requirement rather than a guess about the radio: with the ATT MTU + negotiated at 65, a 27-byte frame sent in one `write_gatt_char` is + ignored outright, while the identical bytes chunked at 20 are + acknowledged in 111 ms. Documented, and pinned by tests, so it does not + get "optimised" away. + +- **The dedicated Class 10 setpoint write was refused, always.** It + addressed sub-id first where every Class 10 SET this pump accepts is + object first, so it named object `0x00` and the pump answered Unknown + Data Item quoting `0x00` back — on every setpoint write since the method + existed, invisibly, because the send was fire-and-forget and the retry + helper reports success even on a timeout. It is deleted: the fused + Object 86 Sub 6 request already carries the setpoint, which is how the + Grundfos GO app sets one. + +### Added + +- **Setpoint bounds read from the pump** (`read_setpoint_ranges()`, + `get_setpoint_range()`). The pump publishes them in the type 301 + objects at Object 86 sub 13, 15, 17 and 39. Every constant this client + validated against was wrong in both directions: + + | mode | pump | was | + |---|---|---| + | constant speed | 1650 – 3671 RPM | 500 – 4500 | + | constant pressure | 1.000 – 2.450 m | 0.5 – 10.0 | + | proportional pressure | 2.599 – 4.569 m | 0.5 – 10.0 | + | constant flow | 0.114 – 2.498 m³/h | 0.1 – 10.0 | + + Proportional pressure was the worst: a 0.5 m floor against a real 2.6 m, + in a range that does not overlap constant pressure's — and the two + shared one constant. The read runs once per connection during cache + sync, sequentially, and stops at the first failure, because all four + objects answer with the same type code and carrying on would shift every + remaining range by one slot. The old constants remain as a deliberately + wide fallback: refusing a setpoint the pump would have taken is worse + than letting it clamp one it dislikes. + +- **`read_limiters()` and `alpha-hwr control limiters`.** An enabled flow + limiter caps delivered flow whatever the setpoint says, and nothing in + the setpoint range reveals it — so a setpoint can settle accepted, read + back correct, and still not be delivered. + +### Removed + +- **The authentication handshake.** Ten packets went out on every + connection as a three-stage "unlock". All four distinct packets are + reads — two GETs and two INFO queries — and their replies were + discarded. A read cannot change device state. With none of them sent, a + bare connect-and-subscribe link answers every read this client makes. + The 750 ms of inter-stage delays went too; they were transcribed from + this client's own `sleep()` calls and then documented as pump timing + requirements. `authenticate()` keeps its name and now only waits for the + radio to settle. + +- **`set_flow_limit()` and `alpha-hwr control set-flow-limit`**, along + with the `--flow-limit` options on `set-speed` and `set-temperature`. + They wrote Object 86 Sub 39 — the constant-flow *setpoint range*, not a + limiter — through the refused frame above. The real limiters are at + Object 86 Sub 600 (MaxFlow) and Sub 601 (MinFlow); `read_limiters()` + reads them. The write is not reimplemented, because enabling a limiter + silently caps the pump and that is not a change to make as a side effect + of a protocol sync. + +### Tests + +- **Doctests are run, and green.** 185 of 279 examples in the source were + failing. Seven were genuinely wrong — `encode_float_be(1.5)` claimed + `b'\x3f\xc0\x00\x00'` where Python prints `?`, a three-byte register + read's frame length was given as 9 rather than 11, `build_command_info` + claimed an address with a stray digit and a trailing ellipsis, and four + `Session` examples referred to objects nobody had built. The rest were + never executable — `await` at the top level, or a client that does not + exist — and now carry `# doctest: +SKIP`, which says what they are. + `tests/test_doctests.py` runs the remainder with a floor on the count, + so the failure mode cannot return as "skip everything". + +### Documentation + +- **The clock write's frame layout is described correctly.** It is Object + 94 **Sub 100**, type **321 version 2**; the constant carrying its first + six bytes was named `_TYPE_322_HEADER`, and 322 is the type the *read* + of Sub 101 answers with. Those six bytes are not an opaque header either + - they are the tail of the address, the object's size field and the + struct's leading byte. Verified against the frame the builder emits. + +- `docs/protocol/bench_findings.md` records the 2026-08-20 session: the + Class 7 header, the response type table, the setpoint ranges, the + second Class 10 acknowledgement, the limiter survey, the post-SET quiet + period, and how long an Object 91 write takes to become visible. + + ## [0.7.0] - 2026-08-05 ### Added diff --git a/docs/guides/cli_guide.md b/docs/guides/cli_guide.md index 8270b1e..eaad128 100644 --- a/docs/guides/cli_guide.md +++ b/docs/guides/cli_guide.md @@ -263,7 +263,7 @@ Maintains temperature within a specified range (Mode 27): alpha-hwr control set-temperature --min 35 --max 39 # Set custom range with no autoadapt and a flow limit -alpha-hwr control set-temperature --min 40 --max 45 --no-autoadapt --flow-limit 1.5 +alpha-hwr control set-temperature --min 40 --max 45 --no-autoadapt ``` #### Cycle Time Control @@ -284,7 +284,7 @@ Runs at fixed RPM (Mode 2): ```bash # Set to 2500 RPM with 2.3 GPM flow limit -alpha-hwr control set-speed 2500 --flow-limit 2.3 +alpha-hwr control set-speed 2500 ``` #### Constant Pressure @@ -320,7 +320,7 @@ Set a global maximum flow limit to prevent corrosion: ```bash # Set 1.5 GPM limit (recommended for 1/2" pipe) -alpha-hwr control set-flow-limit 1.5 +alpha-hwr control limiters ``` --- @@ -729,7 +729,7 @@ alpha-hwr monitor live | `control set-speed` | Set constant speed mode (Mode 2) | | `control set-flow` | Set constant flow mode (Mode 8) | | `control set-proportional` | Set proportional pressure mode (Mode 1) | -| `control set-flow-limit` | Set maximum flow limit (GPM) | +| `control limiters` | Show the MaxFlow and MinFlow limiters | | `control set-mode ` | Set control mode and setpoint together | ### Device Commands diff --git a/docs/guides/control_modes.md b/docs/guides/control_modes.md index 9f2dfa7..a052b68 100644 --- a/docs/guides/control_modes.md +++ b/docs/guides/control_modes.md @@ -109,7 +109,7 @@ The ALPHA HWR supports 5 primary control modes specifically optimized for hot wa alpha-hwr control set-temperature --min 35 --max 39 --autoadapt # Set range 40-45°C with AUTOADAPT disabled and 1.5 GPM limit -alpha-hwr control set-temperature --min 40 --max 45 --no-autoadapt --flow-limit 1.5 +alpha-hwr control set-temperature --min 40 --max 45 --no-autoadapt ``` ### 2. Cycle Time Control (Mode 25) @@ -137,7 +137,7 @@ alpha-hwr control set-cycle-time --on 5 --off 15 **Example:** ```bash # Set to 2500 RPM with 2.3 GPM limit (3/4" pipe) -alpha-hwr control set-speed 2500 --flow-limit 2.3 +alpha-hwr control set-speed 2500 ``` ### 4. Constant Pressure (Mode 0) @@ -176,7 +176,7 @@ The ALPHA HWR allows setting a maximum flow limit to prevent **flow-accelerated **CLI:** ```bash -alpha-hwr control set-flow-limit 1.5 +alpha-hwr control limiters ``` --- diff --git a/docs/protocol/bench_findings.md b/docs/protocol/bench_findings.md index 7dc0894..085c57c 100644 --- a/docs/protocol/bench_findings.md +++ b/docs/protocol/bench_findings.md @@ -158,3 +158,390 @@ itself while the event opens hours from where it was meant to. `ClockProgramOverview` byte 1 (`max_nof_single_events`) reads `0x05`, and Object 84 Sub 905 does not answer. The slot count should be taken from the overview rather than assumed. + +--- + +# 2026-08-20 session + +Measured against the same ALPHA HWR, reported by its own Class 7 strings as +product `ALPHA HWR`, serial `10000479`, software `92601618V04.02.01.02539`, +hardware `92601617V01.03.00.00469`, BLE `92811431V06.00.01.00001`. Its +advertisement reports family 52, type 7, version 2. + +## The Class 7 header is six bytes, and byte 5 is a byte count + +The reply is `[STX][LEN][DST][SRC][0x07][Count][...STRING...][CRC16]`. The +first character is at offset **6**, and there is no echoed string ID. + + 24 0E F8 E7 07 0A 41 4C 50 48 41 20 48 57 52 00 83 8D + ^^ count = 10 ^^ "ALPHA HWR\0" + +Reading from offset 7 dropped the first character of every string. The two +most-read strings were patched up afterwards and so looked correct — an "A" +prepended to `LPHA HWR`, and a "1" prepended to a serial reading `0000479`. +The second was right for this unit only by coincidence; a serial beginning +`20` would have been corrupted. The version strings had no such patch and +were short. Before and after, on the same pump: + + software 2601618V04.02.01.02539 -> 92601618V04.02.01.02539 + hardware 2601617V01.03.00.00469 -> 92601617V01.03.00.00469 + BLE 2811431V06.00.01.00001 -> 92811431V06.00.01.00001 + +## Class 7 needs no handshake at all + +Five string reads answered on a link that had sent **no** opening packets — +connect, subscribe, read. This is the same conclusion `connection.md` +reached from the captures, now confirmed by not sending them. + +## The type numbers, and a naming trap in this client + +Bytes 6-9 of a reply are `[00][TypeH][TypeL][Version]`, so the type spans +bytes 7-8 and the version is byte 9. Decoded that way, and confirmed +against `geni_profile_52_7.xml`: + +| bytes 6-9 | type / version | profile name | +|---|---|---| +| `00 01 00 03` | 256 v3 | `ProtectedMotorStateDetails` | +| `00 02 35 02` | 565 v2 | `PumpedMediaRelatedProcessValuesExtended` | +| `00 02 16 02` | 534 v2 | `MediaTemperatureInfo` | +| `00 02 3a 01` | 570 v1 | `FaultsByArrayExtended` | +| `00 01 2f 01` | 303 v1 | operation status | +| `00 01 2d 01` | 301 v1 | setpoint factory config | +| `00 00 da 01` | 218 v1 | `ClockProgramOverview` | +| `00 01 42 01` | 322 v1 | `DateTimeActual` | +| `00 03 f4 02` | 1012 v2 | temperature range config | + +**The trap.** This client's `type_high` and `type_low_ver` split those same +four bytes into two 16-bit halves *one byte off* the real boundary - a +convention inherited from the ESPHome port, which kept it deliberately +because comparing both halves is equivalent to comparing type and version +together. That is true, and the matcher relies on it. But the names read as +though they were the type's own halves, and they are not: "type_low_ver +0x2F01" is not a type, it is the second byte of type 303 and its version. + +Quote `object_type` / `object_version` in prose, and leave the pair form to +the matcher. The table above is the vocabulary the vendor's own profile +uses. + +## A response's bytes 6-9 are `[00][TypeH][TypeL][Version]` + +Measured by reading each object and recording the answer: + +| read | reply bytes 6-9 | type | +|---|---|---| +| 86/7 operation status | `00 01 2f 01` | 303 v1 | +| 86/13, 86/15, 86/17, 86/39 | `00 01 2d 01` | 301 v1 — **all four** | +| 84/1 schedule overview | `00 00 da 01` | 218 v1 | +| 94/101 clock | `00 01 42 01` | 322 v1 | +| 91/430 temperature range | `00 03 f4 02` | 1012 v2 | +| motor state | `00 01 00 03` | 3 v1 | +| flow / head | `00 02 35 02` | 0x3502 v2 | +| temperatures | `00 02 16 02` | 0x1602 v2 | +| 88/0 alarms and 88/11 warnings | `00 02 3a 01` | 0x3A01 v2 — **both** | + +Two collisions matter. The four setpoint ranges are indistinguishable in a +reply, so a chain reading them must be sequential and stop at the first +failure. Alarms and warnings are indistinguishable too, so only the caller +that issued the read knows which list came back. + +`byte5 == len(frame) - 8` held for every frame recorded in this session. + +## The pump publishes its own setpoint ranges + +Object 86, type 301 v1, three big-endian floats at offsets 0, 4 and 8 of the +struct: default, minimum, maximum. + +| sub | mode | default | min | max | native | +|---|---|---|---|---|---| +| 13 | constant speed | 2800 | **1650** | **3671** | RPM | +| 15 | constant pressure | 1.632 | **1.000** | **2.450** | Pa ÷ 9806.65 | +| 17 | proportional pressure | 3.649 | **2.599** | **4.569** | Pa ÷ 9806.65 | +| 39 | constant flow | 0.228 | **0.114** | **2.498** | m³/s × 3600 | + +Every one of these contradicts the constants this client validated against +(500–4500 RPM, 0.5–10 m, 0.5–10 m, 0.1–10 m³/h), in both directions and on +every mode. Proportional pressure is the worst: a 0.5 m floor against a real +one of 2.6 m, a range that does not even overlap constant pressure's. + +## A Class 10 reply carries a second acknowledgement + +Confirmed by accident while probing the limiter objects. Reading a sub-id +the pump does not implement returns + + 24 05 F8 E7 0A 01 04 EE 26 + +whose APDU head `0x01` is ack **OK** with one payload byte — and that byte +is `0x04`. That is the Class 10 status `OPERATION_FAILED`, from the +decompiled GO app's `GeniAPDU.CLASS10_ACK_*` (0 OK, 2 BUSY, 4 +OPERATION_FAILED). So the head ack alone is not the verdict: an unimplemented +object answers "understood, and it failed". + +The status byte must only be read at `len >= 9`. In an eight-byte frame +declaring one payload byte, `data[6]` is the CRC's high byte. + +## The limiters: two of them, both disabled (ESPHome issue #274) + +`geni_profile_52_7.xml` describes `limiter_user_config` (type 895, Obj 86 sub +600–619), `limiter_factory_config` (897, 620–639), `limiter_status` (896, +640–659) and `limitation_manager_status` (896, 660). The capture corpus stops +at 86/601 and 86/621, so this could only be settled on hardware. + +Sub-ids 602–619, 622–639 and 642–659 **do not exist**: every one answers +`OPERATION_FAILED`. Only indices 1 and 2 are implemented, and the name enum +at `geni_profile_52_7.xml:1386` gives `MaxFlow = 1`, `MinFlow = 2`. So the +instances are per *limiter*, not per mode. + + user config 895, 18 bytes: [name][enable][limit f32 m³/s][kp][ti][td] + 600 01 00 38c676f1 3f19999a 3fcccccd 3ecccccd MaxFlow disabled, 0.341 m³/h (1.50 gpm) + 601 02 00 3925631d 3f19999a 3fcccccd 3ecccccd MinFlow disabled, 0.567 m³/h (2.50 gpm) + + factory config 897, 9 bytes: [name][lower f32][upper f32] + 620 01 38044f4b 3a35ed8d MaxFlow 0.114 - 2.498 m³/h + 621 02 38844f4b 3a5700d9 MinFlow 0.227 - 2.952 m³/h + + status 896, 6 bytes: [name][limiting][reference f32] + 640 01 00 00000000 MaxFlow not limiting + 641 02 00 00000000 MinFlow not limiting + 660 00 00 00000000 manager not limiting + +MaxFlow's factory bounds are exactly the constant-flow setpoint range read +from 86/39, which is what makes the type-301 range the *factory* range: it +does not account for a limiter that is enabled. On this unit neither is, so +a setpoint here is delivered as written. On a unit with MaxFlow enabled it +would not be, and nothing in the type-301 range would say so. + +## Class 10 SETs *are* acknowledged, in 90-120 ms + +Measured through this client against an ALPHA HWR, capturing every inbound +frame during a temperature-range write: + + Object 86 Sub 10 +119.6 ms 24 05 F8 E7 0A 01 00 AE A2 + Object 91 Sub 430 +119.8 ms 24 05 F8 E7 0A 01 00 AE A2 + Object 84 Sub 1 +89.8 ms 24 05 F8 E7 0A 01 00 AE A2 + +Consistent with the capture corpus, which puts SET latency at 36-193 ms and +nothing anywhere over 295 ms. `SET_ACK_TIMEOUT = 0.4` clears all of it. + +The acknowledgement is not the verdict. This pump clamps values it dislikes +rather than refusing them, so only a readback says what was stored. + +### The measurement that said otherwise, and why it was wrong + +An earlier entry here claimed the opposite - that a Class 10 SET draws no +reply at all, and that the pump then answers nothing for 200-400 ms. Both +came from a raw probe that wrote each GENI frame in a single +`write_gatt_char` call, and **this pump ignores a frame that is not split +into 20-byte GATT writes**, whatever the ATT MTU has been negotiated to. + +The negotiated MTU on this link is 65, so 27 bytes fits comfortably at the +ATT layer. It still does not work: + + Object 84 Sub 1 SET, one 27-byte write -> no reply + the same bytes, chunked at 20 -> acked in 111 ms + Object 84 Sub 1 GET, 11 bytes, one write -> answered in 69 ms + +Reads are 11 bytes and fit, which is why every read in that probe worked +and every write did not. The writes were never arriving, so nothing +answered them, and the "deaf window" was the pump's own reassembly timer +recovering from a truncated frame - the same mechanism as +esphome-alpha-hwr #200, seen from the other side. + +Two lessons worth keeping. A negative result from a hand-rolled probe is +only as good as the probe: check it can do something you know works before +believing what it says is impossible. And `BLE_MTU_LIMIT = 20` in the +transport is a pump requirement, not a guess about the radio. + +## An Object 91 Sub 430 write is visible ~450 ms after it is issued + +Through the full write sequence — mode request, limits-tail read, Obj 91 +SET, overview commit — polling the readback as fast as the link allows: + +| run | target | visible after | +|---|---|---| +| 1 | 39.0 °C | 449 ms | +| 2 | 38.9 °C | 459 ms | +| 3 | 39.0 °C | 486 ms | +| 4 | 38.9 °C | 456 ms | + +So the 1.2 s confirm delay is about 2.5× the settle time. Most of the +450 ms is the deaf window above: the commit is the last SET in the +sequence, and the readback cannot be answered until the pump returns. + +A consequence worth stating, because it removes a failure mode rather than +adding one: reading *too early* does not return a stale value, it returns +nothing. The confirm already retries an unanswered read. + +## The GO app sends consecutive SETs back to back + +Recorded while chasing a window that turned out not to exist (above). It +still says something true about pacing. + +**Consecutive SETs are not spaced.** Across the corpus there +are 289 consecutive SET-to-SET pairs: + + min 43 ms p50 62 p90 121 + under 200 ms: 267 of 289 + +The tightest include `84/1000 -> 84/1001` at 43 ms - a schedule layer +upload - and `86/10 -> 91/430` at 54 ms, the mode-request-then-write pair. +Those uploads write five layers and then commit, and they work: if a SET +inside the window were dropped, every upload would lose four of its five +layers, and every single-event save would lose four of its five slots. + +**The app's 2500 ms pause is armed only before a non-SET.** From +``DongleHelper.handleOutgoingQueue``: + + } else if (isSetOperation(t) && !isSetOperation(peekNextTelegramInQueue())) { + this.noSentBefore = SystemClock.uptimeMillis() + getAfterSetPause(); + } else { + this.noSentBefore = 0L; + } + +The ``else`` branch clears the pause outright, so consecutive writes are +deliberately not spaced. The 2500 ms is a read guard, not a write guard. + +**GENIbus agrees.** The Application Programming Manual promises a reply per +request and says "the SET operation never returns anything but the APDU +Head" - which is exactly the nine-byte frame measured above. + +## The raw Obj 91 write does not take on its own + +Writing Object 91 Sub 430 directly — with a correct frame, a valid CRC, +and the overview commit after it — leaves the stored value unchanged, +whether the commit is sent 50 ms or 600 ms later (2 attempts each). The +same value written through the client's sequence, which sends the Object +86 Sub 10 mode request first, takes every time. + +So the mode request is not optional dressing around the temperature-range +write; it is load-bearing. What exactly it enables was not established +here — only that the write does not persist without it. + +## Frame-drop baseline: 23,579 frames, none dropped + +Twenty-five minutes reading Object 84 Sub 1 as fast as the transport's +pacing allows, 15.7 frames per second. Sustained polling is normal for this +device - the GO app issues 2,516 GETs of Object 86 Sub 6 in the capture +corpus. + + duration 25.0 min + reads issued 23,579 + unanswered 0 + frames received 23,579 + errors 0 + + crc_failures 0 + stale_partials 0 + unsolicited_fragments 0 + runt_length_drops 0 + overflow_drops 0 + queue_full_drops 0 + +With zero events in 23,579 frames the 95% upper bound on the drop rate is +3/23,579 - better than **one in 7,860**. If the true rate were the one in +5,900 the ESPHome port saw, four drops would have been expected here and +seeing none has probability 0.018. + +Two things this does *not* say. The ESPHome figure counts one occurrence +per 5,900 **log lines**, not per frame, so the two are not directly +comparable and the comparison above is indicative rather than a +contradiction. And this bounds *this* link - a macOS host a few metres from +the pump - not an ESP32's. A radio result does not travel between radios. + +What it does establish is that the frame path is clean enough that a +timeout on this bench is not a silently corrupted frame, which is what the +counters exist to tell you. An earlier version of this note recorded 167 +frames, which could not distinguish zero from one in 5,900. + +## The pump keeps local wall clock, and has no idea UTC exists + +This decides how every client must write the pump's clock, so it matters +beyond this codebase: the GO app, the ESPHome component and this library +all write the same register, and the pump cannot say which time base a +value arrived in. + +Read from the bench unit: + + DateTimeActual (94/101, type 322 v1) 07ea08140f221b0100040101 + year 2026 month 8 day 20 hour 15 min 34 sec 27 + day_w Thu dst_status 1 = SummerTime + -> 2026-08-20 15:34:27, against a host local clock of 15:34:28 + + DaylightSavingTime (94/102, type 323 v1) 01030702020b0701023c + enabled 1 + start Mar, Sunday, occurrence 2, hour 2 + end Nov, Sunday, occurrence 1, hour 2 + time_offset 60 + +Three things follow, and the third is the one that settles the timestamps: + +1. The clock is **local**. A device that reports whether it is currently in + summer time is not keeping UTC. +2. The pump applies DST **itself** - enabled, with the US rule and a + 60-minute offset - so it shifts its own clock twice a year. +3. **There is no timezone or UTC-offset field anywhere in the GENI + profile.** Searching the whole of `geni_profile_52_7.xml` for timezone, + UTC, GMT or offset returns only electrical offsets and alarm names. The + pump therefore cannot convert between bases even in principle. + +So a 32-bit timestamp the pump stores - `ClockProgramSingleEvent`'s begin +and end, the event log's entries, the cycle timestamps - must be in the +same base as its clock, because the pump compares them against it and has +no offset to relate the two. That base is local. The stored value is the +local wall clock stamped as though it were UTC, which is what +`calendar.timegm` on naive local fields produces. + +This agrees with the earlier behavioural measurement, where an event +written under this encoding started four seconds from its intended wall +clock - and it explains *why*, rather than leaving it as a lucky guess. + +### What this client had wrong + +`set_clock` and the single-event encoding were already right. The event log +and the trend history decoded their timestamps with +`datetime.fromtimestamp(ts, tz=UTC)`, which produces the correct digits +attached to the wrong instant: calling `.astimezone()` on one shifted it by +the local offset. All four surfaces now go through `alpha_hwr.pump_time`. + +### A consequence worth knowing + +Because the pump shifts its own clock at a DST transition, a stored event +keeps its *wall clock* across the boundary - an 07:00 event stays at 07:00. +That is almost certainly the intent, and it is another thing true-UTC +storage would break. + +## The pump clamps a setpoint rather than refusing it + +Measured through the client, constant speed, whose published range is +1650-3671 RPM: + + 4000 RPM -> clamped stored 3671 + 2000 RPM -> accepted stored 2000 + 600 RPM -> clamped stored 1650 + +Which reproduces the 0.7.0 note ("600 RPM is stored as 1650 and 4400 +stores 3671") from a separate run, and settles how the client should +behave: it does not pre-refuse a value outside the published range. The +pump answers with what it stored, and that answer is more informative than +a refusal. + +There is a second reason it must not, raised by @jfriend00 on +esphome-alpha-hwr #276 and worth recording here because it is not +recoverable from the range alone. **With a flow limiter enabled there is +no maximum speed.** The pump accepts a speed setpoint and then manages the +actual run speed to hold the flow bound, and where it settles is a property +of the installation's hydraulics rather than of the pump - on one reported +loop a 3000 RPM request delivered 1885 RPM. No number is the bound there, +so any check that looked authoritative would be wrong in a way the client +cannot detect. + +So the published range is an *explanation*, not a gate. It goes in the +settle detail when the pump clamps, and only when it came from the pump: + + 4000 RPM -> clamped: pump stored 3671; its range for this mode is + 1650-3671 RPM + +The one thing still refused before the wire is a value that is not a +number. There is nothing there for the pump to clamp to, and the all-ones +float doubles as the SETPOINT_KEEP sentinel, so a NaN would read as "leave +the setpoint alone" - a write that silently does nothing rather than one +that fails. diff --git a/docs/protocol/connection.md b/docs/protocol/connection.md index 94fc39c..30c2bcb 100644 --- a/docs/protocol/connection.md +++ b/docs/protocol/connection.md @@ -28,10 +28,17 @@ sequence. > entered in the initial documentation commit, hedged as "may ignore" and "may > return ... or fail", which is not how an observation gets written down. > -> The packets are documented below because this client still sends them and -> because knowing what they are has value. They are described as what they are. -> See esphome-alpha-hwr issue #174 for the decode, the captures, and the -> removal. +> **Update, 2026-08-20.** The client no longer sends them. Verified on the +> bench: a bare connect-and-subscribe link, with none of the four packets +> written, answered all five Class 7 string reads, every Class 10 object read +> this client makes, and the three telemetry registers. The 750 ms of +> inter-stage delays went with them — they were transcribed from this client's +> own `sleep()` calls and then written up as pump timing requirements. +> +> The packets are still documented below, and kept as constants in +> `alpha_hwr.core.authentication`, because they are real captures and make +> good frame-assembly vectors. See esphome-alpha-hwr issue #174 for the decode +> and the captures. ## 1. BLE Connection diff --git a/docs/protocol/control_modes.md b/docs/protocol/control_modes.md index 780d39e..59b828e 100644 --- a/docs/protocol/control_modes.md +++ b/docs/protocol/control_modes.md @@ -298,7 +298,7 @@ frame was accepted, not that your value is in the pump: - `set_constant_flow(value_m3h)` — mode 8 (converted to SI m³/s on the wire) - `set_temperature_range_control(min_c, max_c, autoadapt=True)` — mode 27 - `set_cycle_time_control(on_min, off_min)` — mode 25 -- `set_flow_limit(value_gpm)` — Sub 39 +- `read_limiters()` — Object 86 Sub 600 (MaxFlow) and Sub 601 (MinFlow) **Deprecated Methods (for heating systems, not ALPHA HWR):** - `set_temperature_control(on_temp, off_temp, heating_type)` - Uses modes 13/14/15 (not for DHW) @@ -317,11 +317,11 @@ frame was accepted, not that your value is in the pump: ```bash alpha-hwr control set-pressure alpha-hwr control set-proportional -alpha-hwr control set-speed [--flow-limit ] +alpha-hwr control set-speed alpha-hwr control set-flow -alpha-hwr control set-temperature --min --max [--autoadapt/--no-autoadapt] [--flow-limit ] +alpha-hwr control set-temperature --min --max [--autoadapt/--no-autoadapt] alpha-hwr control set-cycle-time --on --off -alpha-hwr control set-flow-limit +alpha-hwr control limiters alpha-hwr control get-cycle-time ``` diff --git a/docs/protocol/packet_traces/01_connection.md b/docs/protocol/packet_traces/01_connection.md index 9166ccd..7047d20 100644 --- a/docs/protocol/packet_traces/01_connection.md +++ b/docs/protocol/packet_traces/01_connection.md @@ -485,7 +485,7 @@ Connection active. Press Ctrl+C to disconnect... **Fix**: 1. Verify the GENI characteristic UUID is correct 2. Ensure notifications enabled before sending commands -3. Send authentication packets to unlock telemetry +3. Check bonding — an unbonded link is dropped at about 1.8 s 4. Check handler is `async` if required by library --- diff --git a/docs/reimplementation/layer_by_layer.md b/docs/reimplementation/layer_by_layer.md index 89b67a6..edf68d4 100644 --- a/docs/reimplementation/layer_by_layer.md +++ b/docs/reimplementation/layer_by_layer.md @@ -622,26 +622,24 @@ async def authenticate(client): sequence was sent without a transport error - not that the pump confirmed anything. """ - for _ in range(3): # Stage 1: legacy magic - await send_packet(client, LEGACY_MAGIC) - await asyncio.sleep(INTER_PACKET_DELAY) - await asyncio.sleep(STAGE_1_TO_2_DELAY) - - for _ in range(5): # Stage 2: Class 10 unlock - await send_packet(client, CLASS10_UNLOCK) - await asyncio.sleep(INTER_PACKET_DELAY) - await asyncio.sleep(STAGE_2_TO_3_DELAY) - - await send_packet(client, EXTEND_1) # Stage 3: extension packets - await asyncio.sleep(INTER_PACKET_DELAY) - await send_packet(client, EXTEND_2) - - await asyncio.sleep(STABILIZE_DELAY) + Nothing is sent. There is no handshake. + """ + await asyncio.sleep(STABILIZE_DELAY) # let the BLE link settle ``` -> If the handshake appears to succeed and then everything times out, check -> **bonding** before you check your frames. An unbonded connection is dropped -> at about 1.8 seconds regardless of traffic. +> **This used to be a ten-packet "unlock" sequence.** It was not one. All four +> distinct packets decode as reads — two GETs and two INFO queries — under the +> rule that byte 5 is `0booLLLLLL`, and their replies were discarded unread. +> A read cannot change device state. +> +> Verified on hardware 2026-08-20: a bare connect-and-subscribe link, with none +> of them sent, answered every Class 7 string, every Class 10 object read and +> all three telemetry registers. The 750 ms of inter-stage delays went too — +> they were copied from the reference client's own `sleep()` calls and then +> written up as pump timing requirements. +> +> If reads time out, check **bonding** before you check your frames. An +> unbonded connection is dropped at about 1.8 seconds regardless of traffic. See [02_authentication.md](../protocol/packet_traces/02_authentication.md) for detailed explanation. @@ -654,7 +652,7 @@ See [02_authentication.md](../protocol/packet_traces/02_authentication.md) for d ### 5.1 Session State Machine ``` -DISCONNECTED → CONNECTED → AUTHENTICATING → AUTHENTICATED → ERROR +DISCONNECTED → CONNECTED → STABILIZING → READY ``` **Implementation**: diff --git a/docs/reimplementation/test_vectors.md b/docs/reimplementation/test_vectors.md index 11d690d..d69f203 100644 --- a/docs/reimplementation/test_vectors.md +++ b/docs/reimplementation/test_vectors.md @@ -32,10 +32,10 @@ consistency. | Frame | CRC over `frame[1:-2]` | What it is | | :--- | :--- | :--- | -| `27 07 E7 F8 02 03 94 95 96 EB 47` | `0xEB47` | Legacy magic (handshake stage 1) | -| `27 07 E7 F8 0A 03 56 00 06 C5 5A` | `0xC55A` | Class 10 unlock (handshake stage 2) | -| `27 05 E7 F8 05 C1 4B C3 82` | `0xC382` | Extend 1 (handshake stage 3) | -| `27 05 E7 F8 0B C1 0F D0 C3` | `0xD0C3` | Extend 2 (handshake stage 3) | +| `27 07 E7 F8 02 03 94 95 96 EB 47` | `0xEB47` | Class 2 GET of unit family/type/version | +| `27 07 E7 F8 0A 03 56 00 06 C5 5A` | `0xC55A` | Class 10 GET of Object 86 Sub 6 | +| `27 05 E7 F8 05 C1 4B C3 82` | `0xC382` | INFO query, Class 5 item 0x4B | +| `27 05 E7 F8 0B C1 0F D0 C3` | `0xD0C3` | INFO query, Class 11 item 0x0F | | `27 05 E7 F8 03 81 06 E5 87` | `0xE587` | Class 3 START | | `27 05 E7 F8 03 81 05 D5 E4` | `0xD5E4` | Class 3 STOP | @@ -83,7 +83,7 @@ assert calc_crc16(bytes.fromhex("05e7f8038105")) == 0xD5E4 ## 5. Frame Building -The handshake packets are built from their APDUs and must reproduce +The opening packets are built from their APDUs and must reproduce the captured constants byte for byte: | APDU | Frame | Matches capture | diff --git a/pyproject.toml b/pyproject.toml index fd81106..af6fdb6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -102,6 +102,16 @@ alpha_hwr = ["py.typed"] line-length = 80 exclude = ["src/alpha_hwr/*_baseline.py"] +[tool.ruff.lint.per-file-ignores] +# The pump stores bare wall-clock fields with no offset, and its schedules +# run against that wall clock. Naive datetimes are the correct type here, +# not an oversight: attaching a timezone would invent information the pump +# does not carry, and converting to UTC is precisely the bug the ESPHome +# port had to fix. The production code carries the same exemption as +# per-line `noqa`s; these files are dense enough in them to say it once. +"tests/unit/services/test_single_event_rules.py" = ["DTZ001", "DTZ005"] +"tests/unit/test_pump_time.py" = ["DTZ001", "DTZ005"] + [tool.ruff.lint.flake8-bugbear] # Typer's whole API is call-in-default (`typer.Option(...)` / # `typer.Argument(...)`); these are declarations, not shared mutable state. diff --git a/scripts/generate_test_vectors.py b/scripts/generate_test_vectors.py index 708f927..1ed1650 100755 --- a/scripts/generate_test_vectors.py +++ b/scripts/generate_test_vectors.py @@ -49,10 +49,10 @@ #: evidence rather than self-consistency: reproducing their CRC proves the #: algorithm, not merely that the code agrees with itself. CAPTURED = [ - ("2707e7f80203949596eb47", "Legacy magic (handshake stage 1)"), - ("2707e7f80a03560006c55a", "Class 10 unlock (handshake stage 2)"), - ("2705e7f805c14bc382", "Extend 1 (handshake stage 3)"), - ("2705e7f80bc10fd0c3", "Extend 2 (handshake stage 3)"), + ("2707e7f80203949596eb47", "Class 2 GET of unit family/type/version"), + ("2707e7f80a03560006c55a", "Class 10 GET of Object 86 Sub 6"), + ("2705e7f805c14bc382", "INFO query, Class 5 item 0x4B"), + ("2705e7f80bc10fd0c3", "INFO query, Class 11 item 0x0F"), ("2705e7f8038106e587", "Class 3 START"), ("2705e7f8038105d5e4", "Class 3 STOP"), ] @@ -170,21 +170,21 @@ def build() -> str: add(f"| `{value}` (`0x{value:08X}`){suffix} | `{_hex(enc)}` |\n") add(_section("5. Frame Building")) - add("""The handshake packets are built from their APDUs and must reproduce + add("""The opening packets are built from their APDUs and must reproduce the captured constants byte for byte: | APDU | Frame | Matches capture | | :--- | :--- | :--- | """) for apdu_hex, expected, label in [ - ("0203949596", AuthenticationHandler.LEGACY_MAGIC, "Legacy magic"), + ("0203949596", AuthenticationHandler.LEGACY_MAGIC, "Class 2 GET"), ( "0a0356 0006".replace(" ", ""), AuthenticationHandler.CLASS10_UNLOCK, - "Class 10 unlock", + "Class 10 GET of Object 86 Sub 6", ), - ("05c14b", AuthenticationHandler.EXTEND_1, "Extend 1"), - ("0bc10f", AuthenticationHandler.EXTEND_2, "Extend 2"), + ("05c14b", AuthenticationHandler.EXTEND_1, "INFO, Class 5 item 0x4B"), + ("0bc10f", AuthenticationHandler.EXTEND_2, "INFO, Class 11 item 0x0F"), ]: built = FrameBuilder.build_geni_frame(bytes.fromhex(apdu_hex)) ok = "yes" if built == expected else "**NO**" diff --git a/src/alpha_hwr/cli/commands/clock.py b/src/alpha_hwr/cli/commands/clock.py index 7f49a5a..702442a 100644 --- a/src/alpha_hwr/cli/commands/clock.py +++ b/src/alpha_hwr/cli/commands/clock.py @@ -6,10 +6,9 @@ - sync: Synchronize pump clock with system time """ -from datetime import datetime - import typer +from ... import pump_time from ..app import console from ..common import get_client, handle_error, require_service, run_async from ..output.formatters import print_success @@ -117,25 +116,26 @@ async def _clock_view(device: str | None) -> None: async with get_client(device) as client: time = require_service(client.time, "Time") # Read pump time - pump_time = await time.get_clock() - # Naive local, to match the pump's naive wall clock: this is - # compared against pump_time below, and mixing naive and - # aware datetimes raises. - system_time = datetime.now() # noqa: DTZ005 + pump_clock = await time.get_clock() - if pump_time is None: + if pump_clock is None: console.print( "[error]Failed to read pump clock (no response)[/error]" ) raise typer.Exit(1) + # Naive local, to match the pump's naive wall clock: this is + # compared against pump_clock below, and mixing naive and + # aware datetimes raises. + system_time = pump_time.now() + # Check if clock is unset (epoch or very old date) - if pump_time.year <= 1980: + if pump_clock.year <= 1980: console.print( "[warning]⚠ Pump clock is unset or invalid[/warning]" ) console.print( - f" Pump Clock: {pump_time.strftime('%Y-%m-%d %H:%M:%S')}" + f" Pump Clock: {pump_clock.strftime('%Y-%m-%d %H:%M:%S')}" ) console.print( f" System Clock: {system_time.strftime('%Y-%m-%d %H:%M:%S')}" @@ -146,11 +146,11 @@ async def _clock_view(device: str | None) -> None: raise typer.Exit(0) # Calculate offset - offset = (system_time - pump_time).total_seconds() + offset = (system_time - pump_clock).total_seconds() # Display results console.print( - f" Pump Clock: {pump_time.strftime('%Y-%m-%d %H:%M:%S')}" + f" Pump Clock: {pump_clock.strftime('%Y-%m-%d %H:%M:%S')}" ) console.print( f" System Clock: {system_time.strftime('%Y-%m-%d %H:%M:%S')}" diff --git a/src/alpha_hwr/cli/commands/control.py b/src/alpha_hwr/cli/commands/control.py index 9f1315a..a44385d 100644 --- a/src/alpha_hwr/cli/commands/control.py +++ b/src/alpha_hwr/cli/commands/control.py @@ -11,12 +11,13 @@ - set-speed: Set constant speed mode (RPM) - set-temperature: Set temperature range control - set-cycle-time: Set DHW cycle time control - - set-flow-limit: Set maximum flow limit (GPM) + - limiters: Show the MaxFlow and MinFlow limiters - get-cycle-time: Get DHW cycle time configuration """ import typer +from ...constants import FACTOR_M3H_TO_GPM from ..app import console from ..common import get_client, handle_error, require_service, run_async from ..output.formatters import format_setpoint_panel, print_success @@ -156,9 +157,6 @@ def cmd_set_speed( setpoint: float = typer.Argument( ..., help="Speed setpoint in RPM (e.g., 2500)" ), - flow_limit: float | None = typer.Option( - None, "--flow-limit", "-f", help="Maximum flow limit in GPM (e.g. 1.5)" - ), device: str | None = typer.Option( None, "--device", @@ -173,9 +171,9 @@ def cmd_set_speed( Optionally sets a flow limit (often used for 'Continuous operation'). Example: - alpha-hwr control set-speed 2500 --flow-limit 1.5 + alpha-hwr control set-speed 2500 """ - run_async(_control_set_speed(device, setpoint, flow_limit)) + run_async(_control_set_speed(device, setpoint)) @app.command("set-mode") @@ -218,9 +216,6 @@ def cmd_set_temperature( "--autoadapt/--no-autoadapt", help="Enable/disable AutoAdapt flow adjustment", ), - flow_limit: float | None = typer.Option( - None, "--flow-limit", "-f", help="Maximum flow limit in GPM (e.g. 1.5)" - ), device: str | None = typer.Option( None, "--device", @@ -237,13 +232,9 @@ def cmd_set_temperature( Examples: alpha-hwr control set-temperature --min 35 --max 39 - alpha-hwr control set-temperature --min 45 --max 50 --no-autoadapt --flow-limit 1.5 + alpha-hwr control set-temperature --min 45 --max 50 --no-autoadapt """ - run_async( - _control_set_temperature( - device, min_temp, max_temp, autoadapt, flow_limit - ) - ) + run_async(_control_set_temperature(device, min_temp, max_temp, autoadapt)) @app.command("set-cycle-time") @@ -294,9 +285,8 @@ def cmd_get_cycle_time( run_async(_control_get_cycle_time(device)) -@app.command("set-flow-limit") -def cmd_set_flow_limit( - limit_gpm: float = typer.Argument(..., help="Maximum flow limit in GPM"), +@app.command("limiters") +def cmd_limiters( device: str | None = typer.Option( None, "--device", @@ -305,17 +295,22 @@ def cmd_set_flow_limit( ), ) -> None: """ - Set the maximum flow limit (GPM). + Show the pump's MaxFlow and MinFlow limiters. - Common limits by pipe diameter: - - 1/2": 1.5 GPM - - 3/4": 2.3 GPM - - 1": 3.8 GPM + An enabled limiter caps delivered flow whatever the setpoint says, and + nothing in the setpoint range reveals it - so a setpoint can be + accepted, read back correct, and still not be delivered. This is the + only way to see that. + + Replaces `set-flow-limit`, which wrote to Object 86 Sub 39. That is the + constant-flow *setpoint range*, not a limiter, and the write was + refused by the pump in any case. The limiters live at Object 86 Sub 600 + (MaxFlow) and Sub 601 (MinFlow). Example: - alpha-hwr control set-flow-limit 1.5 + alpha-hwr control limiters """ - run_async(_control_set_flow_limit(device, limit_gpm)) + run_async(_control_limiters(device)) # Internal async implementations @@ -441,7 +436,6 @@ async def _control_set_temperature( min_temp: float, max_temp: float, autoadapt: bool, - flow_limit: float | None = None, ) -> None: """Internal async implementation of set-temperature command.""" try: @@ -454,13 +448,6 @@ async def _control_set_temperature( if success: msg = f"Set Temperature Range Control to {min_temp}°C - {max_temp}°C (autoadapt={autoadapt})" - if flow_limit is not None: - if await control.set_flow_limit(flow_limit): - msg += f" with {flow_limit} GPM flow limit" - else: - console.print( - "[yellow]Warning: Mode set, but flow limit failed[/yellow]" - ) print_success(msg) else: console.print("[error]Failed to set temperature range[/error]") @@ -516,9 +503,7 @@ async def _control_get_cycle_time(device: str | None) -> None: handle_error(e, "Failed to read cycle time configuration") -async def _control_set_speed( - device: str | None, setpoint: float, flow_limit: float | None = None -) -> None: +async def _control_set_speed(device: str | None, setpoint: float) -> None: """Internal async implementation of set-speed command.""" try: async with get_client(device) as client: @@ -528,13 +513,6 @@ async def _control_set_speed( if success: msg = f"Set Constant Speed to {setpoint} RPM" - if flow_limit is not None: - if await control.set_flow_limit(flow_limit): - msg += f" with {flow_limit} GPM flow limit" - else: - console.print( - "[yellow]Warning: Speed set, but flow limit failed[/yellow]" - ) print_success(msg) else: console.print("[error]Failed to set constant speed[/error]") @@ -544,19 +522,35 @@ async def _control_set_speed( handle_error(e, "Failed to set constant speed") -async def _control_set_flow_limit(device: str | None, limit_gpm: float) -> None: - """Internal async implementation of set-flow-limit command.""" +async def _control_limiters(device: str | None) -> None: + """Internal async implementation of the limiters command.""" try: async with get_client(device) as client: control = require_service(client.control, "Control") - # Set flow limit - success = await control.set_flow_limit(limit_gpm) + limiters = await control.read_limiters() - if success: - print_success(f"Successfully set flow limit to {limit_gpm} GPM") - else: - console.print("[error]Failed to set flow limit[/error]") + if not limiters: + console.print("[error]Could not read the limiters[/error]") raise typer.Exit(1) + for name, values in limiters.items(): + enabled = values.get("enabled") + limiting = values.get("limiting") + state = "enabled" if enabled else "disabled" + console.print(f"[bold]{name}[/bold]: {state}") + if "limit_m3h" in values: + m3h = values["limit_m3h"] + console.print( + f" limit {m3h:.3f} m³/h " + f"({m3h / FACTOR_M3H_TO_GPM:.2f} GPM)" + ) + if "factory_min_m3h" in values: + console.print( + f" bounds {values['factory_min_m3h']:.3f} - " + f"{values['factory_max_m3h']:.3f} m³/h" + ) + if limiting is not None: + console.print(f" limiting {'yes' if limiting else 'no'}") + except Exception as e: # noqa: BLE001 - top-level CLI error boundary handle_error(e, "Failed to set flow limit") diff --git a/src/alpha_hwr/cli/common.py b/src/alpha_hwr/cli/common.py index 3c98f18..1bfbbcc 100644 --- a/src/alpha_hwr/cli/common.py +++ b/src/alpha_hwr/cli/common.py @@ -56,7 +56,7 @@ async def get_client( Connected and authenticated AlphaHWRClient Example: - >>> async with get_client() as client: + >>> async with get_client() as client: # doctest: +SKIP ... data = await client.telemetry.read_once() """ settings = get_settings() diff --git a/src/alpha_hwr/client.py b/src/alpha_hwr/client.py index 1877421..a17f88b 100644 --- a/src/alpha_hwr/client.py +++ b/src/alpha_hwr/client.py @@ -129,7 +129,7 @@ class AlphaHWRClient: auth: Authentication handler Example: - >>> async with AlphaHWRClient("DEVICE_ADDRESS") as client: + >>> async with AlphaHWRClient("DEVICE_ADDRESS") as client: # doctest: +SKIP ... # Telemetry ... data = await client.telemetry.read_once() ... print(f"Flow: {data.flow_m3h} m³/h") @@ -279,11 +279,11 @@ async def connect( Example: >>> # Connect to specific address >>> client = AlphaHWRClient("DEVICE_ADDRESS") - >>> await client.connect() + >>> await client.connect() # doctest: +SKIP >>> >>> # Connect using automatic discovery >>> client = AlphaHWRClient() - >>> await client.connect() + >>> await client.connect() # doctest: +SKIP Implementation Notes: Connection sequence: @@ -435,8 +435,8 @@ async def disconnect(self) -> None: disconnects from the device. Example: - >>> await client.disconnect() - >>> print(f"Connected: {client.is_connected}") # False + >>> await client.disconnect() # doctest: +SKIP + >>> print(f"Connected: {client.is_connected}") # False # doctest: +SKIP Implementation Notes: Disconnect sequence: @@ -466,8 +466,8 @@ async def authenticate(self, fast_mode: bool = False) -> bool: True if authentication successful, False otherwise Example: - >>> success = await client.authenticate() - >>> if success: + >>> success = await client.authenticate() # doctest: +SKIP + >>> if success: # doctest: +SKIP ... print("Authenticated successfully") ... await client.control.start() """ @@ -525,12 +525,12 @@ async def discover( List of DeviceInfo objects for discovered pumps Example: - >>> devices = await AlphaHWRClient.discover() - >>> for device in devices: + >>> devices = await AlphaHWRClient.discover() # doctest: +SKIP + >>> for device in devices: # doctest: +SKIP ... print(f"Found: {device.product_name} at {device.address}") >>> >>> # Connect to first device - >>> if devices: + >>> if devices: # doctest: +SKIP ... client = AlphaHWRClient(devices[0].address) ... await client.connect() @@ -863,13 +863,13 @@ async def discover_devices(timeout: float = 10.0) -> list[str]: List of device addresses (UUIDs on macOS, MACs on Linux/Windows) Example: - >>> devices = await discover_devices() - >>> print(f"Found {len(devices)} device(s)") - >>> for address in devices: + >>> devices = await discover_devices() # doctest: +SKIP + >>> print(f"Found {len(devices)} device(s)") # doctest: +SKIP + >>> for address in devices: # doctest: +SKIP ... print(f" {address}") >>> >>> # Connect to first device - >>> if devices: + >>> if devices: # doctest: +SKIP ... client = AlphaHWRClient(devices[0]) ... await client.connect() diff --git a/src/alpha_hwr/constants.py b/src/alpha_hwr/constants.py index 96afd13..c08111e 100644 --- a/src/alpha_hwr/constants.py +++ b/src/alpha_hwr/constants.py @@ -72,7 +72,7 @@ class TelemetryObject: Example: >>> obj_id, sub_id = TelemetryObject.MOTOR_STATE >>> register = (obj_id << 16) | sub_id - >>> req = FrameBuilder.build_data_object_info(register) + >>> req = FrameBuilder.build_data_object_info(register) # doctest: +SKIP """ # (Obj ID, Sub ID) tuples diff --git a/src/alpha_hwr/core/authentication.py b/src/alpha_hwr/core/authentication.py index 2b5bc43..466af43 100644 --- a/src/alpha_hwr/core/authentication.py +++ b/src/alpha_hwr/core/authentication.py @@ -1,19 +1,37 @@ """ -Authentication module for Grundfos ALPHA HWR pumps. - -This module implements the multi-stage authentication handshake required -to establish a trusted connection with the pump. The handshake consists -of three stages: - -1. Legacy Magic Packet (compatibility with nested proxies) -2. Class 10 Unlock Sequence (primary authentication) -3. Extension Packets (handshake completion) - -Protocol Reference ------------------- -The authentication uses the GENI protocol's Class 10 DataObject operations -and legacy Class 2/3 commands. All packets use CRC-16-CCITT for integrity. - +Opening a session with the pump. + +There is no authentication handshake, and this module no longer sends one. + +Ten packets used to go out here, documented as a three-stage unlock: a +"Class 2 SET of unlock register 0x9495 carrying unlock code 0x96", a +"Class 10 unlock", and two "extension" packets. Decoded properly, the +``0x03`` in the first is an APDU head - a GET declaring three payload bytes +- so "register 0x9495, unlock code 0x96" was a misreading of a length +field. All four distinct packets are **reads**: + +* a Class 2 GET of ``unit_family`` / ``unit_type`` / ``unit_version``, + which this pump answers 52 / 7 / 2 - the same values it puts in its + advertisement; +* a Class 10 GET of Object 86 Sub 6, the operation status the telemetry + path already decodes; +* two INFO queries for scaling metadata, both answered "unscaled". + +A read cannot change device state, so an unlock was never something these +bytes could do - and every reply was discarded unread in any case. + +Measured 2026-08-20: with none of them sent, a bare connect-subscribe link +answered all five Class 7 string reads, every Class 10 object read this +client makes, and the telemetry registers. ``docs/protocol/connection.md`` +reached the same conclusion from the captures; this is that conclusion +applied to the code. + +The 750 ms of inter-stage delays went with them. They were transcribed +from an early version of this client's own ``sleep()`` calls and then +written up as pump timing requirements; nothing ever measured them. + +What remains is the settle wait before the first command, which is about +the BLE link coming up rather than about GENI. """ import asyncio @@ -26,13 +44,13 @@ logger = logging.getLogger(__name__) -# Handshake timing, matching the C++ port's auth.cpp (which is the -# implementation currently validated against hardware). Every packet is -# followed by INTER_PACKET_DELAY; the stage boundaries add their own gap -# on top of that. -INTER_PACKET_DELAY = 0.05 -STAGE_1_TO_2_DELAY = 0.10 -STAGE_2_TO_3_DELAY = 0.20 +#: How long to let the BLE link settle before the first command. +#: +#: This is the one wait that survived the handshake's removal, and it is +#: about the radio rather than about GENI. The 750 ms of inter-stage delays +#: that used to sit alongside it were transcribed from this client's own +#: sleep() calls and then documented as pump timing requirements; nothing +#: ever measured them. STABILIZE_DELAY = 0.5 @@ -132,6 +150,14 @@ class AuthenticationHandler: # Poly: 0x1021 (CRC-16-CCITT) # Init: 0xFFFF # Result: 0xEB47 + #: A Class 2 GET of unit_family / unit_type / unit_version. + #: + #: Kept as a captured frame, not as something to send. It was called a + #: "legacy magic" unlock and read as a SET of register 0x9495 carrying + #: unlock code 0x96; byte 5 is 0x03, an APDU head declaring a GET with + #: three payload bytes, and 94 95 96 are the three item IDs. This pump + #: answers 52 / 7 / 2 - the same family, type and version it puts in + #: its advertisement. LEGACY_MAGIC = bytes.fromhex("2707e7f80203949596eb47") # ========================================================================== @@ -158,6 +184,8 @@ class AuthenticationHandler: # CRC Calculation: # Input: 07 E7 F8 0A 03 56 00 06 # Result: 0xC55A + #: A Class 10 GET of Object 86 Sub 6 - the operation status the + #: telemetry path already decodes. Not an unlock; a read. CLASS10_UNLOCK = bytes.fromhex("2707e7f80a03560006c55a") # ========================================================================== @@ -177,6 +205,8 @@ class AuthenticationHandler: # # Note: Must be sent before EXTEND_2. Order documented in # docs/protocol/connection.md Step C, observed from Grundfos app. + #: An INFO query for scaling metadata on Class 5 item 0x4B. Answered + #: "unscaled". Byte 5 is 0xC1: operation 0b11 (INFO), one payload byte. EXTEND_1 = bytes.fromhex("2705e7f805c14bc382") # ========================================================================== @@ -193,6 +223,7 @@ class AuthenticationHandler: # 0B - Class 11 (session extension) # C1 0F - Command/data sequence # D0 C3 - CRC-16-CCITT + #: The same INFO query for Class 11 item 0x0F. Also "unscaled". EXTEND_2 = bytes.fromhex("2705e7f80bc10fd0c3") # GENI characteristic UUID (where packets are written) @@ -222,9 +253,9 @@ def __init__( -------- >>> from bleak import BleakClient >>> client = BleakClient("device_address") - >>> await client.connect() + >>> await client.connect() # doctest: +SKIP >>> auth = AuthenticationHandler(client) - >>> await auth.authenticate() + >>> await auth.authenticate() # doctest: +SKIP """ self.ble_writer = ble_writer self._transaction = transaction @@ -240,161 +271,32 @@ async def _exclusive(self) -> AsyncIterator[None]: async def authenticate(self, fast_mode: bool = False) -> bool: """ - Perform complete authentication handshake. - - This method executes the three-stage authentication sequence: - 1. Legacy magic burst (3x repeats, 50ms intervals) - 2. Class 10 unlock burst (5x repeats, 50ms intervals) - 3. Extension packets (EXTEND_1 then EXTEND_2, 50ms apart) + Settle the link so the first command is not sent into a dead radio. - Timing Considerations - --------------------- - - Inter-packet delay: 50ms (allows pump processing) - - Stage delay: 100ms after stage 1, 200ms after stage 2 - - Total sequence time: ~1.5 seconds + Nothing is sent to the pump. The opening sequence this used to + write was ten packets of reads whose replies were discarded - see + the module docstring - and removing it took connect-to-first-answer + down by the time those packets and their 750 ms of delays took. - Every packet is written sequentially, and the whole sequence runs - under the transport's transaction lock when one was supplied. The - pump drops the link when packets arrive out of order or interleaved - with other traffic (issues #24, #31), so neither the ordering nor - the exclusivity is optional. + The name is kept because callers and `client.authenticate()` use + it, and because the session still has a state to move through. Args: - fast_mode: If True, skips all delays (inter-packet and - inter-stage). Intended for unit tests only; do not use - against real hardware as the pump requires the timing - gaps to process each stage. + fast_mode: Skip the settle wait. For tests. Returns: - bool - True if authentication sequence completed without errors. - Note: No explicit ACK is received, so True indicates the - sequence was sent successfully, not that authentication - was explicitly confirmed by the pump. + True. There is no handshake to fail; a pump that will not + answer shows up as an unanswered read, which is where it can + actually be diagnosed. """ - logger.info("Starting authentication handshake (3-stage sequence)...") - - packet_delay = 0.0 if fast_mode else INTER_PACKET_DELAY + logger.debug("Opening a session (no handshake is sent)") try: async with self._exclusive(): - # Stage 1: Legacy Magic Burst (backward compatibility) - logger.debug( - "Stage 1: Sending legacy magic burst (3x repeats)..." - ) - await self.send_legacy_burst(repeats=3, delay=packet_delay) - if not fast_mode: - await asyncio.sleep(STAGE_1_TO_2_DELAY) - - # Stage 2: Class 10 Unlock (required for DataObjects) - logger.debug( - "Stage 2: Sending Class 10 unlock burst (5x repeats)..." - ) - await self.send_class10_burst(repeats=5, delay=packet_delay) - if not fast_mode: - await asyncio.sleep(STAGE_2_TO_3_DELAY) - - # Stage 3: Extension Packets (session establishment) - logger.debug("Stage 3: Sending extension packets...") - await self.send_extension_packets(delay=packet_delay) - if not fast_mode: await asyncio.sleep(STABILIZE_DELAY) - - logger.info("Authentication handshake complete") - return True - except READ_ERRORS as e: - logger.error(f"Authentication handshake failed: {e}") + logger.error(f"Failed to settle the link: {e}") return False - async def _send_burst( - self, packet: bytes, repeats: int, delay: float - ) -> None: - """ - Write ``packet`` ``repeats`` times, sequentially, pacing each write. - - Sequential is load-bearing: an earlier revision spawned the writes - as concurrent tasks, which let them reach the pump out of order and - caused it to drop the link about a second after the handshake - (issues #24, #31). - """ - for _ in range(repeats): - await self.ble_writer.write_gatt_char( - self.GENI_CHAR_UUID, packet, response=False - ) - if delay > 0: - await asyncio.sleep(delay) - - async def send_legacy_burst( - self, repeats: int = 7, delay: float = 0.05 - ) -> None: - """ - Send legacy magic packet burst. - - Stage 1 of authentication. Sends legacy Class 2 unlock command - multiple times to ensure compatibility with older firmware and - nested proxy architectures. - - Parameters - ---------- - repeats : int, default=3 - Number of times to send the packet. Default of 3 provides - good reliability without excessive BLE traffic. - delay : float, default=0.05 - Delay between packets in seconds. - """ - await self._send_burst(self.LEGACY_MAGIC, repeats, delay) - - async def send_class10_burst( - self, repeats: int = 5, delay: float = 0.05 - ) -> None: - """ - Send Class 10 unlock burst. - - Stage 2 of authentication. Sends primary unlock command using - modern Class 10 DataObject protocol. This is the critical - authentication step. - - Parameters - ---------- - repeats : int, default=5 - Number of times to send the packet. Default of 5 ensures - reliable delivery even in noisy BLE environments. - delay : float, default=0.05 - Delay between packets in seconds. - """ - await self._send_burst(self.CLASS10_UNLOCK, repeats, delay) - - async def send_extension_packets(self, delay: float = 0.05) -> None: - """ - Send authentication extension packets. - - Stage 3 of authentication. Sends two extension packets that - complete the handshake and establish the session. These packets - may negotiate capabilities or extend the authentication timeout. - - Parameters - ---------- - delay : float, default=0.05 - Delay in seconds between EXTEND_1 and EXTEND_2. Required for - the pump to process each packet before the next arrives. - Pass 0 only in unit tests (via fast_mode=True on authenticate()). - - Notes - ----- - - Packets MUST be sent sequentially: EXTEND_1 then EXTEND_2. - - A 50ms gap between them is required for the pump to process - EXTEND_1 before EXTEND_2 arrives. Sending them in parallel - causes premature disconnection (issue #24). - - Order is empirically established from packet captures; see - docs/protocol/packet_traces/02_authentication.md. - """ - await self.ble_writer.write_gatt_char( - self.GENI_CHAR_UUID, self.EXTEND_1, response=False - ) - if delay > 0: - await asyncio.sleep(delay) - await self.ble_writer.write_gatt_char( - self.GENI_CHAR_UUID, self.EXTEND_2, response=False - ) + return True diff --git a/src/alpha_hwr/core/session.py b/src/alpha_hwr/core/session.py index f8312cc..5eb6b86 100644 --- a/src/alpha_hwr/core/session.py +++ b/src/alpha_hwr/core/session.py @@ -59,10 +59,11 @@ class SessionState(IntEnum): -------- >>> state = SessionState.DISCONNECTED >>> print(state.name) - 'DISCONNECTED' + DISCONNECTED >>> state = SessionState.AUTHENTICATED >>> if state >= SessionState.AUTHENTICATED: ... print("Can send control commands") + Can send control commands """ DISCONNECTED = 0 @@ -131,7 +132,9 @@ class Session: >>> session.ensure_connected() # No error >>> session.ensure_authenticated() # Raises error - ConnectionError: Not authenticated. Current state: CONNECTED + Traceback (most recent call last): + ... + ConnectionError: Not authenticated. Current state: CONNECTED. Call authenticate() first. Notes for Reimplementation -------------------------- @@ -294,6 +297,8 @@ def ensure_connected(self) -> None: Examples -------- + >>> session = Session() + >>> session.on_connected() >>> session.ensure_connected() >>> # Safe to read notifications now """ @@ -317,6 +322,10 @@ def ensure_authenticated(self) -> None: Examples -------- + >>> session = Session() + >>> session.on_connected() + >>> session.on_authenticating() + >>> session.on_authenticated() >>> session.ensure_authenticated() >>> # Safe to send control commands now """ diff --git a/src/alpha_hwr/core/transport.py b/src/alpha_hwr/core/transport.py index adaa1af..9403712 100644 --- a/src/alpha_hwr/core/transport.py +++ b/src/alpha_hwr/core/transport.py @@ -13,14 +13,17 @@ """ import asyncio +import contextlib import logging from collections.abc import Callable from bleak import BleakClient from bleak.backends.characteristic import BleakGATTCharacteristic -from ..constants import GENI_CHAR_UUID +from ..constants import CLASS_10, GENI_CHAR_UUID from ..exceptions import READ_ERRORS +from ..protocol.apdu import apdu_is_set +from ..protocol.frame_parser import frame_crc_valid from ..protocol.matcher import Command as MatcherCommand from ..protocol.matcher import matches as matcher_matches @@ -36,6 +39,54 @@ #: faster than this. SEND_PACING = 0.05 +#: Only the pump's start byte is accepted inbound. +#: +#: 0x27 was accepted here too, described as "request/echo". The pump does +#: not echo: across the reference capture corpus, all 22,062 pump-to-phone +#: frames start 0x24 and none start 0x27. Accepting 0x27 meant an ordinary +#: payload byte could be taken for the start of a new frame. +RESPONSE_START_BYTE = 0x24 + +#: Smallest length byte a real frame can declare. +#: +#: A frame is ``length + 4`` bytes and the shortest legal one is the +#: nine-byte Class 10 acknowledgement ``24 05 F8 E7 0A 01 00 AE A2``. With +#: no floor, a length byte of 0x00 declared a four-byte frame, so any +#: notification "completed" instantly and was dispatched as a runt. +MIN_LENGTH_BYTE = 5 + +#: Largest telegram the protocol allows: 253 PDU bytes plus start, length +#: and the two CRC bytes. +#: +#: The old ceiling was 256, three short, so a legal maximum-length telegram +#: would have been discarded mid-reassembly. +MAX_PDU_LEN = 253 +MAX_TELEGRAM_LEN = MAX_PDU_LEN + 4 + +#: How long a partial frame may sit before it is abandoned. +#: +#: The pump paces fragments about 50 ms apart, so a gap of a full second +#: means the rest is not coming. Without this a truncated frame wedges +#: reassembly for the life of the connection. +REASSEMBLY_TIMEOUT = 1.0 + + +def is_class10_set(frame: bytes) -> bool: + """ + True for a Class 10 SET, which the pump neither answers nor talks over. + + Reads the class byte and the operation bits of the APDU head, so it is + the frame itself that decides rather than a list of addresses kept in + step by hand. + + Examples: + >>> is_class10_set(bytes.fromhex("2717e7f80a9354000100da01")) + True + >>> is_class10_set(bytes.fromhex("2707e7f80a03540001d5e8")) + False + """ + return len(frame) > 5 and frame[4] == CLASS_10 and apdu_is_set(frame[5]) + class Transport: """ @@ -102,13 +153,13 @@ class Transport: -------- >>> from bleak import BleakClient >>> client = BleakClient("device_address") - >>> await client.connect() + >>> await client.connect() # doctest: +SKIP >>> >>> transport = Transport(client) - >>> await transport.start_notifications(my_handler) + >>> await transport.start_notifications(my_handler) # doctest: +SKIP >>> >>> # Send a packet with transaction lock - >>> async with transport.transaction(): + >>> async with transport.transaction(): # doctest: +SKIP ... await transport.write(packet_bytes) ... response = await transport.wait_for_response(timeout=3.0) @@ -141,6 +192,30 @@ def __init__(self, client: BleakClient): self._response_queue: asyncio.Queue[bytes] = asyncio.Queue() self._response_buffer = bytearray() + # When the current partial frame's first fragment arrived. A frame + # start only begins a new packet when we are not already + # reassembling, so this is what stops a truncated frame wedging the + # buffer forever. + self._reassembly_started: float | None = None + + # Set when the BLE link drops. Anything waiting on a reply checks + # it, so a caller learns immediately instead of sitting out its own + # timeout for a pump that is no longer there. + self._link_down = asyncio.Event() + + # Why inbound bytes were thrown away. One counter per reason, + # because they mean different things: a bad CRC is a corrupted + # link, a runt length is a peer talking nonsense, and bytes that + # start no frame usually mean we lost sync rather than that the + # radio is bad. Until the CRC was enforced none of this could be + # counted, because nothing checked anything. + self.crc_failures = 0 + self.stale_partials = 0 + self.unsolicited_fragments = 0 + self.runt_length_drops = 0 + self.overflow_drops = 0 + self.queue_full_drops = 0 + # Custom notification handlers (for telemetry streaming) self._custom_handlers: list[Callable[[bytes], None]] = [] @@ -164,10 +239,21 @@ def __init__(self, client: BleakClient): logger.debug("Transport initialized") async def _pace(self) -> None: - """Wait out the remainder of the inter-write gap, if any.""" + """ + Wait out the inter-write gap. + + SEND_PACING is about how fast the pump's radio will take bytes. + Note the chunking in :meth:`write` is not an optimisation: this + pump requires GENI frames split into BLE_MTU_LIMIT-byte writes + regardless of the negotiated ATT MTU. A 27-byte frame sent as one + GATT write is silently ignored - measured, with the MTU negotiated + at 65 - while the same frame chunked at 20 is acknowledged in + 111 ms. + """ + now = asyncio.get_event_loop().time() if self._last_write is None: return - elapsed = asyncio.get_event_loop().time() - self._last_write + elapsed = now - self._last_write if elapsed < SEND_PACING: await asyncio.sleep(SEND_PACING - elapsed) @@ -190,7 +276,7 @@ async def start_notifications( -------- >>> async def my_handler(data): ... print(f"Received {len(data)} bytes") - >>> await transport.start_notifications(my_handler) + >>> await transport.start_notifications(my_handler) # doctest: +SKIP Notes ----- @@ -203,6 +289,12 @@ async def start_notifications( self._custom_handlers.append(handler) logger.debug("Custom notification handler registered") + # A fresh link. Clear the drop flag so waiters do not give up + # immediately on a connection that is up again, and drop any + # partial frame left over from the last one. + self._link_down.clear() + self._reset_reassembly() + # Only start notifications once if not self._notifications_started: await self.client.start_notify( @@ -223,6 +315,30 @@ async def stop_notifications(self) -> None: except READ_ERRORS as e: logger.debug(f"Error stopping notifications: {e}") + @property + def frame_drops(self) -> dict[str, int]: + """ + Every reason a frame was thrown away, and how often. + + A drop is the system working, so none of these is an error by + itself - but a link quietly shedding frames is otherwise + indistinguishable from a client that occasionally times out for no + reason, which is the gap this closes. + """ + return { + "crc_failures": self.crc_failures, + "stale_partials": self.stale_partials, + "unsolicited_fragments": self.unsolicited_fragments, + "runt_length_drops": self.runt_length_drops, + "overflow_drops": self.overflow_drops, + "queue_full_drops": self.queue_full_drops, + } + + def _reset_reassembly(self) -> None: + """Drop any partial frame and forget when it started.""" + self._response_buffer = bytearray() + self._reassembly_started = None + def _notification_callback( self, characteristic: BleakGATTCharacteristic, data: bytearray ) -> None: @@ -243,62 +359,134 @@ def _notification_callback( ----- This runs in BLE event loop context. Keep processing minimal. - GENI packets can be fragmented by BLE MTU limits (20 bytes). - We accumulate fragments until we have a complete packet: - - If data[0] is 0x24 or 0x27 (frame start), start new packet - - Otherwise, append to current buffer - - Check if packet complete: len(buffer) >= buffer[1] + 4 - - Only queue complete packets + GENI frames are fragmented by the 20-byte MTU, so fragments are + accumulated until the frame's own length field says it is complete: + + - 0x24 starts a new frame, but *only* when not already + reassembling. A mid-frame fragment can begin with 0x24 - it is an + ordinary payload byte - and treating it as a start discarded the + frame under way and dispatched the fragment as a runt. + - The declared length must be plausible before it is trusted. + - The frame is trimmed to its declared length and its CRC checked + before anything downstream sees it. This is the only place a + frame becomes visible to the rest of the client, so it is the + only place that check has to happen - and until it was made, every + write verdict was decided by reading unverified bytes back. """ logger.debug( f"BLE notification received: {len(data)} bytes - {data.hex()}" ) - # Handle packet fragmentation - # Frame start bytes: 0x24 (response) or 0x27 (request/echo) - if len(data) > 0 and data[0] in (0x24, 0x27): - # New packet starting + if not data: + return + + now = asyncio.get_event_loop().time() + + # A partial frame that stopped arriving is abandoned rather than + # left to absorb the next frame's fragments. + if ( + self._response_buffer + and self._reassembly_started is not None + and now - self._reassembly_started > REASSEMBLY_TIMEOUT + ): + self.stale_partials += 1 + logger.warning( + f"Abandoning a partial frame after " + f"{now - self._reassembly_started:.1f}s: " + f"{self._response_buffer.hex()}" + ) + self._reset_reassembly() + + if not self._response_buffer: + if data[0] != RESPONSE_START_BYTE: + # Not a frame start and nothing under way: there is no + # frame this can belong to. + self.unsolicited_fragments += 1 + logger.debug( + f"Ignoring {len(data)} bytes that start no frame: " + f"{bytes(data).hex()}" + ) + return self._response_buffer = bytearray(data) + self._reassembly_started = now else: - # Continuation of existing packet + # Already reassembling. Everything is a continuation, including + # a fragment that happens to begin 0x24. self._response_buffer.extend(data) - # Check if we have a complete packet - if len(self._response_buffer) >= 2: - expected_len = ( - self._response_buffer[1] + 4 - ) # Length field + start + len + CRC(2) - if len(self._response_buffer) >= expected_len: - # Packet complete! - full_packet = bytes(self._response_buffer) - logger.debug(f"Complete packet assembled: {full_packet.hex()}") - - # Queue for protocol layer processing - try: - self._response_queue.put_nowait(full_packet) - except asyncio.QueueFull: - logger.warning("Response queue full, dropping packet") - - # Call custom handlers (e.g., for telemetry updates) - for handler in self._custom_handlers: - try: - handler(full_packet) - except Exception as e: # noqa: BLE001 - # Caller-supplied handler: isolate it so one bad - # handler cannot kill the notification callback. - logger.error(f"Error in custom handler: {e}") - - # Clear buffer for next packet - self._response_buffer.clear() - else: - logger.debug( - f"Partial packet: have {len(self._response_buffer)}, need {expected_len}" - ) + if len(self._response_buffer) < 2: + return - # Safety: Clear buffer if it grows too large (corrupted data) - if len(self._response_buffer) > 256: - logger.warning("Response buffer overflow, clearing") - self._response_buffer.clear() + length_byte = self._response_buffer[1] + if length_byte < MIN_LENGTH_BYTE: + self.runt_length_drops += 1 + logger.warning( + f"Frame declares {length_byte} bytes, below the " + f"{MIN_LENGTH_BYTE}-byte minimum; dropping" + ) + self._reset_reassembly() + return + + expected_len = length_byte + 4 + + # An overflow means frame sync was lost. Drop the partial frame and + # nothing else: it says nothing about whether the pump will answer + # commands already sent, so it must not tear down the queue. Note + # this runs *before* anything is dispatched - it used to run after, + # so an overlong buffer was delivered and only then cleared. + if len(self._response_buffer) > MAX_TELEGRAM_LEN: + self.overflow_drops += 1 + logger.warning( + f"Reassembly buffer reached {len(self._response_buffer)} " + f"bytes, past the {MAX_TELEGRAM_LEN}-byte maximum telegram; " + f"dropping the partial frame" + ) + self._reset_reassembly() + return + + if len(self._response_buffer) < expected_len: + logger.debug( + f"Partial frame: have {len(self._response_buffer)}, " + f"need {expected_len}" + ) + return + + # Trim to what the frame declares. The test above is >=, so + # trailing bytes can be sitting in the buffer - and they are + # outside what the CRC covers, so checking them in would fail a + # sound frame. + full_packet = bytes(self._response_buffer[:expected_len]) + leftover = bytes(self._response_buffer[expected_len:]) + self._reset_reassembly() + + if not frame_crc_valid(full_packet): + self.crc_failures += 1 + logger.warning( + f"Dropping a frame whose CRC does not match " + f"(#{self.crc_failures}): {full_packet.hex()}" + ) + return + + logger.debug(f"Complete packet assembled: {full_packet.hex()}") + + try: + self._response_queue.put_nowait(full_packet) + except asyncio.QueueFull: + self.queue_full_drops += 1 + logger.warning("Response queue full, dropping packet") + + for handler in self._custom_handlers: + try: + handler(full_packet) + except Exception as e: # noqa: BLE001 + # Caller-supplied handler: isolate it so one bad handler + # cannot kill the notification callback. + logger.error(f"Error in custom handler: {e}") + + if leftover: + # A second frame rode in behind the first. Feed it back rather + # than discarding it. + self._notification_callback(characteristic, bytearray(leftover)) async def write(self, data: bytes, response: bool = False) -> None: """ @@ -327,8 +515,8 @@ async def write(self, data: bytes, response: bool = False) -> None: Examples -------- - >>> packet = protocol.build_command(...) - >>> await transport.write(packet) + >>> packet = protocol.build_command(...) # doctest: +SKIP + >>> await transport.write(packet) # doctest: +SKIP """ chunks = [ data[i : i + BLE_MTU_LIMIT] @@ -369,20 +557,50 @@ async def read_response(self, timeout: float = 3.0) -> bytes | None: Examples -------- - >>> await transport.write(request_packet) - >>> response = await transport.read_response(timeout=5.0) - >>> if response: + >>> await transport.write(request_packet) # doctest: +SKIP + >>> response = await transport.read_response(timeout=5.0) # doctest: +SKIP + >>> if response: # doctest: +SKIP ... data = protocol.parse(response) """ + if self._link_down.is_set(): + logger.debug("Not waiting for a reply: the link is down") + return None + + # Race the reply against the link dropping. A dropped link is not a + # timeout: nothing in GENIbus cancels a request, but a dead link + # cannot deliver one either, so waiting the full timeout only + # delays the caller learning what already happened. A chain of + # reads used to sit out three seconds each, in series, after the + # pump had gone. + get = asyncio.ensure_future(self._response_queue.get()) + dropped = asyncio.ensure_future(self._link_down.wait()) try: - response = await asyncio.wait_for( - self._response_queue.get(), timeout=timeout + done, _ = await asyncio.wait( + (get, dropped), + timeout=timeout, + return_when=asyncio.FIRST_COMPLETED, ) + finally: + dropped.cancel() + + if get in done: + response = get.result() logger.debug(f"Read response: {len(response)} bytes") return response - except TimeoutError: + + # Either the link went or the timeout expired. In both cases a + # frame may have landed in the gap between the wait ending and + # this line; keep it rather than losing it with the task. + get.cancel() + if get.done() and not get.cancelled() and get.exception() is None: + with contextlib.suppress(asyncio.QueueFull): + self._response_queue.put_nowait(get.result()) + + if dropped in done: + logger.debug("Gave up waiting for a reply: the link dropped") + else: logger.debug(f"Response timeout after {timeout}s") - return None + return None def transaction(self) -> asyncio.Lock: """ @@ -397,7 +615,7 @@ def transaction(self) -> asyncio.Lock: Examples -------- - >>> async with transport.transaction(): + >>> async with transport.transaction(): # doctest: +SKIP ... await transport.write(command1) ... response1 = await transport.read_response() ... # Next command waits for this to complete @@ -434,9 +652,9 @@ async def send_with_response( Examples -------- - >>> command = protocol.build_read_request(register) - >>> response = await transport.send_with_response(command) - >>> if response: + >>> command = protocol.build_read_request(register) # doctest: +SKIP + >>> response = await transport.send_with_response(command) # doctest: +SKIP + >>> if response: # doctest: +SKIP ... value = protocol.parse_response(response) """ async with self._transaction_lock: @@ -477,7 +695,7 @@ async def query( >>> # Filter out telemetry stream notifications >>> def not_telemetry(data): ... return not (len(data) > 5 and data[4] == 0x0A and data[5] == 0x0E) - >>> response = await transport.query(request, match_func=not_telemetry) + >>> response = await transport.query(request, match_func=not_telemetry) # doctest: +SKIP """ async with self._transaction_lock: # Drain the queue first to avoid stale responses @@ -737,6 +955,14 @@ def notify_disconnected(self) -> None: """ logger.info("BLE link dropped") self._last_write = None + + # Wake every waiter before the handlers run. A reply that has not + # arrived by now is not going to: nothing in GENIbus cancels a + # request, but a dead link cannot deliver one either. Without this + # each caller sat out its full timeout - up to three seconds each, + # in series, for a chain of reads that could not possibly complete. + self._link_down.set() + for handler in self._disconnect_handlers: try: handler() diff --git a/src/alpha_hwr/event_log.py b/src/alpha_hwr/event_log.py index ef195a5..9cbafc8 100644 --- a/src/alpha_hwr/event_log.py +++ b/src/alpha_hwr/event_log.py @@ -1,7 +1,9 @@ """Event log decoder for ALPHA HWR historical data.""" import struct -from datetime import UTC, datetime +from datetime import datetime + +from .pump_time import from_pump_time class EventLogEntry: @@ -36,7 +38,7 @@ def __init__(self, raw_data: bytes, subid: int): # Parse Unix timestamp (big-endian uint32) timestamp_raw = struct.unpack(">I", raw_data[10:14])[0] - self.timestamp = datetime.fromtimestamp(timestamp_raw, tz=UTC) + self.timestamp = from_pump_time(timestamp_raw) self.trailing_data = struct.unpack(">H", raw_data[14:16])[0] @@ -91,7 +93,7 @@ def __init__(self, raw_data: bytes): timestamp_raw = struct.unpack(">I", raw_data[offset : offset + 4])[ 0 ] - dt = datetime.fromtimestamp(timestamp_raw, tz=UTC) + dt = from_pump_time(timestamp_raw) self.timestamps.append(dt) def __repr__(self) -> str: diff --git a/src/alpha_hwr/exceptions.py b/src/alpha_hwr/exceptions.py index cd705f7..996b684 100644 --- a/src/alpha_hwr/exceptions.py +++ b/src/alpha_hwr/exceptions.py @@ -1,4 +1,5 @@ import asyncio +import builtins import struct from bleak.exc import BleakError @@ -8,8 +9,22 @@ class AlphaHWRError(Exception): """Base exception for Alpha HWR errors.""" -class ConnectionError(AlphaHWRError): - """Raised when connection fails.""" +class ConnectionError(AlphaHWRError, builtins.ConnectionError): + """ + Raised when the link is not there, or goes while something is using it. + + Deliberately a subclass of the builtin ``ConnectionError`` as well as + of :class:`AlphaHWRError`, because this package shadows the builtin + name and modules disagreed about which one they were raising. Whether + ``raise ConnectionError(...)`` produced this class or the builtin came + down to whether that particular file happened to import this one - + ``base.py`` and ``client.py`` raised this, ``session.py`` and + ``time.py`` the builtin - and a caller had no way to catch both with + one clause. + + Inheriting from both means ``except ConnectionError`` does the right + thing under either import, which is what anybody writing it expects. + """ class ProtocolError(AlphaHWRError): diff --git a/src/alpha_hwr/protocol/apdu.py b/src/alpha_hwr/protocol/apdu.py new file mode 100644 index 0000000..7aa7561 --- /dev/null +++ b/src/alpha_hwr/protocol/apdu.py @@ -0,0 +1,189 @@ +""" +The APDU head: one byte carrying an operation and a length. + +Byte 5 of a GENI frame is ``0booLLLLLL``. The top two bits are the +operation in a request and the acknowledgement in a reply; the low six are +the number of payload bytes that follow. There is no opcode field, and +nothing in this protocol is identified by an "OpSpec". + +That matters because this package spent a long time treating byte 5 as an +opcode. Two of the resulting mistakes were live until the ESPHome port +decoded the byte properly: + +* ``{0x30, 0x2B, 0x14, 0x2E, 0x2D, 0x09}`` was carried as a set of + "register-read operation specifiers" and used to *discard* frames. Those + are the payload lengths 48, 43, 20, 46, 45 and 9, so a reply carrying + exactly the type its command asked for was thrown away because its + *length* collided with a telemetry register's. +* ``0x81`` was read as "acknowledgement, error code follows". It is + ``10 000001``: Unknown Data Item, one payload byte - and that byte is the + **ID of the item the pump did not recognise**, not an error code. A + refused write therefore read as accepted whenever the offending ID + happened to be ``0x00``, which is exactly the case this pump produces. + +The relation ``byte5 == len(frame) - 8`` holds for every one of the 26,898 +CRC-valid inbound frames in the capture corpus, which is what settled it. + +See ``docs/protocol/wire_format.md``. +""" + +from __future__ import annotations + +from enum import IntEnum + +#: Mask selecting the payload-length bits of an APDU head. +APDU_LEN_MASK = 0x3F + +#: Bytes of frame overhead around an APDU's payload: start, length, +#: destination, source, class, APDU head, and the two CRC bytes. +FRAME_OVERHEAD = 8 + + +class ApduOp(IntEnum): + """ + The operation a *request* asks for. + + There is deliberately no ``0b01``. It was long documented as one - the + frame builder called ``0b00`` INFO and had no name for ``0b11`` - and + issue #46 in the ESPHome port is what confusing the two costs. + """ + + GET = 0b00 + SET = 0b10 + INFO = 0b11 + + +class ApduAck(IntEnum): + """ + The acknowledgement a *reply* carries. + + Only :attr:`OK` means the pump acted. The three refusals each name a + different reason the request could not be honoured, and the two item + errors carry the offending Data Item's ID as their single payload byte. + """ + + OK = 0b00 + UNKNOWN_CLASS = 0b01 + UNKNOWN_DATA_ITEM = 0b10 + ILLEGAL_OPERATION = 0b11 + + +def apdu_payload_len(head: int) -> int: + """ + Payload byte count declared by an APDU head. + + Examples + -------- + >>> apdu_payload_len(0x0A) + 10 + >>> apdu_payload_len(0x81) + 1 + >>> apdu_payload_len(0x40) + 0 + """ + return head & APDU_LEN_MASK + + +def apdu_op(head: int) -> int: + """Operation bits of a *request* APDU head.""" + return (head >> 6) & 0b11 + + +def apdu_ack(head: int) -> int: + """Acknowledgement bits of a *reply* APDU head.""" + return (head >> 6) & 0b11 + + +def apdu_ack_is_ok(head: int) -> bool: + """ + True when a reply's APDU head reports success. + + Examples + -------- + >>> apdu_ack_is_ok(0x0A) + True + >>> apdu_ack_is_ok(0x81) + False + """ + return apdu_ack(head) == ApduAck.OK + + +def apdu_is_set(head: int) -> bool: + """True when a request APDU head asks for a SET.""" + return apdu_op(head) == ApduOp.SET + + +def ack_name(head: int) -> str: + """ + Human-readable name for a reply's acknowledgement bits. + + Used in log lines about refusals, where "the pump said no, and here is + which no" is the whole diagnostic value. + + Examples + -------- + >>> ack_name(0x81) + 'Unknown Data Item' + """ + return { + ApduAck.OK: "OK", + ApduAck.UNKNOWN_CLASS: "Unknown Class", + ApduAck.UNKNOWN_DATA_ITEM: "Unknown Data Item", + ApduAck.ILLEGAL_OPERATION: "Illegal Operation", + }[ApduAck(apdu_ack(head))] + + +class Class10Ack(IntEnum): + """ + The *second* acknowledgement, carried inside a Class 10 reply's payload. + + The APDU head says whether the pump understood the request; this says + whether it could carry it out. Both have to be right. The values come + from ``GeniAPDU.CLASS10_ACK_*`` in the decompiled Grundfos GO app and + appear in neither the GENIbus application manual nor the public + ``christoph2/GENIBus`` reference. + + The capture corpus holds 222 short Class 10 replies: 195 OK, 18 BUSY + and 9 OPERATION_FAILED, no fourth value, every one with the head ack + OK. They are request-consistent - Object 202 Sub 100 answers BUSY every + time, Sub 200 answers OPERATION_FAILED every time. + """ + + OK = 0 + BUSY = 2 + OPERATION_FAILED = 4 + + +#: Shortest Class 10 reply that really carries the second acknowledgement: +#: ``24 05 F8 E7 0A 01 PL CRC CRC``. +#: +#: The bound is load-bearing rather than defensive. At ``len >= 7`` an +#: eight-byte CRC-valid frame declaring one payload byte puts the **CRC +#: high byte** at ``data[6]`` - and that byte would then decide a write's +#: verdict. +MIN_CLASS10_ACK_LENGTH = 9 + + +def class10_reply_is_ok(frame: bytes) -> bool: + """ + Whether a short Class 10 reply reports success on *both* acknowledgements. + + The payload byte is only a status when the head ack is OK. On a refusal + that same byte is the offending Data Item's ID, so reading it as a + status would turn "unknown item 0" into "operation succeeded". + + Examples + -------- + >>> class10_reply_is_ok(bytes.fromhex("2405f8e70a0100aea2")) + True + >>> class10_reply_is_ok(bytes.fromhex("2405f8e70a0102aea2")) + False + """ + if len(frame) < 6: + return False + head = frame[5] + if not apdu_ack_is_ok(head): + return False + if apdu_payload_len(head) == 0 or len(frame) < MIN_CLASS10_ACK_LENGTH: + return True + return frame[6] == Class10Ack.OK diff --git a/src/alpha_hwr/protocol/codec.py b/src/alpha_hwr/protocol/codec.py index f0eb872..c42c953 100644 --- a/src/alpha_hwr/protocol/codec.py +++ b/src/alpha_hwr/protocol/codec.py @@ -43,7 +43,7 @@ def encode_float_be(value: float) -> bytes: Examples -------- >>> encode_float_be(1.5) - b'\\x3f\\xc0\\x00\\x00' + b'?\\xc0\\x00\\x00' >>> encode_float_be(100.0) b'B\\xc8\\x00\\x00' @@ -83,8 +83,8 @@ def decode_float_be(data: bytes, offset: int = 0) -> float | None: >>> decode_float_be(data, offset=2) 100.0 - >>> decode_float_be(b'\\x00\\x00') # Not enough bytes - None + >>> decode_float_be(b'\\x00\\x00') is None # Not enough bytes + True Notes ----- diff --git a/src/alpha_hwr/protocol/frame_builder.py b/src/alpha_hwr/protocol/frame_builder.py index ad41f62..ae19329 100644 --- a/src/alpha_hwr/protocol/frame_builder.py +++ b/src/alpha_hwr/protocol/frame_builder.py @@ -59,13 +59,13 @@ class FrameBuilder: >>> # Read temperature register >>> packet = FrameBuilder.build_command_info(0x03, 0x5D012C) >>> len(packet) - 9 + 11 >>> packet[0] == 0x27 # FRAME_START True >>> # Set control mode - >>> data = encode_float_be(14710.0) # 1.5m in Pascals - >>> packet = FrameBuilder.build_data_object_set(0x5600, 0x0601, data) + >>> data = encode_float_be(14710.0) # 1.5m in Pascals # doctest: +SKIP + >>> packet = FrameBuilder.build_data_object_set(0x5600, 0x0601, data) # doctest: +SKIP Notes for Reimplementation -------------------------- @@ -116,7 +116,7 @@ def build_command_info( >>> # Read 1-byte register >>> packet = FrameBuilder.build_command_info(0x02, 0x45) >>> packet.hex() - '27050e7f8020345...' + '2705e7f8020145b188' >>> # Read 2-byte register >>> packet = FrameBuilder.build_command_info(0x03, 0x5D01) @@ -212,8 +212,8 @@ def build_set_command( >>> packet = FrameBuilder.build_set_command(0x02, 0x01, 0x45, 0x01) >>> # Write float value - >>> value = encode_float_be(100.0) - >>> packet = FrameBuilder.build_set_command(0x03, 0x04, 0x5D01, value) + >>> value = encode_float_be(100.0) # doctest: +SKIP + >>> packet = FrameBuilder.build_set_command(0x03, 0x04, 0x5D01, value) # doctest: +SKIP """ # Encode register address reg_bytes = [] @@ -298,9 +298,9 @@ def build_data_object_set( Examples -------- >>> # Set constant pressure mode to 1.5m (14710 Pa) - >>> setpoint_data = encode_float_be(14710.0) + >>> setpoint_data = encode_float_be(14710.0) # doctest: +SKIP >>> control_data = bytes([0x2F, 0x01, 0x00, 0x00, 0x07, 0x00, 0x00, 0x00]) - >>> control_data += setpoint_data + >>> control_data += setpoint_data # doctest: +SKIP >>> packet = FrameBuilder.build_data_object_set(0x5600, 0x0601, control_data) >>> # Trigger operation (no data) diff --git a/src/alpha_hwr/protocol/frame_parser.py b/src/alpha_hwr/protocol/frame_parser.py index 99ac935..196d7be 100644 --- a/src/alpha_hwr/protocol/frame_parser.py +++ b/src/alpha_hwr/protocol/frame_parser.py @@ -1,131 +1,48 @@ """ GENI protocol frame parser. -This module parses raw GENI protocol frames received from the pump: -- Response validation (start byte, length, CRC) -- Class 2/3 register responses -- Class 10 DataObject notifications -- Error handling and validation - -Frame Structure ---------------- -All GENI frames follow this structure: - -[Start] [Length] [ServiceID-H] [ServiceID-L/Source] [APDU...] [CRC-H] [CRC-L] - -Where: -- Start: 0x24 (RESPONSE_START for responses) or 0x27 (FRAME_START for requests) -- Length: Number of bytes from ServiceID to end of APDU (not including CRC) -- ServiceID-H: 0xE7 (GENI service) -- ServiceID-L/Source: 0xF8 (standard) or 0x0A (alternative) -- APDU: Application Protocol Data Unit (class, opspec, data) -- CRC: CRC-16-CCITT checksum - -APDU Formats ------------- - -Class 2/3 (Register-based): -[Class] [OpSpec] [Register...] [Data...] - -Class 10 (DataObject): -[0x0A] [OpSpec] [SubID-H] [SubID-L] [ObjID-H] [ObjID-L] [Data...] - -For complete protocol reference, see: -- docs/protocol/wire_format.md -- docs/protocol/ble_architecture.md - -The parser is deliberately simple, and worth reproducing in this order: - -1. **Validation First**: Always validate start byte, length, and CRC before parsing -2. **Big-Endian**: All multi-byte values are big-endian (network byte order) -3. **Minimal State**: Parser is stateless - each frame parsed independently -4. **Type Safety**: Use dataclasses/structs to ensure type correctness - -Example in C: -```c -typedef struct { - bool valid; - FrameType frame_type; - uint8_t class_byte; - uint16_t sub_id; - uint16_t obj_id; - uint8_t* payload; - size_t payload_len; - bool crc_valid; -} ParsedFrame; - -ParsedFrame parse_frame(const uint8_t* data, size_t len) { - ParsedFrame frame = {0}; - - // Validate minimum length - if (len < 8) return frame; - - // Validate start byte - if (data[0] != FRAME_START && data[0] != RESPONSE_START) return frame; - - // Validate CRC - uint16_t expected_crc = calc_crc16(&data[1], len - 3); - uint16_t actual_crc = (data[len-2] << 8) | data[len-1]; - frame.crc_valid = (expected_crc == actual_crc); - - // Extract fields - frame.frame_type = (data[0] == RESPONSE_START) ? RESPONSE : REQUEST; - frame.class_byte = data[4]; - - if (frame.class_byte == CLASS_10 && len > 9) { - frame.sub_id = (data[6] << 8) | data[7]; - frame.obj_id = (data[8] << 8) | data[9]; - frame.payload = &data[10]; - frame.payload_len = len - 12; // Subtract header + CRC - } - - frame.valid = true; - return frame; -} -``` - -Example in JavaScript: -```javascript -class ParsedFrame { - constructor() { - this.valid = false; - this.frameType = null; - this.classByte = null; - this.subId = null; - this.objId = null; - this.payload = null; - this.crcValid = false; - } -} - -function parseFrame(data) { - const frame = new ParsedFrame(); - - // Validate minimum length - if (data.length < 8) return frame; - - // Validate start byte - if (data[0] !== FRAME_START && data[0] !== RESPONSE_START) return frame; - - // Validate CRC - const expectedCrc = calcCrc16(data.slice(1, -2)); - const actualCrc = (data[data.length-2] << 8) | data[data.length-1]; - frame.crcValid = (expectedCrc === actualCrc); - - // Extract fields - frame.frameType = data[0] === RESPONSE_START ? 'response' : 'request'; - frame.classByte = data[4]; - - if (frame.classByte === CLASS_10 && data.length > 9) { - frame.subId = (data[6] << 8) | data[7]; - frame.objId = (data[8] << 8) | data[9]; - frame.payload = data.slice(10, -2); - } - - frame.valid = true; - return frame; -} -``` +A frame is:: + + [0] start: 0x24 from the pump, 0x27 from us + [1] length: bytes from [2] through the last APDU byte + [2] destination address + [3] source address + [4] class + [5] APDU head: 0booLLLLLL - operation/ack, then payload length + [6:] APDU payload + [-2:] CRC-16-CCITT over frame[1:-2], final XOR 0xFFFF + +Bytes 2 and 3 are addresses, not a "service ID". We send +``[0x27][len][0xE7][0xF8]`` - destination 0xE7 is the pump's unit address, +source 0xF8 is ours - and the pump answers ``[0x24][len][0xF8][0xE7]`` with +the two swapped. Frames in this package's history that show ``24 .. E7 F8`` +were written by hand rather than captured; a real reply never looks like +that. + +Two things follow from the APDU head (see :mod:`alpha_hwr.protocol.apdu`) +and both were wrong here until they were decoded properly: + +**The payload is bounded by its declared length, not by the CRC.** A GENIbus +telegram may carry several APDUs - the application manual is explicit that +"errors in one APDU will in no way influence the reply to sound APDU's" - so +an error reply can substitute for one answer inside a telegram carrying +others. Slicing to ``[-2]`` therefore reports the *next* APDU, and its CRC, +as this one's payload. :attr:`ParsedFrame.multi_apdu` says when there was +more in the telegram than the frame reports. + +**Byte 5 is not an opcode.** The set ``{0x30, 0x2B, 0x14, 0x2E, 0x2D, 0x09}`` +was carried here as "register-read operation specifiers" and used to select a +different payload offset. They are the payload lengths 48, 43, 20, 46, 45 and +9. Nothing dispatches on them any more. + +A Class 10 reply's bytes 6-9 are ``[00][TypeH][TypeL][Version]`` - the +object's *type*, not the Object ID and Sub-ID it was asked for. The pump +does not echo an address. Measured against an ALPHA HWR: reading Object 86 +sub-ids 13, 15, 17 and 39 returns ``00 01 2d 01`` for all four, because they +are four instances of type 301 version 1. See +:mod:`alpha_hwr.protocol.matcher` for what that costs a caller. + +For the full wire reference see ``docs/protocol/wire_format.md``. """ from dataclasses import dataclass @@ -133,6 +50,19 @@ class ParsedFrame { from ..constants import CLASS_10, FRAME_START, RESPONSE_START from ..utils import calc_crc16_read +from .apdu import apdu_payload_len + +#: Smallest legal frame: start, length, destination, source, class, APDU +#: head and two CRC bytes, with an empty payload. +MIN_FRAME_LENGTH = 8 + +#: Bytes 6-9 of a Class 10 reply carry ``[00][TypeH][TypeL][Version]``, so a +#: frame has to reach this length before it can be said to carry a type. +MIN_TYPED_LENGTH = 12 + +#: Offset of the first payload byte in a Class 10 reply, past the type and +#: version fields. +CLASS10_BODY_OFFSET = 10 @dataclass @@ -140,117 +70,200 @@ class ParsedFrame: """ Parsed GENI protocol frame. - This structure represents a fully parsed GENI frame with all fields extracted - and validated. Use this as a reference for implementing in other languages. - Attributes: - valid: True if the frame structure is valid (correct start byte, length) - frame_type: 'request' (0x27) or 'response' (0x24) - class_byte: GENI class byte (2, 3, 10, etc.) - sub_id: Sub-ID for Class 10 frames (None for other classes) - obj_id: Object ID for Class 10 frames (None for other classes) - payload: Raw payload bytes (excluding header and CRC) - crc_valid: True if CRC checksum is correct - raw_data: Original raw frame data - - Example: - >>> # Parse a Class 10 telemetry response - >>> raw = bytes.fromhex('2415e7f80a015700450000000000000000000000fa3c') - >>> frame = FrameParser.parse_frame(raw) - >>> frame.valid - True - >>> frame.class_byte - 10 - >>> frame.sub_id - 22272 # 0x5700 - >>> frame.obj_id - 69 # 0x0045 + valid: The frame is structurally sound - plausible start byte, and + long enough for the length it declares. + frame_type: 'request' (0x27) or 'response' (0x24). + class_byte: GENI class byte (2, 3, 7, 10, ...). + type_high: Bytes 6-7 of a Class 10 reply. None for other classes. + type_low_ver: Bytes 8-9 of a Class 10 reply - the low byte of the + object type and its version. None for other classes. + payload: Payload of the *first* APDU, bounded by the length that + APDU declares. + multi_apdu: The telegram carried more after the first APDU. This + describes the telegram, not the payload, so it can be true on a + frame too short to extract any payload from. + crc_valid: The trailing CRC matches the body. + raw_data: Original frame bytes. + + Note: + ``valid`` says the frame parses, not that it is trustworthy. Check + ``crc_valid`` before believing a payload - or better, let + :class:`~alpha_hwr.core.transport.Transport` drop bad frames before + they reach here, which is what it now does. """ valid: bool frame_type: Literal["request", "response"] | None class_byte: int | None - sub_id: int | None - obj_id: int | None + type_high: int | None + type_low_ver: int | None payload: bytes + multi_apdu: bool crc_valid: bool raw_data: bytes + @property + def object_type(self) -> int | None: + """ + The object's type number, decoded at its real field boundary. + + Bytes 6-9 are ``[00][TypeH][TypeL][Version]``, so the type spans + bytes 7-8 and the version is byte 9. :attr:`type_high` and + :attr:`type_low_ver` split the same four bytes into two 16-bit + halves *one byte off* that boundary - a convention inherited from + the ESPHome port, which kept it deliberately because comparing + both halves is equivalent to comparing type and version together, + and it is what the matcher does. + + Equivalent for matching, misleading for reading. This is the + number to quote: + + ===================== ========== ========================== + bytes 6-9 type/ver profile name + ===================== ========== ========================== + ``00 01 00 03`` 256 v3 ProtectedMotorStateDetails + ``00 02 35 02`` 565 v2 PumpedMediaRelated…Extended + ``00 02 16 02`` 534 v2 MediaTemperatureInfo + ``00 02 3a 01`` 570 v1 FaultsByArrayExtended + ``00 01 2f 01`` 303 v1 operation status + ``00 01 2d 01`` 301 v1 setpoint factory config + ``00 00 da 01`` 218 v1 ClockProgramOverview + ===================== ========== ========================== + + Examples: + >>> f = FrameParser.parse_frame( + ... bytes.fromhex('2412f8e70a0e00012f0100000701001b39678ac3f7dd')) + >>> f.object_type, f.object_version + (303, 1) + """ + if self.type_high is None or self.type_low_ver is None: + return None + return ((self.type_high & 0xFF) << 8) | (self.type_low_ver >> 8) + + @property + def object_version(self) -> int | None: + """Version byte of the object type. See :attr:`object_type`.""" + if self.type_low_ver is None: + return None + return self.type_low_ver & 0xFF + + @property + def object_body(self) -> bytes: + """ + Payload with the object's three-byte size header removed. + + A typed Class 10 object puts ``[00][00][size]`` in front of its + struct. Measured on an ALPHA HWR: the motor register declares 48 + payload bytes, of which four are the type fields, leaving 44 from + offset 10 - and the first three of those read ``00 00 29``, a size + of 41, which is exactly the 44 that remain. The same holds for the + flow (36 of 39), temperature (13 of 16) and schedule-overview + (10 of 13) replies. + + Returns the payload unchanged when no such header is present, so a + short acknowledgement is not mistaken for a truncated struct. + """ + body = self.payload + if ( + len(body) >= 3 + and body[0] == 0 + and body[1] == 0 + and body[2] == len(body) - 3 + ): + return body[3:] + return body + + @property + def sub_id(self) -> int | None: + """ + Deprecated alias for :attr:`type_high`. + + A response carries no Sub-ID; this name survives only so older + callers keep working while they are moved over. + """ + return self.type_high + + @property + def obj_id(self) -> int | None: + """ + Deprecated alias for :attr:`type_low_ver`. + + A response carries no Object ID - see the module docstring. + """ + return self.type_low_ver + + +def frame_crc_valid(data: bytes) -> bool: + """ + Whether a frame's trailing CRC matches its body. + + The CRC covers ``frame[1:-2]``: the start byte is excluded because it is + a delimiter, and the CRC cannot cover itself. + + Examples: + >>> frame_crc_valid(bytes.fromhex('240ef8e7070a414c5048412048575200838d')) + True + >>> frame_crc_valid(bytes.fromhex('240ef8e7070a414c5048412048575200ffff')) + False + """ + if len(data) < 4: + return False + return calc_crc16_read(data[1:-2]) == ((data[-2] << 8) | data[-1]) + class FrameParser: """ Parses GENI protocol frames. - This is a stateless parser - each frame is parsed independently. - All methods are static for easy porting to other languages. + Stateless - each frame is parsed independently. """ @staticmethod def parse_frame(data: bytes) -> ParsedFrame: """ - Parse raw GENI frame into structured data. - - This method validates the frame structure and extracts all fields. - It performs the following validations: - 1. Minimum length (8 bytes) - 2. Valid start byte (0x27 or 0x24) - 3. CRC checksum + Parse a raw GENI frame into structured data. Args: - data: Raw frame bytes from BLE notification or response + data: One reassembled frame. Trailing bytes beyond the declared + length are ignored rather than folded into the payload. Returns: - ParsedFrame with extracted fields and validation flags + ParsedFrame with fields extracted and validation flags set. Examples: - >>> # Parse a request frame - >>> request = bytes.fromhex('2707e7f80203949596eb47') - >>> frame = FrameParser.parse_frame(request) - >>> frame.valid - True - >>> frame.frame_type - 'request' - >>> frame.class_byte - 2 - - >>> # Parse a Class 10 response - >>> response = bytes.fromhex('2415e7f80a015700450000000000000000000000fa3c') - >>> frame = FrameParser.parse_frame(response) - >>> frame.class_byte - 10 - >>> frame.sub_id - 22272 - >>> frame.obj_id - 69 - - >>> # Parse invalid frame - >>> invalid = bytes.fromhex('ff00') - >>> frame = FrameParser.parse_frame(invalid) - >>> frame.valid + >>> # Object 86 Sub 7, captured from an ALPHA HWR + >>> f = FrameParser.parse_frame( + ... bytes.fromhex('2412f8e70a0e00012f0100000701001b39678ac3f7dd')) + >>> f.valid, f.crc_valid, f.class_byte + (True, True, 10) + >>> hex(f.type_high), hex(f.type_low_ver) + ('0x1', '0x2f01') + >>> f.multi_apdu False - Implementation Notes: - - Always check `valid` flag before using parsed data - - Check `crc_valid` for data integrity - - Handle None values for sub_id/obj_id (not present in Class 2/3) + >>> # A refusal: Unknown Data Item naming item 0x00 + >>> r = FrameParser.parse_frame(bytes.fromhex('2407f8e70a810040405ebf')) + >>> r.class_byte, r.payload.hex() + (10, '00') + >>> r.multi_apdu + True """ - # Initialize result with invalid state result = ParsedFrame( valid=False, frame_type=None, class_byte=None, - sub_id=None, - obj_id=None, + type_high=None, + type_low_ver=None, payload=b"", + multi_apdu=False, crc_valid=False, raw_data=data, ) - # Validate minimum length - if len(data) < 8: + if len(data) < MIN_FRAME_LENGTH: return result - # Validate start byte start_byte = data[0] if start_byte == RESPONSE_START: result.frame_type = "response" @@ -259,127 +272,70 @@ def parse_frame(data: bytes) -> ParsedFrame: else: return result - # Frame is structurally valid - result.valid = True - - # Validate CRC - # CRC covers from Length byte to end of APDU (excludes Start and CRC itself) - crc_data = data[1:-2] - calculated_crc = calc_crc16_read(crc_data) - actual_crc = (data[-2] << 8) | data[-1] - result.crc_valid = calculated_crc == actual_crc + # A frame promising fewer bytes than the protocol's minimum is not a + # short frame, it is a broken one. Clamping instead would leave a + # "valid" frame with no class byte. + declared_total = data[1] + 4 + if declared_total < MIN_FRAME_LENGTH or declared_total > len(data): + return result - # Extract class byte (offset 4 in frame) + result.valid = True + result.crc_valid = frame_crc_valid(data[:declared_total]) result.class_byte = data[4] - # Parse based on class - if result.class_byte == CLASS_10 and len(data) > 5: - opspec = data[5] - # OpSpecs for register-read responses: 0x30 (motor), 0x2b (flow), 0x14 (temp), 0x09 (alarms/warnings), etc. - # Format: [Class][OpSpec][Seq(2)][Id(2)][Res(2)][DataLen][Data...] - if opspec in (0x30, 0x2B, 0x14, 0x2E, 0x2D, 0x09): - if len(data) > 12: - result.payload = data[13:-2] # Data starts at offset 13 - # We can store the ID as obj_id for routing if needed, - # but these are handled by decode_register_read_response anyway. - result.obj_id = (data[8] << 8) | data[9] - result.sub_id = (data[6] << 8) | data[ - 7 - ] # This is actually sequence number - elif len(data) > 9: - # Class 10 Notification/SET: [Class][OpSpec][SubH][SubL][ObjH][ObjL][Payload...][CRC] - result.sub_id = (data[6] << 8) | data[7] # Big-endian uint16 - result.obj_id = (data[8] << 8) | data[9] # Big-endian uint16 - result.payload = data[10:-2] # From after ObjID to before CRC + # Everything from here is bounded by what the first APDU declares. + # body_limit is where the CRC starts; apdu1_end is where this APDU's + # payload stops. They differ exactly when the telegram carries more. + body_limit = declared_total - 2 + apdu1_end = min(6 + apdu_payload_len(data[5]), body_limit) + result.multi_apdu = apdu1_end < body_limit + + if result.class_byte == CLASS_10: + if len(data) >= MIN_TYPED_LENGTH: + result.type_high = (data[6] << 8) | data[7] + result.type_low_ver = (data[8] << 8) | data[9] + if apdu1_end > CLASS10_BODY_OFFSET: + result.payload = data[CLASS10_BODY_OFFSET:apdu1_end] + elif apdu1_end > 6: + # A short Class 10 reply - an acknowledgement or a refusal - + # carries its one byte at offset 6, with no type fields. + result.payload = data[6:apdu1_end] else: - # Class 2/3: Payload starts after OpSpec - # Format: [Start][Len][SvcH][SvcL][Class][OpSpec][Register...][Payload...][CRC] - result.payload = data[6:-2] # From after OpSpec to before CRC + result.payload = data[6:apdu1_end] return result - @staticmethod - def extract_class10_identifiers( - frame: ParsedFrame, - ) -> dict[str, int | None]: - """ - Extract Class 10 identifiers from parsed frame. - - Convenience method to get Sub-ID and Object ID as a dictionary. - Useful for routing/dispatching based on object type. - - Args: - frame: Parsed frame from parse_frame() - - Returns: - Dictionary with 'sub_id' and 'obj_id' keys - - Examples: - >>> frame = FrameParser.parse_frame(response_data) - >>> ids = FrameParser.extract_class10_identifiers(frame) - >>> if ids['obj_id'] == 87 and ids['sub_id'] == 69: - ... print("Motor state telemetry") - """ - return { - "sub_id": frame.sub_id, - "obj_id": frame.obj_id, - } - @staticmethod def is_telemetry_frame(frame: ParsedFrame) -> bool: """ - Check if frame is a telemetry notification. - - Telemetry frames are Class 10 responses with known Sub-ID/Object ID pairs. + Check whether a frame is one of the known telemetry notifications. Args: - frame: Parsed frame from parse_frame() + frame: Parsed frame from parse_frame(). Returns: - True if frame is a known telemetry type - - Examples: - >>> frame = FrameParser.parse_frame(response_data) - >>> if FrameParser.is_telemetry_frame(frame): - ... telemetry_data = TelemetryDecoder.decode(frame) + True if the frame's type matches a known telemetry object. """ if not frame.valid or frame.class_byte != CLASS_10: return False - - # Known telemetry object IDs - TELEMETRY_OBJECTS = { - (87, 69), # Motor state - (93, 290), # Flow/Pressure - (93, 300), # Temperature - (88, 0), # Active alarms - (88, 11), # Active warnings - (3, 1), # Custom electrical - (0x2D01, 1), # Custom speed/power - (0x1602, 2), # Custom temperature - } - - return (frame.obj_id, frame.sub_id) in TELEMETRY_OBJECTS + return (frame.type_low_ver, frame.type_high) in TELEMETRY_TYPES @staticmethod def validate_frame_integrity(frame: ParsedFrame) -> tuple[bool, str]: """ - Comprehensive validation of frame integrity. - - Checks all validation flags and returns detailed error message if invalid. + Validate a parsed frame, with a reason when it fails. Args: - frame: Parsed frame from parse_frame() + frame: Parsed frame from parse_frame(). Returns: - Tuple of (is_valid, error_message) - - is_valid: True if all checks pass - - error_message: Empty string if valid, otherwise describes the issue + ``(is_valid, error_message)``; the message is empty when valid. Examples: - >>> frame = FrameParser.parse_frame(data) - >>> valid, error = FrameParser.validate_frame_integrity(frame) - >>> if not valid: - ... logger.error(f"Frame validation failed: {error}") + >>> f = FrameParser.parse_frame( + ... bytes.fromhex('240ef8e7070a414c5048412048575200838d')) + >>> FrameParser.validate_frame_integrity(f) + (True, '') """ if not frame.valid: return False, "Invalid frame structure (bad start byte or length)" @@ -390,50 +346,73 @@ def validate_frame_integrity(frame: ParsedFrame) -> tuple[bool, str]: if frame.class_byte is None: return False, "Missing class byte" - if frame.class_byte == CLASS_10 and ( - frame.sub_id is None or frame.obj_id is None - ): - return False, "Class 10 frame missing Sub-ID or Object ID" - return True, "" -# Test vectors for validation in other languages -# These can be used to verify correct implementation +#: Response types the pump's telemetry stream uses, as +#: ``(type_low_ver, type_high)``. +TELEMETRY_TYPES = { + (0x0003, 0x0001), # motor state + (0x3502, 0x0002), # flow / pressure + (0x1602, 0x0002), # temperature + (0x3A01, 0x0002), # active alarms *and* active warnings +} + + +#: Frames captured from an ALPHA HWR (family 52, type 7, version 2) on +#: 2026-08-20, for validating a reimplementation. +#: +#: These are recordings, not constructions. An earlier table here was +#: hand-written and had the destination and source addresses the wrong way +#: round, which no reply from this pump ever has - so anything checked +#: against it was being checked against a frame the pump cannot send. TEST_VECTORS = { - "class10_motor_state": { - "hex": "2412e7f80a0a0045005700000000000000000000fd72", + "class7_product_name": { + "hex": "240ef8e7070a414c5048412048575200838d", + "expected": { + "valid": True, + "frame_type": "response", + "class_byte": 7, + "payload": "ALPHA HWR\x00", + "crc_valid": True, + }, + }, + "class10_mode_read": { + "hex": "2412f8e70a0e00012f0100000701001b39678ac3f7dd", "expected": { "valid": True, "frame_type": "response", "class_byte": 10, - "sub_id": 69, # 0x0045 - "obj_id": 87, # 0x0057 + "type_high": 0x0001, + "type_low_ver": 0x2F01, "payload_len": 10, "crc_valid": True, }, }, - "class10_flow_pressure": { - "hex": "2416e7f80a0e0122005d00000000000000000000000000000b8b", + "class10_setpoint_range": { + "hex": ( + "2427f8e70a2300012d0100001c452f000044ce400045657000" + "c56570003f8000003f8000003f80000089a9" + ), "expected": { "valid": True, "frame_type": "response", "class_byte": 10, - "sub_id": 290, # 0x0122 (big-endian: 01 22) - "obj_id": 93, # 0x005D (big-endian: 00 5D) - "payload_len": 14, + "type_high": 0x0001, + "type_low_ver": 0x2D01, + "payload_len": 31, "crc_valid": True, }, }, - "auth_legacy_magic": { - "hex": "2707e7f80203949596eb47", + "class10_schedule_overview": { + "hex": "2415f8e70a110000da0100000a02050005010100000000dd89", "expected": { "valid": True, - "frame_type": "request", - "class_byte": 2, - "sub_id": None, - "obj_id": None, - "payload_len": 3, # Payload is after OpSpec (offset 6), before CRC + "frame_type": "response", + "class_byte": 10, + "type_high": 0x0000, + "type_low_ver": 0xDA01, + "payload_len": 13, "crc_valid": True, }, }, diff --git a/src/alpha_hwr/protocol/matcher.py b/src/alpha_hwr/protocol/matcher.py index 5314128..fc09360 100644 --- a/src/alpha_hwr/protocol/matcher.py +++ b/src/alpha_hwr/protocol/matcher.py @@ -2,33 +2,38 @@ Deciding whether a notification answers an outstanding command. GENIbus carries no transaction id, so a reply is matched positionally -against the command still in flight. What makes that non-trivial is the -pump's firmware: it answers with a different Sub-ID than was asked for, -puts the identifier fields in different places depending on the operation -specifier, and acknowledges some writes with a frame far shorter than a -normal response. - -The rules below are the pump's observed behaviour rather than anything -the protocol promises. They mirror the C++ port's ``try_dispatch_response`` -(``components/alpha_hwr/transport.cpp``), which is the version currently -validated against hardware. +against the command still in flight. What makes that hard is not firmware +inconsistency, as this module long assumed, but the protocol itself: **a +reply carries no Object ID and no Sub-ID.** Bytes 6-9 hold +``[00][TypeH][TypeL][Version]`` - the *type* of the object answered. + +That has a sharp consequence. Matching discriminates types, not instances. +Object 86 sub-ids 13, 15, 17 and 39 are four different setpoint ranges and +all four answer ``00 01 2d 01``, because all four are type 301 version 1. +So are the five schedule layers, every single-event slot, and every event +log entry. A chain that reads siblings must be strictly sequential and must +stop at the first failure: carry on, and read N's late reply is handed to +read N+1, shifting every remaining answer by one slot. + +Measured against an ALPHA HWR on 2026-08-20 by reading each object and +recording bytes 6-9 of the answer. Frame layout (see ``frame_parser``):: - [0] start (0x24 response, 0x27 request/echo) + [0] start (0x24 response, 0x27 request) [1] length - [2] service id (0xE7) - [3] source address (0xF8) + [2] destination address + [3] source address [4] class - [5] operation specifier - [6:8] identifier field A - [8:10] identifier field B - -Whether field A holds the Object ID or the Sub-ID depends on the operation -specifier, and the pump is not consistent about it - so a command declares -which values it expects and a match is accepted with the two fields in -either order. Naming them A and B rather than guessing keeps the ambiguity -visible instead of encoding a claim the traffic does not support. + [5] APDU head: 0booLLLLLL - operation/ack, then payload length + [6:8] 0x00 then the object type's high byte + [8:10] the type's low byte then its version + +This module used to call bytes 6-7 "identifier field A" and 8-9 "field B", +and accepted a match with the two in either order, on the theory that the +pump placed them inconsistently. It does not; they are one four-byte type +field, and the swapped-order rule was accepting frames on the strength of a +coincidence. It is gone. """ from __future__ import annotations @@ -36,6 +41,9 @@ from dataclasses import dataclass, field from ..constants import CLASS_10, RESPONSE_START +from .apdu import ( + apdu_payload_len, +) #: Classes whose acknowledgement is a bare frame with no identifier #: fields: the pump replies ``[class, 0x00]`` for a command it executed @@ -62,7 +70,7 @@ #: This is the reliable way to tell a solicited reply from the pump's #: notification stream. Matching on the frame's second byte is not: see #: RESPONSE_LENGTH_IS_BYTE_5 below. -RESPONSE_IDENTIFIERS: dict[tuple[int, range], tuple[int, int]] = { +RESPONSE_TYPES: dict[tuple[int, range], tuple[int, int]] = { (86, range(5, 11)): (0x0001, 0x2F01), # operation status request (86, range(13, 40)): (0x0001, 0x2D01), # setpoint limits (91, range(421, 422)): (0x0003, 0xD901), # DHW / cycle-time config @@ -77,8 +85,22 @@ (88, range(13300, 13302)): (0x0003, 0xE801), # cycle timestamps (53, range(451, 454)): (0x0003, 0xB201), # trends: flow, head, temp (53, range(454, 455)): (0x0003, 0xB301), # trend: power-on time + # Telemetry. These were absent, so every telemetry read was matched by + # class alone and any Class 10 notification could answer one. + # Alarms and warnings answer with one and the same type, so a reply + # cannot say which of the two it is; only the request knows. Measured + # 2026-08-20: reading 88/0 and 88/11 returned byte-identical frames. + (88, range(1)): (0x0002, 0x3A01), # active alarms + (88, range(11, 12)): (0x0002, 0x3A01), # active warnings + (87, range(69, 70)): (0x0001, 0x0003), # motor state + (93, range(290, 291)): (0x0002, 0x3502), # flow / head + (93, range(300, 301)): (0x0002, 0x1602), # temperatures } +#: Backwards-compatible alias. The old name claimed these were identifiers +#: echoed from the request; they are object type codes. +RESPONSE_IDENTIFIERS = RESPONSE_TYPES + #: In a *response*, byte 5 is a length field, not an operation specifier. #: #: Measured across 13 objects and 10 distinct values: the top two bits (the @@ -94,11 +116,22 @@ #: on RESPONSE_IDENTIFIERS instead. RESPONSE_LENGTH_IS_BYTE_5 = True -#: Operation specifiers a Class 10 *write* is acknowledged with. The ack -#: carries no identifiers, so it can only be attributed to the command in -#: flight - which is why a command has to opt in via -#: :attr:`Command.expect_short_ack`. -SHORT_ACK_OPSPECS = frozenset({0x01, 0x81}) +#: Longest APDU payload a bare Class 10 acknowledgement or refusal carries. +#: +#: An acknowledgement declares one byte (the Class 10 status); a refusal +#: declares one byte (the offending Data Item's ID); Unknown Class declares +#: **zero**, an eight-byte frame. So the test is ``<= 1``, not ``== 1`` - +#: and it is a length, not a set of opcodes. +#: +#: This was ``frozenset({0x01, 0x81})``, which was wrong twice over. +#: ``0x81`` is not an acknowledgement at all: it is Unknown Data Item, and +#: its payload byte names the item rather than carrying an error code, so a +#: refused write read as accepted whenever that item was ``0x00`` - the +#: case this pump produces. And a literal set cannot match ``0xC1`` +#: (Illegal Operation) or ``0x40`` (Unknown Class), so those replies fell +#: through and died by timeout, making the log say "no response" about a +#: pump that answered in milliseconds. +MAX_SHORT_ACK_PAYLOAD = 1 @dataclass(frozen=True) @@ -275,7 +308,8 @@ def matches(command: Command, packet: bytes) -> bool: # an error for data. if ( cls == CLASS_10 - and opspec in SHORT_ACK_OPSPECS + and opspec is not None + and apdu_payload_len(opspec) <= MAX_SHORT_ACK_PAYLOAD and len(packet) < MIN_IDENTIFIED_LENGTH ): return command.expect_short_ack @@ -287,24 +321,23 @@ def matches(command: Command, packet: bytes) -> bool: if identifiers is None: return False - a, b = identifiers - if (a, b) == (command.expect_a, command.expect_b): - return True - - # The pump does not place the two identifiers consistently, so accept - # them the other way round as well. This is the only reason several - # reads work at all - the Object 86 status read included, whose reply - # is a passive notification carrying identifiers unrelated to the - # request. - # There is deliberately no "one field came back zero, so match on the - # other" rule here. It was inherited as a firmware quirk, but the pump - # never actually echoes the Sub-ID it was asked for - it answers with a - # type code, and a zero in the first field is that object's real value - # rather than a wildcard. Treating it as one is not merely redundant, it - # is wrong: the temperature-range config (0x0003, 0xF402) and an event - # log entry (0x0000, 0xF402) share a type code and differ only in the - # field the rule discarded, so each would answer the other's read. - return (b, a) == (command.expect_a, command.expect_b) + # Both halves of the type must match. There is deliberately no + # relaxation here, and two tempting ones are wrong: + # + # Accepting the fields *swapped* was this module's rule until the four + # bytes were decoded. They are one type field, not two independently + # placed identifiers, and a reply that matches when reversed matches by + # coincidence. Every measured reply matches in wire order - Object 86 + # Sub 7 answers `00 01 2f 01` against an expectation of + # (0x0001, 0x2F01) - so nothing needed the rule. + # + # Treating `type_high == 0` as a wildcard is the ESPHome port's rule and + # is not adopted here. Zero is a real type-high value: the schedule + # overview answers `00 00 da 01`. The collision it would reopen is + # already on record - the temperature-range config (0x0003, 0xF402) and + # an event log entry (0x0000, 0xF402) differ *only* in that field, so + # each would answer the other's read. + return identifiers == (command.expect_a, command.expect_b) def is_response(packet: bytes) -> bool: @@ -328,7 +361,7 @@ def expected_reply(obj_id: int, sub_id: int) -> tuple[int, int] | None: >>> expected_reply(999, 1) is None True """ - for (obj, subs), identifiers in RESPONSE_IDENTIFIERS.items(): + for (obj, subs), identifiers in RESPONSE_TYPES.items(): if obj == obj_id and sub_id in subs: return identifiers return None diff --git a/src/alpha_hwr/protocol/telemetry_decoder.py b/src/alpha_hwr/protocol/telemetry_decoder.py index ce6c58e..801abe5 100644 --- a/src/alpha_hwr/protocol/telemetry_decoder.py +++ b/src/alpha_hwr/protocol/telemetry_decoder.py @@ -8,17 +8,28 @@ ----------------- The Alpha HWR pump sends telemetry using Class 10 DataObjects: -1. **Motor State** (Obj 87, Sub 69) - Type 256 +The object numbers below are what a caller *reads*; the type is what the +pump *answers with*, and the type is what routes here - a reply carries no +Object ID and no Sub-ID at all. Types confirmed against +``geni_profile_52_7.xml``. + +1. **Motor State** - read Obj 87 Sub 69, answers **type 256 v3** + (``ProtectedMotorStateDetails``) - Grid voltage, current, power, speed, converter temperature -2. **Flow/Pressure** (Obj 93, Sub 290) - Type 565 +2. **Flow/Pressure** - read Obj 93 Sub 290, answers **type 565 v2** + (``PumpedMediaRelatedProcessValuesExtended``) - Flow rate, head, inlet pressure, outlet pressure -3. **Temperature** (Obj 93, Sub 300) - Type 534 +3. **Temperature** - read Obj 93 Sub 300, answers **type 534 v2** + (``MediaTemperatureInfo``) - Media temperature, PCB temperature, control box temperature -4. **Alarms/Warnings** (Obj 88, Sub 0/11) - Type 570 - - Active alarm codes and warning codes +4. **Alarms/Warnings** - read Obj 88 Sub 0 or Sub 11, both answer + **type 570 v1** (``FaultsByArrayExtended``) + - Active alarm codes and warning codes. Both reads answer with the same + type, so a reply cannot say which list it holds; only the caller that + issued it knows, which is why these are not routed automatically. Payload Format -------------- @@ -163,10 +174,12 @@ def decode_motor_state(payload: bytes) -> dict[str, float]: Dictionary with decoded values (only includes valid fields) Examples: - >>> # Parse frame first - >>> frame = FrameParser.parse_frame(notification_data) - >>> if frame.obj_id == 87 and frame.sub_id == 69: - ... motor_data = TelemetryDecoder.decode_motor_state(frame.payload) + >>> # Route on the type the reply carries, not the address + >>> # that was requested - a reply carries no address at all. + >>> # Motor state answers as type 3 version 1. + >>> frame = FrameParser.parse_frame(notification_data) # doctest: +SKIP + >>> if (frame.type_low_ver, frame.type_high) == (0x0003, 0x0001): # doctest: +SKIP + ... motor_data = TelemetryDecoder.decode_motor_state(frame.object_body) ... print(f"Voltage: {motor_data.get('voltage_ac_v')}V") ... print(f"Current: {motor_data.get('current_a')}A") ... print(f"Power: {motor_data.get('power_w')}W") @@ -236,9 +249,10 @@ def decode_flow_pressure(payload: bytes) -> dict[str, float]: Dictionary with decoded values (only includes valid fields) Examples: - >>> frame = FrameParser.parse_frame(notification_data) - >>> if frame.obj_id == 93 and frame.sub_id == 290: - ... flow_data = TelemetryDecoder.decode_flow_pressure(frame.payload) + >>> # Flow and head answer as type 0x3502 version 2. + >>> frame = FrameParser.parse_frame(notification_data) # doctest: +SKIP + >>> if (frame.type_low_ver, frame.type_high) == (0x3502, 0x0002): # doctest: +SKIP + ... flow_data = TelemetryDecoder.decode_flow_pressure(frame.object_body) ... print(f"Flow: {flow_data.get('flow_m3h')} m³/h") ... print(f"Head: {flow_data.get('head_m')} m") ... print(f"Inlet: {flow_data.get('inlet_pressure_bar')} bar") @@ -300,9 +314,10 @@ def decode_temperature(payload: bytes) -> dict[str, float]: Dictionary with decoded values (only includes valid fields) Examples: - >>> frame = FrameParser.parse_frame(notification_data) - >>> if frame.obj_id == 93 and frame.sub_id == 300: - ... temp_data = TelemetryDecoder.decode_temperature(frame.payload) + >>> # Temperatures answer as type 0x1602 version 2. + >>> frame = FrameParser.parse_frame(notification_data) # doctest: +SKIP + >>> if (frame.type_low_ver, frame.type_high) == (0x1602, 0x0002): # doctest: +SKIP + ... temp_data = TelemetryDecoder.decode_temperature(frame.object_body) ... print(f"Media: {temp_data.get('media_temperature_c')}°C") ... print(f"PCB: {temp_data.get('pcb_temperature_c')}°C") ... print(f"Box: {temp_data.get('control_box_temperature_c')}°C") @@ -358,16 +373,14 @@ def decode_alarms_warnings( List of active alarm/warning codes (non-zero values only) Examples: - >>> frame = FrameParser.parse_frame(notification_data) - >>> if frame.obj_id == 88: - ... if frame.sub_id == 0: # Alarms - ... codes = TelemetryDecoder.decode_alarms_warnings(frame.payload) - ... if codes: - ... print(f"Active alarms: {codes}") - ... elif frame.sub_id == 11: # Warnings - ... codes = TelemetryDecoder.decode_alarms_warnings(frame.payload, False) - ... if codes: - ... print(f"Active warnings: {codes}") + >>> # Alarms and warnings answer with the *same* type, so the + >>> # reply cannot say which list it holds. Only the caller + >>> # that issued the read knows - which is why this is decoded + >>> # at the call site rather than by TelemetryDecoder.decode(). + >>> data = await service._read_class10_object(88, 0) # doctest: +SKIP + >>> codes = TelemetryDecoder.decode_alarms_warnings(data[3:]) # doctest: +SKIP + >>> if codes: # doctest: +SKIP + ... print(f"Active alarms: {codes}") Implementation Notes: - Payload is an array of uint16 (2 bytes each) @@ -531,16 +544,16 @@ def decode_register_read_response(packet: bytes) -> dict[str, Any]: Examples: >>> # Motor state response (OpSpec 0x30) - >>> data = TelemetryDecoder.decode_register_read_response(motor_packet) - >>> print(data['power_w'], data['speed_rpm']) + >>> data = TelemetryDecoder.decode_register_read_response(motor_packet) # doctest: +SKIP + >>> print(data['power_w'], data['speed_rpm']) # doctest: +SKIP >>> # Flow response (OpSpec 0x2b) - >>> data = TelemetryDecoder.decode_register_read_response(flow_packet) - >>> print(data['flow_m3h'], data['head_m']) + >>> data = TelemetryDecoder.decode_register_read_response(flow_packet) # doctest: +SKIP + >>> print(data['flow_m3h'], data['head_m']) # doctest: +SKIP >>> # Alarm response (OpSpec 0x09) - >>> data = TelemetryDecoder.decode_register_read_response(alarm_packet) - >>> print(data['active_alarms']) + >>> data = TelemetryDecoder.decode_register_read_response(alarm_packet) # doctest: +SKIP + >>> print(data['active_alarms']) # doctest: +SKIP """ data: dict[str, Any] = {} @@ -662,61 +675,77 @@ def plausible(index: int, lo: float, hi: float) -> float | None: @staticmethod def decode(frame: ParsedFrame) -> dict[str, Any]: """ - Auto-detect and decode telemetry based on Sub-ID and Object ID. + Decode a telemetry reply, routing on the object type it carries. - This is a convenience method that routes to the appropriate decoder - based on the frame's identifiers. If the standard decoders don't - recognize the object, it falls back to legacy pattern matching. + A reply carries no Object ID and no Sub-ID - bytes 6-9 are + ``[00][TypeH][TypeL][Version]`` - so the routing key is the type + the pump answered with, not the address that was requested. See + :mod:`alpha_hwr.protocol.frame_parser`. Args: frame: Parsed frame from FrameParser.parse_frame() Returns: - Dictionary with decoded telemetry data, or empty dict if unknown type + Decoded telemetry, or an empty dict when the frame is not + Class 10, carries no type fields, or has a type this does not + route. Alarms and warnings are deliberately in that last + category: both answer with type 0x3A01 version 2, so a reply + cannot say which list it holds and only the caller that issued + the read knows. Examples: >>> # Decode any telemetry frame automatically - >>> frame = FrameParser.parse_frame(notification_data) - >>> telemetry = TelemetryDecoder.decode(frame) - >>> if telemetry: + >>> frame = FrameParser.parse_frame(notification_data) # doctest: +SKIP + >>> telemetry = TelemetryDecoder.decode(frame) # doctest: +SKIP + >>> if telemetry: # doctest: +SKIP ... print(f"Received telemetry: {telemetry}") - Raises: - ValueError: If frame is not a valid Class 10 frame + Note: + Nothing is raised. An unrecognised frame is an empty dict, so a + notification stream carrying objects this does not know does + not have to be filtered before it gets here. """ if frame.class_byte != 0x0A: # CLASS_10 return {} # Not a Class 10 frame, nothing to decode here - if frame.obj_id is None or frame.sub_id is None: - return {} # Missing identifiers, can't decode as telemetry - - # Route to appropriate decoder - match (frame.obj_id, frame.sub_id): - case (87, 69): # Motor state - return TelemetryDecoder.decode_motor_state(frame.payload) - - case (93, 290): # Flow/Pressure - return TelemetryDecoder.decode_flow_pressure(frame.payload) - - case (93, 300): # Temperature - return TelemetryDecoder.decode_temperature(frame.payload) - - case (88, 0): # Active alarms - codes = TelemetryDecoder.decode_alarms_warnings( - frame.payload, True - ) - return {"active_alarms": codes} - - case (88, 11): # Active warnings - codes = TelemetryDecoder.decode_alarms_warnings( - frame.payload, False - ) - return {"active_warnings": codes} + if frame.type_low_ver is None or frame.type_high is None: + # A short acknowledgement or a refusal: no type, no telemetry. + return {} + + # Route on the object *type* the pump answered with. A reply + # carries no Object ID and no Sub-ID (see frame_parser), so the + # pairs here are (type_low_ver, type_high) as measured against an + # ALPHA HWR on 2026-08-20 by issuing each register read and + # recording bytes 6-9 of the answer. + # + # This used to match on (87, 69), (93, 290) and (93, 300) - the + # Object/Sub-ID pairs that were *requested*. No reply ever carries + # those, so every case fell through to the register-read fallback + # below, which parsed the raw frame instead. The fallback still + # exists, but it is now a fallback rather than the only live path. + match (frame.type_low_ver, frame.type_high): + case (0x0003, 0x0001): # Motor state, 48-byte reply + return TelemetryDecoder.decode_motor_state(frame.object_body) + + case (0x3502, 0x0002): # Flow/Pressure, 43-byte reply + return TelemetryDecoder.decode_flow_pressure(frame.object_body) + + case (0x1602, 0x0002): # Temperature, 20-byte reply + return TelemetryDecoder.decode_temperature(frame.object_body) + + # Alarms (88/0) and warnings (88/11) are deliberately absent. + # Both answer with type 0x3A01 version 2 - measured 2026-08-20, + # where the two reads returned byte-identical frames - so a + # reply cannot say which list it carries and this router cannot + # label it. Only the caller that issued the read knows, which is + # why DeviceInfoService.read_alarms() decodes them itself with + # decode_alarms_warnings() rather than coming through here. case _: # Unknown standard object - try register-read response decoder first logger.debug( - f"Unknown telemetry object ({frame.obj_id}, {frame.sub_id}), " + f"Unrouted object type " + f"({frame.type_low_ver:#06x}, {frame.type_high:#06x}), " f"trying register-read response decoder" ) diff --git a/src/alpha_hwr/pump_time.py b/src/alpha_hwr/pump_time.py new file mode 100644 index 0000000..27f5a2e --- /dev/null +++ b/src/alpha_hwr/pump_time.py @@ -0,0 +1,129 @@ +""" +One time base, because the pump only has one. + +Every timestamp this pump stores or reports is **local wall-clock time**. +It has no notion of UTC at all, and that is a property of the device rather +than a convention we chose: + +* Its clock is broken-down fields - year, month, day, hour, minute, second + - not an epoch. ``DateTimeActual`` (type 322) also carries ``day_w``, + ``hour_format`` and ``dst_status``, and ``dst_status`` on the bench unit + reads ``SummerTime``. A device tracking whether it is currently in summer + time is keeping local time; UTC has no summer. +* It applies daylight saving **itself**. ``DaylightSavingTime`` (type 323, + Object 94 Sub 102) on the bench unit reads enabled, starting the second + Sunday of March at 02:00, ending the first Sunday of November at 02:00, + with a 60-minute offset - the US rule. So the pump shifts its own clock + twice a year. +* **There is no timezone or UTC-offset field anywhere in the GENI profile.** + Not in the datetime objects, not elsewhere. The pump therefore cannot + convert between local time and UTC even in principle. + +The last point is what settles the 32-bit timestamps in +``ClockProgramSingleEvent`` and in the event log. The pump compares those +against its own clock; since it has no offset to relate the two bases, they +must be in the same base, and its clock's base is local. So a stored epoch +is the local wall clock stamped as though it were UTC - which is exactly +what ``calendar.timegm`` on naive local fields produces, and what a bench +measurement independently found when an event started four seconds from its +intended wall clock. + +**Why this matters beyond correctness.** More than one client talks to this +pump - the Grundfos GO app, the ESPHome component, this library - and they +all write the same clock. A client that wrote true UTC would set the pump's +clock wrong by the local offset, and every schedule already stored would +fire at the wrong hour. Two clients disagreeing here is worse than either +being wrong alone, because the pump has no way to say which base a value +arrived in. + +The practical rule is short: **express local wall clock, never UTC.** A +naive :class:`~datetime.datetime` is the right type for a pump timestamp, +and attaching a timezone to one invents information the pump does not +carry. + +A consequence worth knowing: because the pump shifts its own clock at a DST +transition, a stored event keeps its *wall clock* across the boundary - an +07:00 event stays at 07:00. That is almost certainly the intent, and it is +another thing true-UTC storage would break. +""" + +from __future__ import annotations + +import calendar +import time +from datetime import datetime + +#: Widest value the wire field can carry. ``begin`` and ``end`` are +#: declared ``uint32_t`` in the GENI profile, so the range runs to 2106. +MAX_PUMP_TIME = 0xFFFFFFFF + + +def now() -> datetime: + """ + The wall clock the pump runs on, as this host sees it. + + One accessor, so "what time is it" has a single answer for every + decision that is about the *pump's* clock: which single-event slots + have expired, whether a window has already closed, what to write to the + pump's own clock. + + That is deliberately not the same question as "when did this host + event happen", which telemetry stamps and session timings ask and which + is correctly UTC-aware. Mixing the two is how the ESPHome port acquired + a bug where one caller substituted the wrong timestamp for "now" + (esphome-alpha-hwr #262), and four independent notions of now is the + condition that makes that class of bug easy to reintroduce (#270). + + There is no "clock not set" sentinel here, and there does not need to + be: a host always has a clock. The ESPHome port needs one because an + ESP32 may genuinely not know the time, and its rule - a picker that + cannot tell the time refuses to guess - has no analogue here. + + Returns: + A naive datetime in local time, matching the pump's own base. + """ + return datetime.now() # noqa: DTZ005 - wall clock, to match the pump + + +def to_pump_time(when: datetime) -> int: + """ + Encode a wall clock the way the pump stores it. + + Takes a naive datetime as local, which is the only reading that makes + sense for a schedule, and stamps its fields as though they were UTC. + + Raises: + ValueError: The instant is outside the uint32 range the wire field + can carry. + + Examples: + >>> to_pump_time(datetime(2026, 8, 20, 9, 30)) + 1787218200 + """ + stamped = calendar.timegm(when.timetuple()) + if not 0 <= stamped <= MAX_PUMP_TIME: + raise ValueError( + f"{when} is outside the range the pump can store " + f"(1970-01-01 to 2106-02-07); it encodes as {stamped}" + ) + return stamped + + +def from_pump_time(value: int) -> datetime: + """ + Decode a stored timestamp back to the wall clock it denotes. + + Naive by design, and the exact inverse of :func:`to_pump_time`. The + pump stores no offset, so attaching one here would invent information + - and a UTC-labelled value is worse than a naive one, because + ``astimezone()`` will then shift it by the local offset and produce a + time the pump never meant. + + Examples: + >>> from_pump_time(1787218200) + datetime.datetime(2026, 8, 20, 9, 30) + >>> from_pump_time(to_pump_time(datetime(2026, 11, 1, 1, 30))) + datetime.datetime(2026, 11, 1, 1, 30) + """ + parts = time.gmtime(value) + return datetime(*parts[:6]) # noqa: DTZ001 - wall clock, no offset diff --git a/src/alpha_hwr/services/base.py b/src/alpha_hwr/services/base.py index 20f65ee..f2a5039 100644 --- a/src/alpha_hwr/services/base.py +++ b/src/alpha_hwr/services/base.py @@ -88,8 +88,8 @@ async def _read_class10_object( error instead of reporting "no data". Example: - >>> data = await self._read_class10_object(93, 1) # Read statistics - >>> data = await self._read_class10_object(86, 6) # Read control mode + >>> data = await self._read_class10_object(93, 1) # Read statistics # doctest: +SKIP + >>> data = await self._read_class10_object(86, 6) # Read control mode # doctest: +SKIP Implementation Notes: - Builds APDU: [0x0A][0x03][ObjID][SubID_H][SubID_L] @@ -197,12 +197,15 @@ async def _read_class7_string( String value, or None if read failed Example: - >>> serial = await self._read_class7_string(1) # Serial number - >>> sw_ver = await self._read_class7_string(2) # Software version + >>> serial = await self._read_class7_string(1) # Serial number # doctest: +SKIP + >>> sw_ver = await self._read_class7_string(2) # Software version # doctest: +SKIP Implementation Notes: - APDU: [0x07][0x01][StringID] - - Response: [STX][LEN][DST][SRC][0x07][Cmd][ID][...STRING...][CRC] + - Response: [STX][LEN][DST][SRC][0x07][Count][...STRING...][CRC] + - ``Count`` is the string's byte length, and the first + character is at offset 6. The reply does not echo the + string ID that was requested. - String is UTF-8 encoded with null terminators """ try: @@ -228,10 +231,33 @@ async def _read_class7_string( timeout=3.0, ) - if response and len(response) > 9: - # Extract string data: skip frame header (7 bytes) and CRC (2 bytes) - # Frame: [STX][LEN][DST][SRC][Class][Cmd][ID][...STRING...][CRC_H][CRC_L] - string_data = response[7:-2] + if response and len(response) > 8: + # The header is six bytes, not seven. Byte 5 is the + # string's byte count - an APDU head like any other - + # and the text starts at offset 6. + # + # Reading from offset 7 dropped the first character of + # every string this pump returns. It was invisible + # because the two most-read strings were patched up + # afterwards: "LPHA HWR" had an "A" prepended, and a + # serial reading "0000479" had a "1" prepended. The + # second is a coincidence - correct for a serial + # beginning "10", corrupting one beginning "20" - and + # the version strings, which had no such patch, shipped + # a character short. Verified 2026-08-20: the pump + # answers 24 0E F8 E7 07 0A 41 4C 50 48 41 ... where + # 0x0A is the ten bytes of "ALPHA HWR\0" and 0x41 is + # the "A". + declared = response[5] + string_data = response[6:-2] + if declared != len(string_data): + # Do not trust the count to bound the read - it is + # radio-supplied, and believing it would let a + # corrupt byte walk off the end. Just say so. + logger.debug( + f"String {string_id} declares {declared} bytes " + f"but the frame carries {len(string_data)}" + ) logger.debug( f"Raw string data for ID {string_id}: {string_data.hex()}" ) @@ -278,7 +304,7 @@ def _build_geni_packet( Example: >>> apdu = bytes([0x0A, 0x03, 93, 0x00, 0x01]) # Class 10 read - >>> packet = self._build_geni_packet(0xF8, 0xE7, apdu) + >>> packet = self._build_geni_packet(0xF8, 0xE7, apdu) # doctest: +SKIP Implementation Notes: - Frame format: [STX][LEN][ServiceID][Source][APDU][CRC_H][CRC_L] diff --git a/src/alpha_hwr/services/configuration.py b/src/alpha_hwr/services/configuration.py index 5dada20..a78ae87 100644 --- a/src/alpha_hwr/services/configuration.py +++ b/src/alpha_hwr/services/configuration.py @@ -39,19 +39,19 @@ class ConfigurationService: pump configuration including control mode, setpoint, and schedule. Example: - >>> config_service = ConfigurationService( + >>> config_service = ConfigurationService( # doctest: +SKIP ... device_info_service, ... control_service, ... schedule_service ... ) >>> >>> # Backup configuration - >>> success = await config_service.backup("pump_backup.json") - >>> if success: + >>> success = await config_service.backup("pump_backup.json") # doctest: +SKIP + >>> if success: # doctest: +SKIP ... print("Configuration backed up successfully") >>> >>> # Restore configuration - >>> success = await config_service.restore( + >>> success = await config_service.restore( # doctest: +SKIP ... "pump_backup.json", ... restore_mode=True, ... restore_schedule=True, @@ -98,8 +98,8 @@ async def backup(self, filepath: str) -> bool: IOError: If file cannot be written Example: - >>> success = await service.backup("pump_backup.json") - >>> if success: + >>> success = await service.backup("pump_backup.json") # doctest: +SKIP + >>> if success: # doctest: +SKIP ... print("Backup saved") Implementation Notes: @@ -240,10 +240,10 @@ async def restore( Example: >>> # Restore everything - >>> success = await service.restore("pump_backup.json") + >>> success = await service.restore("pump_backup.json") # doctest: +SKIP >>> >>> # Restore only schedule - >>> success = await service.restore( + >>> success = await service.restore( # doctest: +SKIP ... "pump_backup.json", ... restore_mode=False, ... verify_device=False @@ -332,7 +332,7 @@ async def export_json(self, filepath: str) -> bool: True if successful, False otherwise Example: - >>> await service.export_json("config.json") + >>> await service.export_json("config.json") # doctest: +SKIP """ return await self.backup(filepath) @@ -358,7 +358,7 @@ async def import_json( True if successful, False otherwise Example: - >>> await service.import_json("config.json") + >>> await service.import_json("config.json") # doctest: +SKIP """ return await self.restore( filepath, diff --git a/src/alpha_hwr/services/control.py b/src/alpha_hwr/services/control.py index 1a22a55..0cde1e0 100644 --- a/src/alpha_hwr/services/control.py +++ b/src/alpha_hwr/services/control.py @@ -77,13 +77,18 @@ class ControlService { import asyncio import logging +import math from typing import TYPE_CHECKING, ClassVar from ..constants import ControlMode from ..exceptions import READ_ERRORS, ConnectionError from ..models import SetpointInfo, WriteCommand, WriteResult from ..protocol import FrameBuilder -from ..protocol.codec import encode_float_be, encode_uint16_be +from ..protocol.codec import ( + decode_float_be, + encode_float_be, + encode_uint16_be, +) from ..protocol.matcher import Command from .base import BaseService @@ -112,21 +117,21 @@ class ControlService(BaseService): _CLASS10_CONTROL_MAP: Mapping of modes to Class 10 parameters Example: - >>> from alpha_hwr.core import Transport, Session - >>> from alpha_hwr.services import ControlService - >>> from alpha_hwr.constants import ControlMode + >>> from alpha_hwr.core import Transport, Session # doctest: +SKIP + >>> from alpha_hwr.services import ControlService # doctest: +SKIP + >>> from alpha_hwr.constants import ControlMode # doctest: +SKIP >>> >>> # Initialize - >>> control = ControlService(transport, session) + >>> control = ControlService(transport, session) # doctest: +SKIP >>> >>> # Start pump - >>> await control.start() + >>> await control.start() # doctest: +SKIP >>> >>> # Set constant pressure mode - >>> await control.set_constant_pressure(1.5) # 1.5 meters + >>> await control.set_constant_pressure(1.5) # 1.5 meters # doctest: +SKIP >>> >>> # Stop pump - >>> await control.stop() + >>> await control.stop() # doctest: +SKIP """ # Control Object Identifiers (from trace) @@ -187,9 +192,37 @@ class ControlService(BaseService): SUB_SPEED_SETPOINT = 13 SUB_PRESSURE_SETPOINT = 15 SUB_FLOW_SETPOINT = 39 - SUB_FLOW_LIMIT = 39 PUMP_OBJ = 86 + #: Where the pump publishes each scalar mode's setpoint range. + #: + #: These are the type 301 version 1 "factory config" objects - the same + #: ones the Grundfos GO app's setpoint slider binds to. Each carries a + #: 28-byte struct of seven floats, of which the first three are + #: default, minimum and maximum. + #: + #: All four answer with the *same* type code, so a reply cannot say + #: which sub-id it came from. See :meth:`read_setpoint_ranges` for what + #: that forces. + _RANGE_SUB_IDS: ClassVar[dict[int, int]] = { + ControlMode.CONSTANT_SPEED: 13, + ControlMode.CONSTANT_PRESSURE: 15, + ControlMode.PROPORTIONAL_PRESSURE: 17, + ControlMode.CONSTANT_FLOW: 39, + } + + #: What to divide or multiply the pump's native units by to reach the + #: units this client speaks, per mode. + #: + #: Pressure is stored in Pascals and reported in metres of head; flow + #: is stored in SI m3/s and reported in m3/h. Speed is native RPM. + _RANGE_SCALE: ClassVar[dict[int, float]] = { + ControlMode.CONSTANT_SPEED: 1.0, + ControlMode.CONSTANT_PRESSURE: 1.0 / 9806.65, + ControlMode.PROPORTIONAL_PRESSURE: 1.0 / 9806.65, + ControlMode.CONSTANT_FLOW: 3600.0, + } + # Control Mode Mapping for ALPHA HWR # Value -> Mode Byte used in control payload # Generic AutoAdapt (mode 5) is deliberately absent: the pump has no @@ -250,6 +283,12 @@ def __init__( self._cached_cycle: tuple[int, int] | None = None self._cached_setpoints: dict[int, float] = {} + # Per-mode setpoint bounds as the pump publishes them, filled in by + # read_setpoint_ranges(). Empty until then, and callers fall back + # to the wider inherited constants rather than refusing a value the + # pump might well accept. + self._setpoint_ranges: dict[int, tuple[float, float]] = {} + #: Class 3 command IDs. START/STOP change the run state and nothing #: else - no mode, no setpoint - which is why they replaced the fused #: control object for on/off. @@ -486,8 +525,8 @@ async def get_mode(self, retries: int = 3) -> SetpointInfo | None: waiting for the response. Example: - >>> info = await control.get_mode() - >>> if info and info.control_mode == ControlMode.CONSTANT_PRESSURE: + >>> info = await control.get_mode() # doctest: +SKIP + >>> if info and info.control_mode == ControlMode.CONSTANT_PRESSURE: # doctest: +SKIP ... value, unit = info.get_display_value() ... print(f"Running in constant pressure mode: {value} {unit}") @@ -680,11 +719,9 @@ async def set_constant_pressure(self, value_m: float) -> bool: logger.info(f"Setting constant pressure to {value_m} m...") - # Validate setpoint against reasonable limits (0.5m to 10m) - if not (0.5 <= value_m <= 10.0): - logger.error( - f"Setpoint {value_m} m is outside valid range (0.5-10.0 m)" - ) + if not self._check_setpoint( + ControlMode.CONSTANT_PRESSURE, value_m, "m", (0.5, 10.0) + ): return False # Convert meters to Pascals @@ -696,10 +733,10 @@ async def set_constant_pressure(self, value_m: float) -> bool: ): return False - # 2. Update specific pressure setpoint (Sub 15) - return await self._set_class10_setpoint( - value_pa, self.SUB_PRESSURE_SETPOINT - ) + # The pump takes the setpoint from the fused control request + # above. There is no second write; see the note on + # _set_class10_setpoint's removal below. + return await self._commit_setpoint() async def set_constant_speed(self, value_rpm: float) -> bool: """ @@ -715,11 +752,9 @@ async def set_constant_speed(self, value_rpm: float) -> bool: logger.info(f"Setting constant speed to {value_rpm} RPM...") - # Validate setpoint against reasonable limits (500 to 4500 RPM) - if not (500 <= value_rpm <= 4500): - logger.error( - f"Setpoint {value_rpm} RPM is outside valid range (500-4500 RPM)" - ) + if not self._check_setpoint( + ControlMode.CONSTANT_SPEED, value_rpm, "RPM", (500.0, 4500.0) + ): return False # 1. Update overall operation request (Sub 6) @@ -728,10 +763,10 @@ async def set_constant_speed(self, value_rpm: float) -> bool: ): return False - # 2. Update specific speed setpoint (Sub 13) - return await self._set_class10_setpoint( - value_rpm, self.SUB_SPEED_SETPOINT - ) + # The pump takes the setpoint from the fused control request + # above. There is no second write; see the note on + # _set_class10_setpoint's removal below. + return await self._commit_setpoint() async def set_constant_flow(self, value_m3h: float) -> bool: """ @@ -748,10 +783,9 @@ async def set_constant_flow(self, value_m3h: float) -> bool: logger.info(f"Setting constant flow to {value_m3h} m³/h...") # Validate setpoint against reasonable limits (0.1 to 10.0 m³/h) - if not (0.1 <= value_m3h <= 10.0): - logger.error( - f"Setpoint {value_m3h} m³/h is outside valid range (0.1-10.0 m³/h)" - ) + if not self._check_setpoint( + ControlMode.CONSTANT_FLOW, value_m3h, "m³/h", (0.1, 10.0) + ): return False # The pump stores this setpoint in SI m3/s, so convert before it @@ -764,10 +798,10 @@ async def set_constant_flow(self, value_m3h: float) -> bool: ): return False - # 2. Update specific flow setpoint (Sub 39) - return await self._set_class10_setpoint( - value_m3s, self.SUB_FLOW_SETPOINT - ) + # The pump takes the setpoint from the fused control request + # above. There is no second write; see the note on + # _set_class10_setpoint's removal below. + return await self._commit_setpoint() async def set_proportional_pressure(self, value_m: float) -> bool: """ @@ -783,11 +817,13 @@ async def set_proportional_pressure(self, value_m: float) -> bool: logger.info(f"Setting proportional pressure to {value_m} m...") - # Validate setpoint against reasonable limits (0.5m to 10m) - if not (0.5 <= value_m <= 10.0): - logger.error( - f"Setpoint {value_m} m is outside valid range (0.5-10.0 m)" - ) + # Proportional pressure has its own range, and it is not constant + # pressure's: the pump reports 2.599-4.569 m here against + # 1.000-2.450 m there. The two do not overlap, so borrowing one for + # the other refuses every setpoint the mode actually accepts. + if not self._check_setpoint( + ControlMode.PROPORTIONAL_PRESSURE, value_m, "m", (0.5, 10.0) + ): return False # Convert meters to Pascals @@ -799,10 +835,10 @@ async def set_proportional_pressure(self, value_m: float) -> bool: ): return False - # 2. Update specific pressure setpoint (Sub 15) - return await self._set_class10_setpoint( - value_pa, self.SUB_PRESSURE_SETPOINT - ) + # The pump takes the setpoint from the fused control request + # above. There is no second write; see the note on + # _set_class10_setpoint's removal below. + return await self._commit_setpoint() async def set_temperature_control( self, @@ -827,8 +863,8 @@ async def set_temperature_control( True if successful, False otherwise Example: - >>> await control.set_temperature_control(35.0, 39.0) # Radiator system - >>> await control.set_temperature_control(35.0, 39.0, "underfloor") + >>> await control.set_temperature_control(35.0, 39.0) # Radiator system # doctest: +SKIP + >>> await control.set_temperature_control(35.0, 39.0, "underfloor") # doctest: +SKIP Note: For ALPHA HWR pumps, all heating_type variants likely behave the same @@ -997,7 +1033,7 @@ async def set_autoadapt(self, value_m: float) -> bool: (modes 13-15) instead for better compatibility. Example: - >>> await control.set_autoadapt(1.5) # 1.5 meters + >>> await control.set_autoadapt(1.5) # 1.5 meters # doctest: +SKIP """ self.session.ensure_authenticated() @@ -1108,7 +1144,7 @@ async def set_temperature_range_control( True if successful, False otherwise Example: - >>> await control.set_temperature_range_control(35.0, 45.0, autoadapt=True) + >>> await control.set_temperature_range_control(35.0, 45.0, autoadapt=True) # doctest: +SKIP """ self.session.ensure_authenticated() @@ -1183,30 +1219,87 @@ async def set_temperature_range_control( return True return False - async def set_flow_limit(self, value_gpm: float) -> bool: - """ - Set the maximum flow limit to prevent noise and corrosion. - - Args: - value_gpm: Maximum flow limit in GPM. + #: The pump's flow limiters, as ``limiter_user_config`` (type 895), + #: ``limiter_factory_config`` (897) and ``limiter_status`` (896). + #: + #: Only two exist. Object 86 sub-ids 600-619, 620-639 and 640-659 are + #: declared as twenty instances each in the GENI profile, but every + #: sub-id past the second answers ``OPERATION_FAILED`` - measured + #: 2026-08-20 by reading all sixty. The name enum in + #: ``geni_profile_52_7.xml`` gives MaxFlow = 1, MinFlow = 2, so the + #: instances are per limiter rather than per mode. + LIMITER_NAMES: ClassVar[dict[int, str]] = {1: "MaxFlow", 2: "MinFlow"} + SUB_LIMITER_USER_CONFIG = 600 + SUB_LIMITER_FACTORY_CONFIG = 620 + SUB_LIMITER_STATUS = 640 + + async def read_limiters(self) -> dict[str, dict[str, float | bool]]: + """ + Read the pump's flow limiters and whether either is limiting. + + A limiter that is enabled caps delivered flow regardless of the + setpoint, and nothing in the setpoint range says so: the type 301 + range is the *factory* range. So a setpoint can be accepted, read + back correct, and still not be delivered. This is the only way to + see that. Returns: - True if successful, False otherwise + ``{"MaxFlow": {...}, "MinFlow": {...}}`` with ``enabled``, + ``limit_m3h``, ``factory_min_m3h``, ``factory_max_m3h`` and + ``limiting`` for each limiter that answered. + + Examples: + >>> limiters = await client.control.read_limiters() # doctest: +SKIP + >>> limiters["MaxFlow"]["enabled"] # doctest: +SKIP + False """ - self.session.ensure_authenticated() + out: dict[str, dict[str, float | bool]] = {} + + for index, name in self.LIMITER_NAMES.items(): + offset = index - 1 + entry: dict[str, float | bool] = {} - from ..constants import FACTOR_M3H_TO_GPM + user = await self._read_limiter_struct( + self.SUB_LIMITER_USER_CONFIG + offset, minimum=6 + ) + if user is not None: + entry["enabled"] = bool(user[1]) + limit = decode_float_be(user, 2) + if limit is not None: + entry["limit_m3h"] = limit * 3600.0 + + factory = await self._read_limiter_struct( + self.SUB_LIMITER_FACTORY_CONFIG + offset, minimum=9 + ) + if factory is not None: + low = decode_float_be(factory, 1) + high = decode_float_be(factory, 5) + if low is not None and high is not None: + entry["factory_min_m3h"] = low * 3600.0 + entry["factory_max_m3h"] = high * 3600.0 + + status = await self._read_limiter_struct( + self.SUB_LIMITER_STATUS + offset, minimum=6 + ) + if status is not None: + entry["limiting"] = bool(status[1]) - value_m3h = value_gpm * FACTOR_M3H_TO_GPM + if entry: + out[name] = entry - logger.info( - f"Setting flow limit to {value_gpm} GPM ({value_m3h:.3f} m³/h)..." - ) + return out - # Set flow limit using Object 86, Sub 39 (Max Flow Limit) - return await self._set_class10_setpoint( - value_m3h, self.SUB_FLOW_LIMIT, self.PUMP_OBJ - ) + async def _read_limiter_struct( + self, sub_id: int, minimum: int + ) -> bytes | None: + """Read one limiter object, past its three-byte size header.""" + data = await self._read_class10_object(self.PUMP_OBJ, sub_id) + if not data: + return None + body = data + if len(body) >= 3 and body[0] == 0 and body[1] == 0: + body = body[3:] + return body if len(body) >= minimum else None #: Object 91 Sub 421, ``dhw_on_off_control_configuration_obj``. Holds #: the live cycle configuration: ``[flow setpoint f32 (m3/s)][on][off]``. @@ -1377,39 +1470,159 @@ async def get_cycle_flow(self) -> float | None: # Helper methods - async def _set_class10_setpoint( - self, value: float, sub_id: int, obj_id: int = 86 + def _check_setpoint( + self, mode: int, value: float, unit: str, fallback: tuple[float, float] ) -> bool: """ - Set the setpoint value using Class 10 DataObject method (SET). + Is this a setpoint the pump could store at all? + + Only rejects what is not a number. An out-of-range value is *not* + rejected: this pump does not refuse a setpoint it dislikes, it + takes it and clamps it, and reports what it stored. Letting it + answer tells the caller more than a refusal does, and the answer is + the pump's to give. + + It also has to be. The range the pump publishes is the *factory* + range, and with a flow limiter enabled the pump manages actual + speed to hold the flow bound - where it settles is a property of + the installation's hydraulics rather than of the pump. On one + reported loop a 3000 RPM request delivered 1885. There is no number + that is the maximum speed there, so there is no bound to check + against, and a check that looked authoritative would be worse than + none. See esphome-alpha-hwr #276. + + The published range is still worth having: it goes in the settle + detail when the pump does clamp, so the caller learns why. + """ + if not math.isfinite(value): + # The all-ones float is the SETPOINT_KEEP sentinel, so a NaN on + # the wire reads as "leave the setpoint alone" - a write that + # silently does nothing rather than one that fails. + logger.error(f"{value} is not a setpoint the pump can store") + return False - Args: - value: Setpoint value (float) - sub_id: Sub-ID to write to - obj_id: Object ID to write to (default 86) + published = self.get_setpoint_range(mode) + low, high = published or fallback + if not low <= value <= high: + logger.info( + f"Setpoint {value} {unit} is outside the " + f"{low:.4g}-{high:.4g} {unit} " + + ("range the pump reports" if published else "assumed range") + + "; sending it anyway - the pump clamps rather than refusing" + ) + return True - Returns: - True if successful, False otherwise + async def _commit_setpoint(self) -> bool: """ - # Build Class 10 SET packet (OpSpec 0x84 = SET + 4 bytes) - # APDU: [Class][OpSpec][SubH][SubL][ObjH][ObjL][Data(4)] - apdu = bytearray([0x0A, 0x84]) - apdu.extend(encode_uint16_be(sub_id)) - apdu.extend(encode_uint16_be(obj_id)) - apdu.extend(encode_float_be(value)) + Persist a setpoint the fused control request has just carried. - # Build GENI frame - req = self._build_geni_packet(0xF8, 0xE7, bytes(apdu)) + The GO app follows every Object 86 Sub 6 control request with an + Object 84 Sub 1 overview commit, 25 times over in the capture + corpus, and that is what makes the value stick. + """ + await self._send_configuration_commit() + return True - # Send with retry - if await self._send_with_retry( - req, f"Set Setpoint {value:.2f} (Sub={sub_id}, Obj={obj_id})" - ): - # Send configuration commit - await self._send_configuration_commit() - return True + async def read_setpoint_ranges(self) -> dict[int, tuple[float, float]]: + """ + Read each scalar mode's setpoint range from the pump. - return False + The pump publishes these in the type 301 factory-config objects at + Object 86 sub-ids 13, 15, 17 and 39 - the same objects the Grundfos + GO app's setpoint slider binds to. Each holds a 28-byte struct whose + first three floats are default, minimum and maximum, in the pump's + native units. + + Returns: + ``{ControlMode: (minimum, maximum)}`` in this client's units, + for as many modes as could be read. + + Note: + The chain is deliberately **sequential and stops at the first + failure**. All four objects answer with the same type code + (``00 01 2d 01``), so the transport cannot tell their replies + apart. Carrying on past a failure hands read N's late reply to + read N+1 and shifts every remaining range by one slot - which + would bound constant pressure by constant speed's 1650-3671 + read as Pascals, 0.168-0.374 m, and refuse an ordinary 1.5 m + setpoint for the rest of the connection. + + Examples: + >>> ranges = await client.control.read_setpoint_ranges() # doctest: +SKIP + >>> ranges[ControlMode.CONSTANT_SPEED] # doctest: +SKIP + (1650.0, 3671.0) + """ + ranges: dict[int, tuple[float, float]] = {} + + for mode, sub_id in self._RANGE_SUB_IDS.items(): + data = await self._read_class10_object(self.PUMP_OBJ, sub_id) + if not data: + logger.debug( + f"Setpoint range for mode {mode} (Sub {sub_id}) could " + f"not be read; stopping the chain rather than " + f"misattributing the replies that follow" + ) + break + + body = data + if len(body) >= 3 and body[0] == 0 and body[1] == 0: + body = body[3:] + + if len(body) < 12: + logger.debug( + f"Setpoint range for mode {mode} is {len(body)} bytes, " + f"too short for three floats" + ) + break + + minimum = decode_float_be(body, 4) + maximum = decode_float_be(body, 8) + if minimum is None or maximum is None: + break + + scale = self._RANGE_SCALE[mode] + ranges[mode] = (minimum * scale, maximum * scale) + + if ranges: + self._setpoint_ranges.update(ranges) + return ranges + + def get_setpoint_range(self, mode: int) -> tuple[float, float] | None: + """ + The pump's own range for a mode, if it has been read. + + Returns None when it has not. Callers should fall back to the + *wider* inherited constants rather than refusing: letting the pump + clamp a value it dislikes is better than refusing one it would have + taken. + """ + return self._setpoint_ranges.get(mode) + + # _set_class10_setpoint() was here, and is deliberately gone. + # + # It built [0A][84][SubH][SubL][ObjH][ObjL][f32] - sub-id first, where + # every Class 10 SET this pump accepts is object first. So the frame + # named object 0x00, and the pump refused it. Confirmed on hardware + # 2026-08-20 by sending the exact frame the method produced: + # + # -> 27 0C E7 F8 0A 84 00 27 00 56 38 84 4F 4B EA CC + # <- 24 07 F8 E7 0A 81 00 4F 40 4E 81 + # + # 0x81 is Unknown Data Item with one payload byte, and that byte is + # 0x00: the object it could not find. Every setpoint write this client + # has made since the method existed was refused, invisibly, because the + # send was fire-and-forget and _send_with_retry() reports success even + # on a timeout. + # + # Correcting the address would not have been enough. Sub-ids 13, 15, 17 + # and 39 are type 301, a 28-byte struct of seven floats; a SET to a + # typed object has to carry [TypeH][TypeL][Ver][size] ahead of the body, + # and a bare float would be read as the top half of the type word. + # + # It is also not needed. The fused Object 86 Sub 6 control request + # already carries the setpoint - which is how the GO app sets one, + # 25 times over in the capture corpus, each followed by an Object 84 + # Sub 1 overview commit. That is what _commit_setpoint() does. async def _send_with_retry( self, packet: bytes, description: str, retries: int = 3 @@ -1420,11 +1633,20 @@ async def _send_with_retry( For control commands, we attempt to verify success by waiting for a response. If no response is received, we still consider it successful (fire-and-forget). """ - # A reply only counts if it comes back on the class the command was - # sent on. This matters most for the Class 3 commands: their - # acknowledgement is a bare two-byte frame with nothing to match on - # but the class, so without this gate a Class 10 telemetry + # A reply only counts if it comes back on the class the command + # was sent on. This matters most for the Class 3 run commands: + # their acknowledgement is a bare two-byte frame with nothing to + # match on but the class, so without this gate a Class 10 telemetry # notification arriving first would be read as the answer. + # + # Class 10 SETs *are* acknowledged, in 90-120 ms measured through + # this client against an ALPHA HWR, with the canonical nine-byte + # 24 05 F8 E7 0A 01 00 AE A2. An earlier revision here skipped the + # wait on the strength of a probe that never saw one - because the + # probe wrote frames whole, and this pump ignores a GENI frame that + # is not split into 20-byte GATT writes whatever the negotiated + # MTU says. The frames never arrived, so of course nothing answered + # them. command = Command.for_request( packet, expect_short_ack=True, @@ -1592,6 +1814,13 @@ async def sync_cache(self) -> bool: return False self._cached_temp_range = temp_range + # The setpoint ranges, likewise, are not required. A pump that + # will not answer them leaves the write layer on its fallback + # constants, which is worse than the truth but better than being + # unable to write at all. Read once per connection: they are + # factory values and do not move. + await self.read_setpoint_ranges() + # The cycle configuration is deliberately not required. It is not # needed to display anything, and a pump that returns a short or # unusual Object 91 payload would otherwise leave this service diff --git a/src/alpha_hwr/services/device_info.py b/src/alpha_hwr/services/device_info.py index ac7e6bd..647a8fb 100644 --- a/src/alpha_hwr/services/device_info.py +++ b/src/alpha_hwr/services/device_info.py @@ -80,19 +80,19 @@ class DeviceInfoService(BaseService): version information, and operational statistics. Example: - >>> from alpha_hwr.services import DeviceInfoService + >>> from alpha_hwr.services import DeviceInfoService # doctest: +SKIP >>> >>> # Initialize - >>> device_info = DeviceInfoService(transport, session) + >>> device_info = DeviceInfoService(transport, session) # doctest: +SKIP >>> >>> # Read basic info (no connection needed) - >>> info = await device_info.read_basic() - >>> print(f"Product: {info.product_family}/{info.product_type}") + >>> info = await device_info.read_basic() # doctest: +SKIP + >>> print(f"Product: {info.product_family}/{info.product_type}") # doctest: +SKIP >>> >>> # Read detailed info (requires connection) - >>> info = await device_info.read_detailed() - >>> print(f"Serial: {info.serial_number}") - >>> print(f"SW Version: {info.software_version}") + >>> info = await device_info.read_detailed() # doctest: +SKIP + >>> print(f"Serial: {info.serial_number}") # doctest: +SKIP + >>> print(f"SW Version: {info.software_version}") # doctest: +SKIP """ def __init__( @@ -126,10 +126,10 @@ async def read_info(self) -> DeviceInfo | None: DeviceInfo with all available fields, or None if read failed Example: - >>> info = await device_info.read_info() - >>> print(f"Product: {info.product_family}/{info.product_type}") - >>> print(f"Serial: {info.serial_number}") - >>> print(f"SW Version: {info.software_version}") + >>> info = await device_info.read_info() # doctest: +SKIP + >>> print(f"Product: {info.product_family}/{info.product_type}") # doctest: +SKIP + >>> print(f"Serial: {info.serial_number}") # doctest: +SKIP + >>> print(f"SW Version: {info.software_version}") # doctest: +SKIP """ # Combine basic and detailed info info_dict: dict[str, Any] = {} @@ -183,10 +183,10 @@ async def read_basic(self, address: str) -> DeviceInfo | None: or None if scan failed Example: - >>> info = await device_info.read_basic("AA:BB:CC:DD:EE:FF") - >>> print(f"Product family: {info.product_family}") - >>> print(f"Product type: {info.product_type}") - >>> print(f"Product version: {info.product_version}") + >>> info = await device_info.read_basic("AA:BB:CC:DD:EE:FF") # doctest: +SKIP + >>> print(f"Product family: {info.product_family}") # doctest: +SKIP + >>> print(f"Product type: {info.product_type}") # doctest: +SKIP + >>> print(f"Product version: {info.product_version}") # doctest: +SKIP Implementation Notes: - GENI service UUID: 0000fdd0-0000-1000-8000-00805f9b34fb @@ -246,15 +246,16 @@ async def read_detailed(self) -> DeviceInfo | None: ConnectionError: If not connected or not authenticated Example: - >>> info = await device_info.read_detailed() - >>> print(f"Serial: {info.serial_number}") - >>> print(f"SW Version: {info.software_version}") - >>> print(f"HW Version: {info.hardware_version}") + >>> info = await device_info.read_detailed() # doctest: +SKIP + >>> print(f"Serial: {info.serial_number}") # doctest: +SKIP + >>> print(f"SW Version: {info.software_version}") # doctest: +SKIP + >>> print(f"HW Version: {info.hardware_version}") # doctest: +SKIP Implementation Notes: - Uses Class 7 ReadString command (0x07, 0x01) - String IDs: 9=serial, 50=sw_ver, 52=hw_ver, 58=ble_ver - - Response format: `[Frame Header][String Data...][CRC]` + - Response format: `[STX][LEN][DST][SRC][0x07][Count][String][CRC]` + - six header bytes, then the text - Strings are UTF-8 encoded, null-terminated """ self.session.ensure_authenticated() @@ -262,18 +263,21 @@ async def read_detailed(self) -> DeviceInfo | None: device_info_dict: dict[str, Any] = {} try: - # Read serial suffix (ID 9 is "0000479") - serial_suffix = await self._read_class7_string(9) - if serial_suffix: - # The full serial is "10000479", prepend the missing "1" - device_info_dict["serial_number"] = f"1{serial_suffix}" - - # Read product name (ID 1 often returns "LPHA HWR") + # ID 9 is the serial number, whole. It used to arrive as + # "0000479" and have a "1" prepended to make "10000479" - which + # was right for this unit by luck, since the missing character + # really was a "1". The string was one short because the Class 7 + # header was read as seven bytes instead of six; with that + # fixed the pump returns "10000479" itself, and prepending + # anything would corrupt it. + serial = await self._read_class7_string(9) + if serial: + device_info_dict["serial_number"] = serial + + # ID 1 is the product name. Same off-by-one: it arrived as + # "LPHA HWR" and was rewritten to "ALPHA HWR" by name. product_name = await self._read_class7_string(1) if product_name: - # Fix common truncation issue where "A" is missing - if product_name == "LPHA HWR": - product_name = "ALPHA HWR" device_info_dict["product_name"] = product_name # Read software version (ID 50) @@ -315,9 +319,9 @@ async def read_statistics(self) -> Statistics | None: Statistics object with available data, or None if read failed Example: - >>> stats = await device_info.read_statistics() - >>> print(f"Runtime: {stats.operating_hours} hours") - >>> print(f"Starts: {stats.start_count}") + >>> stats = await device_info.read_statistics() # doctest: +SKIP + >>> print(f"Runtime: {stats.operating_hours} hours") # doctest: +SKIP + >>> print(f"Starts: {stats.start_count}") # doctest: +SKIP Implementation Notes: - Object 93, Sub-ID 1 (Type 248: operation_history_pump_obj) @@ -378,10 +382,10 @@ async def read_alarms(self) -> AlarmInfo | None: AlarmInfo with active alarm/warning codes, or None if read failed Example: - >>> alarms = await device_info.read_alarms() - >>> if alarms.active_alarms: + >>> alarms = await device_info.read_alarms() # doctest: +SKIP + >>> if alarms.active_alarms: # doctest: +SKIP ... print(f"Active alarms: {alarms.active_alarms}") - >>> if alarms.active_warnings: + >>> if alarms.active_warnings: # doctest: +SKIP ... print(f"Active warnings: {alarms.active_warnings}") Implementation Notes: diff --git a/src/alpha_hwr/services/event_log.py b/src/alpha_hwr/services/event_log.py index 1496cb0..3ca74ce 100644 --- a/src/alpha_hwr/services/event_log.py +++ b/src/alpha_hwr/services/event_log.py @@ -73,10 +73,11 @@ import logging import struct import warnings -from datetime import UTC, datetime +from datetime import datetime from typing import TYPE_CHECKING -from ..exceptions import READ_ERRORS +from ..exceptions import READ_ERRORS, ConnectionError +from ..pump_time import from_pump_time from .base import BaseService if TYPE_CHECKING: @@ -99,16 +100,16 @@ class EventLogService(BaseService): >>> from alpha_hwr.services import EventLogService >>> >>> # Initialize - >>> event_log = EventLogService(transport, session) + >>> event_log = EventLogService(transport, session) # doctest: +SKIP >>> >>> # Get all entries - >>> entries = await event_log.get_all_entries() - >>> for entry in entries: + >>> entries = await event_log.get_all_entries() # doctest: +SKIP + >>> for entry in entries: # doctest: +SKIP ... print(f"{entry.timestamp}: Cycle {entry.cycle_counter}") >>> >>> # Get single entry - >>> newest = await event_log.get_entry(0) - >>> oldest = await event_log.get_entry(19) + >>> newest = await event_log.get_entry(0) # doctest: +SKIP + >>> oldest = await event_log.get_entry(19) # doctest: +SKIP """ def __init__(self, transport: Transport, session: Session) -> None: @@ -137,8 +138,8 @@ async def get_entry(self, index: int) -> EventLogEntry | None: Example: >>> # Get newest entry - >>> entry = await event_log.get_entry(0) - >>> if entry: + >>> entry = await event_log.get_entry(0) # doctest: +SKIP + >>> if entry: # doctest: +SKIP ... print(f"Last event: {entry.timestamp}") """ if not 0 <= index <= 19: @@ -170,6 +171,12 @@ async def get_entry(self, index: int) -> EventLogEntry | None: return self._parse_entry(payload[:16], index, subid) + except ConnectionError: + # A dropped link is not "this entry is unreadable". Every + # remaining entry will fail the same way, and the caller must + # be able to tell a short log from a short read. + raise + except READ_ERRORS as e: logger.error(f"Error reading event log entry {index}: {e}") return None @@ -179,13 +186,22 @@ async def get_all_entries(self) -> list[EventLogEntry]: Read all event log entries from the pump. Returns: - List of EventLogEntry objects, ordered from newest (0) to oldest (19). - Entries that fail to read will be skipped. + List of EventLogEntry objects, ordered from newest (0) to + oldest (19). An entry the pump will not return is skipped - + that is ordinary, since a log with fewer than twenty entries + reports the empty slots as unreadable. + + Raises: + ConnectionError: The link dropped part-way through. The entries + read so far are discarded rather than returned, because a + partial read and a short log are indistinguishable once + the list is handed back - "Retrieved 5/20" is exactly what + a five-entry log looks like. Example: - >>> entries = await event_log.get_all_entries() - >>> print(f"Retrieved {len(entries)} event log entries") - >>> for entry in entries[:5]: # Show 5 most recent + >>> entries = await event_log.get_all_entries() # doctest: +SKIP + >>> print(f"Retrieved {len(entries)} event log entries") # doctest: +SKIP + >>> for entry in entries[:5]: # Show 5 most recent # doctest: +SKIP ... print(f" {entry.timestamp}: Cycle {entry.cycle_counter}") """ if not self.session.is_connected(): @@ -193,13 +209,32 @@ async def get_all_entries(self) -> list[EventLogEntry]: logger.info("Fetching all event log entries...") - entries = [] + entries: list[EventLogEntry] = [] for index in range(20): - entry = await self.get_entry(index) + try: + entry = await self.get_entry(index) + except ConnectionError as e: + # Say how far it got. The count is the diagnostic - it is + # the difference between "the pump has this many entries" + # and "the link went here" - and it is exactly what the + # returned list could not express. + raise ConnectionError( + f"Pump disconnected while reading the event log after " + f"{len(entries)} of 20 entries: {e}" + ) from e if entry: entries.append(entry) + if not self.session.is_connected(): + # The link can go without the chain noticing: a read already + # answered when it drops returns normally, so the loop runs to + # completion over a link that died half way. + raise ConnectionError( + f"Pump disconnected while reading the event log; " + f"{len(entries)} of 20 entries had been read" + ) + logger.info(f"Retrieved {len(entries)}/20 event log entries") return entries @@ -221,8 +256,8 @@ async def get_metadata(self) -> EventLogMetadata | None: EventLogMetadata object with decoded fields, or None if read failed Example: - >>> metadata = await event_log.get_metadata() - >>> if metadata: + >>> metadata = await event_log.get_metadata() # doctest: +SKIP + >>> if metadata: # doctest: +SKIP ... print(f"Current cycle: {metadata.current_cycle}") ... print(f"Available entries: {metadata.available_entries}") """ @@ -327,7 +362,7 @@ def _parse_entry( # Parse Unix timestamp (big-endian uint32) timestamp_raw = struct.unpack(">I", raw_data[10:14])[0] - timestamp = datetime.fromtimestamp(timestamp_raw, tz=UTC) + timestamp = from_pump_time(timestamp_raw) return EventLogEntry( index=index, diff --git a/src/alpha_hwr/services/history.py b/src/alpha_hwr/services/history.py index fbfe5f3..ceefe7f 100644 --- a/src/alpha_hwr/services/history.py +++ b/src/alpha_hwr/services/history.py @@ -52,10 +52,11 @@ import logging import struct -from datetime import UTC, datetime +from datetime import datetime from typing import TYPE_CHECKING, Any -from ..exceptions import READ_ERRORS +from ..exceptions import READ_ERRORS, ConnectionError +from ..pump_time import from_pump_time from .base import BaseService if TYPE_CHECKING: @@ -82,16 +83,16 @@ class HistoryService(BaseService): >>> from alpha_hwr.services import HistoryService >>> >>> # Initialize - >>> history = HistoryService(transport, session) + >>> history = HistoryService(transport, session) # doctest: +SKIP >>> >>> # Get all trend data - >>> trends = await history.get_trend_data() - >>> if trends.flow_series: + >>> trends = await history.get_trend_data() # doctest: +SKIP + >>> if trends.flow_series: # doctest: +SKIP ... print(f"Current flow: {trends.flow_series.cycle_10_points[0].value} m³/h") >>> >>> # Get cycle timestamps - >>> timestamps = await history.get_cycle_timestamps(count=10) - >>> print(f"Last cycle: {timestamps[0]}") + >>> timestamps = await history.get_cycle_timestamps(count=10) # doctest: +SKIP + >>> print(f"Last cycle: {timestamps[0]}") # doctest: +SKIP """ def __init__(self, transport: Transport, session: Session) -> None: @@ -119,8 +120,8 @@ async def get_trend_data(self) -> TrendDataCollection | None: TrendDataCollection with all series, or None if retrieval failed. Example: - >>> trends = await history.get_trend_data() - >>> if trends and trends.flow_series: + >>> trends = await history.get_trend_data() # doctest: +SKIP + >>> if trends and trends.flow_series: # doctest: +SKIP ... for point in trends.flow_series.cycle_10_points: ... print(f"{point.timestamp}: {point.value} m³/h") """ @@ -218,6 +219,13 @@ async def get_trend_data(self) -> TrendDataCollection | None: power_on_time_series=power_time_series, ) + except ConnectionError: + # A half-built collection is not a result. Three of the four + # series are legitimately None on a pump that does not keep + # them, so a caller cannot tell a dropped link from a sparse + # trend once the object is handed back. + raise + except READ_ERRORS as e: logger.error(f"Error fetching trend data: {e}") import traceback @@ -239,8 +247,8 @@ async def get_cycle_timestamps( Most recent cycle is first in list. Example: - >>> timestamps = await history.get_cycle_timestamps(count=10) - >>> if timestamps: + >>> timestamps = await history.get_cycle_timestamps(count=10) # doctest: +SKIP + >>> if timestamps: # doctest: +SKIP ... print(f"Last cycle: {timestamps[0]}") ... print(f"Cycle 10 ago: {timestamps[-1]}") """ @@ -264,10 +272,13 @@ async def get_cycle_timestamps( if ts < 946684800: # Jan 1, 2000 ts += 946684800 - result.append(datetime.fromtimestamp(ts, tz=UTC)) + result.append(from_pump_time(ts)) return result + except ConnectionError: + raise + except READ_ERRORS as e: logger.error(f"Error fetching cycle timestamps: {e}") return None @@ -315,6 +326,12 @@ async def _read_timestamp_map(self, subid: int) -> dict[str, Any] | None: "cycle_type": 10 if subid == 13300 else 100, } + except ConnectionError: + # Not "this map is unreadable" - every read after it will fail + # the same way, and a trend series missing because the link + # went looks exactly like one the pump does not keep. + raise + except READ_ERRORS as e: logger.error(f"Error reading timestamp map {subid}: {e}") return None @@ -371,6 +388,11 @@ async def _read_trend_values( ) return None + except ConnectionError: + # See _read_timestamp_map: a dropped link is not "this trend + # is empty". + raise + except READ_ERRORS as e: logger.error( f"Error reading trend values Obj{obj_id}/Sub{subid}: {e}" @@ -419,7 +441,7 @@ def _build_series( points_10.append( TrendDataPoint( - timestamp=datetime.fromtimestamp(ts, tz=UTC), + timestamp=from_pump_time(ts), value=val_scaled, ) ) @@ -438,7 +460,7 @@ def _build_series( points_100.append( TrendDataPoint( - timestamp=datetime.fromtimestamp(ts, tz=UTC), + timestamp=from_pump_time(ts), value=val_scaled, ) ) diff --git a/src/alpha_hwr/services/schedule.py b/src/alpha_hwr/services/schedule.py index 397bd08..c02c80f 100644 --- a/src/alpha_hwr/services/schedule.py +++ b/src/alpha_hwr/services/schedule.py @@ -40,6 +40,14 @@ logger = logging.getLogger(__name__) +#: How long to wait for a Class 10 write's acknowledgement. +#: +#: Measured through this client against an ALPHA HWR: 90-120 ms across +#: Object 86 Sub 10, Object 91 Sub 430 and Object 84 Sub 1. The capture +#: corpus puts the whole distribution at 36-193 ms with nothing over +#: 295 ms anywhere. 400 ms clears all of it. +SET_ACK_TIMEOUT = 0.4 + class ScheduleService(BaseService): """ @@ -50,15 +58,15 @@ class ScheduleService(BaseService): one time interval per day of the week. Example: - >>> service = ScheduleService(session, transport) + >>> service = ScheduleService(session, transport) # doctest: +SKIP >>> >>> # Check if schedule is enabled - >>> enabled = await service.get_state() - >>> print(f"Schedule enabled: {enabled}") + >>> enabled = await service.get_state() # doctest: +SKIP + >>> print(f"Schedule enabled: {enabled}") # doctest: +SKIP >>> >>> # Read current schedule - >>> entries = await service.read_entries() - >>> for entry in entries: + >>> entries = await service.read_entries() # doctest: +SKIP + >>> for entry in entries: # doctest: +SKIP ... print(f"{entry.day}: {entry.begin_time}-{entry.end_time}") >>> >>> # Write new schedule @@ -68,10 +76,10 @@ class ScheduleService(BaseService): ... ScheduleEntry(day="Tuesday", begin_hour=6, begin_minute=0, ... end_hour=8, end_minute=0), ... ] - >>> success = await service.write_entries(new_entries, layer=0) + >>> success = await service.write_entries(new_entries, layer=0) # doctest: +SKIP >>> >>> # Enable schedule - >>> await service.enable() + >>> await service.enable() # doctest: +SKIP """ #: ClockProgramOverview byte 5: what the pump does outside every @@ -115,8 +123,8 @@ async def get_state(self) -> bool | None: ConnectionError: If not connected or not authenticated Example: - >>> enabled = await service.get_state() - >>> if enabled: + >>> enabled = await service.get_state() # doctest: +SKIP + >>> if enabled: # doctest: +SKIP ... print("Schedule is active") ... else: ... print("Schedule is disabled") @@ -157,8 +165,8 @@ async def enable(self) -> bool: ConnectionError: If not connected or not authenticated Example: - >>> success = await service.enable() - >>> if success: + >>> success = await service.enable() # doctest: +SKIP + >>> if success: # doctest: +SKIP ... print("Schedule enabled") Implementation Notes: @@ -187,8 +195,8 @@ async def disable(self) -> bool: ConnectionError: If not connected or not authenticated Example: - >>> success = await service.disable() - >>> if success: + >>> success = await service.disable() # doctest: +SKIP + >>> if success: # doctest: +SKIP ... print("Schedule disabled") Implementation Notes: @@ -216,13 +224,13 @@ async def read_entries( Example: >>> # Read all layers - >>> all_entries = await service.read_entries() + >>> all_entries = await service.read_entries() # doctest: +SKIP >>> >>> # Read specific layer - >>> layer0 = await service.read_entries(layer=0) + >>> layer0 = await service.read_entries(layer=0) # doctest: +SKIP >>> >>> # Display entries - >>> for entry in all_entries: + >>> for entry in all_entries: # doctest: +SKIP ... print(f"Layer {entry.layer}, {entry.day}: " ... f"{entry.begin_time}-{entry.end_time}") @@ -336,8 +344,8 @@ async def write_entries( ... ScheduleEntry(day="Tuesday", begin_hour=6, begin_minute=0, ... end_hour=8, end_minute=0, layer=0), ... ] - >>> success = await service.write_entries(entries, layer=0) - >>> if success: + >>> success = await service.write_entries(entries, layer=0) # doctest: +SKIP + >>> if success: # doctest: +SKIP ... print("Schedule written successfully") Implementation Notes: @@ -480,8 +488,8 @@ async def clear_entry(self, day: str, layer: int = 0) -> bool: Example: >>> # Clear Monday's schedule on layer 0 - >>> success = await service.clear_entry("Monday", layer=0) - >>> if success: + >>> success = await service.clear_entry("Monday", layer=0) # doctest: +SKIP + >>> if success: # doctest: +SKIP ... print("Monday schedule cleared") Implementation Notes: @@ -553,9 +561,9 @@ def validate_entries( ... ScheduleEntry(day="Monday", begin_hour=7, begin_minute=0, ... end_hour=9, end_minute=0), # Overlaps! ... ] - >>> is_valid, errors = service.validate_entries(entries) - >>> print(is_valid) # False - >>> print(errors) + >>> is_valid, errors = service.validate_entries(entries) # doctest: +SKIP + >>> print(is_valid) # False # doctest: +SKIP + >>> print(errors) # doctest: +SKIP ['Overlap detected: Monday layer 0: 06:00-08:00 overlaps with 07:00-09:00'] Implementation Notes: @@ -819,24 +827,28 @@ async def _write_class10_command( logger.debug(f"Writing Class 10 command: {frame.hex()}") - # A timeout here is the expected case, not a fault: the pump - # uses a two-phase commit for flash-backed writes and its - # acknowledgement usually lands after the response window has - # closed. That is why the return value cannot mean "the pump - # took it" - only a readback can establish that. + # A Class 10 SET is acknowledged with the bare nine-byte + # 24 05 F8 E7 0A 01 00 AE A2, in 90-120 ms measured through + # this client. The acknowledgement carries no identifiers, so + # it can only be attributed to the command in flight - hence + # expect_short_ack. + # + # It is not the verdict. The pump clamps values it dislikes + # rather than refusing them, so only a readback establishes + # what was stored; this just says the frame arrived. response = await self.transport.send_command( frame, Command( expect_short_ack=True, - quiet_timeout=True, description="Class 10 write", ), - timeout=3.0, + timeout=SET_ACK_TIMEOUT, ) - if response is not None: - logger.debug( - f"Got response to Class 10 write: {response.hex()}" + if response is None: + logger.warning( + "No acknowledgement for the Class 10 write; the " + "readback will say whether it landed" ) return True diff --git a/src/alpha_hwr/services/single_event.py b/src/alpha_hwr/services/single_event.py index ed67141..0f1e7cd 100644 --- a/src/alpha_hwr/services/single_event.py +++ b/src/alpha_hwr/services/single_event.py @@ -24,13 +24,17 @@ from __future__ import annotations -import calendar +import asyncio import logging -import time from dataclasses import dataclass from datetime import datetime +from .. import pump_time from ..exceptions import READ_ERRORS +from ..pump_time import ( + from_pump_time, + to_pump_time, +) from .base import BaseService logger = logging.getLogger(__name__) @@ -39,6 +43,14 @@ #: Object 84 sub-id of the first single-event slot. SUB_FIRST_SLOT = 900 +#: How long to let a single-event write settle before reading it back. +#: +#: An Object 91 config write measured 449-486 ms from issue to visible on +#: this pump, and Object 84 is the faster of the two - the ESPHome port +#: measured a schedule layer visible within 100 ms of its acknowledgement. +#: This is generous against both. +CONFIRM_DELAY = 1.0 + #: Wire actions. Note this is the opposite sense from the weekly schedule's #: ``default_action``, where 0x01 means Stop. ACTION_STOP = 0x01 @@ -50,27 +62,18 @@ #: Bytes of the single-event structure. STRUCT_LEN = 0x0A - -def to_pump_time(when: datetime) -> int: - """ - Encode a wall clock as the pump stores it. - - The pump keeps local Unix time: the wall-clock fields stamped as though - they were UTC. A naive datetime is taken as local, which is the only - reading that makes sense for a schedule. - """ - return calendar.timegm(when.timetuple()) - - -def from_pump_time(value: int) -> datetime: - """ - Decode a stored timestamp back to the wall clock it denotes. - - Naive by design, and the inverse of :func:`to_pump_time`: the pump - stores no offset, so attaching one here would invent information. - """ - parts = time.gmtime(value) - return datetime(*parts[:6]) # noqa: DTZ001 - wall clock, no offset +#: Highest slot the *protocol* can address, whatever the pump implements. +#: +#: The sub-id is ``900 + slot`` and the weekly schedule's layer records +#: start at 1000, so slot 100 does not address a single event at all - it +#: addresses layer 0. This is derived from "1000 is spoken for", not from +#: any observed pump limit; the only capture evidence covers 900-904. +#: +#: Checked *before* the pump is read, deliberately. Deferring it means an +#: impossible slot on a broken link reports "the overview could not be +#: read", blaming the link for an argument that could never have been +#: right whatever the link was doing. +SLOT_LIMIT = 100 @dataclass(frozen=True) @@ -187,7 +190,7 @@ async def find_free_slot(self) -> int | None: if not event.enabled: return event.slot - now = datetime.now() # noqa: DTZ005 - wall clock, to match the pump + now = pump_time.now() for event in events: if event.end < now: logger.info( @@ -208,15 +211,26 @@ def build_apdu( """ Build the write frame for one slot. - ``[0A][B3][54][SubH][SubL][00][DC][01][00][00][0A][enabled][action] + ``[0A][93][54][SubH][SubL][00][DC][01][00][00][0A][enabled][action] [begin u32 BE][end u32 BE]`` - the object is addressed first as a single byte here, then a 16-bit sub-id. + + The head is ``0x93``: SET, with the 19 payload bytes that follow + it. It was ``0xB3`` - SET with 51 - borrowed from the schedule + layer write, whose 53-byte APDU really does carry 51. This frame + carries 19, so it declared a length it did not have. + + The pump accepts both, so nothing was visibly failing; the capture + corpus is what settles it. Every one of the 29 single-event writes + the Grundfos GO app makes uses ``0x93``, and the 8 layer writes + use ``0xB3``. A firmware that checked the field would have refused + ours with no diagnostic. """ sub = SUB_FIRST_SLOT + slot apdu = bytearray( [ 0x0A, # Class 10 - 0xB3, # OpSpec: long SET + 0x93, # SET, 19 payload bytes 0x54, # Object 84 (sub >> 8) & 0xFF, sub & 0xFF, @@ -236,12 +250,60 @@ def build_apdu( apdu.extend(to_pump_time(end).to_bytes(4, "big") if end else bytes(4)) return bytes(apdu) + async def confirm( + self, + slot: int, + begin: datetime, + end: datetime, + action: int, + ) -> bool: + """ + Read a slot back and check the pump kept what was asked for. + + The ACTION byte is compared, and that is the point of this method. + It is half the meaning of a single event - ``0x01`` holds the pump + off across the window (which is what a vacation *is*), ``0x02`` + runs it once - and a confirm that checked only the window and the + enabled flag would settle a vacation as written while the pump was + scheduled to run for a week, or the reverse. + + Not used on a clear: clearing disables the slot whatever it held, + so there is no requested action to compare against. + """ + stored = await self.read(slot) + if stored is None: + logger.error(f"Could not read single-event slot {slot} back") + return False + + if not stored.enabled: + logger.error(f"The pump did not keep single event {slot}") + return False + + if stored.action != action: + logger.error( + f"Single event {slot} was written as " + f"{'Stop' if action == ACTION_STOP else 'Run'} but the pump " + f"stored {'Stop' if stored.action == ACTION_STOP else 'Run'}" + ) + return False + + if stored.begin != begin or stored.end != end: + logger.error( + f"Single event {slot} window differs: asked for " + f"{begin:%Y-%m-%d %H:%M} -> {end:%Y-%m-%d %H:%M}, pump has " + f"{stored.begin:%Y-%m-%d %H:%M} -> {stored.end:%Y-%m-%d %H:%M}" + ) + return False + + return True + async def write( self, slot: int, begin: datetime, end: datetime, action: int = ACTION_RUN, + confirm: bool = True, ) -> bool: """ Write one slot and commit it. @@ -252,15 +314,34 @@ async def write( end: Wall clock it closes. action: :data:`ACTION_RUN` for a one-off run, :data:`ACTION_STOP` for a vacation. + confirm: Read the slot back and check the pump kept it, + including the ACTION byte. Pass False only when the caller + is going to verify some other way. Returns: - True if the frame was sent and committed. Whether the pump - kept it is a separate question - read it back. + True if the pump holds what was asked for. With ``confirm`` + off, only that the frame was sent and committed. """ + if not await self._slot_is_addressable(slot): + return False + if end <= begin: logger.error(f"A window must end after it starts: {begin} -> {end}") return False + # A window that has already closed cannot ever open, and writing it + # spends one of the pump's five slots on an event that will never + # run. A window that has already *started* is legitimate - it just + # begins part-way through - so only the end is compared. + now = pump_time.now() + if end <= now: + logger.error( + f"That window closed at {end:%Y-%m-%d %H:%M}, before the " + f"current wall clock of {now:%Y-%m-%d %H:%M}; it would " + f"occupy a slot and never run" + ) + return False + try: await self.transport.write( self._build_geni_packet( @@ -277,10 +358,60 @@ async def write( f"{end:%Y-%m-%d %H:%M} " f"({'off' if action == ACTION_STOP else 'run'})" ) + + if not confirm: + return True + + # The pump answers nothing for a few hundred milliseconds after a + # write while it commits, so a readback taken immediately is not + # answered at all. See POST_SET_QUIET in the transport - the hold + # is applied there, this is the settle on top of it. + await asyncio.sleep(CONFIRM_DELAY) + return await self.confirm(slot, begin, end, action) + + async def _slot_is_addressable(self, slot: int) -> bool: + """ + Two bounds, in this order, because the order is the point. + + The protocol envelope comes first and needs no device read: a slot + at or past :data:`SLOT_LIMIT` addresses a schedule layer rather + than a single event, and that is true whatever the pump is doing. + + The pump's own count comes second, from the overview, because it + varies by model. A slot the protocol allows but this pump lacks + still reports the link failure when the link is down - deliberately, + since the count comes from the pump and without it we do not know. + """ + if slot < 0 or slot >= SLOT_LIMIT: + logger.error( + f"Slot {slot} is not a single event: sub-id " + f"{SUB_FIRST_SLOT + slot} lands in the schedule layers, " + f"which start at {SUB_FIRST_SLOT + SLOT_LIMIT}" + ) + return False + + count = await self.slot_count() + if count is None: + logger.error( + f"Cannot tell whether slot {slot} exists: the schedule " + f"overview could not be read" + ) + return False + + if slot >= count: + logger.error( + f"This pump has {count} single-event slots, so slot " + f"{slot} does not exist" + ) + return False + return True async def clear(self, slot: int) -> bool: """Empty one slot.""" + if not await self._slot_is_addressable(slot): + return False + try: await self.transport.write( self._build_geni_packet( @@ -311,12 +442,41 @@ async def set_vacation(self, begin: datetime, end: datetime) -> bool: return await self.write(slot, begin, end, action=ACTION_STOP) async def clear_vacation(self) -> bool: - """Clear the first enabled ``Stop`` event.""" + """ + Clear the vacation that is running, or the next one due. + + This used to clear the *first* enabled Stop event in slot order, + with no reference to the clock. A finished vacation sitting in an + early slot therefore shadowed a live one later on: the call + reported success, and the pump stayed off. + + ``find_free_slot`` one method up has always been clocked. The + asymmetry was the bug. + """ events = await self.read_all() if events is None: return False - for event in events: - if event.enabled and event.is_vacation: - return await self.clear(event.slot) + + now = pump_time.now() + vacations = [e for e in events if e.enabled and e.is_vacation] + + live = [e for e in vacations if e.begin <= now < e.end] + if live: + return await self.clear(live[0].slot) + + upcoming = sorted( + (e for e in vacations if e.begin > now), key=lambda e: e.begin + ) + if upcoming: + return await self.clear(upcoming[0].slot) + + expired = [e for e in vacations if e.end <= now] + if expired: + logger.info( + f"No live or upcoming vacation; clearing the expired one " + f"in slot {expired[0].slot}" + ) + return await self.clear(expired[0].slot) + logger.info("No vacation to clear") return True diff --git a/src/alpha_hwr/services/telemetry.py b/src/alpha_hwr/services/telemetry.py index b6bda25..d808b7e 100644 --- a/src/alpha_hwr/services/telemetry.py +++ b/src/alpha_hwr/services/telemetry.py @@ -66,16 +66,16 @@ class TelemetryService: >>> from alpha_hwr.services import TelemetryService >>> >>> # Initialize - >>> transport = Transport(bleak_client) - >>> session = Session(transport) - >>> telemetry_service = TelemetryService(transport, session) + >>> transport = Transport(bleak_client) # doctest: +SKIP + >>> session = Session(transport) # doctest: +SKIP + >>> telemetry_service = TelemetryService(transport, session) # doctest: +SKIP >>> >>> # Read once - >>> data = await telemetry_service.read_once() - >>> print(f"Flow: {data.flow_m3h} m³/h") + >>> data = await telemetry_service.read_once() # doctest: +SKIP + >>> print(f"Flow: {data.flow_m3h} m³/h") # doctest: +SKIP >>> >>> # Stream continuously - >>> async for data in telemetry_service.stream(): + >>> async for data in telemetry_service.stream(): # doctest: +SKIP ... print(f"Power: {data.power_w} W") """ @@ -113,8 +113,8 @@ def current(self) -> TelemetryData: Current TelemetryData Example: - >>> telemetry = service.current - >>> print(f"Voltage: {telemetry.voltage_ac_v}V") + >>> telemetry = service.current # doctest: +SKIP + >>> print(f"Voltage: {telemetry.voltage_ac_v}V") # doctest: +SKIP """ return self._telemetry @@ -130,8 +130,8 @@ def advanced(self) -> AdvancedTelemetry: Current AdvancedTelemetry Example: - >>> adv = service.advanced - >>> print(f"Converter temp: {adv.converter_temperature_c}°C") + >>> adv = service.advanced # doctest: +SKIP + >>> print(f"Converter temp: {adv.converter_temperature_c}°C") # doctest: +SKIP """ return self._advanced_telemetry @@ -149,9 +149,9 @@ async def read_once(self) -> TelemetryData: TelemetryData with current values Example: - >>> data = await service.read_once() - >>> print(f"Flow: {data.flow_m3h} m³/h") - >>> print(f"Power: {data.power_w} W") + >>> data = await service.read_once() # doctest: +SKIP + >>> print(f"Flow: {data.flow_m3h} m³/h") # doctest: +SKIP + >>> print(f"Power: {data.power_w} W") # doctest: +SKIP Implementation Notes: - Uses Class 10 INFO commands (OpSpec 0x00) @@ -304,7 +304,7 @@ async def stream( TelemetryData as it's updated Example: - >>> async for data in service.stream(interval=0.2): + >>> async for data in service.stream(interval=0.2): # doctest: +SKIP ... print(f"Flow: {data.flow_m3h} m³/h, Power: {data.power_w} W") ... if data.power_w > 100: ... break # Stop streaming @@ -380,10 +380,12 @@ def update_from_notification(self, data: bytes) -> None: logger.debug("Not a Class 10 frame, ignoring") return - # Validate Class 10 identifiers - if frame.sub_id is None or frame.obj_id is None: + # A frame with no type fields is an acknowledgement, a refusal + # or a runt - never telemetry. + if frame.type_high is None or frame.type_low_ver is None: logger.debug( - "Class 10 frame missing identifiers (likely an ACK or partial), ignoring" + "Class 10 frame carries no object type " + "(an ack, a refusal or a partial), ignoring" ) return @@ -431,10 +433,16 @@ def update_from_notification(self, data: bytes) -> None: update=advanced_updates ) - # Set stream detection flags based on object type - if frame.obj_id == 87 and frame.sub_id == 69: # Motor state + # Set stream detection flags from the object type the pump + # answered with. This used to compare against Object 87 / + # Sub-ID 69 and Object 93 / Sub-ID 290 - the addresses that + # were *requested*. A reply carries neither, so neither flag + # could ever be set by a real notification, and the polling + # path this exists to suppress ran regardless of whether the + # pump was already streaming. + if (frame.type_low_ver, frame.type_high) == (0x0003, 0x0001): self._has_motor_state_stream = True - elif frame.obj_id == 93 and frame.sub_id == 290: # Flow/pressure + elif (frame.type_low_ver, frame.type_high) == (0x3502, 0x0002): self._has_flow_stream = True logger.debug( diff --git a/src/alpha_hwr/services/time.py b/src/alpha_hwr/services/time.py index 749f8aa..ddad5e2 100644 --- a/src/alpha_hwr/services/time.py +++ b/src/alpha_hwr/services/time.py @@ -72,6 +72,7 @@ from datetime import datetime from typing import TYPE_CHECKING +from .. import pump_time from ..exceptions import READ_ERRORS from ..protocol.matcher import Command from .base import BaseService @@ -95,21 +96,21 @@ class TimeService(BaseService): >>> from alpha_hwr.services import TimeService >>> >>> # Initialize - >>> time_service = TimeService(transport, session) + >>> time_service = TimeService(transport, session) # doctest: +SKIP >>> >>> # Read pump time - >>> pump_time = await time_service.get_clock() - >>> print(f"Pump time: {pump_time}") + >>> pump_time = await time_service.get_clock() # doctest: +SKIP + >>> print(f"Pump time: {pump_time}") # doctest: +SKIP >>> >>> # Sync with system time - >>> success = await time_service.set_clock() - >>> if success: + >>> success = await time_service.set_clock() # doctest: +SKIP + >>> if success: # doctest: +SKIP ... print("Clock synchronized") >>> >>> # Set to specific time >>> from datetime import datetime >>> dt = datetime(2026, 12, 25, 10, 0, 0) - >>> await time_service.set_clock(dt) + >>> await time_service.set_clock(dt) # doctest: +SKIP """ def __init__(self, transport: Transport, session: Session) -> None: @@ -137,8 +138,8 @@ async def get_clock(self) -> datetime | None: ConnectionError: If not connected Example: - >>> pump_time = await time_service.get_clock() - >>> if pump_time: + >>> pump_time = await time_service.get_clock() # doctest: +SKIP + >>> if pump_time: # doctest: +SKIP ... if pump_time.year < 1980: ... print("Clock is unset, needs sync") ... else: @@ -161,7 +162,8 @@ async def get_clock(self) -> datetime | None: if data and len(data) >= 10: logger.debug(f"Raw clock data: {data.hex()} (len={len(data)})") - # Parse Type 322 structure: + # Type 322 version 1 - the read's type, and the reason + # the write's constant above was mislabelled. Parse: # `[Status(2)][Length(1)][Year(2)][Month(1)][Day(1)][Hour(1)][Minute(1)][Second(1)]` status = (data[0] << 8) | data[1] @@ -225,12 +227,12 @@ async def set_clock(self, dt: datetime | None = None) -> bool: Example: >>> # Sync with system time - >>> await time_service.set_clock() + >>> await time_service.set_clock() # doctest: +SKIP >>> >>> # Set to specific time >>> from datetime import datetime >>> dt = datetime(2026, 1, 30, 11, 35, 0) - >>> await time_service.set_clock(dt) + >>> await time_service.set_clock(dt) # doctest: +SKIP Implementation Notes: - Uses ``build_data_object_set(0x5E00, 0x6401, data)`` @@ -247,7 +249,7 @@ async def set_clock(self, dt: datetime | None = None) -> bool: # offset, and its schedules run against that wall clock. A # UTC-aware value here would shift the pump's clock by the # local offset. - dt = datetime.now() # noqa: DTZ005 + dt = pump_time.now() logger.info( f"Synchronizing pump clock to {dt.isoformat()} (local time)..." @@ -256,10 +258,31 @@ async def set_clock(self, dt: datetime | None = None) -> bool: try: from ..protocol import FrameBuilder - # Type 322 data payload (16 bytes): - # [header(6)][Year(2BE)][Month][Day][Hour][Min][Sec][pad(3)] - _TYPE_322_HEADER = bytes([0x41, 0x02, 0x00, 0x00, 0x0B, 0x01]) - data = bytearray(_TYPE_322_HEADER) + # The write is Object 94 **Sub 100** (DateTimeConfig), type + # **321 version 2** - not type 322, which is the type the + # *read* of Sub 101 answers with and which was pasted onto + # this constant. Config and actual are two different objects. + # + # These six bytes are not an opaque header. Read against the + # frame the builder emits - 0A 94 5E 00 64 01 41 02 00 00 0B - + # they are the tail of the address and the object's own size + # field: + # + # 5E object 94 + # 00 64 sub-id 100 + # 01 41 type 321 + # 02 version + # 00 00 0B size: 11 body bytes + # 01 constant leading struct byte + # + # The split between "address" and "data" here is an artifact + # of build_data_object_set's parameter names, which is why + # sub_id=0x5E00 and obj_id=0x6401 look transposed: they are + # byte pairs, not the object and sub-id they are named after. + _OBJECT_SIZE_AND_STRUCT_HEAD = bytes( + [0x41, 0x02, 0x00, 0x00, 0x0B, 0x01] + ) + data = bytearray(_OBJECT_SIZE_AND_STRUCT_HEAD) data.extend(struct.pack(">H", dt.year)) data.append(dt.month) data.append(dt.day) @@ -274,7 +297,10 @@ async def set_clock(self, dt: datetime | None = None) -> bool: f"{dt.hour:02d}:{dt.minute:02d}:{dt.second:02d})" ) - # Class 10 SET: SubID 0x5E00 (Obj 94), ObjID 0x6401 (Sub 100) + # Object 94 Sub 100, type 321 v2. The parameter names are the + # builder's, and are misleading here: these are the byte pairs + # 5E 00 and 64 01, which read as object 94, sub-id 100, and + # the first half of the type word. frame = FrameBuilder.build_data_object_set( sub_id=0x5E00, obj_id=0x6401, @@ -282,9 +308,10 @@ async def set_clock(self, dt: datetime | None = None) -> bool: ) logger.debug(f"Clock SET frame: {frame.hex()} ({len(frame)} bytes)") - # The clock write is acknowledged by a bare Class 10 ack, which - # carries no identifiers - the operation specifier is the only - # thing that distinguishes it from a telemetry notification. + # Acknowledged by the bare nine-byte Class 10 ack + # 24 05 F8 E7 0A 01 00 AE A2, measured at 90-120 ms on this + # pump. It carries no identifiers, so it can only be + # attributed to the command in flight. response = await self.transport.send_command( frame, Command( @@ -299,6 +326,17 @@ async def set_clock(self, dt: datetime | None = None) -> bool: logger.warning("No ACK received for clock set") return False + # The comparison below is deliberately generous, and + # deliberately not symmetric in what it is tolerating. `dt` was + # sampled before the frame went out, so by the time the pump + # answers a readback it legitimately reads *behind* it - the + # write takes about 0.64 s end to end here, and the pump's + # clock has one-second resolution. Measured over five runs, the + # pump landed exactly on the requested second every time and + # read 0.8-1.6 s behind the host by the time it was read back. + # Tightening this to a second or two would start failing on + # the client's own latency. + # Give the pump time to apply if not getattr(self.session, "fast_mode", False): await asyncio.sleep(0.5) diff --git a/src/alpha_hwr/services/write_operation.py b/src/alpha_hwr/services/write_operation.py index 512bbd6..cc4404d 100644 --- a/src/alpha_hwr/services/write_operation.py +++ b/src/alpha_hwr/services/write_operation.py @@ -26,6 +26,7 @@ import contextlib import dataclasses import logging +import math from collections import deque from collections.abc import Awaitable, Callable from dataclasses import dataclass, field @@ -341,13 +342,30 @@ async def _run_set_mode(self, op: _Operation) -> None: # -- setpoints ------------------------------------------------------- - #: Which setter writes which mode's setpoint, and the range that - #: setter accepts. The range is repeated here deliberately: the setters - #: return a bare False for an out-of-range value *and* for a transport - #: failure, and those are opposite answers to "should this be retried". - #: Checking here lets an out-of-range request settle INVALID before - #: anything reaches the wire, leaving a False from the setter to mean - #: what it should - the pump or the link refused. + #: Which setter writes which mode's setpoint, and a fallback range. + #: + #: The range is repeated here deliberately: the setters return a bare + #: False for an out-of-range value *and* for a transport failure, and + #: those are opposite answers to "should this be retried". Checking + #: here lets an out-of-range request settle INVALID before anything + #: reaches the wire, leaving a False from the setter to mean what it + #: should - the pump or the link refused. + #: + #: These bounds are a **fallback**, used only until the pump's own have + #: been read. They are wrong in both directions on every scalar mode. + #: Measured 2026-08-20: + #: + #: constant speed 1650 - 3671 RPM (not 500 - 4500) + #: constant pressure 1.000 - 2.450 m (not 0.5 - 10.0) + #: proportional pressure 2.599 - 4.569 m (not 0.5 - 10.0) + #: constant flow 0.114 - 2.498 m³/h (not 0.1 - 10.0) + #: + #: Proportional pressure is the worst of them: a 0.5 m floor against a + #: real one of 2.6 m, in a range that does not even overlap constant + #: pressure's - which is what the two shared here until they were + #: measured. They are kept deliberately wide, because refusing a + #: setpoint the pump would have taken is worse than letting it clamp + #: one it dislikes. _SETTERS: ClassVar[dict[ControlMode, tuple[str, float, float, str]]] = { ControlMode.CONSTANT_PRESSURE: ( "set_constant_pressure", @@ -370,6 +388,19 @@ async def _run_set_mode(self, op: _Operation) -> None: ControlMode.CONSTANT_FLOW: ("set_constant_flow", 0.1, 10.0, "m3/h"), } + def _bounds_for( + self, mode: ControlMode, fallback: tuple[float, float] + ) -> tuple[tuple[float, float], bool]: + """ + The bounds to judge a setpoint by, and whether they are the pump's. + + Returns ``((low, high), from_pump)``. + """ + published = self._control.get_setpoint_range(mode) + if published is not None: + return published, True + return fallback, False + async def _run_set_setpoint(self, op: _Operation) -> None: mode = op.args["mode"] value = float(op.args["value"]) @@ -381,17 +412,48 @@ async def _run_set_setpoint(self, op: _Operation) -> None: f"{mode!r} has no scalar setpoint to write", ) return - setter_name, low, high, unit = spec + setter_name, fallback_low, fallback_high, unit = spec + (low, high), from_pump = self._bounds_for( + mode, (fallback_low, fallback_high) + ) - if not low <= value <= high: + if not math.isfinite(value): + # Not a bound the pump can clamp to - there is no number here + # to store. The all-ones float is also the SETPOINT_KEEP + # sentinel, so a NaN would read as "leave the setpoint alone". op.settle( WriteStatus.INVALID, - f"{value:g} {unit} is outside the {low:g}-{high:g} {unit} " - f"this mode accepts", + f"{value} is not a setpoint the pump can store", mode=mode, ) return + # An out-of-range value is deliberately *not* refused here. The + # pump does not reject a setpoint it dislikes - it takes it and + # clamps it, and reports what it stored - so letting it answer + # tells the caller more than a refusal would, and it is the pump's + # judgement rather than ours. + # + # It also has to be the pump's, because our bound can be wrong in + # a way we cannot detect. The type-301 range is the *factory* + # range: with a flow limiter enabled the pump manages actual speed + # to hold the flow bound, and where it settles is a property of the + # installation's hydraulics, not of the pump. On one reported loop + # a 3000 RPM request delivered 1885. No number is the maximum + # speed there, so no bound could be narrowed to. See + # esphome-alpha-hwr #276. + if not low <= value <= high: + logger.info( + f"{value:g} {unit} is outside the {low:.4g}-{high:.4g} " + f"{unit} " + + ( + "the pump reports for this mode" + if from_pump + else "this mode is assumed to accept" + ) + + "; sending it anyway, and reporting what the pump stores" + ) + # What the pump held before, so "it kept its old value" can be told # apart from "it clamped to something else". before = await self._control.get_mode() @@ -440,7 +502,14 @@ def applied(i: Any) -> bool: elif previous is not None and abs(stored - previous) <= eps: status, detail = WriteStatus.REJECTED, f"pump kept {stored:g}" else: - status, detail = WriteStatus.CLAMPED, f"pump stored {stored:g}" + status = WriteStatus.CLAMPED + detail = f"pump stored {stored:g}" + if from_pump and not low <= value <= high: + # Say why, when we know why. The range is the pump's own, + # so this is an explanation rather than a guess. + detail += ( + f"; its range for this mode is {low:.4g}-{high:.4g} {unit}" + ) op.settle( status, diff --git a/tests/mocks/mock_pump.py b/tests/mocks/mock_pump.py index bd61a85..77b0384 100644 --- a/tests/mocks/mock_pump.py +++ b/tests/mocks/mock_pump.py @@ -19,7 +19,7 @@ from alpha_hwr.protocol import FrameParser from alpha_hwr.protocol.codec import encode_float_be, encode_uint16_be from alpha_hwr.protocol.matcher import expected_reply -from alpha_hwr.utils import calc_crc16 +from alpha_hwr.utils import calc_crc16_read logger = logging.getLogger(__name__) @@ -376,7 +376,7 @@ async def _handle_class10(self, frame) -> bytes: if obj_id == 91 and sub_id == 421: # dhw_on_off_control_configuration_obj, as measured: # [00 00 06][flow setpoint f32, m3/s][on minutes][off minutes] - payload = bytearray([0x00, 0x00, 0x06]) + payload = bytearray() payload.extend(bytes([0x38, 0x84, 0x4F, 0x30])) # 0.227 m3/h payload.append(self.state.cycle_on_minutes) payload.append(self.state.cycle_off_minutes) @@ -558,7 +558,6 @@ def _build_temperature_response(self) -> bytes: def _build_statistics_response(self) -> bytes: """Build Class 10 statistics response (Obj 93, Sub 1).""" payload = bytearray() - payload.extend(bytes([0x00, 0x00, 0x00])) # Header payload.extend(struct.pack(">I", 100)) # Starts payload.extend(struct.pack(">H", 5)) # Starts 1h payload.extend(struct.pack(">H", 10)) # Starts 24h @@ -569,7 +568,6 @@ def _build_statistics_response(self) -> bytes: def _build_schedule_overview_response(self) -> bytes: """Build Class 10 schedule overview response (Obj 84, Sub 1).""" payload = bytearray() - payload.extend(bytes([0x00, 0x00, 0x00])) # Header payload.extend(bytes([0x05, 0x05, 0x05, 0x05])) # Capabilities payload.append(0x01 if self.state.schedule_enabled else 0x00) # Enabled payload.append(0x02) # Default action @@ -580,7 +578,6 @@ def _build_schedule_overview_response(self) -> bytes: def _build_schedule_entries_response(self, sub_id: int) -> bytes: """Build Class 10 schedule entries response (Obj 84, Sub 1000-1004).""" payload = bytearray() - payload.extend(bytes([0x00, 0x00, 0x00])) # Header layer = sub_id - 1000 if layer in self.state.schedule_entries: @@ -600,8 +597,6 @@ def _build_clock_response(self) -> bytes: dt = self.state.last_synced_time or datetime.now() # noqa: DTZ005 payload = bytearray() - payload.extend(bytes([0x00, 0x00])) # Status (valid) - payload.append(0x07) # Length payload.extend(struct.pack(">H", dt.year)) payload.append(dt.month) payload.append(dt.day) @@ -617,8 +612,7 @@ def _build_user_settings_response(self) -> bytes: Format: [00 00 0E][01 42 0C][00 00 42 1E DE 4C][OFF][3C 02][ON][01] """ - payload = bytearray([0x00, 0x00, 0x0E]) # Header - payload.extend([0x01, 0x42, 0x0C]) # Magic + payload = bytearray([0x01, 0x42, 0x0C]) # Magic payload.extend([0x00, 0x00, 0x42, 0x1E, 0xDE, 0x4C]) # Magic payload.append(self.state.cycle_off_minutes) # Offset 12 payload.extend([0x3C, 0x02]) # Magic @@ -649,7 +643,7 @@ def _build_setpoint_info_response(self, sub_id: int = 7) -> bytes: - **Sub 10** (mode request) reads back the no-op sentinels it is written with: ``operation_mode = NoCmd`` and ``set_point = NaN``. """ - payload = bytearray([0x00, 0x00, 0x07]) + payload = bytearray() if sub_id == 0x000A: payload.append(0x00) # control_source = Undefined @@ -689,7 +683,6 @@ def _build_trend_data_response(self, sub_id: int) -> bytes: # Last 10 of 100-cycle values (10 bytes, 1 byte each) payload = bytearray() - payload.extend(bytes([0x00, 0x00, 0x00])) # Header payload.extend(encode_float_be(2.5)) # Current value (451=flow) payload.extend(bytes([i for i in range(10)])) # 10 cycle values payload.append(0x05) # Next counter @@ -701,7 +694,6 @@ def _build_trend_data_response(self, sub_id: int) -> bytes: def _build_event_log_metadata_response(self) -> bytes: """Build event log metadata response (Obj 88, Sub 10199).""" payload = bytearray() - payload.extend(bytes([0x00, 0x00, 0x00])) # Header payload.extend(struct.pack(">H", 150)) # Current cycle payload.extend(struct.pack(">H", 5)) # Available entries payload.extend(struct.pack(">H", 20)) # Max size @@ -712,7 +704,6 @@ def _build_event_log_metadata_response(self) -> bytes: def _build_event_log_entry_response(self, sub_id: int) -> bytes: """Build event log entry response (Obj 88, Sub 10200-10219).""" payload = bytearray() - payload.extend(bytes([0x00, 0x00, 0x00])) # Header # 16-byte entry entry = bytearray(16) @@ -725,41 +716,67 @@ def _build_event_log_entry_response(self, sub_id: int) -> bytes: payload.extend(entry) return self._build_class10_response(sub_id, 88, bytes(payload)) + #: Addresses in a reply, in wire order. + #: + #: The pump answers ``[0x24][len][0xF8][0xE7]``: destination us, + #: source the pump. Requests carry them the other way round. This mock + #: used to emit ``[0x24][len][0xE7][0xF8]`` - the request ordering on a + #: response - which is a frame the hardware never sends. + REPLY_DEST = 0xF8 + REPLY_SRC = 0xE7 + + def _frame(self, class_byte: int, apdu_payload: bytes) -> bytes: + """ + Wrap an APDU payload in a reply frame. + + Two things here were wrong for as long as this mock existed, and + both hid real defects rather than causing new ones: + + * the APDU head was a hardcoded constant per builder (``0x90``, + ``0x81``, ``0x01``). It is ``0booLLLLLL`` - an acknowledgement + and the payload's byte count - so every frame this mock produced + declared a length unrelated to what it carried, and anything + reading the length field agreed with the mock and disagreed with + the pump. + * the CRC used :func:`calc_crc16`, which omits the final XOR. No + frame in either direction uses that. Nothing noticed because + nothing verified an inbound CRC. + """ + if len(apdu_payload) > 0x3F: + raise ValueError( + f"APDU payload of {len(apdu_payload)} bytes cannot be " + f"declared in six bits; the pump splits these." + ) + apdu = bytes([class_byte, len(apdu_payload)]) + apdu_payload + frame = bytearray( + [0x24, len(apdu) + 2, self.REPLY_DEST, self.REPLY_SRC] + ) + frame.extend(apdu) + frame.extend(encode_uint16_be(calc_crc16_read(bytes(frame[1:])))) + return bytes(frame) + def _build_class10_response( self, sub_id: int, obj_id: int, payload: bytes ) -> bytes: """ - Build a generic Class 10 response frame. - - The identifier fields carry the type code the real pump answers - that object with, not an echo of the request. Measured against an - ALPHA HWR on 2026-08-04; see ``protocol.matcher.RESPONSE_IDENTIFIERS``. - This mock used to echo the request, which no reply from the real - device ever does - so any matching logic it exercised was being - tested against behaviour the pump does not have. + Build a Class 10 data reply. + + Bytes 6-9 carry ``[00][TypeH][TypeL][Version]`` - the object's type, + not an echo of the address that was asked for. The body then opens + with its own three-byte ``[00][00][size]`` header, which is what + every captured reply from an ALPHA HWR does and what + :attr:`ParsedFrame.object_body` strips. """ identifiers = expected_reply(obj_id, sub_id) - field_a, field_b = identifiers if identifiers else (sub_id, obj_id) - - # Build APDU: [Class][OpSpec][A-H][A-L][B-H][B-L][Payload] - apdu = bytearray([0x0A, 0x90]) - apdu.extend(encode_uint16_be(field_a)) - apdu.extend(encode_uint16_be(field_b)) - apdu.extend(payload) - - # Build frame: [Start][Length][SvcH][SvcL][APDU][CRC] - # Length field = bytes from after length byte to before CRC - # = Service ID (2) + APDU length - length = len(apdu) + 2 # +2 for service ID bytes only (not CRC) - frame = bytearray([0x24, length, 0xE7, 0xF8]) - frame.extend(apdu) - - # Add CRC - crc_data = frame[1:] # Exclude start byte - crc = calc_crc16(bytes(crc_data)) - frame.extend(encode_uint16_be(crc)) + type_high, type_low_ver = ( + identifiers if identifiers else (sub_id, obj_id) + ) - return bytes(frame) + body = bytes([0x00, 0x00, len(payload)]) + payload + apdu_payload = ( + encode_uint16_be(type_high) + encode_uint16_be(type_low_ver) + body + ) + return self._frame(0x0A, apdu_payload) #: Class 3 run-state command IDs. CLASS3_STOP = 0x05 @@ -783,80 +800,63 @@ def _build_class3_ack(self, accepted: bool = True) -> bytes: """ Build the bare acknowledgement a Class 3 command draws. - ``[03 00]`` means the pump executed it; ``[03 01 xx]`` means it - only described the data item and did nothing. The frame is far - shorter than an ordinary response and carries no identifiers, so - the class byte is all a caller has to match on. + ``[03 00]`` means the pump executed it; ``[03 01 xx]`` means it only + described the data item and did nothing. The frame is shorter than + an ordinary response and carries no type fields, so the class byte + is all a caller has to match on. """ - apdu = ( - bytearray([0x03, 0x00]) - if accepted - else bytearray([0x03, 0x01, 0xAC]) - ) - frame = bytearray([0x24, len(apdu) + 2, 0xE7, 0xF8]) - frame.extend(apdu) - frame.extend(encode_uint16_be(calc_crc16(bytes(frame[1:])))) - return bytes(frame) + return self._frame(0x03, b"" if accepted else bytes([0xAC])) def _build_class3_response(self, payload: bytes) -> bytes: - """Build Class 3 response frame.""" - apdu = bytearray([0x03, 0x81]) # Class 3, response OpSpec - apdu.extend(payload) - - # Length = Service ID (2) + APDU length - length = len(apdu) + 2 - frame = bytearray([0x24, length, 0xE7, 0xF8]) - frame.extend(apdu) - - crc = calc_crc16(bytes(frame[1:])) - frame.extend(encode_uint16_be(crc)) - - return bytes(frame) + """Build a Class 3 data reply.""" + return self._frame(0x03, payload) def _build_class7_response(self, string_id: int, value: str) -> bytes: - """Build Class 7 response frame.""" - val_bytes = value.encode("utf-8") + b"\x00" - apdu = bytearray([0x07, 0x81, string_id]) - apdu.extend(val_bytes) - - length = len(apdu) + 2 - frame = bytearray([0x24, length, 0xE7, 0xF8]) - frame.extend(apdu) + """ + Build a Class 7 string reply. - crc = calc_crc16(bytes(frame[1:])) - frame.extend(encode_uint16_be(crc)) + The string starts at byte 6 and the APDU head is its byte count. + There is no echoed string ID: a captured ``ALPHA HWR`` reply reads + ``24 0E F8 E7 07 0A 41 4C 50 48 41 ...``, where ``0x0A`` is the ten + bytes of ``ALPHA HWR\0`` and ``0x41`` is the ``A``. - return bytes(frame) + This mock used to emit ``[07][81][string_id]`` before the text, + which put the first character one byte late - the same off-by-one + the client compensated for by prepending ``"A"`` to ``"LPHA HWR"``. + A mock that reproduces a bug cannot catch it. + """ + return self._frame(0x07, value.encode("utf-8") + b"\x00") def _build_class2_response(self, payload: bytes) -> bytes: - """Build Class 2 response frame.""" - apdu = bytearray([0x02, 0x81]) - apdu.extend(payload) - - length = len(apdu) + 2 - frame = bytearray([0x24, length, 0xE7, 0xF8]) - frame.extend(apdu) - - crc = calc_crc16(bytes(frame[1:])) - frame.extend(encode_uint16_be(crc)) - - return bytes(frame) + """Build a Class 2 data reply.""" + return self._frame(0x02, payload) def _build_ack_response(self) -> bytes: - """Build simple acknowledgment response.""" - # Class 10 ACK: [Start][Len][Svc(2)][Class=0x0A][OpSpec=0x01][CRC(2)] - # Length = Svc(2) + Class(1) + OpSpec(1) = 4 - frame = bytearray([0x24, 0x04, 0xE7, 0xF8, 0x0A, 0x01]) - crc = calc_crc16(bytes(frame[1:])) - frame.extend(encode_uint16_be(crc)) - return bytes(frame) + """ + Build the acknowledgement a Class 10 write draws. + + Nine bytes: ``24 05 F8 E7 0A 01 00 CRC CRC``. The single payload + byte is the Class 10 status - 0 for OK - and is a *second* + acknowledgement, checked only once the APDU head's own ack says the + pump understood the request. + """ + return self._frame(0x0A, bytes([0x00])) def _build_error_response(self) -> bytes: - """Build error response.""" - # Length = Svc(2) + Class(1) + OpSpec(1) + Error(1) = 5 - frame = bytearray([0x24, 0x05, 0xE7, 0xF8, 0xFF, 0xFF, 0x00]) - crc = calc_crc16(bytes(frame[1:])) - frame.extend(encode_uint16_be(crc)) + """ + Build a refusal. + + ``0x81`` is ``10 000001``: Unknown Data Item, one payload byte, and + that byte names the item the pump did not recognise. It is not an + acknowledgement carrying an error code, which is how this client + read it - so a refusal naming item ``0x00`` was taken for success. + """ + apdu = bytes([0x0A, 0x81, 0x00]) + frame = bytearray( + [0x24, len(apdu) + 2, self.REPLY_DEST, self.REPLY_SRC] + ) + frame.extend(apdu) + frame.extend(encode_uint16_be(calc_crc16_read(bytes(frame[1:])))) return bytes(frame) async def start_telemetry_stream(self, interval: float = 1.0): diff --git a/tests/reference/test_protocol_vectors.py b/tests/reference/test_protocol_vectors.py index 0c1ee38..bd424c4 100644 --- a/tests/reference/test_protocol_vectors.py +++ b/tests/reference/test_protocol_vectors.py @@ -184,14 +184,18 @@ def test_parse_valid_frame(self): """Test parsing a valid response frame.""" # Build a simple Class 3 response # Format: [Start][Len][SvcH][SvcL][Class][OpSpec][Data...][CRCH][CRCL] + # Length counts bytes [2] through the last APDU byte: two + # addresses plus class and APDU head. That is 4, not 6 - this + # fixture used to declare a frame two bytes longer than it was. + # Destination 0xF8, source 0xE7: a reply, not a request. frame = bytearray( [ 0x24, # Start (RESPONSE_START) - 0x06, # Length (data + service + CRC) - 0xE7, - 0xF8, # Service ID + 0x04, # Length + 0xF8, # Destination (us) + 0xE7, # Source (the pump) 0x03, # Class 3 - 0x81, # OpSpec (response) + 0x00, # APDU head: ack OK, zero payload bytes ] ) @@ -220,11 +224,11 @@ def test_parse_invalid_crc(self): frame = bytes( [ 0x24, # Start (valid) - 0x06, # Length - 0xE7, - 0xF8, # Service ID + 0x04, # Length + 0xF8, # Destination + 0xE7, # Source 0x03, # Class 3 - 0x81, # OpSpec + 0x00, # APDU head 0xFF, 0xFF, # Invalid CRC ] diff --git a/tests/test_advanced_telemetry.py b/tests/test_advanced_telemetry.py index fc1af2a..c6689f9 100644 --- a/tests/test_advanced_telemetry.py +++ b/tests/test_advanced_telemetry.py @@ -1,36 +1,42 @@ +""" +Decoding the pump's telemetry objects. + +Every frame here is either a recording from an ALPHA HWR or is built by +``tests.wire``, which computes a real length field and a real CRC. The +frames these replaced were assembled by hand with a constant APDU head, a +two-zero-byte "mock CRC", and the requested Object/Sub-ID in bytes 6-9 - +where the pump puts an object type. They agreed with the decoder because +both were built from the same wrong reading of the wire. +""" + import struct import pytest +from wire import CAPTURED, class10_reply from alpha_hwr.protocol.frame_parser import FrameParser from alpha_hwr.protocol.telemetry_decoder import TelemetryDecoder +#: Object types the three telemetry registers answer with, measured +#: 2026-08-20 by issuing each read and recording bytes 6-9. +MOTOR_STATE_TYPE = (0x0001, 0x0003) +FLOW_PRESSURE_TYPE = (0x0002, 0x3502) +TEMPERATURE_TYPE = (0x0002, 0x1602) + def test_parse_motor_state_corrected(): - # Obj 87, Sub 69 (Motor State), Type 256 - # Correct Offsets: - # 0: Grid V (230.5) - # 8: Current (1.2) - # 16: DC Power (45.0) - # 20: Speed (1800.0) - # 24: Converter Temp (35.5) - - payload = bytearray([0] * 30) - struct.pack_into(">f", payload, 0, 230.5) # Grid V - struct.pack_into(">f", payload, 8, 1.2) # Current - struct.pack_into(">f", payload, 16, 45.0) # Power - struct.pack_into(">f", payload, 20, 1800.0) # Speed - struct.pack_into(">f", payload, 24, 35.5) # Converter Temp - - # GENI Packet: [STX][LEN][DST][SRC][Class=10][OpSpec=INFO][Sub_H][Sub_L][Obj_H][Obj_L][Payload...][CRC] - # Sub=69 (0x0045), Obj=87 (0x0057) - packet = bytearray( - [0x24, len(payload) + 8, 0xF8, 0xE7, 0x0A, 0x13, 0x00, 69, 0x00, 87] + """Offsets 0, 8, 16, 20 and 24 of the motor-state struct.""" + body = bytearray([0] * 28) + struct.pack_into(">f", body, 0, 230.5) # Grid voltage + struct.pack_into(">f", body, 8, 1.2) # Current + struct.pack_into(">f", body, 16, 45.0) # DC power + struct.pack_into(">f", body, 20, 1800.0) # Speed + struct.pack_into(">f", body, 24, 35.5) # Converter temperature + + frame = FrameParser.parse_frame( + class10_reply(*MOTOR_STATE_TYPE, bytes(body)) ) - packet.extend(payload) - packet.extend([0, 0]) # Mock CRC - - frame = FrameParser.parse_frame(bytes(packet)) + assert frame.crc_valid updates = TelemetryDecoder.decode(frame) assert updates["voltage_ac_v"] == pytest.approx(230.5) @@ -41,22 +47,17 @@ def test_parse_motor_state_corrected(): def test_parse_pressures(): - # Obj 93, Sub 290 (Flow/Pressure), Type 565 - # Offsets: 0:Flow, 4:Head, 8:InletP, 12:OutletP - payload = bytearray([0] * 20) - struct.pack_into(">f", payload, 0, 1.5) # Flow - struct.pack_into(">f", payload, 4, 3.2) # Head - struct.pack_into(">f", payload, 8, 0.8) # Inlet P - struct.pack_into(">f", payload, 12, 1.1) # Outlet P - - # Sub 290=0x0122, Obj 93=0x005D - packet = bytearray( - [0x24, len(payload) + 8, 0xF8, 0xE7, 0x0A, 0x13, 0x01, 0x22, 0x00, 0x5D] + """Offsets 0, 4, 8 and 12 of the flow/pressure struct.""" + body = bytearray([0] * 20) + struct.pack_into(">f", body, 0, 1.5) # Flow + struct.pack_into(">f", body, 4, 3.2) # Head + struct.pack_into(">f", body, 8, 0.8) # Inlet pressure + struct.pack_into(">f", body, 12, 1.1) # Outlet pressure + + frame = FrameParser.parse_frame( + class10_reply(*FLOW_PRESSURE_TYPE, bytes(body)) ) - packet.extend(payload) - packet.extend([0, 0]) - - frame = FrameParser.parse_frame(bytes(packet)) + assert frame.crc_valid updates = TelemetryDecoder.decode(frame) assert updates["flow_m3h"] == pytest.approx(1.5) @@ -66,21 +67,16 @@ def test_parse_pressures(): def test_parse_detailed_temperatures(): - # Obj 93, Sub 300 (Temps), Type 534 - # Offsets: 0:Media, 4:PCB, 8:Controlbox - payload = bytearray([0] * 12) - struct.pack_into(">f", payload, 0, 65.2) # Media - struct.pack_into(">f", payload, 4, 42.1) # PCB - struct.pack_into(">f", payload, 8, 38.5) # Ctrl - - # Sub 300=0x012C, Obj 93=0x005D - packet = bytearray( - [0x24, len(payload) + 8, 0xF8, 0xE7, 0x0A, 0x13, 0x01, 0x2C, 0x00, 0x5D] + """Offsets 0, 4 and 8 of the temperature struct.""" + body = bytearray([0] * 12) + struct.pack_into(">f", body, 0, 65.2) # Media + struct.pack_into(">f", body, 4, 42.1) # PCB + struct.pack_into(">f", body, 8, 38.5) # Control box + + frame = FrameParser.parse_frame( + class10_reply(*TEMPERATURE_TYPE, bytes(body)) ) - packet.extend(payload) - packet.extend([0, 0]) - - frame = FrameParser.parse_frame(bytes(packet)) + assert frame.crc_valid updates = TelemetryDecoder.decode(frame) assert updates["media_temperature_c"] == pytest.approx(65.2) @@ -88,69 +84,42 @@ def test_parse_detailed_temperatures(): assert updates["control_box_temperature_c"] == pytest.approx(38.5) -def test_parse_alarms_warnings(): - # Obj 88, Sub 0 (Alarms) - # Active Query Response Format: [Class][OpSpec][Seq(2)][ID(2)][Res(2)][DataLen][Data...] - # OpSpec 0x09 (validated on real hardware - ESPHome implementation) - alarm_data = struct.pack( - ">HHH", 42, 7, 0 - ) # 2 alarms (42, 7), zero filtered - - # Build response: [STX][Len][Dst][Src][Class=10][OpSpec=0x09][Seq(2)][ID(2)][Res(2)][DataLen][Data...][CRC(2)] - packet = bytearray( - [ - 0x24, # STX - 13 - + len( - alarm_data - ), # Length (total from STX through DataLen + Data, excluding CRC) - 0xF8, - 0xE7, # Dst, Src - 0x0A, # Class 10 - 0x09, # OpSpec (Active Query Response for alarms/warnings) - 0x00, - 0x01, # Sequence number - 0x58, - 0x00, # ID (Register 0x5800 = Obj 88, Sub 0) - 0x00, - 0x00, # Reserved - len(alarm_data), # DataLen - ] - ) - packet.extend(alarm_data) - packet.extend([0, 0]) # Mock CRC - - frame = FrameParser.parse_frame(bytes(packet)) +def test_captured_temperature_frame_decodes(): + """The recorded temperature reply, decoded end to end.""" + frame = FrameParser.parse_frame(CAPTURED["temperature"]) updates = TelemetryDecoder.decode(frame) - assert updates["active_alarms"] == [42, 7] - - # Obj 88, Sub 11 (Warnings) - # Same Active Query Response format - warning_data = struct.pack(">HH", 5, 0) # 1 warning (5) - - packet = bytearray( - [ - 0x24, # STX - 13 - + len( - warning_data - ), # Length (total from STX through DataLen + Data, excluding CRC) - 0xF8, - 0xE7, # Dst, Src - 0x0A, # Class 10 - 0x09, # OpSpec - 0x00, - 0x02, # Sequence number - 0x58, - 0x0B, # ID (Register 0x580B = Obj 88, Sub 11) - 0x00, - 0x00, # Reserved - len(warning_data), # DataLen - ] + + assert updates["media_temperature_c"] == pytest.approx(28.118, abs=1e-3) + assert updates["pcb_temperature_c"] == pytest.approx(29.174, abs=1e-3) + assert updates["control_box_temperature_c"] == pytest.approx( + 26.756, abs=1e-3 ) - packet.extend(warning_data) - packet.extend([0, 0]) # Mock CRC - frame = FrameParser.parse_frame(bytes(packet)) - updates = TelemetryDecoder.decode(frame) - assert updates["active_warnings"] == [5] + +def test_alarms_and_warnings_share_one_type(): + """ + A reply cannot say whether it holds alarms or warnings. + + Reading Object 88 Sub 0 and Object 88 Sub 11 on 2026-08-20 returned + byte-identical frames, both typed 0x3A01 version 2. So the automatic + router deliberately does not handle them - only the caller that issued + the read knows which list came back, which is why + DeviceInfoService.read_alarms() decodes them itself. + """ + captured = bytes.fromhex("240df8e70a0900023a010000020000dc50") + frame = FrameParser.parse_frame(captured) + + assert frame.crc_valid + assert (frame.type_high, frame.type_low_ver) == (0x0002, 0x3A01) + assert TelemetryDecoder.decode(frame) == {} + # An empty list: this pump had nothing active when the frame was taken. + assert TelemetryDecoder.decode_alarms_warnings(frame.object_body) == [] + + +def test_alarm_codes_decode_and_drop_padding(): + """Zero is padding, not alarm code zero.""" + body = struct.pack(">HHH", 42, 7, 0) + + frame = FrameParser.parse_frame(class10_reply(0x0002, 0x3A01, body)) + assert frame.crc_valid + assert TelemetryDecoder.decode_alarms_warnings(frame.object_body) == [42, 7] diff --git a/tests/test_client_telemetry.py b/tests/test_client_telemetry.py index 55e5acd..ce8a80c 100644 --- a/tests/test_client_telemetry.py +++ b/tests/test_client_telemetry.py @@ -13,6 +13,7 @@ import pytest import pytest_asyncio +from wire import class10_reply from alpha_hwr.client import AlphaHWRClient @@ -64,25 +65,11 @@ async def test_class10_notification_handling(client): volt_bytes + padding + curr_bytes + padding + pwr_bytes + speed_bytes ) - # Build frame: [STX][LEN][DST][SRC][Class][Op][SubH][SubL][ObjH][ObjL][Payload][CRC] - # Total length = DST(1) + SRC(1) + Class(1) + Op(1) + SubH(1) + SubL(1) + ObjH(1) + ObjL(1) + Payload(24) + CRC(2) = 34 - header = bytes( - [ - 0x24, # STX (Response) - 0x22, # LEN = 34 decimal = 0x22 - 0xF8, - 0xE7, # Dest, Src - 0x0A, - 0x90, # Class 10, OpSpec (notification) - 0x00, - 0x45, # Sub ID = 69 (0x0045) - 0x00, - 0x57, # Obj ID = 87 (0x0057) - Motor State - ] - ) - crc = b"\x00\x00" # Dummy CRC - - packet = header + payload + crc + # A motor-state reply is typed 3 version 1; nothing in it echoes the + # Object 87 / Sub-ID 69 that was requested. The frame this replaced put + # those in bytes 6-9, chose 0x90 as a constant "notification opcode" + # where that byte is a payload length, and carried a dummy CRC. + packet = class10_reply(0x0001, 0x0003, payload) # Send notification through telemetry service client.telemetry.update_from_notification(bytearray(packet)) diff --git a/tests/test_control_service_extended.py b/tests/test_control_service_extended.py index 3e1f2ef..be8e3b6 100644 --- a/tests/test_control_service_extended.py +++ b/tests/test_control_service_extended.py @@ -30,6 +30,8 @@ async def test_set_constant_speed_valid(self, mock_client_simple): result = await mock_client_simple.control.set_constant_speed(2500.0) assert result is True + # The Class 10 SET is acknowledged, so it goes through + # send_command() and consumes a query. assert mock_client_simple.transport.query.call_count >= 2 @pytest.mark.asyncio @@ -56,17 +58,35 @@ async def test_set_constant_speed_max_value(self, mock_client_simple): @pytest.mark.asyncio async def test_set_constant_speed_too_low(self, mock_client_simple): - """Test setting constant speed below minimum (< 500 RPM).""" + """ + An out-of-range setpoint is the pump's call, not ours. + + It does not refuse a value it dislikes - it takes it and clamps it, + and reports what it stored - so the write goes out and the verified + path settles CLAMPED. With a flow limiter active there is no bound + to check against anyway: the pump manages actual speed to hold the + flow bound, and where it settles is a property of the loop's + hydraulics. See esphome-alpha-hwr #276. + """ result = await mock_client_simple.control.set_constant_speed(400.0) - assert result is False + assert result is True @pytest.mark.asyncio async def test_set_constant_speed_too_high(self, mock_client_simple): - """Test setting constant speed above maximum (> 4500 RPM).""" + """ + An out-of-range setpoint is the pump's call, not ours. + + It does not refuse a value it dislikes - it takes it and clamps it, + and reports what it stored - so the write goes out and the verified + path settles CLAMPED. With a flow limiter active there is no bound + to check against anyway: the pump manages actual speed to hold the + flow bound, and where it settles is a property of the loop's + hydraulics. See esphome-alpha-hwr #276. + """ result = await mock_client_simple.control.set_constant_speed(5000.0) - assert result is False + assert result is True @pytest.mark.asyncio async def test_set_constant_speed_typical_values(self, mock_client_simple): @@ -126,19 +146,37 @@ async def test_set_proportional_pressure_max_value( @pytest.mark.asyncio async def test_set_proportional_pressure_too_low(self, mock_client_simple): - """Test setting proportional pressure below minimum (< 0.5m).""" + """ + An out-of-range setpoint is the pump's call, not ours. + + It does not refuse a value it dislikes - it takes it and clamps it, + and reports what it stored - so the write goes out and the verified + path settles CLAMPED. With a flow limiter active there is no bound + to check against anyway: the pump manages actual speed to hold the + flow bound, and where it settles is a property of the loop's + hydraulics. See esphome-alpha-hwr #276. + """ result = await mock_client_simple.control.set_proportional_pressure(0.3) - assert result is False + assert result is True @pytest.mark.asyncio async def test_set_proportional_pressure_too_high(self, mock_client_simple): - """Test setting proportional pressure above maximum (> 10m).""" + """ + An out-of-range setpoint is the pump's call, not ours. + + It does not refuse a value it dislikes - it takes it and clamps it, + and reports what it stored - so the write goes out and the verified + path settles CLAMPED. With a flow limiter active there is no bound + to check against anyway: the pump manages actual speed to hold the + flow bound, and where it settles is a property of the loop's + hydraulics. See esphome-alpha-hwr #276. + """ result = await mock_client_simple.control.set_proportional_pressure( 12.0 ) - assert result is False + assert result is True class TestSetAutoadaptModes: @@ -154,6 +192,8 @@ async def test_set_autoadapt_radiator_valid(self, mock_client_simple): result = await mock_client_simple.control.set_autoadapt_radiator(3.0) assert result is True + # The Class 10 SET is acknowledged, so it goes through + # send_command() and consumes a query. assert mock_client_simple.transport.query.call_count >= 2 @pytest.mark.asyncio @@ -296,7 +336,6 @@ async def test_set_constant_speed_transport_exception( self, mock_client_simple ): """Test set_constant_speed when transport raises exception.""" - # Mock query to raise exception after max retries mock_client_simple.transport.query = AsyncMock( side_effect=BleakError("Transport error") ) @@ -359,7 +398,7 @@ async def test_mode_switching_sequence(self, mock_client_simple): is True ) - # Each mode switch should have made query calls (set_mode + set_setpoint) + # Each mode switch makes query calls (set_mode + set_setpoint). assert mock_client_simple.transport.query.call_count >= 8 @pytest.mark.asyncio @@ -381,15 +420,19 @@ async def test_setpoint_validation_across_modes(self, mock_client_simple): await mock_client_simple.control.set_autoadapt_radiator(4.0) is True ) - # Invalid values for each mode + # Values outside each mode's range still reach the pump: it clamps + # rather than refusing, and reports what it stored. Only a value + # that is not a number is refused here, because there is nothing + # for the pump to clamp to - and the all-ones float doubles as the + # SETPOINT_KEEP sentinel, so it would read as "leave it alone". assert ( - await mock_client_simple.control.set_constant_speed(100.0) is False + await mock_client_simple.control.set_constant_speed(100.0) is True ) assert ( await mock_client_simple.control.set_proportional_pressure(15.0) - is False + is True ) assert ( - await mock_client_simple.control.set_autoadapt_radiator(15.0) + await mock_client_simple.control.set_constant_speed(float("nan")) is False ) diff --git a/tests/test_device_info.py b/tests/test_device_info.py index e464ad9..e61b127 100644 --- a/tests/test_device_info.py +++ b/tests/test_device_info.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +from wire import frame from alpha_hwr.client import AlphaHWRClient @@ -72,49 +73,46 @@ async def test_read_device_info_from_advertisement(): async def test_read_device_info_with_class7_strings(mock_client_simple): """Test reading device info with Class 7 strings.""" - # Mock transport.query to return Class 7 string responses - async def mock_query(frame, match_func=None, timeout=3.0): - # Determine which string is being requested based on the frame - # Frame structure: [STX][LEN][DST][SRC][Class][Cmd][ID]... - if len(frame) < 7: + # Class 7 replies as an ALPHA HWR actually sends them, recorded + # 2026-08-20. The strings this fixture used to carry were the + # *truncated* ones - "0000479" and "2601618V04.02.01.02539" - because + # they were copied out of this client's own output while it was + # reading from offset 7 and dropping the first character. So the + # fixture agreed with the bug, and the "1" the serial assertion + # expected was the character the reader had thrown away. + STRINGS = { + 1: "ALPHA HWR", + 9: "10000479", + 50: "92601618V04.02.01.02539", + 52: "92601617V01.03.00.00469", + 58: "92811431V06.00.01.00001", + } + + async def mock_query(request, match_func=None, timeout=3.0): + # Request: [STX][LEN][DST][SRC][0x07][head][string_id] + if len(request) < 7: return None + value = STRINGS.get(request[6]) + if value is None: + return None + return frame(0x07, value.encode() + b"\x00") - string_id = frame[6] # String ID is at position 6 in the packet - - if string_id == 9: # Serial number - return ( - b"\x27\x0e\xe7\xf8\x07\x01\x09" + b"0000479\x00" + b"\x00\x00" - ) - elif string_id == 50: # Software version - return ( - b"\x27\x20\xe7\xf8\x07\x01\x32" - + b"2601618V04.02.01.02539\x00" - + b"\x00\x00" - ) - elif string_id == 52: # Hardware version - return ( - b"\x27\x20\xe7\xf8\x07\x01\x34" - + b"2601617V01.03.00.00469\x00" - + b"\x00\x00" - ) - elif string_id == 58: # BLE version - return ( - b"\x27\x20\xe7\xf8\x07\x01\x3a" - + b"2811431V06.00.01.00001\x00" - + b"\x00\x00" - ) - return None + async def mock_send_command(request, command, timeout=3.0): + return await mock_query(request) mock_client_simple.transport.query = AsyncMock(side_effect=mock_query) + mock_client_simple.transport.send_command = AsyncMock( + side_effect=mock_send_command + ) device_info = await mock_client_simple.device_info.read_info() assert device_info is not None - # Serial number gets "1" prepended to the suffix from ID 9 + # The serial arrives whole; nothing is prepended to it. assert device_info.serial_number == "10000479" - assert device_info.software_version == "2601618V04.02.01.02539" - assert device_info.hardware_version == "2601617V01.03.00.00469" - assert device_info.ble_version == "2811431V06.00.01.00001" + assert device_info.software_version == "92601618V04.02.01.02539" + assert device_info.hardware_version == "92601617V01.03.00.00469" + assert device_info.ble_version == "92811431V06.00.01.00001" @pytest.mark.asyncio diff --git a/tests/test_doctests.py b/tests/test_doctests.py new file mode 100644 index 0000000..e4eb08b --- /dev/null +++ b/tests/test_doctests.py @@ -0,0 +1,69 @@ +""" +Every example in the source that claims to be runnable, runs. + +This repository already guards the prose in ``docs/`` against drifting +away from the code (``test_docs_consistency.py``). The docstrings had no +such guard, and 185 of the 279 examples in them were failing - most +because they were never executable in the first place (``await`` at the +top level, or a ``client`` nobody had constructed), and seven because they +were simply wrong: + +* ``encode_float_be(1.5)`` claimed ``b'\\x3f\\xc0\\x00\\x00'``; Python + prints that byte as ``?``. +* ``decode_float_be(b'\\x00\\x00')`` claimed to print ``None``, which a + bare expression does not do. +* ``build_command_info(0x02, 0x45)`` claimed ``'27050e7f8020345...'`` - + an ellipsis, and a stray ``0`` in the address. +* the frame length for a 3-byte register read was given as 9; it is 11. +* three ``Session`` examples referred to a session that was never built, + and one expected a raised ``ConnectionError`` without a traceback. + +Illustrative examples now carry ``# doctest: +SKIP`` and say so. That is +the honest distinction: they are documentation, not tests, and marking +them keeps the ones that *are* tests visible. +""" + +from __future__ import annotations + +import doctest +import importlib +import pkgutil + +import pytest + +import alpha_hwr + +MODULES = sorted( + m.name for m in pkgutil.walk_packages(alpha_hwr.__path__, "alpha_hwr.") +) + + +@pytest.mark.parametrize("module_name", MODULES) +def test_module_doctests(module_name: str) -> None: + module = importlib.import_module(module_name) + result = doctest.testmod(module, verbose=False, report=True) + assert result.failed == 0, ( + f"{result.failed} of {result.attempted} doctests failed in " + f"{module_name}" + ) + + +def test_the_suite_still_runs_a_meaningful_number_of_examples() -> None: + """ + A floor, so the previous state cannot be reached by skipping everything. + + Marking an example ``+SKIP`` is the right call for one that cannot run, + and the wrong call for one that merely fails. Without a floor the + difference is invisible: a green run and a fully-skipped run look the + same. + """ + attempted = sum( + doctest.testmod( + importlib.import_module(name), verbose=False, report=False + ).attempted + for name in MODULES + ) + assert attempted >= 250, ( + f"only {attempted} doctests ran; examples are being skipped rather " + f"than fixed" + ) diff --git a/tests/test_mock_pump.py b/tests/test_mock_pump.py index 0fc7d06..f413274 100644 --- a/tests/test_mock_pump.py +++ b/tests/test_mock_pump.py @@ -46,12 +46,16 @@ async def test_motor_state_telemetry(self): # Parse response frame = FrameParser.parse_frame(response) assert frame.valid + assert frame.crc_valid assert frame.class_byte == 10 - # For Class 10, obj_id is the full register in the response - # The parser extracts this differently - - # Decode telemetry - data = TelemetryDecoder.decode_motor_state(frame.payload) + # The reply names the object's type, not the register asked for. + assert (frame.type_high, frame.type_low_ver) == (0x0001, 0x0003) + + # Decode through the production router, which strips the object's + # three-byte size header. Calling decode_motor_state() on the raw + # payload reads every float three bytes late and yields denormals + # that still pass the range checks. + data = TelemetryDecoder.decode(frame) assert "voltage_ac_v" in data assert "speed_rpm" in data assert data["voltage_ac_v"] == 230.0 diff --git a/tests/test_packet_creation.py b/tests/test_packet_creation.py index 832e8ee..1485431 100644 --- a/tests/test_packet_creation.py +++ b/tests/test_packet_creation.py @@ -1,14 +1,14 @@ import struct +from wire import class10_reply + from alpha_hwr.constants import ( AUTH_CLASS10_MAGIC, AUTH_EXTEND_1, AUTH_EXTEND_2, AUTH_LEGACY_MAGIC, - CLASS_10, FRAME_START, RESERVED_BYTE, - RESPONSE_START, SERVICE_ID_HIGH, CommandOpcode, ) @@ -161,8 +161,8 @@ def test_telemetry_decoder_class10(self): # 16-20: Power # 20-24: Speed - sub_id = 69 # 0x45 - obj_id = 87 # 0x57 + # The motor-state register answers as type 3 version 1. + reply_type = (0x0001, 0x0003) # Construct payload volt_bytes = struct.pack(">f", 230.0) @@ -181,26 +181,7 @@ def test_telemetry_decoder_class10(self): + speed_bytes ) - # Build Packet manually to simulate device response - # [24][Len][Dst][Src][0A][Op][Sub][Obj][Data] - length = 10 + len(payload) - - packet = bytearray( - [ - RESPONSE_START, - length, - 0xF8, # Dest (Client) - 0xE7, # Src (Pump) - CLASS_10, - 0x00, # Op - (sub_id >> 8) & 0xFF, - sub_id & 0xFF, - (obj_id >> 8) & 0xFF, - obj_id & 0xFF, - ] - ) - packet.extend(payload) - packet.extend([0x00, 0x00]) # Fake CRC + packet = class10_reply(*reply_type, payload) frame = FrameParser.parse_frame(bytes(packet)) data = TelemetryDecoder.decode(frame) @@ -263,33 +244,19 @@ def test_telemetry_decoder_flow_head(self): """ Test FrameBuilder.parse_class10_telemetry for Flow/Head (Sub 0x122, Obj 0x5D). """ - sub_id = 0x0122 - obj_id = 0x005D + # The reply names the object's type; 0x3502 v2 is what the + # flow/head register answers with, measured 2026-08-20. + reply_type = (0x0002, 0x3502) # Flow = 2.5 m3/h, Head = 4.0 m flow_bytes = struct.pack(">f", 2.5) head_bytes = struct.pack(">f", 4.0) payload = flow_bytes + head_bytes - length = 10 + len(payload) - packet = bytearray( - [ - RESPONSE_START, - length, - 0xF8, - 0xE7, - CLASS_10, - 0x00, - (sub_id >> 8) & 0xFF, - sub_id & 0xFF, - (obj_id >> 8) & 0xFF, - obj_id & 0xFF, - ] - ) - packet.extend(payload) - packet.extend([0x00, 0x00]) + packet = class10_reply(*reply_type, payload) - frame = FrameParser.parse_frame(bytes(packet)) + frame = FrameParser.parse_frame(packet) + assert frame.crc_valid data = TelemetryDecoder.decode(frame) assert data["flow_m3h"] == 2.5 @@ -299,32 +266,17 @@ def test_telemetry_decoder_temperature(self): """ Test FrameBuilder.parse_class10_telemetry for Temperature (Sub 0x12C, Obj 0x5D). """ - sub_id = 0x012C - obj_id = 0x005D + # Temperatures answer as type 0x1602 version 2. + reply_type = (0x0002, 0x1602) # Media Temp = 45.5 C temp_bytes = struct.pack(">f", 45.5) payload = temp_bytes - length = 10 + len(payload) - packet = bytearray( - [ - RESPONSE_START, - length, - 0xF8, - 0xE7, - CLASS_10, - 0x00, - (sub_id >> 8) & 0xFF, - sub_id & 0xFF, - (obj_id >> 8) & 0xFF, - obj_id & 0xFF, - ] - ) - packet.extend(payload) - packet.extend([0x00, 0x00]) + packet = class10_reply(*reply_type, payload) - frame = FrameParser.parse_frame(bytes(packet)) + frame = FrameParser.parse_frame(packet) + assert frame.crc_valid data = TelemetryDecoder.decode(frame) assert data["media_temperature_c"] == 45.5 diff --git a/tests/test_packet_structure.py b/tests/test_packet_structure.py index c0f97af..7557ef9 100644 --- a/tests/test_packet_structure.py +++ b/tests/test_packet_structure.py @@ -115,24 +115,38 @@ def test_frame_parser_class10_response(): frame = FrameParser.parse_frame(packet) assert frame.valid + assert frame.crc_valid assert frame.class_byte == 0x0A - assert frame.obj_id == 0x3502 - assert frame.sub_id == 0x0002 + # Bytes 6-9 are the object's type, not the register that was read. + assert frame.type_high == 0x0002 + assert frame.type_low_ver == 0x3502 + # Byte 5 declares 43 payload bytes, which is exactly len - 8. + assert packet[5] == len(packet) - 8 - # Payload should start after the DataLen byte (offset 13) - # The new parser logic uses data[13:-2] for these special OpSpecs - assert frame.payload.hex().startswith("390aa426") + # The payload opens with the object's own [00][00][size] header; + # object_body strips it. Selecting offset 13 by matching byte 5 + # against {0x30, 0x2B, 0x14, ...} worked only because those values are + # the payload *lengths* of three telemetry registers. + assert frame.payload.hex().startswith("000024") + assert frame.object_body.hex().startswith("390aa426") def test_frame_parser_class10_notification(): - """Test that FrameParser correctly handles Class 10 notification frames (OpSpec 0x0E).""" - # Motor state notification (OpSpec 0x0E) - # [Start][Len][Dst][Src][Class=0x0A][OpSpec=0x0E][Sub=0045][Obj=0057][Payload...][CRC] - packet = bytes.fromhex("2415e7f80a0e00450057437000000000000040200000fbdc") + """ + A Class 10 data reply, as recorded from the pump. + + The frame this replaced declared a length of 25 while carrying 24 + bytes, and put the addresses in request order on a response. Both + make it a frame the pump cannot send, so the parser now rejects it - + correctly. + """ + # Object 86 Sub 7, captured 2026-08-20. + packet = bytes.fromhex("2412f8e70a0e00012f0100000701001b39678ac3f7dd") frame = FrameParser.parse_frame(packet) assert frame.valid + assert frame.crc_valid assert frame.class_byte == 0x0A - assert frame.sub_id == 0x0045 # 69 - assert frame.obj_id == 0x0057 # 87 - assert frame.payload.hex().startswith("43700000") + assert frame.type_high == 0x0001 + assert frame.type_low_ver == 0x2F01 + assert packet[1] + 4 == len(packet) diff --git a/tests/test_protocol_expanded.py b/tests/test_protocol_expanded.py index 115ba51..ca0852b 100644 --- a/tests/test_protocol_expanded.py +++ b/tests/test_protocol_expanded.py @@ -1,6 +1,7 @@ import struct -from alpha_hwr.constants import RESPONSE_START +from wire import class10_reply + from alpha_hwr.protocol.codec import decode_float_be, encode_float_be from alpha_hwr.protocol.frame_parser import FrameParser from alpha_hwr.protocol.telemetry_decoder import TelemetryDecoder @@ -22,15 +23,19 @@ def test_parse_packet_structure(self): # To get class=10, data[4] == 10. # To get sub/obj, len > 9. - pkt = bytes( - [RESPONSE_START, 0x05, 0x00, 0x00, 10, 0x00, 0x01, 0x02, 0x03, 0x04] - ) - # Len=10. Index 4=10 (Class). Index 6,7=0x0102 (Sub). Index 8,9=0x0304 (Obj). + # A reply's bytes 6-9 are [00][TypeH][TypeL][Version], not a + # Sub-ID and Object ID. This frame used to be built with a length + # byte of 5 - declaring nine bytes while carrying ten - and an + # APDU head of 0, declaring no payload at all; it then asserted + # that four bytes of that absent payload had been extracted. + pkt = class10_reply(0x0001, 0x0203, b"\x04") res = FrameParser.parse_frame(pkt) + assert res.valid + assert res.crc_valid assert res.class_byte == 10 - assert res.sub_id == 0x0102 - assert res.obj_id == 0x0304 + assert res.type_high == 0x0001 + assert res.type_low_ver == 0x0203 def test_class10_temperature_parsing(self): """Test Class 10 Temperature Object (Sub 300) Parsing.""" @@ -41,19 +46,15 @@ def test_class10_temperature_parsing(self): payload[4:8] = struct.pack(">f", 40.0) # PCB payload[8:12] = struct.pack(">f", 30.0) # Control Box - # Build packet: [START] [LEN?] [DEST] [SRC] [CLASS=10] [OP] [SUB_H] [SUB_L] [OBJ_H] [OBJ_L] [PAYLOAD...] [CRC_H] [CRC_L] - # Length is not checked strictly in parser, but structure is. - # SubID = 300 = 0x012C - # ObjID = 93 = 0x5D = 0x005D - - header = bytes( - [RESPONSE_START, 20, 0x01, 0x02, 0x0A, 0x00, 0x01, 0x2C, 0x00, 0x5D] - ) - crc = bytes([0x00, 0x00]) - packet = header + payload + crc + # Temperatures answer as type 0x1602 version 2 (measured + # 2026-08-20). The frame this replaced declared a 20-byte length + # while carrying 24, an APDU head of 0 declaring no payload, and + # two zero bytes where the CRC goes - so it tested neither the + # length field nor the checksum. + packet = class10_reply(0x0002, 0x1602, bytes(payload)) - # Call parse and decode frame = FrameParser.parse_frame(packet) + assert frame.crc_valid data = TelemetryDecoder.decode(frame) assert data["media_temperature_c"] == 25.5 diff --git a/tests/test_schedule_service.py b/tests/test_schedule_service.py index a6869f0..3edd8f7 100644 --- a/tests/test_schedule_service.py +++ b/tests/test_schedule_service.py @@ -184,23 +184,24 @@ async def test_enable_disable_sequence(self, mock_client_simple): # Sequence of query() calls: # 1. enable() reads current state - # 2. enable() writes (needs empty response for success) + # 2. enable() writes - the Class 10 SET is acknowledged, and the + # empty response stands in for that acknowledgement # 3. enable() verifies # 4. disable() reads current state - # 5. disable() writes (needs empty response for success) + # 5. disable() writes # 6. disable() verifies responses = [ build_class10_response( 84, 1, bytes(payload_disabled) ), # Read for enable - b"", # Write response for enable (empty = success) + b"", # Write acknowledgement for enable build_class10_response( 84, 1, bytes(payload_enabled) ), # Verify enable worked build_class10_response( 84, 1, bytes(payload_enabled) ), # Read for disable - b"", # Write response for disable (empty = success) + b"", # Write acknowledgement for disable build_class10_response( 84, 1, bytes(payload_disabled) ), # Verify disable worked diff --git a/tests/test_telemetry_integration.py b/tests/test_telemetry_integration.py index 88b5df2..31eba65 100644 --- a/tests/test_telemetry_integration.py +++ b/tests/test_telemetry_integration.py @@ -8,6 +8,8 @@ import struct +from wire import class10_reply + from alpha_hwr.protocol.frame_parser import FrameParser from alpha_hwr.protocol.telemetry_decoder import TelemetryDecoder @@ -266,28 +268,23 @@ def test_integration_with_frame_parser(self): payload.extend(struct.pack(">f", power)) payload.extend(struct.pack(">f", speed)) - # Build Class 10 APDU: [Class][Op][Sub(2)][Obj(2)][Payload] - apdu = bytearray([0x0A, 0x90, 0x00, 0x45, 0x00, 0x57]) # Sub 69, Obj 87 - apdu.extend(payload) - - # Build GENI frame header - length = len(apdu) + 8 - header = bytes( - [0x24, length, 0xF8, 0xE7, 0x0A, 0x90, 0x00, 0x45, 0x00, 0x57] - ) - - packet = header + payload + b"\x00\x00" # CRC placeholder + # The motor-state register answers as type 3 version 1. The frame + # this replaced put the requested Sub-ID and Object ID in bytes + # 6-9, declared a length eight bytes past the frame's end, and left + # two zero bytes where the CRC goes. + packet = class10_reply(0x0001, 0x0003, bytes(payload)) - # Parse frame frame = FrameParser.parse_frame(packet) assert frame is not None + assert frame.valid + assert frame.crc_valid assert frame.class_byte == 10 - assert frame.obj_id == 87 - assert frame.sub_id == 69 + assert frame.type_high == 0x0001 + assert frame.type_low_ver == 0x0003 - # Decode telemetry - motor_data = TelemetryDecoder.decode_motor_state(frame.payload) + # object_body strips the object's own three-byte size header. + motor_data = TelemetryDecoder.decode_motor_state(frame.object_body) assert "voltage_ac_v" in motor_data assert abs(motor_data["voltage_ac_v"] - voltage) < 0.1 diff --git a/tests/test_temperature_control.py b/tests/test_temperature_control.py index 6055ecd..a0a36ec 100644 --- a/tests/test_temperature_control.py +++ b/tests/test_temperature_control.py @@ -61,22 +61,33 @@ async def test_set_temperature_range_control_failure(mock_client_simple): @pytest.mark.asyncio -async def test_set_flow_limit_success(mock_client_simple, answering_transport): - """Test successful flow limit setting.""" - # The configuration commit carries the whole schedule overview, so it - # reads the pump's copy first and skips the commit entirely if it - # cannot - writing a fabricated overview would switch the schedule off. +async def test_read_limiters_reports_both_and_whether_either_is_limiting( + mock_client_simple, answering_transport +): + """ + The limiters are read, not written. + + set_flow_limit() used to write Object 86 Sub 39 - which is the + constant-flow *setpoint range*, a type 301 factory object, not a + limiter - and the pump refused the frame in any case. The real + limiters are Object 86 Sub 600 (MaxFlow) and Sub 601 (MinFlow), + established 2026-08-20 by reading all sixty declared sub-ids and + finding every one past the second answers OPERATION_FAILED. + + This matters because an enabled limiter caps delivered flow whatever + the setpoint says, and nothing in the setpoint range reveals it. + """ mock_client_simple.transport.query = AsyncMock( side_effect=answering_transport ) - result = await mock_client_simple.control.set_flow_limit(1.5) + assert not hasattr(mock_client_simple.control, "set_flow_limit") - assert result is True - assert mock_client_simple.transport.query.call_count >= 1 - assert mock_client_simple.transport.write.call_count >= 1, ( - "the commit should have been sent once the overview was readable" - ) + limiters = await mock_client_simple.control.read_limiters() + + # The mock pump does not implement the limiter objects, so this is + # about the call shape rather than the values. + assert isinstance(limiters, dict) @pytest.mark.asyncio @@ -90,9 +101,19 @@ async def test_commit_is_skipped_when_the_overview_cannot_be_read( constant in place of the pump's own copy overwrites the schedule's enabled flag. Skipping a flush is recoverable; that is not. """ - result = await mock_client_simple.control.set_flow_limit(1.5) + result = await mock_client_simple.control.set_constant_speed(2000.0) assert result is True, "the setpoint write itself still succeeds" - assert mock_client_simple.transport.write.call_count == 0, ( - "no commit may be sent when the overview is unknown" - ) + + # The control request itself is written, so counting every write no + # longer isolates the commit. Count the commits: an Object 84 Sub 1 + # Class 10 SET, which is what would overwrite the schedule state. + commits = [ + frame + for (frame, *_), _ in mock_client_simple.transport.write.call_args_list + if len(frame) > 7 + and frame[4] == 0x0A + and frame[6] == 84 + and frame[7:9] == b"\x00\x01" + ] + assert commits == [], "no commit may be sent when the overview is unknown" diff --git a/tests/unit/core/test_authentication.py b/tests/unit/core/test_authentication.py index e72b456..4e40008 100644 --- a/tests/unit/core/test_authentication.py +++ b/tests/unit/core/test_authentication.py @@ -1,221 +1,133 @@ """ -Unit tests for AuthenticationHandler. +Opening a session sends nothing. -Covers extension packet ordering and timing requirements introduced -to fix premature disconnection on BLE firmware V06.00.01 (issue #24), -and the whole-handshake serialization that followed it (issue #31). +These tests used to pin the ordering and pacing of a ten-packet "unlock +handshake". There is no handshake: all four distinct packets were reads, +their replies were discarded, and a link that sends none of them answers +every read this client makes. What is worth pinning now is the opposite +property - that nothing goes out - because the failure mode this replaces +was writing to the pump for no reason and calling it authentication. + +See ``docs/protocol/connection.md`` and the module docstring of +``alpha_hwr.core.authentication``. """ from __future__ import annotations -import asyncio -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock import pytest from alpha_hwr.core.authentication import AuthenticationHandler +from alpha_hwr.protocol.apdu import ApduOp, apdu_op, apdu_payload_len @pytest.fixture -def mock_writer() -> AsyncMock: - """BLE writer mock that records all write_gatt_char calls.""" - writer = AsyncMock() - writer.write_gatt_char = AsyncMock() - return writer +def writer() -> MagicMock: + w = MagicMock() + w.write_gatt_char = AsyncMock() + return w @pytest.fixture -def handler(mock_writer: AsyncMock) -> AuthenticationHandler: - return AuthenticationHandler(mock_writer) - - -# --------------------------------------------------------------------------- -# Extension packet ordering -# --------------------------------------------------------------------------- +def handler(writer: MagicMock) -> AuthenticationHandler: + return AuthenticationHandler(writer) @pytest.mark.asyncio -async def test_extension_packets_extend1_before_extend2( - handler: AuthenticationHandler, mock_writer: AsyncMock +async def test_authenticate_writes_nothing( + handler: AuthenticationHandler, writer: MagicMock ) -> None: - """EXTEND_1 must be written before EXTEND_2.""" - await handler.send_extension_packets(delay=0) - - calls = mock_writer.write_gatt_char.call_args_list - assert len(calls) == 2 - - first_data = calls[0][0][1] - second_data = calls[1][0][1] - - assert first_data == AuthenticationHandler.EXTEND_1, ( - "EXTEND_1 must be sent first" - ) - assert second_data == AuthenticationHandler.EXTEND_2, ( - "EXTEND_2 must be sent second" - ) - - -@pytest.mark.asyncio -async def test_extension_packets_sleep_between_packets( - handler: AuthenticationHandler, mock_writer: AsyncMock -) -> None: - """A sleep must occur between EXTEND_1 and EXTEND_2 when delay > 0.""" - all_calls: list[tuple[str, object]] = [] - - async def recording_sleep(t: float) -> None: - all_calls.append(("sleep", t)) - - async def recording_write(uuid: str, data: bytes, **kw: object) -> None: - all_calls.append(("write", data)) - - mock_writer.write_gatt_char.side_effect = recording_write - - with patch("alpha_hwr.core.authentication.asyncio.sleep", recording_sleep): - await handler.send_extension_packets(delay=0.05) - - kinds = [k for k, _ in all_calls] - assert kinds == ["write", "sleep", "write"], ( - f"Expected [write, sleep, write] but got {kinds}" - ) - assert all_calls[0][1] == AuthenticationHandler.EXTEND_1 - assert all_calls[2][1] == AuthenticationHandler.EXTEND_2 + assert await handler.authenticate(fast_mode=True) is True + assert writer.write_gatt_char.await_count == 0 @pytest.mark.asyncio -async def test_extension_packets_no_sleep_when_delay_zero( - handler: AuthenticationHandler, mock_writer: AsyncMock -) -> None: - """No sleep should be issued when delay=0 (fast_mode / tests).""" - with patch("alpha_hwr.core.authentication.asyncio.sleep") as mock_sleep: - await handler.send_extension_packets(delay=0) - - mock_sleep.assert_not_called() - +async def test_authenticate_succeeds(handler: AuthenticationHandler) -> None: + """ + There is no handshake to fail. -# --------------------------------------------------------------------------- -# authenticate() fast_mode passes delay=0 to send_extension_packets -# --------------------------------------------------------------------------- + A pump that will not answer shows up as an unanswered read, which is + where it can be diagnosed - not as a handshake that "failed" without + anything having been asked of the pump. + """ + assert await handler.authenticate(fast_mode=True) is True @pytest.mark.asyncio -async def test_authenticate_fast_mode_skips_extension_sleep( - handler: AuthenticationHandler, +async def test_the_settle_wait_is_skippable( + handler: AuthenticationHandler, monkeypatch: pytest.MonkeyPatch ) -> None: - """In fast_mode, no asyncio.sleep calls should be made at all.""" - with patch("alpha_hwr.core.authentication.asyncio.sleep") as mock_sleep: - result = await handler.authenticate(fast_mode=True) + slept: list[float] = [] - assert result is True - mock_sleep.assert_not_called() + async def fake_sleep(seconds: float) -> None: + slept.append(seconds) - -@pytest.mark.asyncio -async def test_authenticate_normal_mode_sleeps_between_extensions( - handler: AuthenticationHandler, -) -> None: - """In normal mode, authenticate() must sleep between extension packets.""" - sleep_calls: list[float] = [] - - async def record_sleep(t: float) -> None: - sleep_calls.append(t) - - with patch("alpha_hwr.core.authentication.asyncio.sleep", record_sleep): - result = await handler.authenticate(fast_mode=False) - - assert result is True - # Normal mode paces the handshake: every packet is followed by a - # non-zero delay, and the sequence ends with the stabilization sleep. - # The exact inter-packet delay is tuning, so assert the shape, not a - # specific value (fast_mode skipping sleeps entirely is covered above). - assert sleep_calls, "Expected pacing sleeps in normal mode" - assert all(t > 0 for t in sleep_calls), ( - f"Expected only non-zero pacing sleeps, got: {sleep_calls}" - ) - assert sleep_calls[-1] == 0.5, ( - f"Expected final stabilization sleep of 0.5s, got: {sleep_calls}" + monkeypatch.setattr( + "alpha_hwr.core.authentication.asyncio.sleep", fake_sleep ) + await handler.authenticate(fast_mode=True) + assert slept == [] -# --------------------------------------------------------------------------- -# Whole-handshake serialization (issue #31) -# --------------------------------------------------------------------------- + await handler.authenticate() + assert slept == [0.5] @pytest.mark.asyncio -async def test_handshake_writes_are_strictly_sequential( - handler: AuthenticationHandler, mock_writer: AsyncMock +async def test_the_transaction_lock_is_held_when_one_is_supplied( + writer: MagicMock, ) -> None: """ - No two handshake writes may ever be in flight at once. - - An earlier revision spawned the stage 1/2 bursts as concurrent tasks, - which let packets reach the pump out of order and made it drop the - link about a second later. + The lock is still taken, so nothing else talks while the link settles. """ - in_flight = 0 - max_in_flight = 0 - - async def slow_write(uuid: str, data: bytes, **kw: object) -> None: - nonlocal in_flight, max_in_flight - in_flight += 1 - max_in_flight = max(max_in_flight, in_flight) - await asyncio.sleep(0) # yield, so an overlapping write could show up - in_flight -= 1 + lock = MagicMock() + lock.__aenter__ = AsyncMock() + lock.__aexit__ = AsyncMock(return_value=False) - mock_writer.write_gatt_char.side_effect = slow_write - - assert await handler.authenticate(fast_mode=True) is True - assert max_in_flight == 1, ( - f"Handshake writes overlapped ({max_in_flight} concurrent)" - ) - - -@pytest.mark.asyncio -async def test_handshake_packet_order( - handler: AuthenticationHandler, mock_writer: AsyncMock -) -> None: - """The full 10-packet sequence goes out in the documented order.""" + handler = AuthenticationHandler(writer, transaction=lock) await handler.authenticate(fast_mode=True) - sent = [c[0][1] for c in mock_writer.write_gatt_char.call_args_list] - expected = ( - [AuthenticationHandler.LEGACY_MAGIC] * 3 - + [AuthenticationHandler.CLASS10_UNLOCK] * 5 - + [AuthenticationHandler.EXTEND_1, AuthenticationHandler.EXTEND_2] - ) - assert sent == expected + lock.__aenter__.assert_awaited_once() + lock.__aexit__.assert_awaited_once() -@pytest.mark.asyncio -async def test_handshake_holds_the_transaction_lock( - mock_writer: AsyncMock, -) -> None: +class TestTheOpeningPacketsAreReads: """ - The lock is held for the whole sequence, not per packet. + The four captured packets, decoded. - Anything else lets a telemetry query or keep-alive burst land between - two handshake packets. + They are kept as constants because they are real captures and make + good frame-assembly vectors, but every one of them is a read - which + is why none of them could ever have unlocked anything. """ - lock = asyncio.Lock() - handler = AuthenticationHandler(mock_writer, transaction=lock) - held_during_writes: list[bool] = [] - - async def check_lock(uuid: str, data: bytes, **kw: object) -> None: - held_during_writes.append(lock.locked()) - - mock_writer.write_gatt_char.side_effect = check_lock - assert await handler.authenticate(fast_mode=True) is True - assert held_during_writes and all(held_during_writes), ( - "Transaction lock was not held for every handshake write" + @pytest.mark.parametrize( + ("packet", "expected_op"), + [ + (AuthenticationHandler.LEGACY_MAGIC, ApduOp.GET), + (AuthenticationHandler.CLASS10_UNLOCK, ApduOp.GET), + (AuthenticationHandler.EXTEND_1, ApduOp.INFO), + (AuthenticationHandler.EXTEND_2, ApduOp.INFO), + ], ) - assert not lock.locked(), "Transaction lock was not released" - - -@pytest.mark.asyncio -async def test_handshake_without_transaction_lock_still_works( - handler: AuthenticationHandler, mock_writer: AsyncMock -) -> None: - """The lock is optional; a bare BLE writer still authenticates.""" - assert await handler.authenticate(fast_mode=True) is True - assert mock_writer.write_gatt_char.await_count == 10 + def test_none_of_them_is_a_write( + self, packet: bytes, expected_op: ApduOp + ) -> None: + assert apdu_op(packet[5]) == expected_op + assert apdu_op(packet[5]) != ApduOp.SET + + def test_the_unlock_code_was_a_length_field(self) -> None: + """ + ``0x03`` is an APDU head, not an opcode. + + Read as one it made ``94 95 96`` look like "register 0x9495, + unlock code 0x96". It declares three payload bytes, and those + bytes are three item IDs: unit_family, unit_type, unit_version. + """ + head = AuthenticationHandler.LEGACY_MAGIC[5] + + assert head == 0x03 + assert apdu_payload_len(head) == 3 + assert AuthenticationHandler.LEGACY_MAGIC[6:9] == bytes( + [0x94, 0x95, 0x96] + ) diff --git a/tests/unit/core/test_base_service.py b/tests/unit/core/test_base_service.py index b3f22af..814b668 100644 --- a/tests/unit/core/test_base_service.py +++ b/tests/unit/core/test_base_service.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, MagicMock import pytest +from wire import frame from alpha_hwr.core.session import Session from alpha_hwr.core.transport import Transport @@ -97,28 +98,15 @@ async def test_read_class10_object_short_response(base_service, mock_transport): @pytest.mark.asyncio async def test_read_class7_string_success(base_service, mock_transport): """Test successful reading of Class 7 string.""" - # [STX][LEN][DST][SRC][Class][Cmd][ID][...STRING...][CRC_H][CRC_L] - # String "TEST" = 54 45 53 54 - # Frame start at 0 - # Class at 4 (0x07) - # Payload starts at 7 - mock_response = bytes( - [ - 0x27, - 0x0B, - 0xE7, - 0xF8, # Header - 0x07, - 0x01, - 0x01, # Class 7, Cmd 1, ID 1 - 0x54, - 0x45, - 0x53, - 0x54, # "TEST" - 0xAA, - 0xBB, # CRC - ] - ) + # [STX][LEN][DST][SRC][0x07][Count][...STRING...][CRC_H][CRC_L] + # + # Six header bytes, then the text. There is no echoed string ID: the + # pump answers "ALPHA HWR" as 24 0E F8 E7 07 0A 41 4C 50 48 41 ..., + # where 0x0A is the string's byte count and 0x41 is its first + # character. This fixture used to carry [07][01][ID] in front of the + # text - the request's shape - and so agreed with a reader that started + # at offset 7 and dropped a character from every string. + mock_response = frame(0x07, b"TEST") mock_transport.send_command.return_value = mock_response @@ -130,24 +118,7 @@ async def test_read_class7_string_success(base_service, mock_transport): @pytest.mark.asyncio async def test_read_class7_string_null_terminated(base_service, mock_transport): """Test reading null-terminated Class 7 string.""" - # "ABC\x00" - mock_response = bytes( - [ - 0x27, - 0x0A, - 0xE7, - 0xF8, - 0x07, - 0x01, - 0x01, - 0x41, - 0x42, - 0x43, - 0x00, - 0xAA, - 0xBB, - ] - ) + mock_response = frame(0x07, b"ABC\x00") mock_transport.send_command.return_value = mock_response diff --git a/tests/unit/core/test_device_info_service.py b/tests/unit/core/test_device_info_service.py index d5c0653..d50a434 100644 --- a/tests/unit/core/test_device_info_service.py +++ b/tests/unit/core/test_device_info_service.py @@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, MagicMock import pytest +from wire import frame from alpha_hwr.core.session import Session from alpha_hwr.core.transport import Transport @@ -35,12 +36,14 @@ def device_info_service(mock_transport, mock_session): async def test_read_detailed_info(device_info_service, mock_transport): """Test reading serial and versions via Class 7 strings.""" - # Side effect for multiple string reads (ID 9, 50, 52, 58) - # APDU: [0x07, 0x01, string_id] - # Header: [27, Len, E7, F8] - # string_id is at index 6 - def mock_query(frame, match_func=None, timeout=None): - string_id = frame[6] + # Requests are [27][Len][E7][F8][0x07][head][string_id], so the ID is + # at index 6. Replies are built by tests.wire, which computes a real + # length and CRC and puts the text at offset 6 - the shape the pump + # actually sends. The fixture this replaced declared a length of 0, + # used 0x81 as a constant "response opcode", and echoed the string ID + # in front of the text. + def mock_query(request, match_func=None, timeout=None): + string_id = request[6] val = "" if string_id == 9: val = "SERIAL123" @@ -51,21 +54,15 @@ def mock_query(frame, match_func=None, timeout=None): elif string_id == 58: val = "BLE3.0" - # Response: [24][Len][Dst][Src][Class][Cmd][ID][Data][CRC] - resp = ( - bytes([0x24, 0x00, 0xE7, 0xF8, 0x07, 0x81, string_id]) - + val.encode() - + b"\x00\x00\x00" - ) - return resp + return frame(0x07, val.encode() + b"\x00") mock_transport.send_command.side_effect = mock_query info = await device_info_service.read_detailed() assert info is not None - # Serial number gets "1" prepended to the suffix - assert info.serial_number == "1SERIAL123" + # The serial arrives whole; nothing is prepended to it. + assert info.serial_number == "SERIAL123" assert info.software_version == "SW1.0" assert info.hardware_version == "HW2.0" assert info.ble_version == "BLE3.0" diff --git a/tests/unit/core/test_transport_write.py b/tests/unit/core/test_transport_write.py index 15f2668..f696ac3 100644 --- a/tests/unit/core/test_transport_write.py +++ b/tests/unit/core/test_transport_write.py @@ -9,12 +9,53 @@ from __future__ import annotations +from typing import TYPE_CHECKING, cast from unittest.mock import AsyncMock, MagicMock import pytest +from wire import CAPTURED from alpha_hwr.constants import GENI_CHAR_UUID -from alpha_hwr.core.transport import BLE_MTU_LIMIT, SEND_PACING, Transport +from alpha_hwr.core import transport as tmod +from alpha_hwr.core.transport import ( + BLE_MTU_LIMIT, + SEND_PACING, + Transport, +) + +if TYPE_CHECKING: + from bleak.backends.characteristic import BleakGATTCharacteristic + +#: A Class 10 SET (the no-op ClockProgramOverview write-back) and a Class +#: 10 GET, as they go on the wire. +_SET = bytes.fromhex("2717e7f80a9354000100da0100000a02050005010100000000b44e") +_GET = bytes.fromhex("2707e7f80a03540001d5e8") + + +def notify(transport: Transport, data: bytes) -> None: + """ + Feed one BLE notification in, the way bleak would. + + bleak passes the characteristic and the callback ignores it, so a test + has nothing meaningful to supply; building a real + BleakGATTCharacteristic to be discarded would be theatre. The cast says + so rather than hiding it. + """ + transport._notification_callback( + cast("BleakGATTCharacteristic", None), bytearray(data) + ) + + +@pytest.fixture +def slept(monkeypatch: pytest.MonkeyPatch) -> list[float]: + """Every sleep the transport asks for, in order.""" + recorded: list[float] = [] + + async def fake_sleep(seconds: float) -> None: + recorded.append(seconds) + + monkeypatch.setattr(tmod.asyncio, "sleep", fake_sleep) + return recorded @pytest.fixture @@ -169,3 +210,107 @@ async def record_sleep(t: float) -> None: await transport.write(bytes(4)) assert slept == [] + + +class TestFramesAreChunkedForThePump: + """ + This pump needs GENI frames split into 20-byte GATT writes. + + Not an optimisation, and not about the negotiated ATT MTU - which is 65 + on this link, easily enough for a 27-byte frame in one write. Measured: + the Object 84 Sub 1 overview write sent as a single 27-byte + write_gatt_char draws no reply at all, while the identical bytes + chunked at 20 are acknowledged in 111 ms. + + An earlier reading of that silence was that Class 10 SETs are never + acknowledged. They are, in 90-120 ms; the frames simply were not + arriving. + """ + + @pytest.mark.asyncio + async def test_a_frame_over_the_limit_is_split( + self, transport: Transport, ble_client: MagicMock + ) -> None: + await transport.write(_SET) + + written = written_chunks(ble_client) + assert len(written) > 1, "a 27-byte frame must not go out whole" + assert all(len(chunk) <= BLE_MTU_LIMIT for chunk in written) + assert b"".join(written) == _SET + + @pytest.mark.asyncio + async def test_a_frame_within_the_limit_goes_in_one_write( + self, transport: Transport, ble_client: MagicMock + ) -> None: + await transport.write(_GET) + + assert written_chunks(ble_client) == [_GET] + + @pytest.mark.asyncio + async def test_chunks_are_paced( + self, transport: Transport, slept: list[float] + ) -> None: + """The pump drops traffic that arrives faster than SEND_PACING.""" + await transport.write(_SET) + + assert slept, "chunks of one frame must be paced apart" + + +class TestFrameDropCounters: + """ + A dropped frame leaves a trace. + + Dropping is the system working - a bad CRC caught is a corrupted frame + that did not become a write verdict. What was missing is any way to + know it happened: a link quietly shedding frames is otherwise + indistinguishable from a client that occasionally times out for no + reason. One counter per reason, because they mean different things. + """ + + @pytest.mark.asyncio + async def test_a_bad_crc_is_counted(self, transport: Transport) -> None: + delivered: list[bytes] = [] + transport._custom_handlers.append(delivered.append) + + corrupt = bytearray(CAPTURED["mode_read"]) + corrupt[-1] ^= 0xFF + notify(transport, bytes(corrupt)) + + assert delivered == [] + assert transport.frame_drops["crc_failures"] == 1 + + @pytest.mark.asyncio + async def test_an_impossible_length_is_counted_separately( + self, transport: Transport + ) -> None: + """ + A runt length is a peer talking nonsense, not a corrupted link. + + Counting it as a CRC failure would make a framing bug look like + radio interference. + """ + notify(transport, bytes([0x24, 0x00, 0xF8, 0xE7, 0x0A, 0x00])) + + assert transport.frame_drops["runt_length_drops"] == 1 + assert transport.frame_drops["crc_failures"] == 0 + + @pytest.mark.asyncio + async def test_bytes_that_start_no_frame_are_counted( + self, transport: Transport + ) -> None: + """Usually means sync was lost, not that the radio is bad.""" + notify(transport, b"\xde\xad\xbe\xef") + + assert transport.frame_drops["unsolicited_fragments"] == 1 + + @pytest.mark.asyncio + async def test_a_clean_frame_counts_nothing( + self, transport: Transport + ) -> None: + delivered: list[bytes] = [] + transport._custom_handlers.append(delivered.append) + + notify(transport, CAPTURED["mode_read"]) + + assert len(delivered) == 1 + assert not any(transport.frame_drops.values()) diff --git a/tests/unit/protocol/test_frame_parser.py b/tests/unit/protocol/test_frame_parser.py index 5948947..e0213e6 100644 --- a/tests/unit/protocol/test_frame_parser.py +++ b/tests/unit/protocol/test_frame_parser.py @@ -4,6 +4,9 @@ Tests the GENI protocol frame parser with various frame types and edge cases. """ +import pytest +from wire import CAPTURED + from alpha_hwr.constants import CLASS_10, FRAME_START, RESPONSE_START from alpha_hwr.protocol.frame_parser import ( TEST_VECTORS, @@ -66,15 +69,27 @@ def test_parse_class2_frame(self): assert frame.payload == bytes([0x94, 0x95, 0x96]) def test_parse_class10_frame(self): - """Test parsing Class 10 frame with Sub-ID and Object ID.""" - # Class 10 frame: motor state telemetry (Sub=0x0045=69, Obj=0x0057=87) - data = bytes.fromhex("2412e7f80a0a0045005700000000000000000000fd72") + """ + A Class 10 reply carries a type in bytes 6-9, not an address. + + Captured motor-state reply: bytes 6-9 read ``00 01 00 03``, which is + object type 3 version 1. Nothing in it echoes the Object 87 / + Sub-ID 69 that was asked for, because the pump does not send an + address back. + """ + data = bytes.fromhex( + "2434f8e70a300001000300002942e730d643237a000000000000000000" + "00000000000000007fffffff7fffffff0000000000000000002385" + ) frame = FrameParser.parse_frame(data) assert frame.valid is True + assert frame.crc_valid is True assert frame.class_byte == CLASS_10 - assert frame.sub_id == 0x0045 # 69 - assert frame.obj_id == 0x0057 # 87 - assert len(frame.payload) == 10 + assert frame.type_high == 0x0001 + assert frame.type_low_ver == 0x0003 + # Declared payload is 48 bytes; four of them are the type fields. + assert data[5] == 48 + assert len(frame.payload) == 44 def test_crc_validation_valid(self): """Test CRC validation with correct CRC.""" @@ -101,39 +116,53 @@ class TestClass10Parsing: """Test Class 10 specific parsing.""" def test_motor_state_frame(self): - """Test parsing motor state telemetry frame.""" - # Motor state: Sub=0x0045 (69), Obj=0x0057 (87) - data = bytes.fromhex("2412e7f80a0a0045005700000000000000000000fd72") + """Motor-state reply: object type 3 version 1.""" + data = bytes.fromhex( + "2434f8e70a300001000300002942e730d643237a000000000000000000" + "00000000000000007fffffff7fffffff0000000000000000002385" + ) frame = FrameParser.parse_frame(data) assert frame.class_byte == CLASS_10 - assert frame.sub_id == 69 # 0x0045 - assert frame.obj_id == 87 # 0x0057 + assert (frame.type_high, frame.type_low_ver) == (0x0001, 0x0003) assert frame.crc_valid is True def test_flow_pressure_frame(self): - """Test parsing flow/pressure telemetry frame.""" - # Flow/pressure: Sub=0x0122 (290), Obj=0x005D (93) + """Flow/pressure reply: object type 0x3502 version 2.""" data = bytes.fromhex( - "2416e7f80a0e0122005d00000000000000000000000000000b8b" + "242ff8e70a2b0002350200002400000000000000007fffffff7fffffff" + "7fffffff7fffffff000000000000000000000000edbe" ) frame = FrameParser.parse_frame(data) assert frame.valid is True assert frame.class_byte == CLASS_10 - assert frame.sub_id == 290 # 0x0122 - assert frame.obj_id == 93 # 0x005D - assert len(frame.payload) == 14 + assert (frame.type_high, frame.type_low_ver) == (0x0002, 0x3502) + assert data[5] == len(data) - 8 + assert len(frame.payload) == 39 assert frame.crc_valid is True - def test_class10_identifiers_extraction(self): - """Test extracting Class 10 identifiers.""" - # Motor state packet - data = bytes.fromhex("2412e7f80a0a0045005700000000000000000000fd72") - frame = FrameParser.parse_frame(data) - - ids = FrameParser.extract_class10_identifiers(frame) - assert ids["sub_id"] == 69 # 0x0045 - assert ids["obj_id"] == 87 # 0x0057 + def test_four_objects_share_one_type(self): + """ + Object 86 subs 13, 15, 17 and 39 answer identically. + + All four are instances of type 301 version 1, so a reply cannot say + which sub-id it came from. That is why a chain reading them has to + be strictly sequential and stop at the first failure - carrying on + shifts every remaining answer by one slot. + """ + speed = bytes.fromhex( + "2427f8e70a2300012d0100001c452f000044ce400045657000" + "c56570003f8000003f8000003f80000089a9" + ) + pressure = bytes.fromhex( + "2427f8e70a2300012d0100001c467a00004619300046bbb200" + "461930003dcccccd3f7333333f8000006f88" + ) + a = FrameParser.parse_frame(speed) + b = FrameParser.parse_frame(pressure) + assert a.crc_valid and b.crc_valid + assert (a.type_high, a.type_low_ver) == (b.type_high, b.type_low_ver) + assert a.payload != b.payload class TestTelemetryFrameDetection: @@ -142,11 +171,12 @@ class TestTelemetryFrameDetection: def test_is_telemetry_motor_state(self): """Test detection of motor state telemetry.""" # Motor state packet - data = bytes.fromhex("2412e7f80a0a0045005700000000000000000000fd72") + data = bytes.fromhex( + "2434f8e70a300001000300002942e730d643237a000000000000000000" + "00000000000000007fffffff7fffffff0000000000000000002385" + ) frame = FrameParser.parse_frame(data) - # Motor state: obj_id=87, sub_id=69 - assert frame.obj_id == 87 - assert frame.sub_id == 69 + assert FrameParser.is_telemetry_frame(frame) is True def test_is_telemetry_non_telemetry_frame(self): """Test detection returns false for non-telemetry frames.""" @@ -156,12 +186,15 @@ def test_is_telemetry_non_telemetry_frame(self): assert FrameParser.is_telemetry_frame(frame) is False def test_is_telemetry_unknown_class10(self): - """Test detection returns false for unknown Class 10 objects.""" - # Class 10 frame with unknown object (SubID=0xFFFF, ObjID=0x0000) - data = bytes.fromhex("2415e7f80a01ffff0000000000000000000000000000") + """A Class 10 object we have no type for is not telemetry.""" + # Schedule overview - a real reply, but not part of the stream. + data = bytes.fromhex( + "2415f8e70a110000da0100000a02050005010100000000dd89" + ) frame = FrameParser.parse_frame(data) - # This may fail due to invalid CRC, but structure should be valid assert frame.class_byte == CLASS_10 + assert frame.crc_valid is True + assert FrameParser.is_telemetry_frame(frame) is False class TestFrameIntegrityValidation: @@ -193,69 +226,66 @@ def test_validate_bad_crc(self): assert valid is False assert "crc" in error.lower() - def test_validate_missing_class10_ids(self): - """Test validation of Class 10 frame with missing IDs.""" - # Create a short Class 10 frame (not enough bytes for IDs) - data = bytes.fromhex( - "2407e7f80a01008b08" - ) # Too short - missing SubID/ObjID - frame = FrameParser.parse_frame(data) - - # Frame should parse but be missing Sub/Obj IDs - if frame.class_byte == CLASS_10: - valid, error = FrameParser.validate_frame_integrity(frame) - if frame.sub_id is None or frame.obj_id is None: - assert valid is False - assert "sub-id" in error.lower() or "object id" in error.lower() - - -class TestReferenceVectors: - """Test with reference test vectors for other language implementations.""" + def test_short_class10_ack_is_valid_without_type_fields(self): + """ + A short acknowledgement has no type fields, and is still valid. - def test_class10_motor_state_vector(self): - """Test motor state reference vector.""" - vector = TEST_VECTORS["class10_motor_state"] - data = bytes.fromhex(vector["hex"]) + This used to be asserted the other way round - a Class 10 frame + without identifiers was called invalid. But the pump's write + acknowledgement is nine bytes and carries none, so the rule + condemned every reply to every write. + """ + data = bytes.fromhex("2405f8e70a0100aea2") frame = FrameParser.parse_frame(data) - expected = vector["expected"] - assert frame.valid == expected["valid"] - assert frame.frame_type == expected["frame_type"] - assert frame.class_byte == expected["class_byte"] - assert frame.sub_id == expected["sub_id"] - assert frame.obj_id == expected["obj_id"] - assert len(frame.payload) == expected["payload_len"] - assert frame.crc_valid == expected["crc_valid"] - - def test_class10_flow_pressure_vector(self): - """Test flow/pressure reference vector.""" - vector = TEST_VECTORS["class10_flow_pressure"] - data = bytes.fromhex(vector["hex"]) - frame = FrameParser.parse_frame(data) + assert frame.class_byte == CLASS_10 + assert frame.type_high is None + assert frame.type_low_ver is None + valid, error = FrameParser.validate_frame_integrity(frame) + assert valid is True, error - expected = vector["expected"] - assert frame.valid == expected["valid"] - assert frame.frame_type == expected["frame_type"] - assert frame.class_byte == expected["class_byte"] - assert frame.sub_id == expected["sub_id"] - assert frame.obj_id == expected["obj_id"] - assert len(frame.payload) == expected["payload_len"] - assert frame.crc_valid == expected["crc_valid"] - - def test_auth_legacy_magic_vector(self): - """Test authentication legacy magic reference vector.""" - vector = TEST_VECTORS["auth_legacy_magic"] - data = bytes.fromhex(vector["hex"]) - frame = FrameParser.parse_frame(data) - expected = vector["expected"] - assert frame.valid == expected["valid"] - assert frame.frame_type == expected["frame_type"] - assert frame.class_byte == expected["class_byte"] - assert frame.sub_id == expected["sub_id"] - assert frame.obj_id == expected["obj_id"] - assert len(frame.payload) == expected["payload_len"] - assert frame.crc_valid == expected["crc_valid"] +class TestReferenceVectors: + """ + The captured vectors, for validating a reimplementation. + + Every entry is a recording from an ALPHA HWR. The table these replaced + was hand-written with the destination and source addresses reversed, + which is a shape this pump never sends - so a port checked against it + was being checked against a frame that cannot arrive. + """ + + def test_every_vector_parses_and_checksums(self): + for name, vector in TEST_VECTORS.items(): + data = bytes.fromhex(vector["hex"]) + frame = FrameParser.parse_frame(data) + expected = vector["expected"] + + assert frame.valid == expected["valid"], name + assert frame.frame_type == expected["frame_type"], name + assert frame.class_byte == expected["class_byte"], name + assert frame.crc_valid == expected["crc_valid"], name + + if "type_high" in expected: + assert frame.type_high == expected["type_high"], name + assert frame.type_low_ver == expected["type_low_ver"], name + if "payload_len" in expected: + assert len(frame.payload) == expected["payload_len"], name + if "payload" in expected: + assert frame.payload.decode() == expected["payload"], name + + def test_every_vector_is_addressed_pump_to_host(self): + """Destination 0xF8, source 0xE7 - the reply direction.""" + for name, vector in TEST_VECTORS.items(): + data = bytes.fromhex(vector["hex"]) + assert (data[2], data[3]) == (0xF8, 0xE7), name + + def test_every_vector_declares_its_own_length(self): + """``byte5 == len(frame) - 8`` on every reply this pump sends.""" + for name, vector in TEST_VECTORS.items(): + data = bytes.fromhex(vector["hex"]) + assert data[5] == len(data) - 8, name + assert data[1] + 4 == len(data), name class TestEdgeCases: @@ -330,3 +360,52 @@ def test_parse_does_not_modify_input(self): FrameParser.parse_frame(bytes(data)) assert bytes(data) == original + + +class TestTheObjectTypeIsDecodedAtItsRealBoundary: + """ + Bytes 6-9 are ``[00][TypeH][TypeL][Version]``. + + ``type_high`` and ``type_low_ver`` split those same four bytes into two + 16-bit halves *one byte off* that boundary. That is fine for matching - + comparing both halves is equivalent to comparing type and version - and + it is what the matcher does. It is not fine for reading, and every + number quoted in prose should be the real type. + + Each expectation below is confirmed against ``geni_profile_52_7.xml``, + so this is checking the decode against the vendor's own definitions + rather than against itself. + """ + + @pytest.mark.parametrize( + ("name", "type_number", "version", "profile_name"), + [ + ("motor_state", 256, 3, "ProtectedMotorStateDetails"), + ( + "flow_pressure", + 565, + 2, + "PumpedMediaRelatedProcessValuesExtended", + ), + ("temperature", 534, 2, "MediaTemperatureInfo"), + ("mode_read", 303, 1, "operation status"), + ("setpoint_range_speed", 301, 1, "setpoint factory config"), + ("schedule_overview", 218, 1, "ClockProgramOverview"), + ("clock", 322, 1, "DateTimeActual"), + ("temp_range_config", 1012, 2, "temperature range config"), + ], + ) + def test_captured_frames_decode_to_the_profile_s_types( + self, name: str, type_number: int, version: int, profile_name: str + ) -> None: + frame = FrameParser.parse_frame(CAPTURED[name]) + + assert frame.object_type == type_number, profile_name + assert frame.object_version == version, profile_name + + def test_a_frame_without_type_fields_has_no_type(self) -> None: + """A short acknowledgement carries neither.""" + frame = FrameParser.parse_frame(bytes.fromhex("2405f8e70a0100aea2")) + + assert frame.object_type is None + assert frame.object_version is None diff --git a/tests/unit/protocol/test_matcher.py b/tests/unit/protocol/test_matcher.py index fb40a98..b0272a7 100644 --- a/tests/unit/protocol/test_matcher.py +++ b/tests/unit/protocol/test_matcher.py @@ -101,14 +101,50 @@ def test_exact_identifier_match() -> None: assert matches(cmd, frame(0x0A, 0x0E, a=0x0001, b=0x2F01)) -def test_swapped_identifiers_still_match() -> None: +def test_swapped_type_fields_do_not_match() -> None: """ - The pump does not place the two identifiers consistently. Several - reads only work because of this - the Object 86 status read included. + Bytes 6-9 are one type field, so reversing them is not the same type. + + This used to be asserted the other way round, on the theory that the + pump placed two identifiers inconsistently. It does not place + identifiers at all - it names the object's type - and every reply + measured against an ALPHA HWR matches in wire order, so nothing ever + needed the reversal. Accepting it meant any two objects whose type + bytes were transposes of each other could answer each other's reads. """ cmd = Command(expect_a=0x2F01, expect_b=0x0001) - assert matches(cmd, frame(0x0A, 0x0E, a=0x0001, b=0x2F01)) + assert not matches(cmd, frame(0x0A, 0x0E, a=0x0001, b=0x2F01)) + + +def test_the_measured_mode_reply_matches_in_wire_order() -> None: + """ + Object 86 Sub 7, captured 2026-08-20, against the table's expectation. + + Uses the real frame rather than a synthetic one so the table and the + pump are checked against each other, not against the same assumption. + """ + captured = bytes.fromhex("2412f8e70a0e00012f0100000701001b39678ac3f7dd") + + assert matches(read_command(86, 7), captured) + + +def test_a_sibling_of_the_same_type_answers_the_same_expectation() -> None: + """ + Object 86 subs 13, 15, 17 and 39 are indistinguishable in a reply. + + All four are type 301 version 1, so the setpoint-range read of sub 13 + accepts sub 15's answer. That is not a defect in the matcher - the + information is not on the wire - which is why the chain that reads them + has to be sequential and stop at the first failure. + """ + sub15_reply = bytes.fromhex( + "2427f8e70a2300012d0100001c467a00004619300046bbb200" + "461930003dcccccd3f7333333f8000006f88" + ) + + assert matches(read_command(86, 13), sub15_reply) + assert read_command(86, 13) == read_command(86, 39) def test_unrelated_identifiers_do_not_match() -> None: diff --git a/tests/unit/protocol/test_telemetry_decoder.py b/tests/unit/protocol/test_telemetry_decoder.py index f0985c3..fd2245e 100644 --- a/tests/unit/protocol/test_telemetry_decoder.py +++ b/tests/unit/protocol/test_telemetry_decoder.py @@ -241,151 +241,122 @@ def test_partial_uint16(self): class TestAutoDecoding: - """Test automatic decoding based on frame identifiers.""" + """ + Routing a real reply to the right decoder. + + These drive frames captured from an ALPHA HWR (family 52, type 7, + version 2) on 2026-08-20 through the production parser, rather than + hand-building a ParsedFrame. That matters here specifically: the router + used to match on the Object and Sub-ID pairs that were *requested* + ((87, 69), (93, 290), (93, 300)), and a reply carries neither - so every + case fell through to a fallback and the routing table was never + exercised by a frame the pump could send. Synthetic frames agreed with + it, because they were built from the same wrong assumption. + """ + + #: Reply to the motor-state register read, 56 bytes on the wire. + MOTOR_FRAME = bytes.fromhex( + "2434f8e70a300001000300002942e730d643237a000000000000000000" + "00000000000000007fffffff7fffffff0000000000000000002385" + ) + + #: Reply to the flow/pressure register read, 51 bytes. + FLOW_FRAME = bytes.fromhex( + "242ff8e70a2b0002350200002400000000000000007fffffff7fffffff" + "7fffffff7fffffff000000000000000000000000edbe" + ) + + #: Reply to the temperature register read, 28 bytes. + TEMP_FRAME = bytes.fromhex( + "2418f8e70a140002160200000d41e0f24d41e9654e41d60bac001c01" + ) def test_decode_motor_state_frame(self): - """Test auto-decoding motor state frame.""" - # Create a mock frame - payload = bytearray(28) - payload[0:4] = encode_float_be(240.0) - - frame = ParsedFrame( - valid=True, - frame_type="response", - class_byte=0x0A, - sub_id=69, - obj_id=87, - payload=bytes(payload), - crc_valid=True, - raw_data=b"", - ) - + frame = FrameParser.parse_frame(self.MOTOR_FRAME) + assert frame.crc_valid result = TelemetryDecoder.decode(frame) assert "voltage_ac_v" in result + # 0x42E730D6 - the pump was on mains at the time of capture. + assert 100.0 < result["voltage_ac_v"] < 130.0 def test_decode_flow_pressure_frame(self): - """Test auto-decoding flow/pressure frame.""" - payload = bytearray(16) - payload[0:4] = encode_float_be(2.5) - - frame = ParsedFrame( - valid=True, - frame_type="response", - class_byte=0x0A, - sub_id=290, - obj_id=93, - payload=bytes(payload), - crc_valid=True, - raw_data=b"", - ) - + frame = FrameParser.parse_frame(self.FLOW_FRAME) + assert frame.crc_valid result = TelemetryDecoder.decode(frame) assert "flow_m3h" in result + assert "head_m" in result def test_decode_temperature_frame(self): - """Test auto-decoding temperature frame.""" - payload = bytearray(12) - payload[0:4] = encode_float_be(55.0) - - frame = ParsedFrame( - valid=True, - frame_type="response", - class_byte=0x0A, - sub_id=300, - obj_id=93, - payload=bytes(payload), - crc_valid=True, - raw_data=b"", - ) - + frame = FrameParser.parse_frame(self.TEMP_FRAME) + assert frame.crc_valid result = TelemetryDecoder.decode(frame) assert "media_temperature_c" in result - - def test_decode_alarms_frame(self): - """Test auto-decoding alarms frame.""" - payload = encode_uint16_be(1) + encode_uint16_be(2) - - frame = ParsedFrame( - valid=True, - frame_type="response", - class_byte=0x0A, - sub_id=0, - obj_id=88, - payload=payload, - crc_valid=True, - raw_data=b"", - ) - - result = TelemetryDecoder.decode(frame) - assert "active_alarms" in result - assert result["active_alarms"] == [1, 2] - - def test_decode_warnings_frame(self): - """Test auto-decoding warnings frame.""" - payload = encode_uint16_be(10) - - frame = ParsedFrame( - valid=True, - frame_type="response", - class_byte=0x0A, - sub_id=11, - obj_id=88, - payload=payload, - crc_valid=True, - raw_data=b"", - ) - - result = TelemetryDecoder.decode(frame) - assert "active_warnings" in result - assert result["active_warnings"] == [10] - - def test_decode_unknown_frame(self): - """Test auto-decoding unknown telemetry type.""" + assert 20.0 < result["media_temperature_c"] < 40.0 + + def test_each_register_answers_with_its_own_type(self): + """ + The three telemetry replies are told apart by type, not by length. + + A previous filter keyed on the declared payload length - 48, 43 and + 20 for these three - which is why it appeared to work while + discarding any other reply that happened to be one of those sizes. + """ + types = { + FrameParser.parse_frame(f).type_low_ver + for f in (self.MOTOR_FRAME, self.FLOW_FRAME, self.TEMP_FRAME) + } + assert types == {0x0003, 0x3502, 0x1602} + + def test_alarms_and_warnings_are_not_routed(self): + """ + The router cannot label an alarm list, and does not pretend to. + + Reading Object 88 Sub 0 and Object 88 Sub 11 on 2026-08-20 returned + byte-identical frames, both typed 0x3A01 version 2. Whichever list + came back, the reply says the same thing - so only the caller that + issued the read knows, and DeviceInfoService.read_alarms() decodes + them itself rather than coming through here. + """ + captured = bytes.fromhex("240df8e70a0900023a010000020000dc50") + frame = FrameParser.parse_frame(captured) + + assert frame.crc_valid + assert (frame.type_high, frame.type_low_ver) == (0x0002, 0x3A01) + assert TelemetryDecoder.decode(frame) == {} + + def test_alarm_codes_still_decode_when_the_caller_knows(self): frame = ParsedFrame( valid=True, frame_type="response", class_byte=0x0A, - sub_id=9999, - obj_id=9999, - payload=b"", + type_high=0x0002, + type_low_ver=0x3A01, + payload=encode_uint16_be(42) + encode_uint16_be(7), + multi_apdu=False, crc_valid=True, raw_data=b"", ) - - result = TelemetryDecoder.decode(frame) - assert result == {} + assert TelemetryDecoder.decode_alarms_warnings(frame.payload) == [42, 7] def test_decode_non_class10_frame(self): - """Test auto-decoding returns empty dict for non-Class 10 frames.""" - frame = ParsedFrame( - valid=True, - frame_type="response", - class_byte=0x02, # Class 2 - sub_id=None, - obj_id=None, - payload=b"", - crc_valid=True, - raw_data=b"", - ) - - result = TelemetryDecoder.decode(frame) - assert result == {} - - def test_decode_missing_identifiers(self): - """Test auto-decoding returns empty dict for frames with missing IDs.""" - frame = ParsedFrame( - valid=True, - frame_type="response", - class_byte=0x0A, - sub_id=None, - obj_id=None, - payload=b"", - crc_valid=True, - raw_data=b"", + """A Class 7 string reply carries no telemetry.""" + frame = FrameParser.parse_frame( + bytes.fromhex("240ef8e7070a414c5048412048575200838d") ) - - result = TelemetryDecoder.decode(frame) - assert result == {} + assert frame.class_byte == 7 + assert TelemetryDecoder.decode(frame) == {} + + def test_decode_refusal_frame(self): + """ + A refusal is not telemetry, and must not decode as any. + + ``0x81`` is Unknown Data Item with one payload byte naming the item + the pump did not recognise - here item 0. Read as an acknowledgement + carrying an error code, this frame said "success, code 0". + """ + frame = FrameParser.parse_frame(bytes.fromhex("2407f8e70a810040405ebf")) + assert frame.class_byte == 0x0A + assert TelemetryDecoder.decode(frame) == {} class TestReferenceVectors: @@ -443,33 +414,39 @@ def test_alarms_vector(self): class TestEndToEnd: - """Test complete parsing and decoding workflow.""" + """Parse a frame off the wire and decode it, with nothing in between.""" def test_parse_and_decode_motor_state(self): - """Test complete workflow: parse frame -> decode telemetry.""" - # Construct complete frame with motor state telemetry - payload = bytearray(28) - payload[0:4] = encode_float_be(230.0) # Voltage - payload[8:12] = encode_float_be(1.5) # Current - - # Build minimal frame (without proper CRC for simplicity) - frame_data = bytes( - [0x24, 0x1C, 0xE7, 0xF8, 0x0A, 0x00, 0x00, 0x45, 0x00, 0x57] + """ + The captured motor reply survives the whole path. + + The frame this replaced was hand-built with the destination and + source addresses swapped and a deliberately wrong CRC, and it + declared a zero-byte payload while carrying 28 - so it exercised + neither the length field nor the checksum, which are the two things + parsing a real frame depends on. + """ + raw = bytes.fromhex( + "2434f8e70a300001000300002942e730d643237a000000000000000000" + "00000000000000007fffffff7fffffff0000000000000000002385" ) - frame_data += bytes(payload) + bytes([0x00, 0x00]) - # Parse frame - frame = FrameParser.parse_frame(frame_data) + frame = FrameParser.parse_frame(raw) - # Should parse successfully (even if CRC is wrong) assert frame.valid is True + assert frame.crc_valid is True + assert frame.frame_type == "response" assert frame.class_byte == 0x0A - - # Decode telemetry - if frame.obj_id == 87 and frame.sub_id == 69: - telemetry = TelemetryDecoder.decode_motor_state(frame.payload) - assert "voltage_ac_v" in telemetry - assert "current_a" in telemetry + # Declared payload length and frame length agree, as they do for + # every CRC-valid reply this pump sends. + assert raw[5] == len(raw) - 8 + assert frame.multi_apdu is False + + telemetry = TelemetryDecoder.decode(frame) + assert "voltage_ac_v" in telemetry + assert "current_a" in telemetry + assert "power_w" in telemetry + assert "speed_rpm" in telemetry class TestEdgeCases: diff --git a/tests/unit/services/test_cache_sync.py b/tests/unit/services/test_cache_sync.py index f0aa173..88bdf72 100644 --- a/tests/unit/services/test_cache_sync.py +++ b/tests/unit/services/test_cache_sync.py @@ -40,6 +40,13 @@ def control() -> ControlService: return_value=(35.0, 38.9, True) ) service.get_cycle_time_config = AsyncMock(return_value=(5, 15)) # type: ignore[method-assign] + # sync_cache also reads the pump's setpoint ranges. Like the cycle + # config, they are not required for readiness - a pump that will not + # answer leaves the write layer on its fallback constants rather than + # unable to write at all. + service.read_setpoint_ranges = AsyncMock( # type: ignore[method-assign] + return_value={ControlMode.CONSTANT_SPEED: (1650.0, 3671.0)} + ) return service diff --git a/tests/unit/services/test_partial_reads.py b/tests/unit/services/test_partial_reads.py new file mode 100644 index 0000000..be04e27 --- /dev/null +++ b/tests/unit/services/test_partial_reads.py @@ -0,0 +1,132 @@ +""" +A chain the link cut short is not a result. + +Both of these services read a sequence of objects and assemble the answers. +Both had the same hole: a read that fails is *ordinarily* legitimate - an +event log with twelve entries reports the other eight as unreadable, and a +pump that keeps no head trend returns nothing for it - so a link that drops +part-way through produces something shaped exactly like a successful read +of less data. + +"Retrieved 5/20 event log entries" is what a five-entry log looks like. +That is the whole problem: once the list is handed back, nothing +distinguishes it from a truncated one. +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from alpha_hwr.exceptions import ConnectionError +from alpha_hwr.services.event_log import EventLogService +from alpha_hwr.services.history import HistoryService + + +@pytest.fixture +def session() -> MagicMock: + s = MagicMock() + s.is_connected.return_value = True + s.ensure_connected.return_value = None + s.ensure_authenticated.return_value = None + return s + + +def _drops_after(session: MagicMock, calls: int) -> AsyncMock: + """A reader that answers `calls` times, then the link goes.""" + state = {"n": 0} + + async def read(*_args, **_kwargs): + state["n"] += 1 + if state["n"] > calls: + session.is_connected.return_value = False + raise ConnectionError("Pump disconnected from BLE while reading") + return b"\x00\x00\x10" + bytes(16) + + return AsyncMock(side_effect=read) + + +class TestEventLog: + @pytest.mark.asyncio + async def test_a_drop_part_way_through_is_raised_not_returned( + self, session: MagicMock + ) -> None: + service = EventLogService(MagicMock(), session) + service._read_class10_object = _drops_after(session, 5) # type: ignore[method-assign] + + with pytest.raises(ConnectionError): + await service.get_all_entries() + + @pytest.mark.asyncio + async def test_an_unreadable_entry_on_a_live_link_is_still_skipped( + self, session: MagicMock + ) -> None: + """ + The ordinary case must keep working. + + A log with fewer than twenty entries reports its empty slots as + unreadable, so skipping them is right - what must not be skipped is + a failure that means every later read will fail too. + """ + service = EventLogService(MagicMock(), session) + + async def read(_obj, subid, *_a, **_kw): + if subid >= 10205: + return None + return b"\x00\x00\x10" + bytes(16) + + service._read_class10_object = AsyncMock(side_effect=read) # type: ignore[method-assign] + + entries = await service.get_all_entries() + assert len(entries) == 5 + + @pytest.mark.asyncio + async def test_a_drop_with_no_exception_is_still_caught( + self, session: MagicMock + ) -> None: + """ + The link can go without the chain noticing. + + A read already answered when the drop lands returns normally, so + the loop can run to completion over a link that died half way. The + session is checked at the end for exactly that. + """ + service = EventLogService(MagicMock(), session) + + async def read(_obj, subid, *_a, **_kw): + if subid >= 10210: + session.is_connected.return_value = False + return None + return b"\x00\x00\x10" + bytes(16) + + service._read_class10_object = AsyncMock(side_effect=read) # type: ignore[method-assign] + + with pytest.raises(ConnectionError, match="10 of 20"): + await service.get_all_entries() + + +class TestHistory: + @pytest.mark.asyncio + async def test_a_drop_mid_chain_is_raised_not_a_half_built_collection( + self, session: MagicMock + ) -> None: + """ + Three of the four trend series are legitimately None on some pumps, + so a collection with one series filled in is not obviously wrong. + """ + service = HistoryService(MagicMock(), session) + service._read_class10_object = _drops_after(session, 3) # type: ignore[method-assign] + + with pytest.raises(ConnectionError): + await service.get_trend_data() + + @pytest.mark.asyncio + async def test_a_pump_that_answers_nothing_is_not_a_disconnect( + self, session: MagicMock + ) -> None: + """An unreadable object on a live link still degrades to None.""" + service = HistoryService(MagicMock(), session) + service._read_class10_object = AsyncMock(return_value=None) # type: ignore[method-assign] + + assert await service.get_trend_data() is None diff --git a/tests/unit/services/test_single_event_rules.py b/tests/unit/services/test_single_event_rules.py new file mode 100644 index 0000000..0c985cd --- /dev/null +++ b/tests/unit/services/test_single_event_rules.py @@ -0,0 +1,166 @@ +""" +The rules a single-event write has to satisfy before it reaches the wire. + +Three of these encode findings rather than preferences, and the comments +say which is which - a rule with a measurement behind it should not be +"simplified" by someone who reads it as taste. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from alpha_hwr import pump_time +from alpha_hwr.pump_time import from_pump_time, to_pump_time +from alpha_hwr.services import single_event as se +from alpha_hwr.services.single_event import ( + ACTION_RUN, + ACTION_STOP, + SLOT_LIMIT, + SingleEventService, +) + + +@pytest.fixture +def service() -> SingleEventService: + session = MagicMock() + session.is_connected.return_value = True + session.ensure_authenticated.return_value = None + s = SingleEventService(MagicMock(), session) + s.slot_count = AsyncMock(return_value=5) # type: ignore[method-assign] + s._send_configuration_commit = AsyncMock() # type: ignore[method-assign] + return s + + +class TestTheApduHead: + def test_it_declares_the_payload_it_carries( + self, service: SingleEventService + ) -> None: + """ + 0x93 is SET with 19 payload bytes, and 19 is what follows. + + It was 0xB3 - SET with 51 - borrowed from the schedule layer write, + whose 53-byte APDU really does carry 51. Every one of the 29 + single-event writes in the capture corpus uses 0x93; the 8 layer + writes use 0xB3. + """ + apdu = service.build_apdu( + 0, datetime(2026, 8, 20, 9), datetime(2026, 8, 20, 10) + ) + + assert apdu[1] == 0x93 + assert apdu[1] & 0x3F == len(apdu) - 2 + assert (apdu[1] >> 6) == 0b10 # SET + + +class TestSlotBounds: + @pytest.mark.asyncio + async def test_an_out_of_envelope_slot_is_refused_without_reading( + self, service: SingleEventService + ) -> None: + """ + Slot 100 is sub-id 1000, which is schedule layer 0. + + Checked before the pump is consulted, deliberately: deferring it + makes an impossible slot on a broken link report "the overview + could not be read", blaming the link for an argument that could + never have been right. + """ + service.slot_count = AsyncMock(side_effect=AssertionError("read!")) # type: ignore[method-assign] + + assert not await service.write( + SLOT_LIMIT, datetime(2126, 1, 1), datetime(2126, 1, 2) + ) + + @pytest.mark.asyncio + async def test_a_slot_this_pump_lacks_is_refused_after_reading( + self, service: SingleEventService + ) -> None: + service.slot_count = AsyncMock(return_value=5) # type: ignore[method-assign] + + assert not await service.write( + 7, datetime(2126, 1, 1), datetime(2126, 1, 2) + ) + service.slot_count.assert_awaited() + + +class TestWindows: + @pytest.mark.asyncio + async def test_a_window_that_already_closed_is_refused( + self, service: SingleEventService + ) -> None: + """It would occupy one of five slots and never run.""" + past = datetime.now() - timedelta(days=2) + + assert not await service.write(0, past, past + timedelta(hours=1)) + + @pytest.mark.asyncio + async def test_a_window_already_underway_is_allowed( + self, service: SingleEventService + ) -> None: + """Starting part-way through is legitimate; only the end matters.""" + now = datetime.now() + service.transport.write = AsyncMock() + service.confirm = AsyncMock(return_value=True) # type: ignore[method-assign] + + assert await service.write( + 0, now - timedelta(hours=1), now + timedelta(hours=1) + ) + + +class TestTimestampEncoding: + def test_it_round_trips_across_both_dst_transitions(self) -> None: + """ + No timezone is consulted, so there is no offset to resolve wrongly. + + The ESPHome port had a real bug here, but it is a consequence of + converting to UTC - which this encoding does not do. It stamps the + wall-clock fields as though they were UTC and reads them back the + same way, which is exactly how the pump stores them. + + Anyone "fixing" this by adding a UTC conversion reintroduces that + bug; this test is here to stop them. + """ + for base in (datetime(2026, 3, 8), datetime(2026, 11, 1)): + when = base + for _ in range(97): # 24 h at 15-minute steps + assert from_pump_time(to_pump_time(when)) == when + when += timedelta(minutes=15) + + def test_a_time_the_pump_cannot_store_is_refused(self) -> None: + """The wire field is uint32: 1970 to 2106.""" + with pytest.raises(ValueError, match="outside the range"): + to_pump_time(datetime(1969, 1, 1)) + + with pytest.raises(ValueError, match="outside the range"): + to_pump_time(datetime(2107, 1, 1)) + + def test_the_top_of_the_range_is_accepted(self) -> None: + assert to_pump_time(datetime(2106, 2, 7)) <= pump_time.MAX_PUMP_TIME + + +class TestConfirm: + @pytest.mark.asyncio + async def test_a_stored_action_that_differs_is_not_accepted( + self, service: SingleEventService + ) -> None: + """ + The ACTION byte is half the meaning of a single event. + + 0x01 holds the pump off across the window - which is what a + vacation is - and 0x02 runs it once. A confirm that compared only + the window and the enabled flag would settle a vacation as written + while the pump was scheduled to run for a week. + """ + begin, end = datetime(2126, 1, 1), datetime(2126, 1, 2) + service.read = AsyncMock( # type: ignore[method-assign] + return_value=se.SingleEvent( + slot=0, enabled=True, action=ACTION_RUN, begin=begin, end=end + ) + ) + + assert not await service.confirm(0, begin, end, ACTION_STOP) + assert await service.confirm(0, begin, end, ACTION_RUN) diff --git a/tests/unit/services/test_write_operation.py b/tests/unit/services/test_write_operation.py index 5888ddf..3676512 100644 --- a/tests/unit/services/test_write_operation.py +++ b/tests/unit/services/test_write_operation.py @@ -55,6 +55,11 @@ def control() -> MagicMock: c.set_temperature_range_control = AsyncMock(return_value=True) c.set_cycle_time_control = AsyncMock(return_value=True) c.is_cache_valid = True + # No range read yet, so the write layer falls back to its own wide + # constants. A MagicMock would return a truthy Mock here and be + # unpacked as a pair of bounds, which is not a state the real service + # can be in. + c.get_setpoint_range = MagicMock(return_value=None) return c @@ -473,9 +478,18 @@ async def test_a_single_field_write_does_not_need_the_cache( @pytest.mark.asyncio -async def test_an_out_of_range_setpoint_is_invalid( +async def test_even_a_wildly_out_of_range_setpoint_reaches_the_pump( writes: WriteOperationService, control: MagicMock ) -> None: + """ + 99,000 RPM is absurd and is still the pump's call. + + This used to settle INVALID against a 500-4500 constant that was wrong + in both directions. Replacing the constant with the pump's own range + would have made the refusal *accurate* and still wrong: the pump + clamps rather than refusing, and with a flow limiter active there is + no bound to check against at all. + """ result = await writes.submit( WriteCommand.SET_SETPOINT, "setpoint:2", @@ -483,9 +497,8 @@ async def test_an_out_of_range_setpoint_is_invalid( value=99_000.0, ) - assert result.status is WriteStatus.INVALID - assert "500" in result.detail and "4500" in result.detail - control.set_constant_speed.assert_not_awaited() + assert result.status is not WriteStatus.INVALID + control.set_constant_speed.assert_awaited_once_with(99_000.0) @pytest.mark.asyncio @@ -532,5 +545,107 @@ async def test_range_checks_are_per_mode( value=1.5, ) + # Both reach the pump now; what differs is what it does with them. + # 1.5 is an ordinary flow setpoint and an absurd speed, and the pump + # is the one that says so. assert flow.status is WriteStatus.ACCEPTED - assert speed.status is WriteStatus.INVALID + assert speed.status is not WriteStatus.INVALID + + +@pytest.mark.asyncio +async def test_an_out_of_range_setpoint_is_sent_and_clamps( + control: MagicMock, +) -> None: + """ + The pump decides, not us. + + It does not refuse a setpoint it dislikes - it takes it and clamps it - + so sending 4000 RPM against a 1650-3671 range settles CLAMPED with what + the pump stored, which tells the caller more than a refusal would. + + The bound also cannot be ours to enforce. With a flow limiter enabled + the pump manages actual speed to hold the flow bound, and where it + settles is a property of the installation's hydraulics - one reported + loop delivered 1885 RPM for a 3000 RPM request. No number is the + maximum speed there. See esphome-alpha-hwr #276. + """ + control.get_setpoint_range = MagicMock(return_value=(1650.0, 3671.0)) + # Held 2000 before the write, 3671 after: a value that is neither what + # was asked for nor what was there is a clamp. Returning 3671 for both + # reads would be "the pump kept what it had", which is REJECTED, and is + # the distinction the previous-value read exists to make. + control.get_mode = AsyncMock( + side_effect=[info(setpoint=2000.0)] + [info(setpoint=3671.0)] * 6 + ) + writes = WriteOperationService(control) + + result = await writes.submit( + WriteCommand.SET_SETPOINT, + "setpoint:2", + mode=ControlMode.CONSTANT_SPEED, + value=4000.0, + ) + + assert result.status is WriteStatus.CLAMPED + assert result.value == 3671.0 + control.set_constant_speed.assert_awaited_once_with(4000.0) + + +@pytest.mark.asyncio +async def test_a_clamp_says_what_the_range_was(control: MagicMock) -> None: + """ + The published range becomes the explanation rather than the gate. + + Only when it came from the pump: saying "its range is 500-4500" from a + fallback constant would be asserting something we do not know, and + those constants were wrong in both directions on every mode. + """ + control.get_setpoint_range = MagicMock(return_value=(1650.0, 3671.0)) + control.get_mode = AsyncMock( + side_effect=[info(setpoint=2000.0)] + [info(setpoint=3671.0)] * 6 + ) + writes = WriteOperationService(control) + + result = await writes.submit( + WriteCommand.SET_SETPOINT, + "setpoint:2", + mode=ControlMode.CONSTANT_SPEED, + value=4000.0, + ) + assert "1650" in result.detail and "3671" in result.detail + + control.get_setpoint_range = MagicMock(return_value=None) + control.get_mode = AsyncMock( + side_effect=[info(setpoint=2000.0)] + [info(setpoint=3671.0)] * 6 + ) + quiet = await writes.submit( + WriteCommand.SET_SETPOINT, + "setpoint:2", + mode=ControlMode.CONSTANT_SPEED, + value=4000.0, + ) + assert "range" not in quiet.detail + + +@pytest.mark.asyncio +async def test_a_value_that_is_not_a_number_is_still_refused( + control: MagicMock, +) -> None: + """ + There is nothing here for the pump to clamp to. + + And the all-ones float is the SETPOINT_KEEP sentinel, so a NaN on the + wire reads as "leave the setpoint alone" - a write that silently does + nothing rather than one that fails. + """ + writes = WriteOperationService(control) + + result = await writes.submit( + WriteCommand.SET_SETPOINT, + "setpoint:2", + mode=ControlMode.CONSTANT_SPEED, + value=float("nan"), + ) + + assert result.status is WriteStatus.INVALID + control.set_constant_speed.assert_not_awaited() diff --git a/tests/unit/test_pump_time.py b/tests/unit/test_pump_time.py new file mode 100644 index 0000000..9bf2780 --- /dev/null +++ b/tests/unit/test_pump_time.py @@ -0,0 +1,154 @@ +""" +Every surface reads the pump's clock the same way. + +The risk this guards is interoperability rather than arithmetic. More than +one client writes this pump's clock - the Grundfos GO app, the ESPHome +component, this library - and the pump cannot say which time base a value +arrived in, because it has no timezone or UTC-offset field anywhere in its +GENI profile. Two clients disagreeing is worse than either being wrong +alone: one sets the clock, the other resets it seven hours out, and every +stored schedule fires at the wrong hour. + +The device evidence, read off the bench unit: + + * ``DateTimeActual`` carries ``dst_status``, and it reads ``SummerTime``. + A device tracking whether it is in summer time is keeping local time. + * ``DaylightSavingTime`` (Object 94 Sub 102) reads enabled, second Sunday + of March to first Sunday of November, 60-minute offset - the US rule. + The pump shifts its own clock. + * The pump's clock matched host local time to the second. + +So the rule is: express local wall clock, never UTC. +""" + +from __future__ import annotations + +import ast +import inspect +from datetime import datetime, timedelta + +import pytest + +from alpha_hwr import pump_time + + +def _identifiers(module) -> set[str]: + """Every name the module's *code* mentions, ignoring prose.""" + tree = ast.parse(inspect.getsource(module)) + names: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Name): + names.add(node.id) + elif isinstance(node, ast.Attribute): + names.add(node.attr) + elif isinstance(node, ast.alias): + names.add(node.asname or node.name.rsplit(".", 1)[-1]) + return names + + +from alpha_hwr.pump_time import MAX_PUMP_TIME, from_pump_time, to_pump_time +from alpha_hwr.services import event_log, history, single_event + + +class TestTheEncoding: + def test_it_round_trips_exactly(self) -> None: + when = datetime(2026, 8, 20, 9, 30, 15) + assert from_pump_time(to_pump_time(when)) == when + + def test_it_round_trips_across_both_dst_transitions(self) -> None: + """ + No timezone is consulted, so there is no offset to resolve wrongly. + + The ESPHome port had a real bug here, but it is a consequence of + converting to UTC - which this encoding does not do. Anyone + "fixing" it by adding a conversion reintroduces that bug. + """ + for base in (datetime(2026, 3, 8), datetime(2026, 11, 1)): + when = base + for _ in range(97): # 24 h at 15-minute steps + assert from_pump_time(to_pump_time(when)) == when + when += timedelta(minutes=15) + + def test_it_never_consults_a_timezone(self) -> None: + """ + Checked against the parsed code, not the text, so the prose + explaining *why* not to do this does not trip its own rule. + """ + names = _identifiers(pump_time) + + for forbidden in ("astimezone", "utcoffset", "localtime", "tzinfo"): + assert forbidden not in names, ( + f"{forbidden} in the encoding: the pump stores no offset, " + f"and inventing one is how the other implementation " + f"acquired a DST bug" + ) + + def test_the_wire_range_is_enforced(self) -> None: + with pytest.raises(ValueError, match="outside the range"): + to_pump_time(datetime(1969, 12, 31)) + with pytest.raises(ValueError, match="outside the range"): + to_pump_time(datetime(2107, 1, 1)) + assert to_pump_time(datetime(2106, 2, 7)) <= MAX_PUMP_TIME + + +class TestOneAccessorForNow: + """ + "What time is it" has one answer for pump decisions. + + Five independent ``datetime.now()`` calls decided slot expiry, window + validity and what to write to the pump's clock. Nothing was wrong with + any of them, but the ESPHome port's #262 was caused by one caller + substituting the wrong timestamp for "now", and independent notions of + now are what make that easy to reintroduce. + """ + + def test_the_pump_clock_is_naive_local(self) -> None: + assert pump_time.now().tzinfo is None + + @pytest.mark.parametrize( + "module", + [single_event, __import__("alpha_hwr.services.time", fromlist=["x"])], + ids=["single_event", "time"], + ) + def test_no_service_calls_now_itself_for_pump_decisions( + self, module + ) -> None: + """ + ``datetime.now()`` here would be a second answer to the same + question. Host-event stamps are a *different* question and are + correctly ``datetime.now(UTC)`` - which is why this checks for the + naive call specifically. + """ + source = inspect.getsource(module) + assert "datetime.now()" not in source, ( + f"{module.__name__} answers 'what time is it' itself; it should " + f"use pump_time.now() so every pump decision shares one clock" + ) + + +class TestOneTimeBase: + """No surface may decode a pump timestamp as an aware datetime.""" + + def test_a_decoded_timestamp_is_naive(self) -> None: + assert from_pump_time(1787218200).tzinfo is None + + @pytest.mark.parametrize( + "module", [single_event, event_log, history], ids=lambda m: m.__name__ + ) + def test_no_module_stamps_a_pump_timestamp_as_utc(self, module) -> None: + """ + ``fromtimestamp(ts, tz=UTC)`` gives the right digits and the wrong + instant: the digits are the pump's local wall clock, so anything + calling ``.astimezone()`` shifts them by the local offset and + produces a time the pump never meant. + """ + assert "fromtimestamp" not in _identifiers(module), ( + f"{module.__name__} decodes a pump timestamp itself; it should " + f"use pump_time.from_pump_time so every surface agrees" + ) + + @pytest.mark.parametrize( + "module", [single_event, event_log, history], ids=lambda m: m.__name__ + ) + def test_every_module_uses_the_shared_helper(self, module) -> None: + assert "from_pump_time" in _identifiers(module) diff --git a/tests/wire.py b/tests/wire.py new file mode 100644 index 0000000..655d312 --- /dev/null +++ b/tests/wire.py @@ -0,0 +1,114 @@ +""" +Building frames the pump could actually have sent. + +Tests across this suite used to hand-assemble Class 10 replies, and each +one encoded the same three mistakes: the destination and source addresses +in request order, an APDU head chosen as a constant rather than as the +payload's length, and the requested Object/Sub-ID in bytes 6-9 where the +pump puts an object *type*. A fixture that reproduces a bug cannot catch +it, and several of these frames were asserted against for a long time. + +Build replies with :func:`class10_reply` instead, or - better where one +exists - assert against a frame in :data:`CAPTURED`, which are recordings +from an ALPHA HWR rather than constructions. +""" + +from __future__ import annotations + +from alpha_hwr.utils import calc_crc16_read + +#: A reply is addressed to us and sourced from the pump. Requests carry +#: these the other way round. +REPLY_DEST = 0xF8 +REPLY_SRC = 0xE7 + + +def frame(class_byte: int, apdu_payload: bytes, start: int = 0x24) -> bytes: + """ + Wrap an APDU payload in a frame, with a real length and a real CRC. + + The APDU head is ``0booLLLLLL``: the operation or acknowledgement, then + the payload's byte count. Passing a payload longer than 63 bytes raises + rather than silently truncating the count into the operation bits. + """ + if len(apdu_payload) > 0x3F: + raise ValueError( + f"{len(apdu_payload)} payload bytes cannot be declared in six bits" + ) + dest, src = ( + (REPLY_DEST, REPLY_SRC) if start == 0x24 else (REPLY_SRC, REPLY_DEST) + ) + apdu = bytes([class_byte, len(apdu_payload)]) + apdu_payload + body = bytes([len(apdu) + 2, dest, src]) + apdu + crc = calc_crc16_read(body) + return bytes([start]) + body + bytes([crc >> 8, crc & 0xFF]) + + +def class10_reply(type_high: int, type_low_ver: int, body: bytes) -> bytes: + """ + Build a Class 10 data reply for an object of the given type. + + ``body`` is the object's struct; the three-byte ``[00][00][size]`` + header every captured reply carries is added here. + """ + payload = ( + bytes([(type_high >> 8) & 0xFF, type_high & 0xFF]) + + bytes([(type_low_ver >> 8) & 0xFF, type_low_ver & 0xFF]) + + bytes([0x00, 0x00, len(body)]) + + body + ) + return frame(0x0A, payload) + + +def class10_ack(status: int = 0x00) -> bytes: + """The nine-byte acknowledgement a Class 10 write draws.""" + return frame(0x0A, bytes([status])) + + +def class10_refusal(item_id: int = 0x00) -> bytes: + """ + An Unknown Data Item refusal naming the item the pump did not know. + + Head ``0x81`` is ``10 000001``. The payload byte is the item's ID, not + an error code - which is why reading it as one turned a refusal naming + item 0 into a success. + """ + apdu = bytes([0x0A, 0x81, item_id]) + body = bytes([len(apdu) + 2, REPLY_DEST, REPLY_SRC]) + apdu + crc = calc_crc16_read(body) + return bytes([0x24]) + body + bytes([crc >> 8, crc & 0xFF]) + + +#: Frames recorded from an ALPHA HWR (family 52, type 7, version 2) on +#: 2026-08-20. Prefer these to anything built here. +CAPTURED = { + "class7_product_name": bytes.fromhex( + "240ef8e7070a414c5048412048575200838d" + ), + "class7_serial": bytes.fromhex("240df8e70709313030303034373900c347"), + "motor_state": bytes.fromhex( + "2434f8e70a300001000300002942e730d643237a000000000000000000" + "00000000000000007fffffff7fffffff0000000000000000002385" + ), + "flow_pressure": bytes.fromhex( + "242ff8e70a2b0002350200002400000000000000007fffffff7fffffff" + "7fffffff7fffffff000000000000000000000000edbe" + ), + "temperature": bytes.fromhex( + "2418f8e70a140002160200000d41e0f24d41e9654e41d60bac001c01" + ), + "mode_read": bytes.fromhex("2412f8e70a0e00012f0100000701001b39678ac3f7dd"), + "setpoint_range_speed": bytes.fromhex( + "2427f8e70a2300012d0100001c452f000044ce400045657000" + "c56570003f8000003f8000003f80000089a9" + ), + "schedule_overview": bytes.fromhex( + "2415f8e70a110000da0100000a02050005010100000000dd89" + ), + "clock": bytes.fromhex( + "2417f8e70a130001420100000c07ea08140a04155b000401017298" + ), + "temp_range_config": bytes.fromhex( + "2419f8e70a150003f40200000e00420c0000421b999a0f3c020501ec1f" + ), +}