Skip to content

fix(protocol): resolve protocol and parser defects from MHS1 firmware analysis (#77) - #82

Merged
GreenGrassBlueOcean merged 6 commits into
masterfrom
fix/issue-77-gateway-protocol-defects
Oct 6, 2026
Merged

GreenGrassBlueOcean merged 6 commits into
masterfrom
fix/issue-77-gateway-protocol-defects

Conversation

@GreenGrassBlueOcean

@GreenGrassBlueOcean GreenGrassBlueOcean commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Resolves #77. gdluck ran the MyHomeServer1 translator programs (bt_luci, coso, bt_termo, bt_supervisione, bt_device, bt_multi) on an emulated SCS bus and found ten OWNd defects. This PR fixes them, B1 (dropping WHO 16 from the MyHomeServer1 profile) was removed from this PR: see Not included.

Sources

Label Meaning Published
Firmware An OWN message was sent to the MyHomeServer1 firmware on an emulated bus. Recorded: the bus frame it produced, or that it produced none. au1.tsv–au4.tsv in gdluck's zip on #77. The older sweeps (oracle2/dl*.tsv) are quoted in the README but not published.
Emitted A message the firmware itself produces for a bus frame (15,529 messages). Quoted in the #77 README. The corpus is not published.
Gateway A read-only request to a real MyHomeServer1 (fw 3.1.8). gateway_probe.txt in the zip
Encyclopedia Official specification, as recorded in the OpenWebNet Encyclopedia. public
Capture A real plant trace in MyHOME tests/fixtures/traces. public
BTicino client BTicino's own touch-screen client library, libqtdevices tag TS10_1_0_23 (thermal_device.cpp, probe_device.cpp and their tests), as pointed out by gdluck. OpenWebNet-HA/libqtdevices

Items, sources and tests

All tests are in tests/test_issue_77_firmware_findings.py. Each test uses the source frames verbatim and names its source in its docstring. 68 of its 180 cases fail on master (f7894c4), and every item has at least one of them. The other cases check that frames captured on real plants still decode the same way.

# Change Source Test(s)
1 set_central_mode: off 303, heat 1, cool 0, auto 311, antifreeze 102, protection 302 (was 100/101/102/103/110/111) Firmware: the old 100/101/110 produce no frame, and 102/103/111 are forwarded but mean something else. 303, 1, 0, 311 and 102 produce a frame for #0 and #0#2 (own-firmware-oracle#10); 302 is in gdluck's list of accepted central WHATs (audit A1). BTicino client: sends *4*303*#0##, *4*1*#0##, *4*0*#0## and *4*302*#0## to the 99-zone central; its enum names 102 winter, 202 summer and 302 generic protection, so "protection" is 302 (202 would also switch the plant to summer). Encyclopedia: WHO 4 what.md table. Capture: a MyHomeServer1 plant in summer mode reports *4*0*#0## (MyHOME#453). Not verified: how a physical central unit reacts. test_fix1_central_mode_uses_the_who4_what_table (12 cases; also checks what OWNd's parser reads back: off, heat, cool and auto as themselves, antifreeze 102 and protection 302 as off), test_fix1_captured_central_unit_mode_parses_as_cooling
2 set_fan_speed: always the plain zone *#4*Z*#11*S##; ValueError for central-unit addresses and for speeds outside 0–3 Firmware: *#4*#1*#11*1##, *#4*#0*#11*1##, *#4*#0#2*#11*1## and *#4*1*#11*4## produce no frame. *#4*1*#11*0..3## and zone 99 do. Encyclopedia: speed is 0 or 1..3. test_fix2_* (4 tests)
3 set_mode(AUTO) is always built as *4*311*#Z##, also for a zone flagged standalone (was *4*311*Z##) Firmware: *4*311*1## produces no frame; *4*311*#1## and *4*303*1## do. BTicino client: setAutomatic sends *4*311*#23#1##. Encyclopedia: AUTO is *4*311*#WHERE##. An earlier revision returned None; MyHOME flags every zone that is not a central unit standalone by default, so that silently dropped AUTO for zones that do run under a central unit (gdluck's review, 2.4). test_fix3_auto_is_always_built_for_the_hash_zone
4 Dimension 20 without #actuator falls back to actuator 1 instead of raising IndexError Emitted: *#4*#0*20*0## and *#4*100*20*0## (141 messages). Capture: *#4*1#1*20*1## test_fix4_* (2)
5 Dimension 19 with an empty field is not decoded, instead of raising ValueError Emitted: *#4*40*19**0##, *#4*40*19*1*##. Gateway: *#4*41*19*0*0## test_fix5_* (2)
6 _DIMENSION_REQUEST accepts #param, so *#18*51*511#10#2## and *#18*51*52#26#9## parse Gateway: *#18*51*511#10#1## and *#18*51*52#26#9## were accepted. Encyclopedia: WHO 18 dimensions 511 and 52. Code: the builders produced requests OWNd could not parse. test_fix6_* (2)
7 set_brightness(0) sends switch-off; the level is capped at 100 Firmware: *#1*31*#1*100*0## and …*100*5## produce no frame; *1*0*31##, *1*0#1*31## … *1*0#255*31## and *#1*31*#1*101*0## do (au1, au4) test_fix7_* (130 cases)
8 interface is also read for WHO 0 and WHO 14 Firmware: *0*1*31#4#01## and *14*0*31#4#01## are routed like a light. Encyclopedia (WHO 0): 01..99#4#I. WHO 14 rests on the firmware alone. test_fix8_interface_is_read_for_scenarios_and_who14
9 A WHO 25 WHAT outside 21–28 and 31/32 becomes a plain OWNEvent, not "contact OFF" Emitted: *25*11#121*10##, *25*12*131##. Encyclopedia: dry contact is WHAT 31/32. Capture: real 31/32 and 21–24 frames still parse to the same class (MyHOME#453, #466). test_fix9_* (2)
10 Conformance matrix examples replaced Firmware (au1, au2.own, au4) and Gateway for every replacement. Capture: WHO 15 MyHOME#541, WHO 25 MyHOME#418. test_fix10_matrix_lists_no_frame_the_firmware_refuses, test_fix10_matrix_examples_all_parse

Not included

  • B1, MyHomeServer1 without WHO 16. It rested on emulation alone (openserver not emulated; the real gateway's refusal may mean no sound system installed) and gdluck did not propose it. MyHOME skips WHO 16 discovery and the media_player platform when the profile lacks WHO 16, so it would break MyHomeServer1 users with a sound system. master already reports MyHomeServer1 sound as unmeasured. It needs a WHO 16 test on a real gateway with a sound system.

Downstream notes

  • Fix 1 changes existing WHAT values (102 was cool and is now antifreeze; protection was 111, automatic heating on the firmware, and is now 302): bump OWNd and MyHOME together.
  • The parser is unchanged: it has always read 102/202/302 (and 103/203/303) as off, so a plant that echoes antifreeze or protection shows off. That matches Home+Control plants, which report a zone switched off as *4*102*Z## or *4*202*Z## (golden captures, MyHOME#429). Giving them their own mode name would need a matching HVAC mode or preset in MyHOME, so it is left out here. MyHOME never sends antifreeze or protection.
  • Fix 3 changes behaviour on real plants: MyHOME flags every zone that is not a central unit standalone by default, so its AUTO went out as *4*311*Z##, which the firmware drops. It now goes out as *4*311*#Z## and reaches the bus.
  • Fix 8 changes unique_id for WHO 0 / WHO 14 frames with #4#NN; check MyHOME entity IDs when bumping.
  • Fix 9 leaves a WHO 25 frame with an unparseable WHAT as a dry contact.

Found during review, not in this PR

The golden corpus was checked against the firmware data (gdluck's au1–au4, audit and real-gateway probe, own-firmware-oracle results, MyHOME traces). The firmware data contradicts none of the 48 community-plant-capture fixtures, though it does not cover many of them. Six document-derived fixtures disagree with it; #83 corrects them, together with the three small code findings from the audit (fan zone parsing, capped brightness log, WHO 25 command parser):

  • *4*110*1## "antifreeze" and *4*111*1## "protection": the firmware drops 110 and treats 111 as automatic heating; libqtdevices names them WIN_MANUAL and WIN_AUTOMATIC. The corpus's own captures show antifreeze as *4*102*Z##.
  • *2*1*21#4#1##: a one-digit interface is refused (au2, MH200N oracle); every interface capture uses two digits.
  • *25*31#1*15##: real dry contacts on MH200N, MH202, F454 and H4890 all have a WHERE starting with 3.
  • *#4*#0*14##: refused by a real MyHomeServer1 (gateway probe) and by an F454 (MyHOME#629).
  • *15*01#3*0001##: the spec allows WHERE 0001, but bt_luci refuses it. No capture exists either way.

own-firmware-oracle#11 needs a re-run of the MH200N suites with bt_termo and bt_energia before its WHO 4 / WHO 18 rows are used (the harness keeps only the openserver clients named in the target spec).

Other changes

  • Golden: the four central-mode fixtures now cite encyclopedia and the firmware run, not legrand-spec; the three 99-zone ones also cite the BTicino client test that sends the same frame. The golden README requires a corrected fixture to name its new source.
  • README / sound.py: they now say WHO 16 only. OWNd has no WHO 22 parser (gdluck's README note).

Downstream (MyHOME, not in this PR)

  • climate.py async_set_fan_mode did not catch the new ValueError (fix 2): OpenWebNet-HA/MyHOME#641 turns it into a service error (merged). OpenWebNet-HA/MyHOME#630 aligns MyHOME's tests and climate code with this PR, including AUTO on standalone zones. Conformance run 37518042220 (OWNd 885b8e4 + MyHOME de8cf4b3) is green. #630 merges once this PR is released as OWNd 2.0.0b10.
  • On MyHOME v2-phase1-architecture, 11 tests still assert the old frames; #630 updates them: test_climate_central_discovery.py, test_component_climate.py (2), test_gateway_messages.py::TestDryContactEvents::test_dry_contact_off, test_golden_conformance.py (4 central-mode builder-parity cases, from tests/golden/frames/who04_thermo.yaml), test_heating.py::test_set_fan_speed_variations, test_phase2_architecture.py (2).

Verification

  • At 885b8e4: 1,199 passed, 1 skipped. 3,631 / 3,631 statements covered (scripts/verify_coverage.py).
  • CI: the OWNd jobs pass. Golden corpus & smoke gates and MyHOME test suite fail as expected, because they run MyHOME's v2-phase1-architecture, whose tests still assert the old frames (the 11 tests listed above). Dispatched against MyHOME#630 instead, both jobs pass on 885b8e4: run 37518042220.
  • ruff check, tools/golden/validate_corpus.py (115 frames) and the README profile table are clean.
  • Firmware oracle: own-firmware-oracle#10 replays the PR's heating and lights builders on MyHomeServer1 2.82.06 and the lights builders on MH200N 1.1.8. The MH200N replay of the other suites in own-firmware-oracle#11 ran without bt_termo and bt_energia, so its WHO 4 and WHO 18 NACKs are not evidence about the MH200N; see the correction on the cross-validation comment. Apart from that, fixes 4, 5 and 9 rest on the Emitted corpus and the unit tests, not on a replay.

🤖 Generated with Claude Code

… analysis (#77)

- Fix 1: Align central mode WHAT codes to canonical 303 (off), 1 (heat), 0 (cool), 311 (auto), 102 (antifreeze), 202 (protection)
- Fix 2: Strictly require plain zone (1..99) and speed (0..3) in set_fan_speed, rejecting '#' central addresses
- Fix 3: Return None for CLIMATE_MODE_AUTO on standalone zones (dropped by gateway)
- Fix 4: Guard dimension 20 actuator status against empty where_param IndexError
- Fix 5: Guard dimension 19 valve status against empty field ValueError
- Fix 6: Support dimension parameters in _DIMENSION_REQUEST regex and extract _dimension_param
- Fix 7: Route brightness <= 0 through switch_off and clamp command level to 101..200
- Fix 8: Recognize private bus routing (#4#NN) in interface property for WHO 0 and WHO 14
- Fix 9: Fall back to generic OWNEvent for non-31/32 WHO 25 frames rather than misclassifying as contact OFF
- Fix 10: Correct canonical frame examples in protocol conformance matrix
- Fix B1: Set supports_audio=False and omit WHO 16 from supported_who in MyHomeServer1Profile (MHS1 exclusively uses WHO 22)
- Synchronize golden thermo fixtures, regenerate corpus.json, and maintain 100.0% coverage across all 24 modules
Replace the single combined test with tests/test_issue_77_firmware_findings.py.
Each test uses the frames from the source it names, verbatim: gdluck's
MyHomeServer1 firmware run (au1-au4.tsv, OWNd#77 README), the read-only
gateway probe, the OpenWebNet Encyclopedia, or MyHOME plant captures
(#453, #466, #541). 69 of the new cases fail on a6eda4d, covering every item
(fixes 1-10 and B1); the rest pin captured frames that must keep decoding.

- Golden: the four central-mode fixtures changed in the previous commit still
  claimed source legrand-spec. They now cite the Encyclopedia WHO 4 table
  (what.md: 0, 1, 102, 202, 303, 311) and the firmware run, as the golden
  README requires for a corrected fixture. corpus.json regenerated.
- Matrix: WHO 15 has captures (MyHOME#541 press and short release), so the
  row is VERIFIED with those frames plus the firmware-checked #2/#3 forms; the
  footnote names the source of every changed example.
- README/sound.py: sound.py implements WHO 16 only (gdluck's README note).
- Restore the turn_off assertion dropped from test_message_exhaustive.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@codecov-commenter

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

…ring, merge master

B1 rested on emulation alone and would make MyHOME skip WHO 16 discovery and the media_player platform for MyHomeServer1 users with a sound system. master already marks MyHomeServer1 sound as unmeasured.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@GreenGrassBlueOcean

Copy link
Copy Markdown
Contributor Author

Live test: fix 7 (brightness 0) on a real F414 dimmer

Gateway: MH200 at 192.168.1.40 (fw 2.1.0). Device: bureau dimmer, point 79 (F414), on at 100% (*1*10*79##) before the test and restored to that after. Frames sent through the myhome-gateway MCP, which logs the event session only, so ACK/NACK is not visible; the results below are the bus events that followed.

Frame Bus response Result
*#1*79*#1*100*0## (what master builds for brightness 0) none; light unchanged refused
*1*0*79## (what this PR builds for brightness 0) *1*0*79## off
*#1*79*#1*150*5## *#1*79*1*150*5## 50% accepted
*#1*79*#1*101*0## *#1*79*1*101*5## 1% accepted
*1*0#5*79## (brightness 0, transition 5) *#1*79*1*100*5## ramped to 0%

So the F414 on an MH200 behaves like the MyHomeServer1 firmware emulation: level 100 is not accepted, switch-off is.

Not tested live: the cap at level 100 (a request above 100 was not sent), and the other fixes. Fix 1 (central unit modes) was not written to the heating plant.

Also pushed to this branch: master merged, B1 (MyHomeServer1 without WHO 16) removed (see the PR description), and the actuator fallback kept as the string "1".

@GreenGrassBlueOcean

Copy link
Copy Markdown
Contributor Author

CI: the two MyHOME conformance checks are expected to fail on this PR

Golden corpus & smoke gates and MyHOME test suite run this checkout against MyHOME's v2-phase1-architecture, whose tests still assert the old frames (central-unit modes *4*101*#0## etc., fan speed *#4*#5*#11*1##, WHO 25 dry contact). The failures are those assertions, not OWNd defects.

The downstream change is MyHOME#630. It cannot merge until OWNd 2.0.0b10 is on PyPI, and b10 needs this PR, so the two block each other.

Evidence that the pair fits: conformance run 37348391705, dispatched on this branch with myhome_ref=fix/issue-77-climate-protocol-alignment (#630's branch). Both jobs pass.

Proposal: merge this PR with those two required checks overridden, citing the run above, then release b10 and merge #630. All other checks pass.

@GreenGrassBlueOcean

Copy link
Copy Markdown
Contributor Author

Firmware oracle: the PR's builders against MyHomeServer1 2.82.06 and MH200N 1.1.8

The frames come from OWNd's own builders, master (f7894c4) vs this PR (6038ceb), not copied by hand. Suites, results and hashes: own-firmware-oracle#10. Each suite re-run gives zero diff. Status firmware_observed: it says what the gateway puts on the bus, not how a physical device reacts.

Heating, MyHomeServer1 2.82.06 (#0#2 behaves the same as #0)

Fix master builds firmware PR builds firmware
1 central off *4*100*#0## no bus frame *4*303*#0## bus frame
1 central heat *4*101*#0## no bus frame *4*1*#0## bus frame
1 central cool *4*102*#0## bus frame (other meaning) *4*0*#0## bus frame
1 central auto *4*103*#0## bus frame (other meaning) *4*311*#0## bus frame
1 antifreeze *4*110*#0## no bus frame *4*102*#0## bus frame
1 protection *4*111*#0## bus frame (other meaning) *4*202*#0## bus frame
2 fan speed *#4*#1*#11*1##, *#4*#99*#11*1## no bus frame *#4*1*#11*1##, *#4*99*#11*1## bus frame
3 standalone auto *4*311*1##, *4*311*99## no bus frame not built (None) n/a

Lights, both images (set_brightness)

Call master PR MH200N MyHomeServer1
level 0 *#1*31*#1*100*0## *1*0*31## master NACK, PR ACK + bus master no bus, PR bus
level 0, transition 5 *#1*31*#1*100*5## *1*0#5*31## master NACK, PR ACK + bus master no bus, PR bus
level 120 *#1*31*#1*220*0## *#1*31*#1*200*0## (capped) master NACK, PR ACK + bus master no bus, PR bus

On MyHomeServer1 the reply column reads nack on every row (known reply quirk), so the bus frame is the evidence. The cap at 100, which the live F414 test did not exercise, is now confirmed on both firmwares.

Not covered: heating on MH200N (its oracle target has no bt_termo), fixes 4, 5, 8 and 9 (events, not commands).

@GreenGrassBlueOcean

Copy link
Copy Markdown
Contributor Author

CI on own-firmware-oracle#10 (the firmware oracle results above) is green.

@GreenGrassBlueOcean

GreenGrassBlueOcean commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor Author

Important

Correction, 2026-10-06 (after fedem95's and gdluck's reviews). Several statements below are wrong. None of them changes the code in this PR.

  1. Section 2 is retracted, and the MH200N column for fixes 1–6 means "not tested", not "gateway-specific". The MH200N's own cfg/stack_open.xml registers bt_energia (client_04) and bt_termo (client_05) as openserver clients. The oracle harness trims that list to the programs in the target spec (prepare_stack_open() in oracle/qemu_target.py), and oracle/targets/MH200N/010108.yaml starts only bt_luci and bt_device. Every WHO 4 and WHO 18 NACK in that column comes from the harness. A real MH200N on 1.1.8 answers *#18*51*51## … *#18*57*51## in three captures (MyHOME tests/fixtures/traces/issue_466/); the oracle NACKs exactly that frame. The same real gateway also relays WHO 4 bus frames.
  2. A NACK in this harness does not mean that no daemon handles the frame. The MH200N answered every WHO 25 command with a NACK and still put it on the bus. *0*1*31#4#01## got no reply at all and two bus frames.
  3. Fix 7: the PR doesn't clamp to 101. It caps at level 100 % (*#1*W*#1*200*S## for 120) and sends switch-off for 0. Level 100 is refused as a dimension 1 write, but it is the normal "off" value in a dimension 4 reply: see the golden capture *#1*13*4*100*2## (MyHOME#466) and gdluck's audit, section C. Evidence: MH200 (live F414), MH200N and MyHomeServer1 (both emulated).
  4. "This closes the Not covered items" does not hold. Fixes 4 and 5 are parser fixes: the replay sent the requests *#4*#0*20## and *#4*40*19##, not the emitted frames in the table. Fix 9 changes only WHATs outside 21–28 and 31/32, but the replay sent 21 and 23.
  5. The MyHomeServer1 column for fixes 2–6 and 8–10 has no published oracle run: own-firmware-oracle has only ownd-pr82-heating and ownd-pr82-lights for 028206. Those cells come from gdluck's sweeps (au1–au4, audit) and his real-gateway probe.
  6. Emulation: the MH200N target runs the programs under user-mode qemu-arm with kernel release 2.4.19, not under QEMU system emulation of Linux 2.6.28.

Empirical cross-validation of OWNd#77 across MH200N (010108) and MyHomeServer1 (028206)

Following the initial oracle run (own-firmware-oracle#10), an exhaustive cross-gateway empirical replay of all 10 OWNd#77 audit suites was executed against the MH200N gateway (010108, ARM9 / Linux 2.6.28) under QEMU system emulation.

All suites, raw TSVs, checks, and the hash-pinned index are published in own-firmware-oracle#11. This closes the "Not covered" items noted earlier. (It does not: see correction 4.)

1. Cross-Gateway Comparison Matrix (All 10 Fixes)

# Fix / Subsystem Stimulus Frame MyHomeServer1 (028206) MH200N (010108) Finding / Epistemic Status
1 Central heating mode *4*303*#0## (off), *4*1*#0## (heat), *4*0*#0## (cool), *4*311*#0## (auto) Emits bus frames; old modes (100, 101, 110) produce no frame NACK (openserver socket) Gateway-specific: MHS1 bt_termo translates; MH200N lacks bt_termo registration.
2 Zone fan speed *#4*1*#11*0..3## vs *#4*#1*#11*1## / *#4*1*#11*4## Plain zone accepted; central addresses & speed 4 rejected NACK (openserver socket) Gateway-specific: MHS1 validates speed 0..3 on plain zones.
3 Standalone auto *4*311*1## vs *4*311*#1## Plain zone produces no frame; central zone does NACK (openserver socket) Gateway-specific: Returning None prevents silent drops.
4 Actuator fallback *#4*#0*20*0## (dim 20 without #actuator) Accepted / emitted on bus (141 messages) NACK (openserver socket) Gateway-specific: Fallback to actuator 1 prevents unhandled IndexError.
5 Valve empty field *#4*40*19**0##, *#4*40*19*1*## Emitted on bus / handled gracefully NACK (openserver socket) Gateway-specific: Skipping unparsed tokens prevents ValueError.
6 Energy params *#18*51*511#10#1##, *#18*51*52#26#9## Accepted / emitted by bt_energia NACK (openserver socket) Gateway-specific: Accepts #param syntax on MHS1.
7 Brightness 0 & level 100 *#1*31*#1*100*0## vs *#1*31*#1*101*0## & *1*0*31## Level 100 silent; level 101 & switch-off emit bus frames Level 100 NACKed; level 101 & switch-off ACKed + bus frame Universal: Level 100 is strictly invalid. Clamping to 101 and routing 0 to switch-off holds across all gateways. Confirmed on MH200 (live), MH200N and MyHomeServer1 (emulated) for dimension 1 writes; see correction 3.
8 Interface routing *0*1*31#4#01## vs *0*1*31#4#02##
*14*0*31#4#01## vs *14*0*31#4#02##
Interface byte routed to SCS telegram $06EC01... vs $06EC02... emitted to bus Universal: Routing byte at 5th character position is preserved across gateway generations.
9 CEN+ parsing *25*21#0*WHERE## (start), *25*23#0*WHERE## (release) Emits distinctive CEN+ telegrams Parsed by bt_luci and emitted to bus Universal: CEN+ frames are distinct from dry contact toggles (31/32).
10 Matrix refused frames 11 legacy matrix errata (*2*1*21#4#1##, *#1*1*#1*20##, etc.) Universally silent / rejected All 11 frames receive socket NACK and zero bus emission Universal: Corroborates removal of legacy syntax errors from conformance docs.

2. Architectural Root Cause of Thermoregulation & Energy Divergence (retracted: see correction 1)

The MH200N's NACK on Fixes 1–6 is not a protocol defect or frame malformation. It is governed by OpenWebNet daemon registration in stack_open.xml:

  • MH200N (010108):
    • bt_luci: registers WHO 0, 1, 2, 14, 15, 25, 1000
    • bt_device: registers WHO 13, 1001, 1004, 1005, 1008, 1013, 1018, 1022, 1027
    • Thermoregulation (bt_termo) and Energy (bt_energia) exist as binaries in /usr/bin/ but are not registered as active OpenWebNet clients with openserver in default firmware configuration. Standalone *4...## and *18...## frames sent over the OpenWebNet socket are immediately NACKed by openserver because no listening client claims them.
  • MyHomeServer1 (028206):
    • Modular multi-daemon architecture where bt_termo (WHO 4) and bt_energia (WHO 18) are registered active clients of openserver.

This confirms that OWNd#77's heating and energy builder fixes conform to the firmware implementations of gateways that support those subsystems, rather than being invalid syntax.


3. Hash-Pinned Oracle Index (results/mcp_index.json)

All empirical observations from both gateway firmwares have been compiled into results/mcp_index.json in own-firmware-oracle#11.

  • Covers 89 unique input frames across 14 test suites.
  • Pins binary image SHA-256 (MH200N: e32d3404..., MyHomeServer1: a318182d...), suite SHA-256, and relative TSV line provenance.
  • Provides an automated, tamper-evident reference for downstream consumption by openwebnet-mcp and CI conformance gates.

@fedem95 fedem95 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for running the MH200N image. A few things in this comment don't match the data:

  1. Section 2 (MH200N NACKs WHO 4 / WHO 18 because nothing handles them). For WHO 18 the real MH200N on 1.1.8 says otherwise: in MyHOME tests/fixtures/traces/issue_466/myhome_sweep_MH200N_all_2026-09-25T14-40-10.json (caiosweet's plant), each *#18*5x*51## request (51–57) gets its *#18*5x*51*VALUE## reply within ~150–400 ms. For WHO 4 the same trace only shows the gateway relaying bus events (zone temperatures, *#4*Z#1*20*0##); the *#4*0## request has no reply in the trace, and there are no ACK/NACK records, so it says nothing either way about WHO 4 requests or commands. Please limit section 2 to what the emulator showed, so nobody drops WHO 18 (or WHO 4) from the MH200N profile based on it.
  2. Fix 7 row. The PR doesn't clamp to 101: it caps at level 100 (*#1*31*#1*200*0## for 120) and sends switch-off for 0. "Universal" rests on two emulated images plus one live F414 on an MH200; I'd say "confirmed on MH200 (live), MH200N and MyHomeServer1 (emulated)".
  3. Fixes 4, 5, 9. 4 and 5 change how OWNd parses frames the gateway emits, so a NACK when sending them as commands doesn't test them. Row 9 tests WHAT 21/23 (CEN+), but the code change only affects WHAT outside 21–28 and 31/32. So "this closes the Not covered items" doesn't hold for 4, 5 and 9, nor for 1–6 on the MH200N, which all got NACK.

None of this changes the code in this PR: run 37348391705 on 6038ceb passes both conformance jobs. I'd like the description to stop citing the daemon analysis as evidence before this is merged.

@gdluck

gdluck commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

@gdluck

gdluck commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

OWNd PR #82 reviewed against libqtdevices (TS10_1_0_23) and the mhs1 firmware audit

Reviewed 2026-10-06. PR head 6038ceb (3 commits over master f7894c4), state open, author GreenGrassBlueOcean.
Sources: git clone of OWNd (branch pull/82/head), OpenWebNet-HA/libqtdevices tag TS10_1_0_23, MyHOME v2-phase1-architecture @ 0f7b0de, and AUDIT_MyHOME_OWNd.md in this project. Full test suite of the PR run locally: 1198 passed, 2 skipped.

Legend: FW = firmware sweep result recorded in the audit; TS10 = BTicino's own touch-screen client library (libqtdevices); deduction = my inference, not a quoted fact.

1. Fix by fix

Fix PR change Audit item TS10 says Verdict
1 central mode WHAT set_central_mode: off 303, heat 1, cool 0, auto 311, antifreeze 102, protection 202 (OWNd/message/heating.py:719-726) A1 thermal_device.cpp:101-119: setOff → GENERIC_OFF 303, setProtection → GENERIC_PROTECTION 302, setSummer → SUMMER 0, setWinter → WINTER 1; tests test_thermal_device.cpp:44-70 expect *4*303*#0##, *4*0*…, *4*1*…, *4*302*… for the 99-zone central (THERMAL_DEVICE_99Z_WHERE = "0", test_thermal_device.h:28) Correct for off/heat/cool. Two doubts, see §2.1 and §2.2
2 fan speed plain zone only, 1..99, speed 0..3, #0/#0#N raise ValueError (heating.py:682-713) A2 probe_device.cpp:153-161 setFancoilSpeed: *#4*<simple_where>*#11*<speed>## where simple_where is the probe address without # (probe_device.h:158); test expects *#4*23*#11*3## even for a central-controlled zone #23#1 (test_probe_device.cpp:111,137) Correct, matches TS10 and FW. Behavioural note in §2.3
3 AUTO on plain zone set_mode(..., AUTO) returns None when the zone has no # (heating.py:623-626) A3 probe_device.cpp:131-135 setAutomatic: createCommandFrame(who, 311, where) with where = "#" + zone (ctor line 105); test expects *4*311*#23#1## Correct direction. Returning None silently drops the user's request; see §2.4
4 dim 20 IndexError guard + "1" fallback (heating.py:406-414) A4 not covered by TS10 (no dimension 20 handling in probe_device.cpp) Correct; *#4*#0*20*0## now parses as hvac_action (checked locally)
5 dim 19 empty field requires two non-empty values (heating.py:342-346) A5 not covered by TS10 Correct; *#4*40*19**0## no longer raises, type None
6 _DIMENSION_REQUEST params regex gains dimension_param (base.py:76,149) A6 n/a Correct; *#18*51*52#26#9## parses as OWNEnergyCommand
7 brightness ≤ 0 routes to switch_off, clamps to 101..200 (lighting.py:415-420) A7 lighting_device.cpp dimmer100 uses WHAT 2..10 commands, not #1 writes, so TS10 is silent on level 100 Correct per FW (A7: 100 refused)
8 #4#NN for WHO 0/14 interface property for [0,1,2,14,15] (base.py:235) B "0, 14" n/a Correct per FW
9 WHO 25 non-31/32 falls back to OWNEvent (cen.py:283-284) B "25" n/a Correct; it no longer mislabels *25*12*131## as a dry contact
10 matrix examples two-digit interface, #11#001, 52#26#9, CEN 73/0110, CEN+ 21#1 (docs/protocol_conformance_matrix.md:29-37) A8 n/a Correct per FW
B1 dropped commit 6038ceb reverts the WHO 16 removal from MyHomeServer1Profile A9 n/a Reasonable: A9 was marked bus level in the audit because openserver routing was not emulated

2. Doubts, checked against probe_device.cpp

2.1 "protection": 202 is the summer-only code; TS10 uses 302

probe_device.cpp:52-54 names three protection codes: PROT_ANTIFREEZE = 102, PROT_THERMAL = 202, PROT_GENERIC = 302. ControlledProbeDevice::setProtection() (probe_device.cpp:141-144) and ThermalDevice::setProtection() (thermal_device.cpp:106-109) both send 302, the generic one, never 202. The PR maps "protection" to 202 (heating.py:725), which is the cooling-season thermal protection.

FW evidence: the audit lists 102/202/302 as all accepted by the central (A1 firmware column), so 202 is not refused, but it forces the plant into summer protection. BTicino's own client sends 302 and lets the central keep its season. Recommendation: "protection": 302 (and arguably "antifreeze" should also be 302 unless the caller explicitly wants a season). MyHOME never calls set_central_mode with "antifreeze"/"protection" (climate.py:506-511 maps only off/heat/cool/auto), so this is latent, not user-visible today.

2.2 heat/cool = 1/0 is season selection, not a mode

In TS10 the enum names are SUMMER = 0, WINTER = 1 (thermal_device.cpp:37-38), and the probe parser treats CONDITIONING/HEATING command frames as "central is in AUTO unless we know better" (probe_device.cpp:238-250). The OWNd parser already reads 1 → heat, 0 → cool (heating.py:155,166), so builder and parser now agree, which was the point of A1. The PR fixture notes say "The judge warns that the WHAT is not standard" (tests/golden/frames/who04_thermo.yaml:86). TS10 settles it: 0 and 1 are the codes BTicino's own UI sends. No change needed, but the fixture notes could cite thermal_device.cpp:112-119 instead of hedging.

2.3 Fix 2 turns a silent drop into an exception that MyHOME does not catch

MyHOME climate.py:486-491 calls set_fan_speed(where=self._where, speed=..., standalone=self._standalone) without a try. With the PR, a central entity (_where #0 or #0#1) whose fan mode was enabled (_enable_fan_mode is called on restore if the saved state had fan_mode, climate.py:391-393) will raise ValueError inside the HA service call. Before the PR the frame was built and silently refused by the gateway (A2). Deduction: this is strictly better (the user sees an error instead of nothing) but MyHOME should catch it, which belongs in the downstream PR the author mentions ("two expected MyHOME conformance failures").

standalone is now ignored (heating.py:685 docstring). TS10 confirms it never mattered for dimension 11: setFancoilSpeed always uses the plain address regardless of the central type (probe_device.cpp:153-161).

2.4 Fix 3 drops AUTO silently for a standalone zone

TS10 has no notion of a "standalone" probe sending 311; setAutomatic is only on ControlledProbeDevice and always targets #zone (probe_device.cpp:131-135). So the PR's rule "no #, no AUTO" is right. But set_mode returns None and MyHOME's async_set_hvac_mode only does if cmd is not None: send (climate.py:538-545), so a standalone zone with AUTO in its mode list gets no command and no error, while _attr_hvac_mode is not updated either. Deduction: either (a) build *4*311*#Z## regardless of standalone (the frame works on the FW, A3 sweep: *4*311*#1## → bus frame), or (b) MyHOME should not offer AUTO for standalone zones. (a) is the smaller change and matches TS10.

2.5 Fix 1 not applied to set_mode's #0#N path

set_mode (heating.py:601-632) still only knows OFF 303 and AUTO 311. TS10's ThermalDevice4Zones uses WHERE 1#2-style addresses (test_thermal_device.h:27) and sends 0/1/302/303 to them. Not a defect in the PR (it did not claim to cover it), listed for completeness.

2.6 Things the reviewer fedem95 raised that this review confirms

  • Fixes 4, 5, 9 are parser fixes; a NACK to the same text sent as a command proves nothing about them. The PR's new tests do test them as events (test_issue_77_firmware_findings.py:158-200, 291-340), so the code is covered even if the PR description's "closes the Not covered items" wording is loose.
  • Fix 7 "caps at 100" is accurate: min(int(_level), 100) + 100 (lighting.py:420).

3. Cross-check of the PR's test sources

tests/test_issue_77_firmware_findings.py cites au1-au4.tsv and the OWNd#77 README for fixes 1-3, 7, 10; those match the audit's A1/A2/A3/A7/A8 rows. The four golden fixtures changed from source: legrand-spec to source: encyclopedia (who04_thermo.yaml:78-170); TS10 would be a stronger citation for 0/1/303 than the Encyclopedia table.

4. What I did not verify

  • I did not run the firmware emulator for this review; FW claims above are quoted from AUDIT_MyHOME_OWNd.md (2026-10-02).
  • I did not read the PR description's "empirical cross-validation" table in full (GitHub API access to the repo is not enabled in this session; the diff and comments came via git clone and a page fetch).
  • MH200N behaviour (fedem95's WHO 18 point) is outside what I can check.

GreenGrassBlueOcean and others added 3 commits October 6, 2026 09:59
…devices

"protection" mapped to 202, the summer protection, which also switches the
plant to summer. BTicino's touch-screen client (libqtdevices TS10_1_0_23,
thermal_device.cpp and test/test_thermal_device.cpp) sends 302, generic
protection, to the central unit; its enum names 102 winter, 202 summer and
302 generic protection. gdluck's sweep lists 302 among the central WHATs the
firmware forwards (AUDIT A1). "antifreeze" stays 102, the winter protection
that the MyHomeServer1 captures in the golden corpus report as antifreeze.

The golden notes for the 99-zone central off/heat/cool fixtures now cite the
libqtdevices test that sends the same frames (gdluck's review, 2.1 and 2.2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ng None

set_mode(AUTO, standalone=True) returned None, so MyHOME sent nothing and
logged nothing. MyHOME flags every zone that is not a central unit standalone
by default (climate.py: CONF_STANDALONE defaults to "not central"), so this
dropped AUTO for zones that do run under a central unit, where *4*311*#Z##
works. The firmware forwards 311 only in the # form (AUDIT A3) and BTicino's
client sends that form (libqtdevices ControlledProbeDevice::setAutomatic,
*4*311*#23#1##), so AUTO now ignores standalone. OFF keeps following it:
*4*303*Z## works in both forms.

gdluck's review, option (a) of 2.4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@GreenGrassBlueOcean

Copy link
Copy Markdown
Contributor Author

Thanks @fedem95 and @gdluck. Changes in 2bc6c32, 3014896 and 885b8e4:

gdluck

  • 2.1: "protection" is now 302. libqtdevices sends *4*302*#0## (thermal_device.cpp, test_thermal_device.cpp), and its enum names 102 winter, 202 summer and 302 generic protection. 302 is also in your A1 list of central WHATs the firmware forwards. "antifreeze" stays 102: that is what the MyHomeServer1 captures in the golden corpus report as antifreeze (*4*102*2##).
  • 2.2: the 99-zone off/heat/cool fixture notes now cite the libqtdevices test that sends the same frame. The 4-zone note says that libqtdevices addresses that central as #1#2, so it does not cover #0#1.
  • 2.3: MyHOME now turns the ValueError into a service error: fix(climate): turn OWNd's fan-speed ValueError into a service error (OWNd#77) MyHOME#641.
  • 2.4: I went with your option (a). set_mode(AUTO) now always builds *4*311*#Z##. Option (b) would have done harm: MyHOME defaults every zone that is not a central unit to standalone=True, so hiding AUTO for standalone zones would remove it from zones that do run under a central unit.
  • 2.5: left out on purpose. It is noted for a follow-up.

fedem95: all three points hold, and I've corrected the cross-validation comment in place (see the correction at the top). The PR description no longer cites the daemon analysis. Point 1 goes further than the trace shows: the MH200N's own cfg/stack_open.xml registers bt_energia and bt_termo as openserver clients, but the oracle harness drops every client that isn't in the target spec, and the MH200N target runs only bt_luci and bt_device. So every WHO 4 and WHO 18 NACK in own-firmware-oracle#11 comes from the harness. That PR needs a re-run with those two programs before its MH200N rows mean anything.

Golden corpus versus the firmware data. I checked the fixtures against gdluck's sweeps and probe, the oracle results and the MyHOME traces. The firmware data contradicts none of the 48 plant captures, though many of them (alarm, dimension 7, covers behind an interface) it doesn't cover at all. The CEN+ capture *25*21#1*21## even gives the same bus bytes (04B2019001) on the MyHomeServer1 and the MH200N. Six fixtures that come from documents do disagree with the firmware data. These are for a separate PR, not this one:

  • *4*110*1## "antifreeze" and *4*111*1## "protection": the firmware drops 110 and treats 111 as automatic heating, and libqtdevices names them WIN_MANUAL and WIN_AUTOMATIC. The corpus's own captures show antifreeze as *4*102*Z##.
  • *2*1*21#4#1##: a one-digit interface. au2 gets a NACK, the MH200N oracle gets a NACK, and every interface capture uses two digits.
  • *25*31#1*15##: on four gateways (MH200N, MH202, F454, H4890), every real dry contact has a WHERE starting with 3 (33, 331–339), which matches gdluck's rule.
  • *#4*#0*14##: refused by gdluck's real MyHomeServer1 and by an F454 (MyHOME#629).
  • *15*01#3*0001##: bt_luci refuses WHERE 0001, but the spec allows [00][01-15]. The spec and the firmware disagree here; no capture exists either way.

@fedem95

fedem95 commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

Re-ran the cross-check myself, since the dispatched run (37348391705) is on 6038ceb. MyHOME #630 (b23cb67) suite against this PR at 885b8e4, same pytest invocation as the conformance job but run locally (macOS, Python 3.14, HA 2026.9.2, OWNd taken from the PR tree via PYTHONPATH rather than pip-installed): 3380 passed, 2 failed:

  • tests/test_component_climate.py::test_climate_properties_and_hvac_modes (line 161): expects async_set_hvac_mode(AUTO) on a standalone zone to send nothing.
  • tests/test_component_climate.py::test_climate_central_fan_mode_guard_and_warnings (line 723): same, plus the "command dropped" warning.

Since 3014896, set_mode(AUTO) always returns a command (*4*311*#1## here). With 6038ceb both tests pass. In #630 the else branch that logs the warning can no longer run, so its tests and that branch need updating, unlike what #641 says ("MyHOME needs no change").

The rest checks out on 885b8e4: OWNd suite 1196 passed (test_metadata excluded, my local env has an old OWNd installed), protection → *4*302*#0##, antifreeze → *4*102*#0##, brightness 0 → *1*0*31##, 120 → *#1*31*#1*200*0##; MyHOME golden conformance 558 passed. Happy to approve once #630 is updated and the conformance run is re-dispatched on the current head with myhome_ref pointing at it.

@GreenGrassBlueOcean

Copy link
Copy Markdown
Contributor Author

@fedem95, thanks for re-running it. Both points are done:

  • MyHOME#630 now expects AUTO on a standalone zone to be sent as *4*311*#Z##. The dead "command dropped" branch is gone, and de8cf4b3 puts the None check back before sending: set_mode is still typed OWNHeatingCommand | None, so leaving it out is an arg-type error under mypy --strict. You were right that #641's "MyHOME needs no change" was wrong for #630's tests.
  • Conformance re-dispatched on the current heads: run 37518042220 checked out OWNd 885b8e4 and MyHOME de8cf4b3 (myhome_ref=fix/issue-77-climate-protocol-alignment). Both jobs passed: MyHOME suite 3476 passed, golden corpus 570 passed.

From the separate audit of this PR, I've changed the description only, with no push, so this head and the run above stay valid. It no longer says that antifreeze and protection read back as themselves: the parser has always read 102/302 as off, and the description now says so and explains why that is kept. The smaller audit items (#Z#N fan zone parsing, logging the capped brightness, the WHO 25 command path) will be a follow-up PR after this merges, so they don't reset the review.

#630 waits for OWNd 2.0.0b10 on PyPI. The pin bump is ready to push as soon as it is published.

@fedem95 fedem95 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. Checked against MyHOME #630 at de8cf4b3 locally (OWNd from this PR at 885b8e4 via PYTHONPATH, macOS, Python 3.14, HA 2026.9.2): full suite 3475 passed, 1 skipped, 0 failed; golden conformance 569 passed, 1 skipped. #630 now sends AUTO for standalone zones and the "command dropped" branch is gone. Conformance runs 37513598943 and 37518042220 on 885b8e4 both passed.

Also confirmed the parser reads *4*102*#0## and *4*302*#0## as off, as the updated description says. Fine with merging over the two required MyHOME checks given the cross-run, then b10 and #630.

@GreenGrassBlueOcean
GreenGrassBlueOcean merged commit 00dcf87 into master Oct 6, 2026
16 of 20 checks passed
@GreenGrassBlueOcean
GreenGrassBlueOcean deleted the fix/issue-77-gateway-protocol-defects branch October 6, 2026 20:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Suggestions

4 participants