Skip to content

feat(sound): add WHO 16 media transport commands and empirical conformance fixtures - #95

Merged
GreenGrassBlueOcean merged 3 commits into
OpenWebNet-HA:masterfrom
GreenGrassBlueOcean:feat/who16-sound-source-transport
Oct 9, 2026
Merged

GreenGrassBlueOcean merged 3 commits into
OpenWebNet-HA:masterfrom
GreenGrassBlueOcean:feat/who16-sound-source-transport

Conversation

@GreenGrassBlueOcean

@GreenGrassBlueOcean GreenGrassBlueOcean commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

WHO 16 Media Source Transport & Tuner Control

Adds command builders and event decoders for WHO 16 (Sound System) source transport, tuner frequency, presets and RDS text.


1. What is the issue?

Until now OWNd only built and parsed zone power and volume for WHO 16 (*16*1*where##, *16*10*where##, *#16*where*#1*vol##). It had nothing for source transport or the FM tuner:

  1. No builders for source and tuner commands:

    • Station/track step forward (*16*600N*where##) and back (*16*610N*where##), 1..15 steps.
    • Tuner seek up (*16*5000*where##) and down (*16*5100*where##).
    • Preset/track write and query (Dimension 7: *#16*where*#7*N## / *#16*where*7##).
    • Frequency write and query in kHz (Dimension 6: *#16*where*#6*0*F## / *#16*where*6##).
    • RDS start/stop and query (*16*101*where##, *16*102*where##, *#16*where*8##).
  2. Source events not decoded:

    • WHAT 100 (source busy), 5000/5100 and 6001..6015/6101..6115 were logged as unhandled states.
    • Dimension 6 (frequency), 7 (preset/track) and 8 (RDS text) were discarded.
    • Malformed Dimension 6/7/8 payloads (for example the 12-code RDS glitch frames) had no guard.

2. How is it fixed?

OWNd/message/sound.py (exported via OWNd/message/__init__.py):

  1. OWNSoundCommand factory methods:

    • next_track(where, step=1): *16*600N*where##, 1 <= step <= 15.
    • previous_track(where, step=1): *16*610N*where##, 1 <= step <= 15.
    • seek_up(where) / seek_down(where): *16*5000*where## / *16*5100*where##.
    • select_track(where, track): *#16*where*#7*N##, track >= 1 (tuner preset or media track).
    • request_track(where): *#16*where*7##.
    • set_frequency(where, kilohertz): *#16*where*#6*0*F##, kilohertz > 0. The value is sent without zero padding (96200, not 096200). See section 4.
    • request_frequency(where): *#16*where*6##.
    • start_rds(where) / stop_rds(where): *16*101*where## / *16*102*where##.
    • request_rds(where): *#16*where*8##.
  2. OWNSoundEvent properties:

    • is_source_busy: WHAT 100.
    • is_seek_up / is_seek_down: WHAT 5000 / 5100.
    • track_step_forward / track_step_backward: the step 1..15 from WHAT 6001..6015 / 6101..6115.
    • frequency_khz: Dimension 6 value in kHz (*#16*where*6*0*F##); None for non-positive or non-numeric values.
    • track: Dimension 7 preset/track (*#16*where*7*0*N##); None for non-positive or non-numeric values.
    • rds_text: Dimension 8, exactly 8 ASCII codes (32..126) to text; any other length, or non-printable codes, give None.
    • Malformed values give None without raising; out-of-range builder arguments raise ValueError.

WHO 16 frame summary

OpenWebNet frame Direction Attribute / factory method Notes
*16*6001*101## Event / Command track_step_forward=1 / OWNSoundCommand.next_track("101") step in 1..15
*16*6103*101## Event / Command track_step_backward=3 / OWNSoundCommand.previous_track("101", 3) step in 1..15
*16*5000*101## Event / Command is_seek_up=True / OWNSoundCommand.seek_up("101") Seek up
*16*5100*101## Event / Command is_seek_down=True / OWNSoundCommand.seek_down("101") Seek down
*16*100*101## Event is_source_busy=True Source busy
*#16*101*#7*4## Command OWNSoundCommand.select_track("101", 4) track >= 1
*#16*101*7*0*4## Event / Query track=4 / OWNSoundCommand.request_track("101") Report carries a leading 0
*#16*101*#6*0*96200## Command OWNSoundCommand.set_frequency("101", 96200) kHz, not zero-padded
*#16*101*6*0*96200## Event / Query frequency_khz=96200 / OWNSoundCommand.request_frequency("101") kHz
*16*101*101## / *16*102*101## Command OWNSoundCommand.start_rds("101") / stop_rds("101") RDS start / stop
*#16*101*8*75*82*79*78*69*72*73*84## Event / Query rds_text="KRONEHIT" / OWNSoundCommand.request_rds("101") Exactly 8 ASCII codes (32..126)

3. How is it tested?

tests/test_sound.py:

  • test_who16_sound_source_commands: every builder's frame. Range checks: step 0/16, track 0/negative, kilohertz 0/negative. set_frequency is tested at 107000 and at 96200, below 100 MHz, where padding would show.
  • test_who16_sound_source_events: busy (*16*100*101##), steps (*16*6001*101##, *16*6005*101##, *16*6101*101##), seek (*16*5000*101##, *16*5100*101##), frequency, preset and RDS text.
  • Malformed input: empty or short Dimension 6/7 payloads, non-positive or non-numeric values, RDS frames that are not 8 codes (5-code truncated, 12-code glitch), non-printable codes.

Results: 1264 passed, 1 skipped. OWNd/message/sound.py has 100% statement and branch coverage over the suite. ruff check and mypy are clean.


4. Evidence

Each code, with where its evidence comes from:

Code Live capture Specification Firmware emulation
6001, 6101 (step ±1) F500N, sent; tuner answers with new Dim 6/7/8 yes F454: nack, frame still put on the bus
6002..6015, 6102..6115 (step ±n) L4561N, sent only (6003, 6005, 6102, 6104); no reply yes F454: 6015, 6115 nack, still on the bus
5000, 5100 (seek) F500N, sent; tuner locks on the next/previous station yes F454: nack, still on the bus
100 (busy) none yes F454: nack, silent
101/102 (RDS start/stop) none yes F454: ack, on the bus
Dim 6 write/report (frequency) F500N: write *#16*101*#6*0*96200##, reports 88800..103500 yes F454: ack, on the bus (107000, 98500, 101500)
Dim 7 write/report (preset) F500N: write *#16*101*#7*3##, reports *7*0*1..*7*0*4; L4561N: writes 1/2/5, no reply yes F454: nack/out, translates *#16*101*#7*1..5 to SCS $0493018FC0..C4 (presets 1..5); *#7*0*N was an oracle test artifact that sent preset 0 ($0493018FCF) (own-firmware-oracle#30)
Dim 8 (RDS text) F500N: 8-code frames, plus 12-code glitch frames yes none

Sources:

  1. Live captures:

    • MH200N + F500N tuner (source 101), contributed in MyHOME#427: tests/fixtures/traces/f500_tuner/ in MyHOME. These carry the frequency, preset and RDS reports and the 12-code glitch frames.
    • MH200 2.0.32 + L4561N stereo control (source 102, zone 21), 2026-10-07: tests/fixtures/traces/mh200_l4561n_source/ (MyHOME#662), also in own-firmware-oracle#25. The capture is filtered on WHO 16 and WHERE 102/21, so ACK/NACK are missing. None of the commands changed what was reported, and the L4561N is an aux input, not a tuner. It only shows that these frames went out on a real plant.
  2. Specification: OpenWebNet_Community_4_soundsystem_v1_0_1_EN, v1.0.1 of 2011-11-24 (Legrand R&D; v1.0.0 BTicino, 2006). The Legrand URL is gone; the same file is at sxpert/myhomepy lgg-docs/WHO_16.pdf. WHAT table on pp. 4–5; FREQUENCY WRITE in §1.5.7 (p. 15). That section says "composed by 6 digits", and its only example is 107000, where padding changes nothing. It also says Hz where the value is kHz. The F500N capture shows the unpadded 96200 working, so set_frequency follows the capture.

  3. Firmware emulation: own-firmware-oracle#25 and own-firmware-oracle#30, F454 020051 only (results/F454/020051/oracle/full/sound-who16-source.tsv). It has no MyHomeServer1 rows. nack/out means the gateway answered NACK but still put a telegram on the bus.

Not used: libqtdevices media_device.cpp is WHO 22 only and has none of these codes.


5. Downstream Consumer Integration

This PR powers the Home Assistant integration MyHOME (OpenWebNet-HA/MyHOME#651), so MyHOMESoundSource entities can:

  • Use typed OWNSoundCommand factory methods for all media and tuner controls.
  • React to track_step_forward / track_step_backward events.

6. Release & Rollout Order

graph TD
    A["Maintainer approves & merges OWNd PR #95"] --> B["Merge OWNd PR #98 (bump to 2.0.0-b11)"]
    B --> C["Publish GitHub Release v2.0.0b11 -> PyPI"]
    C --> D["Re-run MyHOME PR #651 CI (all 16 checks turn green)"]
    D --> E["Merge MyHOME PR #651"]
Loading
  1. Merge OWNd feat(sound): add WHO 16 media transport commands and empirical conformance fixtures #95: Core WHO 16 transport command builders & event decoders.
  2. Merge OWNd chore(release): bump version to 2.0.0b11 #98: Bump library version to 2.0.0-b11.
  3. Publish Release v2.0.0b11: GitHub Actions builds and pushes the release wheel to PyPI.
  4. Re-run MyHOME #651 CI: Pinned dependency OWNd==2.0.0b11 resolves from PyPI, turning all matrix jobs green.
  5. Merge MyHOME #651: Integration is live with modernized sound source transport.

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

- Add OWNSoundCommand builders: next_track, previous_track, seek_up, seek_down, select_track, request_track, set_frequency, request_frequency, start_rds, stop_rds, request_rds
- Add OWNSoundEvent properties: frequency_khz, track, rds_text, is_source_busy, track_step_forward, track_step_backward, is_seek_up, is_seek_down
- Add unit test coverage in tests/test_sound.py (100% statement and branch coverage)
@GreenGrassBlueOcean
GreenGrassBlueOcean force-pushed the feat/who16-sound-source-transport branch from 75f9d47 to 78a96dc Compare October 8, 2026 08:47
- Validate track >= 1 in select_track and OWNSoundEvent with spec docstrings on station presets vs. CD tracks
- Validate kilohertz > 0 in set_frequency and OWNSoundEvent with usage docstrings
- Clarify 8-byte RDS PS constraint in OWNSoundEvent and add tests for short/long/blank RDS frames

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

Parsing checks out against the real F500N captures in MyHOME (tests/fixtures/traces/f500_tuner/, MyHOME#427): frequency (*#16*101*6*0*96200## → 96200), preset (*7*0*2 → 2) and RDS (KRONEHIT, Bayern 2, …) all decode, and the two 12-code glitch frames give None. select_track, next_track, previous_track, seek_up and seek_down build exactly the frames sent in those traces. Suite passes locally (1261), CI green.

set_frequency pads to 6 digits ({khz:06d}): set_frequency("101", 96200) builds *#16*101*#6*0*096200##. In myhome_trace_MH200N_f500n_tuner_seek_and_frequency.json the frame that worked is *#16*101*#6*0*96200## (tx 16:40:09.390, *#16*101*6*0*96200## back at 16:40:09.571). No capture or oracle row uses the padded form (oracle#25 also sends *#16*101*#6*0*98500##), and the test only covers 107000, where padding does nothing. Could it send the plain number?

Sources in the description don't match what they contain:

  • "live recordings from physical L4561N": findings/MyHomeServer1/sound-who22.md records a QEMU emulation of WHO 22; sound-who16-source.tsv exists only in open oracle#25, F454 emulation.
  • "ACKed and translated" on F454 and MyHomeServer1: that TSV has no MyHomeServer1 rows, and on the F454 *16*6001*, *16*6015*, *16*6101*, *16*6115*, *16*5000*, *16*5100* are nack / out.
  • "Official Legrand WHO 16 specification": developer.legrand.com/uploads/2019/12/WHO_16.pdf returns 404 (as you noted on #80). Which document is this?
  • libqtdevices: media_device.cpp is WHO 22 only (device("22", …); next/prev track WHAT 9/10, frequency up/down 5/6, RDS 31/32, frequency in dimension 5 as MHz×100). It has none of the WHO 16 codes here.

The F500N captures (MyHOME#427) are the real evidence for 6001/6101/5000/5100 and dimensions 6/7/8; I'd cite those. WHAT 100, RDS start/stop 101/102 and multi-step 6002–6015 have no capture that I could find.

set_frequency formatted the value as {khz:06d}, so 96.2 MHz became
*OpenWebNet-HA#16*101*OpenWebNet-HA#6*0*096200##. The F500N capture on an MH200N (MyHOME#427)
shows the plain *OpenWebNet-HA#16*101*OpenWebNet-HA#6*0*96200## accepted, and no capture or
oracle row uses the padded form. The "6 digits" wording in the WHO 16
spec only comes with a 107000 example, where padding changes nothing.

Adds a test below 100 MHz.

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

Copy link
Copy Markdown
Contributor Author

@fedem95 Thanks for checking this against the captures. You were right on every point. Both are fixed now.

Padding. set_frequency now sends the plain number (5856457), with a test at 96200. MyHOME had the same bug in async_set_frequency; it is fixed in OpenWebNet-HA/MyHOME#662. The 06d came from the spec's FREQUENCY WRITE section (§1.5.7, p. 15): "composed by 6 digits", with 107000 as the only example.

The document. It is OpenWebNet_Community_4_soundsystem_v1_0_1_EN, v1.0.1, 2011-11-24. The Legrand URL is gone; the same file (same git blob) is at https://github.com/sxpert/myhomepy/blob/master/lgg-docs/WHO_16.pdf. It is the only source for WHAT 100, 101/102 and the ±n steps.

Sources. The description now has an evidence table per code. The L4561N trace I meant is now in OpenWebNet-HA/MyHOME#662 (tests/fixtures/traces/mh200_l4561n_source/, replayed in CI) and in OpenWebNet-HA/own-firmware-oracle#25. It is weaker than I had described. It is filtered on WHO 16 and WHERE 102/21, so there is no ACK/NACK, and none of the commands changed the reported state. I cite it only as "sent on a real plant".

Reading the F454 TSV again: next to the nack/out rows you listed, the Dim 6 writes and 101/102 are ack, and 100 is nack/silent. Dim 7 was only replayed as *#7*0*N, not as the *#7*N this PR sends, so that one is still open.

@GreenGrassBlueOcean

Copy link
Copy Markdown
Contributor Author

Replayed *#16*101*#7*1##..5## against the F454 QEMU ARM firmware emulation (020051, bt_difson):

  • *#16*101*#7*1..5## yields nack / out, translating directly to sequential SCS bus telegrams $0493018FC0\r through $0493018FC4\r for presets 1 through 5 (0xC0 + (preset - 1)). The nack / out verdict on the command session is completely consistent with all other media transport and tuner commands on F454 (5000, 5100, 6001..6015, 6101..6115, #10*1..5).
  • The earlier *#7*0*N stimuli in oracle#25 were a test case authoring bug: bt_difson only expects 1 parameter (the preset index). When given *#7*0*N, it parsed the leading 0 as the preset number (0xC0 + (0 - 1) = 0xCF), emitting the corrupted preset-0 frame $0493018FCF\r across all 5 test rows and ignoring the second parameter.
  • The test suite and oracle fixtures have been corrected in fix(oracle): test canonical WHO 16 Dimension 7 *#7*N preset write and update F454 oracle own-firmware-oracle#30.

*#7*N is fully corroborated across all three evidence layers:

  1. Official specification: OpenWebNet_Community_4_soundsystem_v1_0_1_EN §1.5.8 (*#16*where*#7*parameter##, [1..5]).
  2. Live physical capture: myhome_trace_MH200N_f500n_tuner.json (line 176 *#16*101*#7*3## -> line 189 *#16*101*7*0*3##).
  3. Empirical gateway emulation: F454 translates *#7*1..5 -> $0493018FC0..C4.

The PR description evidence table has been updated accordingly; this open point is closed.

@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. set_frequency now sends the plain value (96200 → *#16*101*#6*0*96200##, tested), matching the F500N capture. Checked the cited document (sxpert/myhomepy/lgg-docs/WHO_16.pdf, "OpenWebNet_Community_4_soundsystem_v1_0_1_EN", v1.0.1, 11/24/2011): it defines WHAT 100 (source busy), 101/102 (RDS start/stop), 5000/5100, 6001–6015 / 6101–6115, the 6-digit frequency write with 107000, and the station write *#16*where*#7*parameter##. Oracle#30 confirms *#16*101*#7*1..5## on the emulated F454 (nack/out, presets 1..5 on the bus). Suite passes locally (1261, test_metadata excluded for my env), CI green.

Two notes:

  • Preset range. select_track accepts any track >= 1 (select_track("101", 6) builds *#16*101*#7*6##). The document gives parameter = [1–5] for the station write, and on manfredgittmaier-afk's F500N *#16*101*#7*6## and *#7*15## gave "no audible change and no reply on the bus" (MyHOME#427, comment 5848039616).
  • The fixture cited as a live capture. myhome_trace_MH200N_f500n_tuner.json has rounded timestamps (15:35:00.000000, .100000, .200000, …) and exported_at 15:37:15.000Z, the time of manfred's comment 5847535313. The frames come from his report, but the timing doesn't. The timed evidence for *#7*N is in his comment 5848039616: [18:39:55.175] TX *#16*101*#7*1##, [18:39:55.335] RX *#16*101*7*0*1##.

@GreenGrassBlueOcean
GreenGrassBlueOcean merged commit 4e39126 into OpenWebNet-HA:master Oct 9, 2026
8 checks passed
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