Skip to content

feat(lighting): auto-detect pre-09W29 F414 coarse-only dimmers via speed 240 (0xF0) and emit WHAT 2..10 presets - #134

Open
GreenGrassBlueOcean wants to merge 4 commits into
masterfrom
fix/f414-coarse-dimmer-detection
Open

GreenGrassBlueOcean wants to merge 4 commits into
masterfrom
fix/f414-coarse-dimmer-detection

Conversation

@GreenGrassBlueOcean

@GreenGrassBlueOcean GreenGrassBlueOcean commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

What is the problem

In OpenWebNet-HA/MyHOME#767, @wave68runner reported that dimming a BTicino F414 modular dimmer (WHERE=61 behind an MH200N gateway, firmware 1.0) from the Home Assistant UI did not change the live lamp output, whereas switching the dimmer ON/OFF and dimming from the iMyHome app worked normally:

  1. Two hardware/firmware generations under the same F414 commercial catalog reference (cut-over around batch 09W29):
    • Newer F414 (09W29 and later, e.g. WHERE=99 in MyHOME#466) and modern dimmers (F418U2, F416U1, F417U2): Report a real transition speed (2 at rest, 5 while actively ramping) in the second slot of WHO=1 DIMENSION 1 (*#1*99*1*150*5##) and execute extended 8-byte DIMENSION 1 writes (*#1*WHERE*#1*<101..200>*<SPEED>##) immediately on the live triac output.
    • Older F414 firmware (pre-09W29, e.g. WHERE=61 in MyHOME#767): Always reports 240 (0xF0 on the SCS bus, indicating an unimplemented transition-speed byte) in the second slot of DIMENSION 1 (*#1*61*1*130*240##).
  2. Legacy F414 behavior on DIMENSION 1 writes vs coarse WHAT 2..10 commands:
    • Extended DIMENSION 1 writes with SPEED = 0 (*#1*61*#1*150*0##, what OWNLightingCommand.set_brightness("61", 50, 0) previously emitted) are ignored completely by the actuator.
    • Extended DIMENSION 1 writes with SPEED != 0 (*#1*61*#1*150*1## or *#1*61*#1*150*255##) do not dim the live output while the lamp is ON — they only write 150 into the dimmer's stored turn-on preset register (taking effect only after an OFF *1*0*61## $\rightarrow$ ON *1*1*61## cycle).
    • Standard 4-byte coarse commands (*1*2*61## … *1*10*61##) actuate the live lamp immediately.
  3. Coarse WHAT 2..10 vs fine LEVEL100 (101..200) percentage mismatch:
    • Previously, OWNLightingEvent left brightness = None on coarse preset events (*1*2..10*WHERE##), causing downstream consumers to approximate brightness_preset * 10 (WHAT 5 -> 50%, WHAT 7 -> 70%) even though the physical dimmer and BTicino's libqtdevices (TS10_1_0_23) map WHAT 2..10 non-linearly (2->1%, 3->10%, 4->20%, 5->30%, 6->40%, 7->50%, 8->60%, 9->75%, 10->100%).

How is it solved

  1. Coarse-Only Dimmer Sentinel & Multi-Gateway Per-Plant Registry (OWNd/message/lighting.py, OWNd/message/base.py, OWNd/connection/event_session.py, OWNd/connection/command_session.py, OWNd/transport/serial.py, OWNd/message/__init__.py):
    • Added DIMMER_SPEED_COARSE_ONLY = 240 (0xF0).
    • Added is_coarse_dimmer_where(where, *, gateway=None), register_coarse_dimmer_where(where, is_coarse=True, *, gateway=None), and clear_coarse_dimmer_wheres(gateway=None) backed by _COARSE_DIMMER_BY_GATEWAY: dict[tuple[str | None, str], bool] keyed by (gateway_key, normalize_where(where)):
      • gateway accepts None, a gateway string key (MAC/serial, host, or serial port), or an OWNGateway object (.serial / .unique_id / .host / .address).
      • OWNMessage.parse(data, *, gateway=None), OWNEvent.parse(data, *, gateway=None), and OWNCommand.parse(data, *, gateway=None) bind the active gateway via a ContextVar (_PARSE_GATEWAY_CTX), and OWNEventSession.get_next(), OWNCommandSession._read_command_response(), and AsyncSerialTransport._process_frame() pass their gateway/port identifier automatically.
      • Multi-gateway / multi-plant isolation: If Plant A has a legacy pre-09W29 F414 at WHERE=61 (SPEED = 240) and Plant B has a modern F418U2 at WHERE=61 (SPEED = 2), Plant B's DIMENSION 1 replies (("gw_b", "61") = False) never overwrite or clear Plant A's ("gw_a", "61") = True entry; scoped lookups (gateway=gw_a vs gateway=gw_b) remain strictly isolated, while unscoped single-plant callers (gateway=None) resolve True when all plants that have reported WHERE agree it is coarse (bool(scoped) and all(scoped)).
      • If a legacy F414 at WHERE=61 on a given gateway is replaced by an F418U2 or post-09W29 F414 (SPEED = 2/5), its entry on that gateway is automatically updated to False.
  2. Canonical libqtdevices (TS10_1_0_23) Coarse $\leftrightarrow$ Fine Conversion Helpers & Strict Integer Validation (OWNd/message/lighting.py):
    • Added COARSE_PRESET_TO_PERCENT = {2: 1, 3: 10, 4: 20, 5: 30, 6: 40, 7: 50, 8: 60, 9: 75, 10: 100} and brightness_preset_to_percent(preset) (dimmerLevelTo100 in lighting_device.cpp).
    • Added OWNLightingEvent.brightness_preset_percent (int | None), and when a coarse preset event (*1*2..10*WHERE##) is parsed for a learned coarse-only dimmer address, populated event.brightness with brightness_preset_to_percent(self._state) so MyHOME maintains a consistent 1–100 % state without jumping between preset * 10 (70%) and DIMENSION 1 (50%).
    • Added percent_to_brightness_preset(level) implementing libqtdevices dimmer100LevelTo10 (lighting_device.cpp:240-245: <=0 -> 0, 1..4 -> 2, 5..15 -> 3, 16..25 -> 4, 26..35 -> 5, 36..45 -> 6, 46..53 -> 7, 54..67 -> 8, 68..85 -> 9, >=86 -> 10).
    • Hardened brightness_preset_to_percent(), percent_to_brightness_preset(), OWNLightingCommand.set_brightness_preset(), and OWNLightingCommand.set_brightness() to reject bool and non-integral/non-finite floats (50.9, 7.9, NaN, ±inf) with ValueError rather than silently truncating via int().
  3. Transparent Coarse Command Emission & Accessor Symmetry in OWNLightingCommand (OWNd/message/lighting.py):
    • Updated OWNLightingCommand.set_brightness(where, _level=30, _transition=0, *, coarse: bool | None = None, gateway: Any = None):
      • Resolves use_coarse = is_coarse_dimmer_where(where, gateway=gateway) if coarse is None else bool(coarse).
      • When use_coarse is True, emits *1*0*WHERE## for _level <= 0 (ignoring transition speed per DimmerDevice::setLevel100) and delegates 1..100% to cls.set_brightness_preset(where, percent_to_brightness_preset(int(_level))) (*1*2..10*WHERE##).
    • Added OWNLightingCommand.is_coarse_dimmer (bool), OWNLightingCommand.brightness_preset (int | None), and extended OWNLightingCommand.brightness to decode coarse WHAT 2..10 commands (brightness_preset_to_percent(self._what)) when built via set_brightness(..., coarse=True) or targeting a learned coarse dimmer, keeping command and event accessors symmetric.
  4. Documentation & Changelog (README.md, CHANGELOG.md, scripts/sync_documentation.py):
    • Updated WHO=1 catalog description in README.md / scripts/sync_documentation.py and added ## [Unreleased] notes in CHANGELOG.md.

How is it tested

  • tests/test_who1_ts10_builders.py:
    • test_coarse_dimmer_conversion_and_detection: verifies brightness_preset_to_percent() and percent_to_brightness_preset() across all threshold boundaries and invalid bool, non-integral float (7.9, 50.9), and non-finite float (NaN, ±inf) inputs; verifies automatic learning from *#1*61*1*130*240##, coarse command generation (*1*7*61##, *1*9*61##, *1*0*61##), OWNLightingCommand.is_coarse_dimmer / brightness / brightness_preset symmetry, explicit coarse=True/coarse=False overrides, private riser (61#4#01) isolation, automatic upgrade back to DIMENSION 1 (*#1*61*#1*150*0##) when *#1*61*1*200*2## is received, and multi-gateway isolation when two OWNGateway instances (gw_a with coarse F414 at 61 and gw_b with fine F418U2 at 61) share WHERE=61 in the same process.
  • tests/golden/frames/who01_lighting.yaml & tests/golden/corpus.json:
    • Added light.dim.reply.level.30.speed240.61 (*#1*61*1*130*240##) and light.cmd.preset.7.61 (*1*7*61##).
  • Companion MyHOME Trace Ingestion & Replay (OpenWebNet-HA/MyHOME#776):
    • Ingests @wave68runner's full 43-frame capture into MyHOME/tests/fixtures/traces/issue_767/myhome_trace_MH200N_all_2026-10-21T18-12-44.json and replays all 43 frames end-to-end in MyHOME/tests/test_issue_767_mh200n_f414_trace.py.
  • Full OWNd & MyHOME Suites:
    • OWNd/message/lighting.py at 100.0% statement and branch coverage (607 Stmts, 0 Miss, 200 Branch, 0 BrPart), 1902 passed across the full OWNd suite, mypy OWNd tests/typing (0 issues), ruff check clean, scripts/verify_library_standards.py and scripts/sync_documentation.py --check passing, and MyHOME lighting + trace test suites (353 passed) green.

State Diagram

stateDiagram-v2
    direction TB

    state "1. Per-Gateway Dimmer Capability Learning (OWNLightingEvent)" as Learning {
        [*] --> UnlearnedWhere : Default ((gw_key, where) not in _COARSE_DIMMER_BY_GATEWAY)
        UnlearnedWhere --> CoarseDimmer : Rx *#1*WHERE*1*LEVEL100*240##\n(transition == 240 / 0xF0)\nregister_coarse_dimmer_where(where, True, gateway=gw)
        UnlearnedWhere --> FineDimmer : Rx *#1*WHERE*1*LEVEL100*2..5##\n(transition != 240)\nregister_coarse_dimmer_where(where, False, gateway=gw)
        CoarseDimmer --> FineDimmer : Hardware replaced at WHERE -> Rx *#1*WHERE*1*LEVEL100*2##\n(transition != 240 sets (gw_key, where) = False)
        FineDimmer --> CoarseDimmer : Rx *#1*WHERE*1*LEVEL100*240##
    }

    state "2. Brightness Command Dispatch (OWNLightingCommand.set_brightness)" as Dispatch {
        [*] --> CheckLevel : set_brightness(where, level, transition, coarse=None, gateway=None)
        CheckLevel --> ResolveMode : use_coarse = is_coarse_dimmer_where(where, gateway=gateway) if coarse is None else coarse
        ResolveMode --> CoarseOff : use_coarse=True & level <= 0\n-> *1*0*WHERE##
        ResolveMode --> CoarsePreset : use_coarse=True & level 1..100\npercent_to_brightness_preset(level)\n-> *1*2..10*WHERE##
        ResolveMode --> FineOff : use_coarse=False & level <= 0\n-> *1*0*WHERE## or *1*0#SPEED*WHERE##
        ResolveMode --> FineDim1 : use_coarse=False & level 1..100\n-> *#1*WHERE*#1*(100+level)*SPEED##
        CoarseOff --> [*]
        CoarsePreset --> [*]
        FineOff --> [*]
        FineDim1 --> [*]
    }
Loading

Where are the traces

Subsystem Real Community Capture Files Contributor & Hardware Plant Captured Wire Frames Verified by This PR
WHO=1 Legacy Pre-09W29 BTicino F414 Dimmer (SPEED=240) MyHOME/tests/fixtures/traces/issue_767/myhome_trace_MH200N_all_2026-10-21T18-12-44.json (OpenWebNet-HA/MyHOME#776 / MyHOME#767) & tests/golden/frames/who01_lighting.yaml @wave68runner (MyHOME#767) — BTicino MH200N (FW 1.0, WHO=13 code 44) + legacy BTicino F414 dimmer (WHERE 61) *1*1000#30*61## $\rightarrow$ *1*10*61##, *1*1000#31*61## $\rightarrow$ *1*9*61##, *1*8*61##, *1*7*61##, *1*6*61##, *1*5*61## (frames[16..24]), *1*1*61## $\rightarrow$ *1*5*61## (frames[12..13]), *#1*61*1## $\rightarrow$ *#1*61*1*130*240## (30%, SPEED=240, frames[14..15]), *#1*61*1*178*240##, *#1*61*1*146*240##, *#1*61*1*181*240##, *#1*61*1*140*240## (frames[35..41])
WHO=1 Post-09W29 BTicino F414 (SPEED=2/5) & F418U2 Comparison MyHOME/tests/fixtures/traces/issue_466/myhome_trace_MH200_f414_dimmer_2026-09-26T21-59-00.json & myhome_trace_MH200_f418u2_dimmer_2026-09-27T12-15-00.json (OpenWebNet-HA/MyHOME#466) @GreenGrassBlueOcean (MyHOME#466) — BTicino MH200 (FW 2.1.0) + BTicino F414 (WHERE 99) & BTicino F418U2 (WHERE 62) *#1*99*1## $\rightarrow$ *#1*99*1*200*2##, *1*9*99## $\rightarrow$ *#1*99*1*174*2##, *1*8*99## $\rightarrow$ *#1*99*1*163*2##, *#1*99*#1*150*0## $\rightarrow$ *#1*99*1*150*5## $\rightarrow$ *1*7*99##

What does the Oracle / Encyclopedia say

  1. OpenWebNet Encyclopedia (functional/who-1-lighting/what.md, functional/who-1-lighting/dimensions.md, devices/definitions/own-dev-0025-din-dimmer-1000-w.md):
    • Coarse vs Fine Setter & Cache Tables (libqtdevices TS10_1_0_23, lighting_device.cpp:240-245):
      • DimmerDevice (coarse 10-step dimmer) converts WHAT 2..10 to cached fine levels 1, 10, 20, 30, 40, 50, 60, 75, 100 (dimmerLevelTo100), matching the empirical DIMENSION 1 readbacks on both @wave68runner's F414 (WHAT 5 -> 130, WHAT 7 -> 150) and @GreenGrassBlueOcean's F414/F418U2 (WHAT 3 -> 110, WHAT 5 -> 130, WHAT 7 -> 150, WHAT 8 -> 163, WHAT 9 -> 174, WHAT 10 -> 200).
      • Its inverse setter (dimmer100LevelTo10) ignores the transition speed argument and maps requested fine percentages 0..100 to WHAT 0 (0%) and WHAT 2..10 (1..4 -> 2, 5..15 -> 3, 16..25 -> 4, 26..35 -> 5, 36..45 -> 6, 46..53 -> 7, 54..67 -> 8, 68..85 -> 9, 86..100 -> 10).
    • Hardware/Firmware Generation Cut-Over & bt_luci Disassembly (own-dev-0025-din-dimmer-1000-w.md, functional/who-1-lighting/dimensions.md):
      • F414 (DIN dimmer 1000 W) underwent a hardware/firmware revision around batch 09W29; pre-09W29 units fill the SPEED byte of the extended SCS 0x0D status telegram (18 B8 <WHERE> 01 44 0D 0D <LEVEL_HEX> <SPEED_HEX>) with 0xF0 (240 in *#1*WHERE*1*LEVEL100*SPEED##) and only actuate live output on 4-byte WHAT 2..10 ($03<WW>0012<WHAT-1>D) / 30 / 31 commands.
  2. Firmware Oracle (tests/golden/firmware_oracle.json):
    • Across the emulated lighting gateways (F453 010100, F453AV 030014, F454 020051, F455 020022, F459 020105, F460 010023, F461 010104, H4684 020054, MH200 020102, MH200N 010108, MyHomeServer1 028206), both coarse preset commands (*1*3*31##) and fine DIMENSION 1 write commands (*#1*31*#1*101*0##, *#1*31*#1*150*5##, *#1*31*#1*200*0##) produce verdict: "out", reply: "ack", confirming that the IP gateway forwards both forms onto the SCS bus and that the distinction between live coarse actuation and DIMENSION 1 actuation resides in the downstream F414 actuator firmware (SPEED = 240 vs SPEED = 2/5).

@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!

@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.

Looks good. The legacy dimmer detection, per-gateway isolation, and coarse-to-fine brightness mapping are covered, and all CI checks are passing. Approved.

This branch has not been deployed

No deployments
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.

3 participants