iobroker.yamaha 1.6.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +39 -24
  2. package/build/lib/browse/xml-browse-driver.js +17 -6
  3. package/build/lib/browse/xml-browse-driver.js.map +2 -2
  4. package/build/lib/browse/yxc-browse-driver.js +0 -4
  5. package/build/lib/browse/yxc-browse-driver.js.map +2 -2
  6. package/build/lib/catalog/groups.js +1 -1
  7. package/build/lib/catalog/groups.js.map +2 -2
  8. package/build/lib/catalog/object-tree-coordinator.js +10 -1
  9. package/build/lib/catalog/object-tree-coordinator.js.map +2 -2
  10. package/build/lib/catalog/owner-policy.js +13 -2
  11. package/build/lib/catalog/owner-policy.js.map +2 -2
  12. package/build/lib/catalog/scene-titles.js +81 -0
  13. package/build/lib/catalog/scene-titles.js.map +7 -0
  14. package/build/lib/catalog/types.js +3 -1
  15. package/build/lib/catalog/types.js.map +2 -2
  16. package/build/lib/device-controller.js +418 -22
  17. package/build/lib/device-controller.js.map +2 -2
  18. package/build/lib/lifecycle/probe-memory.js +38 -0
  19. package/build/lib/lifecycle/probe-memory.js.map +2 -2
  20. package/build/lib/pure-helpers.js +82 -3
  21. package/build/lib/pure-helpers.js.map +2 -2
  22. package/build/lib/xml/catalog.js +6 -8
  23. package/build/lib/xml/catalog.js.map +2 -2
  24. package/build/lib/xml/device-controller.js +256 -8
  25. package/build/lib/xml/device-controller.js.map +2 -2
  26. package/build/lib/xml/protocol.js +74 -2
  27. package/build/lib/xml/protocol.js.map +2 -2
  28. package/build/lib/xml/xml-client.js +15 -5
  29. package/build/lib/xml/xml-client.js.map +2 -2
  30. package/build/lib/ynca/catalog.js +222 -47
  31. package/build/lib/ynca/catalog.js.map +2 -2
  32. package/build/lib/ynca/ynca-client.js +26 -1
  33. package/build/lib/ynca/ynca-client.js.map +2 -2
  34. package/build/lib/yxc/capability.js +5 -2
  35. package/build/lib/yxc/capability.js.map +2 -2
  36. package/build/lib/yxc/catalog.js +4 -4
  37. package/build/lib/yxc/catalog.js.map +1 -1
  38. package/build/lib/yxc/command-mapper.js +109 -33
  39. package/build/lib/yxc/command-mapper.js.map +2 -2
  40. package/build/lib/yxc/device-controller.js +270 -24
  41. package/build/lib/yxc/device-controller.js.map +2 -2
  42. package/build/lib/yxc/http-client.js +65 -3
  43. package/build/lib/yxc/http-client.js.map +2 -2
  44. package/build/lib/yxc/object-mapper.js +100 -11
  45. package/build/lib/yxc/object-mapper.js.map +2 -2
  46. package/build/main.js +129 -26
  47. package/build/main.js.map +2 -2
  48. package/io-package.json +68 -28
  49. package/package.json +1 -1
package/README.md CHANGED
@@ -21,6 +21,8 @@ legacy XML protocol of the oldest pre-2010 models — behind one object tree.
21
21
  - **Typed datapoints** — booleans, dropdowns and numbers with unit and range instead of raw text
22
22
  - **Presets and favourites** — recall tuner presets and stored network/USB favourites by number, step through presets, save the current station to a preset slot or bookmark it, and read the stored lists with their names (MusicCast); recently-played recall on MusicCast devices
23
23
  - **Menu browsing** — page through the Net Radio, media-server and USB menus like with the remote: the visible menu lines as datapoints, select-by-line, and a path datapoint that navigates to a favourite in one write
24
+ - **Scenes with their names** — recall a scene by number or from a dropdown that shows the titles the receiver reports, per zone
25
+ - **On-screen remote** — cursor pad and menu keys as datapoints on MusicCast devices, for driving the receiver's own on-screen menus
24
26
  - **Clock & alarm view** — MusicCast desk-audio devices show their clock and alarm settings
25
27
  - **Capability-driven** — states are generated from what each device reports, no hardcoded model list
26
28
  - **Automatic discovery** — an empty device list finds and sets up MusicCast devices at startup
@@ -36,7 +38,7 @@ For details and how to disable it, see the [Sentry plugin documentation](https:/
36
38
 
37
39
  - Node.js >= 22
38
40
  - js-controller >= 7.2.2
39
- - admin >= 7.8.23
41
+ - admin >= 8.0.11
40
42
 
41
43
  ## Ports
42
44
 
@@ -58,18 +60,22 @@ The **Data points** section switches whole groups of datapoints on or off — **
58
60
  Each receiver becomes one device node with themed groups — the same groups the
59
61
  **Data points** switches control. Only what your device reports is created.
60
62
 
61
- - **Amplifier core** (always on) — power, volume, mute, input, sound program, sleep, plus the device info with model, firmware and connection.
62
- - **`player`** — one channel per playback source (Spotify, USB, server, net radio, CD, …) with playback state, artist, album, track, cover art and the transport buttons. The `player.browse` folder mirrors the device's media menu: the eight visible lines (folders and titles marked by symbol), `selectLine` acts like OK on the remote, page/back/root buttons, a `rows` JSON for widgets and a `path` datapoint that walks e.g. `Bookmarks>Radio Paradise` on one write.
63
- - **`tuner`** — AM/FM and DAB radio including RDS texts and frequency.
64
- - **`multiroom`** — zones 2–4, Zone B, the all-zones switches (master power, party mode) and the MusicCast device group in its own `multiroom.group` folder.
65
- - **`hdmi`** — the HDMI outputs and lip sync.
66
- - **`scene`** — the receiver's scene names and a scene recall.
67
- - **`sound`** — tone and sound processing: bass/treble, DSP modes, enhancer, equalizer, ….
68
- - **`advanced`** — setup-level datapoints: maximum/initial volume, speaker configuration, input names.
63
+ - **Amplifier core** (always on) — power, volume, mute, input, sound program, sleep, plus the device info with model, firmware, IP address and connection; MusicCast devices add an on-screen `remote` (cursor pad, menu keys).
64
+ - **`player`** — ONE "now playing" block per zone: `player.source` says what the zone is listening to, and playback state, artist, album, track, cover art, times and the transport buttons always describe exactly that — whatever source is playing. Zones 2–4 get their own block under `multiroom.zoneN.player`. The source folders keep only what is genuinely their own: preset recall & save for net radio/server/USB, the MusicCast favourite/recent/playlist/queue lists under `player.netPlayer`, the CD drive states, Bluetooth pairing and the AirPlay volume interlock. The `player.browse` folder mirrors the device's media menu: the eight visible lines (folders and titles marked by symbol), `selectLine` acts like OK on the remote, page/back/root buttons, a `rows` JSON for widgets and a `path` datapoint that walks e.g. `Bookmarks>Radio Paradise` on one write.
65
+ - **`tuner`** — one band, one frequency (kHz on every generation) and one preset for AM, FM and DAB, plus RDS texts and reception flags; only genuinely DAB-specific detail (service, ensemble, DLS, …) sits under `tuner.dab`.
66
+ - **`multiroom`** — zones 2–4 (each with its own player and scene block), Zone B, the all-zones switches (master power, party mode) and the MusicCast device group in its own `multiroom.group` folder.
67
+ - **`hdmi`** — the HDMI outputs and the two lip-sync offsets.
68
+ - **`scene`** — a recall dropdown carrying the titles the receiver reports (writable by number or title) and a `scene.list` JSON with every scene slot — titled where the device reports titles; zones with their own scenes carry theirs under `multiroom.zoneN.scene`.
69
+ - **`sound`** — tone and sound processing: bass/treble, DSP modes, enhancer, the equalizer in its own `sound.equalizer` folder and the current audio signal under `sound.signal` on MusicCast devices.
70
+ - **`advanced`** — setup-level datapoints: maximum/initial volume, the speaker configuration (A/B switches included) under `advanced.speakers`, input names.
69
71
  - **`clock`** — the clock and alarm settings of MusicCast desk-audio devices (read-only).
70
72
 
71
73
  ## Troubleshooting
72
74
 
75
+ ### Upgrading from 1.x
76
+
77
+ Version 2.0.0 reworks the object tree. On the first start the adapter removes the old datapoints itself and creates the new ones: the per-source player copies become one `player` block per zone, the scene name datapoints become the recall dropdown plus `scene.list`, the two tuner frequencies become one `tuner.frequency` in kHz, the equalizer and signal info move under `sound.equalizer`/`sound.signal`, the lip-sync offsets under `hdmi`, and the speaker A/B switches under `advanced.speakers`. Point scripts and visualizations at the new paths.
78
+
73
79
  ### Upgrading from 0.5.x
74
80
 
75
81
  Version 1.0.0 is a complete rebuild. On the first start after the update the old datapoints (`volume`, `power`, `Commands.*`, `Realtime.*`, …) are removed and your receiver is recreated as a device; its IP address is carried over automatically. Point scripts and visualizations at the new paths — for example `yamaha.0.<device>.power` instead of `yamaha.0.power`.
@@ -88,7 +94,7 @@ If MusicCast changes only refresh every few minutes, another application is occu
88
94
 
89
95
  ### First start takes a while
90
96
 
91
- On the first connect the adapter asks the receiver which functions it supports — up to half a minute per YNCA device. The result is remembered, later starts are faster.
97
+ On the very first contact the adapter asks the receiver which functions it supports — up to half a minute per YNCA device. The answers are remembered per device (and survive restarts), so every later start brings the device online in seconds and refreshes the current values in the background. A firmware update or a different device behind the same address is detected and re-asked automatically.
92
98
 
93
99
  ## Changelog
94
100
 
@@ -96,6 +102,29 @@ On the first connect the adapter asks the receiver which functions it supports
96
102
  Placeholder for the next version (at the beginning of the line):
97
103
  ### **WORK IN PROGRESS**
98
104
  -->
105
+ ### 2.0.0 (2026-09-01)
106
+
107
+ - (krobipd) Changed: one player block per zone now shows what is playing — source, title, artist, cover art and the transport buttons — instead of an identical, mostly empty block under every source
108
+ - (krobipd) Changed: the scene name datapoints are gone — the recall dropdown carries the receiver's own titles, and scene.list offers every scene with number and title for visualizations
109
+ - (krobipd) Changed: the tuner has one band, one frequency (kHz on every generation) and one preset for AM, FM and DAB; only genuinely DAB-specific detail stays under tuner.dab
110
+ - (krobipd) Changed: the equalizer and the audio-signal info moved into their own folders under sound, the lip-sync offsets into hdmi, the speaker A/B switches to the speaker settings
111
+ - (krobipd) New: zones can show different programs — zones 2 to 4 get their own player block, and play/pause/skip always act on whatever that zone is playing
112
+ - (krobipd) Fixed: the on-screen remote keys and the tuner preset stepping on MusicCast devices count as button presses again — they no longer wait behind a running background refresh
113
+ - (krobipd) Fixed: restarting or updating the adapter while a receiver was still connecting no longer produces shutdown warnings about timers in the log
114
+
115
+ ### 1.7.0 (2026-09-01)
116
+
117
+ - (krobipd) Fixed: scene recall now uses the command each device declares itself and the protocol that can deliver it — on 2012-generation receivers a scene write did nothing at all (#615)
118
+ - (krobipd) New: scenes show their titles ("Movie Viewing", …) as datapoints and in the recall dropdown, and zones with their own scenes get their own scene recall
119
+ - (krobipd) Fixed: the menu back button falls back to the older cursor command when a device refuses the standard one, so leaving a folder works on 2012-generation receivers too (#613)
120
+ - (krobipd) New: the adapter now reports when the receiver refuses a command, including the device's own answer — a dead button no longer fails without a trace
121
+ - (krobipd) New: bass and treble on MusicCast-generation receivers over YNCA, dialogue level, DAB signal details, the paired Bluetooth device name and more — the device is asked in its own dialect
122
+ - (krobipd) New: on-screen remote control datapoints (cursor pad and menu keys), the current audio signal info, MusicCast playlists and the play queue on MusicCast devices
123
+ - (krobipd) New: receivers from before 2010 get their tuner back — preset recall, frequency and radio text — and every input dropdown lists exactly the inputs the device really has
124
+ - (krobipd) New: the receiver's IP address is shown as a datapoint, so diagnosis and the device's own web pages are one click away
125
+ - (krobipd) Improved: restarts are fast — the adapter remembers what each device can do, brings it online in seconds and refreshes values in the background; only the first contact asks everything
126
+ - (krobipd) Improved: known devices no longer wait for the network search at startup — it runs in the background and only adds newcomers
127
+
99
128
  ### 1.6.0 (2026-08-27)
100
129
 
101
130
  - (krobipd) New: three states show how many receivers are set up, how many are connected right now and whether that is all of them — one line to watch instead of every device
@@ -119,20 +148,6 @@ On the first connect the adapter asks the receiver which functions it supports
119
148
  - (krobipd) Fixed: recalling a favourite, a recently played item or a tuner preset now goes to the zone that is actually listening instead of always switching the main zone
120
149
  - (krobipd) Improved: stopping or restarting the adapter no longer leaves requests running that write to datapoints afterwards
121
150
 
122
- ### 1.3.0 (2026-08-26)
123
-
124
- - (krobipd) New: menu browsing — page through the Net Radio, server and USB menus like with the remote: visible lines as datapoints, select-by-line, and a path datapoint for one-write navigation (#613)
125
- - (krobipd) New: save presets from ioBroker — store the current tuner or network station to a preset slot and bookmark the playing Net Radio station on YNCA receivers.
126
- - (krobipd) New: Bluetooth pairing and connect controls, FM mono mode and tuning indicators on YNCA receivers.
127
-
128
- ### 1.2.0 (2026-08-25)
129
-
130
- - (krobipd) Fixed: volume writes work again — a written -38 dB reached the receiver as -3.8 dB, so most values were ignored; all numeric controls now send the proper wire format (#612)
131
- - (krobipd) Fixed: the FM frequency datapoint now shows MHz (it was mislabelled kHz) and accepts direct frequency writes in the form the tuner expects.
132
- - (krobipd) New: preset selection — recall tuner presets by number with up/down stepping, and recall stored network or USB favourites per source on YNCA receivers (#613)
133
- - (krobipd) New: MusicCast selection lists — stored favourites and tuner presets with names, a recently-played list with recall by number, and the device's own allowed values as dropdowns.
134
- - (krobipd) New: more device detail — CD track and drive info, DAB and RDS station data, and a read-only clock and alarm view with its own datapoint group switch in the admin settings.
135
-
136
151
  [Older changelogs can be found there](CHANGELOG_OLD.md)
137
152
 
138
153
  ## History
@@ -68,6 +68,13 @@ class XmlBrowseDriver {
68
68
  engine;
69
69
  active;
70
70
  lastTotal = 0;
71
+ /**
72
+ * The cursor value this device accepts for "one level back". The spec vocabulary is
73
+ * `Return`, but the 2012 generation (RX-V473 class) refuses it and accepts `Left`
74
+ * instead (user-measured on the predecessor adapter, #613). First refusal switches
75
+ * permanently for this device.
76
+ */
77
+ backCursor = "Return";
71
78
  /**
72
79
  * Attach the engine that renders the windows (set after both are constructed).
73
80
  *
@@ -111,18 +118,22 @@ class XmlBrowseDriver {
111
118
  async pageDown() {
112
119
  await this.jumpBy(8);
113
120
  }
114
- /** Go one menu level back. */
121
+ /** Go one menu level back — falling back to `Left` when the device refuses `Return`. */
115
122
  async back() {
116
- await this.control("<Cursor>Return</Cursor>");
123
+ try {
124
+ await this.control(`<Cursor>${this.backCursor}</Cursor>`);
125
+ } catch (e) {
126
+ if (this.backCursor !== "Return") {
127
+ throw e;
128
+ }
129
+ this.backCursor = "Left";
130
+ await this.control("<Cursor>Left</Cursor>");
131
+ }
117
132
  }
118
133
  /** Return to the menu root. */
119
134
  async home() {
120
135
  await this.control("<Cursor>Return to Home</Cursor>");
121
136
  }
122
- /** Re-read the current window. */
123
- async refresh() {
124
- await this.fetch();
125
- }
126
137
  /**
127
138
  * Send a List_Control command to the active source and read the window back.
128
139
  *
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/browse/xml-browse-driver.ts"],
4
- "sourcesContent": ["import { ROW_KIND_BY_ATTRIBUTE, type BrowseDriver, type BrowseRow } from \"./types\";\nimport type { BrowseEngine } from \"./browse-engine\";\nimport { decodeXmlText } from \"../xml/entities\";\n\n/** How often to re-read while the device reports Menu_Status Busy. */\nconst BUSY_POLL_MS = 500;\n/** How many busy polls before giving up on one read. */\nconst MAX_BUSY_POLLS = 10;\n\n/**\n * The XML/YNC sources with a List_Info menu (the ones the predecessor adapter's\n * users browsed; rxv drives the same three), with the transport-neutral source key\n * and the input name that activates the source.\n */\nexport const XML_BROWSE_SOURCES: ReadonlyArray<{ element: string; key: string; label: string; input: string }> = [\n { element: \"NET_RADIO\", key: \"netRadio\", label: \"Net Radio\", input: \"NET RADIO\" },\n { element: \"SERVER\", key: \"server\", label: \"Media server\", input: \"SERVER\" },\n { element: \"USB\", key: \"usb\", label: \"USB\", input: \"USB\" },\n];\n\n/** The client surface the driver needs (a slice of the XML client). */\nexport interface XmlBrowseClient {\n /** Send an inner PUT command to an element (a zone or a source). */\n send(element: string, inner: string): Promise<void>;\n /** Read an element's inner GET request and return the raw response body. */\n getXml(element: string, inner: string): Promise<string>;\n}\n\n/** A parsed List_Info response. */\nexport interface XmlListInfo {\n /** Whether the menu is ready (false = the device is still fetching it). */\n ready: boolean;\n /** The menu title. */\n menuName: string;\n /** The menu depth (1 = root). */\n layer: number;\n /** The cursor's absolute line number. */\n currentLine: number;\n /** Total entries in the menu. */\n totalItems: number;\n /** The visible rows. */\n rows: BrowseRow[];\n}\n\n/**\n * Parse a `<List_Info>` response (menu status, layer, name, cursor, and the eight\n * `Current_List` lines with text + attribute \u2014 the shape rxv reads).\n *\n * @param xml the List_Info response body\n * @returns the parsed window\n */\nexport function parseXmlListInfo(xml: string): XmlListInfo {\n const rows: BrowseRow[] = [];\n const linePattern = /<Line_([1-8])>\\s*<Txt>([^<]*)<\\/Txt>\\s*<Attribute>([^<]*)<\\/Attribute>/g;\n for (let match = linePattern.exec(xml); match; match = linePattern.exec(xml)) {\n if (match[2].length > 0) {\n rows.push({\n line: Number(match[1]),\n text: decodeXmlText(match[2]),\n kind: ROW_KIND_BY_ATTRIBUTE[match[3]] ?? \"item\",\n });\n }\n }\n return {\n ready: !/<Menu_Status>Busy<\\/Menu_Status>/.test(xml),\n menuName: decodeXmlText(/<Menu_Name>([^<]*)<\\/Menu_Name>/.exec(xml)?.[1] ?? \"\"),\n layer: Number(/<Menu_Layer>(\\d+)<\\/Menu_Layer>/.exec(xml)?.[1] ?? 0),\n currentLine: Number(/<Current_Line>(\\d+)<\\/Current_Line>/.exec(xml)?.[1] ?? 1),\n totalItems: Number(/<Max_Line>(\\d+)<\\/Max_Line>/.exec(xml)?.[1] ?? 0),\n rows,\n };\n}\n\n/**\n * The XML/YNC list driver over `<List_Info>` + `<List_Control>` (the predecessor\n * adapter's browsing path). Pull-based: every operation re-reads the window, polling\n * while the device reports the menu as busy (levels come from the catalog service).\n * The cursor line stands in for the window start \u2014 the same approximation rxv makes.\n */\nexport class XmlBrowseDriver implements BrowseDriver {\n private engine: BrowseEngine | undefined;\n private active: { element: string; key: string; input: string } | undefined;\n private lastTotal = 0;\n\n /**\n * @param client the XML client slice (send + getXml)\n * @param available the source keys whose List_Info the start-up probe answered\n * @param delay adapter-managed delay\n */\n public constructor(\n private readonly client: XmlBrowseClient,\n private readonly available: ReadonlySet<string>,\n private readonly delay: (ms: number) => Promise<void>,\n ) {}\n\n /**\n * Attach the engine that renders the windows (set after both are constructed).\n *\n * @param engine the browse engine\n */\n public attach(engine: BrowseEngine): void {\n this.engine = engine;\n }\n\n /** @returns the selectable sources this device offers (state value \u2192 label) */\n public sources(): Record<string, string> {\n const entries = XML_BROWSE_SOURCES.filter(source => this.available.has(source.key));\n return Object.fromEntries(entries.map(source => [source.key, source.label]));\n }\n\n /**\n * Open a source's menu: switch the main-zone input to it and read the window.\n *\n * @param source the source key (from {@link sources})\n */\n public async open(source: string): Promise<void> {\n const entry = XML_BROWSE_SOURCES.find(s => s.key === source && this.available.has(s.key));\n if (!entry) {\n return;\n }\n this.active = entry;\n await this.client.send(\"Main_Zone\", `<Input><Input_Sel>${entry.input}</Input_Sel></Input>`);\n await this.fetch();\n }\n\n /**\n * Select a visible line via Direct_Sel \u2014 a folder opens, a playable item starts.\n *\n * @param line the line number (1\u20138)\n */\n public async select(line: number): Promise<void> {\n await this.control(`<Direct_Sel>Line_${line}</Direct_Sel>`);\n }\n\n /** Show the previous 8 lines (jump the cursor back a page). */\n public async pageUp(): Promise<void> {\n await this.jumpBy(-8);\n }\n\n /** Show the next 8 lines (jump the cursor forward a page). */\n public async pageDown(): Promise<void> {\n await this.jumpBy(8);\n }\n\n /** Go one menu level back. */\n public async back(): Promise<void> {\n await this.control(\"<Cursor>Return</Cursor>\");\n }\n\n /** Return to the menu root. */\n public async home(): Promise<void> {\n await this.control(\"<Cursor>Return to Home</Cursor>\");\n }\n\n /** Re-read the current window. */\n private async refresh(): Promise<void> {\n await this.fetch();\n }\n\n /**\n * Send a List_Control command to the active source and read the window back.\n *\n * @param inner the List_Control payload (Direct_Sel, Cursor, Jump_Line)\n */\n private async control(inner: string): Promise<void> {\n if (!this.active) {\n return;\n }\n await this.client.send(this.active.element, `<List_Control>${inner}</List_Control>`);\n await this.fetch();\n }\n\n /**\n * Jump the cursor by a page, clamped to the menu bounds.\n *\n * @param delta the line offset (\u00B18)\n */\n private async jumpBy(delta: number): Promise<void> {\n if (!this.active) {\n return;\n }\n const current = await this.readWindow();\n if (!current) {\n return;\n }\n const target = Math.min(Math.max(1, current.currentLine + delta), Math.max(1, this.lastTotal));\n await this.control(`<Jump_Line>${target}</Jump_Line>`);\n }\n\n /** Read the window (polling while busy) and render it to the engine. */\n private async fetch(): Promise<void> {\n const window = await this.readWindow();\n if (window) {\n this.lastTotal = window.totalItems;\n this.engine?.onWindow({\n menuName: window.menuName,\n layer: window.layer,\n totalItems: window.totalItems,\n currentLine: window.currentLine,\n rows: window.rows,\n });\n }\n }\n\n /**\n * Read the active source's List_Info, polling while the device reports Busy.\n *\n * @returns the parsed window, or undefined without an active source / when busy persists\n */\n private async readWindow(): Promise<XmlListInfo | undefined> {\n if (!this.active) {\n return undefined;\n }\n for (let attempt = 0; attempt < MAX_BUSY_POLLS; attempt++) {\n const info = parseXmlListInfo(await this.client.getXml(this.active.element, \"<List_Info>GetParam</List_Info>\"));\n if (info.ready) {\n return info;\n }\n await this.delay(BUSY_POLL_MS);\n }\n return undefined;\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { ROW_KIND_BY_ATTRIBUTE, type BrowseDriver, type BrowseRow } from \"./types\";\nimport type { BrowseEngine } from \"./browse-engine\";\nimport { decodeXmlText } from \"../xml/entities\";\n\n/** How often to re-read while the device reports Menu_Status Busy. */\nconst BUSY_POLL_MS = 500;\n/** How many busy polls before giving up on one read. */\nconst MAX_BUSY_POLLS = 10;\n\n/**\n * The XML/YNC sources with a List_Info menu (the ones the predecessor adapter's\n * users browsed; rxv drives the same three), with the transport-neutral source key\n * and the input name that activates the source.\n */\nexport const XML_BROWSE_SOURCES: ReadonlyArray<{ element: string; key: string; label: string; input: string }> = [\n { element: \"NET_RADIO\", key: \"netRadio\", label: \"Net Radio\", input: \"NET RADIO\" },\n { element: \"SERVER\", key: \"server\", label: \"Media server\", input: \"SERVER\" },\n { element: \"USB\", key: \"usb\", label: \"USB\", input: \"USB\" },\n];\n\n/** The client surface the driver needs (a slice of the XML client). */\nexport interface XmlBrowseClient {\n /** Send an inner PUT command to an element (a zone or a source). */\n send(element: string, inner: string): Promise<void>;\n /** Read an element's inner GET request and return the raw response body. */\n getXml(element: string, inner: string): Promise<string>;\n}\n\n/** A parsed List_Info response. */\nexport interface XmlListInfo {\n /** Whether the menu is ready (false = the device is still fetching it). */\n ready: boolean;\n /** The menu title. */\n menuName: string;\n /** The menu depth (1 = root). */\n layer: number;\n /** The cursor's absolute line number. */\n currentLine: number;\n /** Total entries in the menu. */\n totalItems: number;\n /** The visible rows. */\n rows: BrowseRow[];\n}\n\n/**\n * Parse a `<List_Info>` response (menu status, layer, name, cursor, and the eight\n * `Current_List` lines with text + attribute \u2014 the shape rxv reads).\n *\n * @param xml the List_Info response body\n * @returns the parsed window\n */\nexport function parseXmlListInfo(xml: string): XmlListInfo {\n const rows: BrowseRow[] = [];\n const linePattern = /<Line_([1-8])>\\s*<Txt>([^<]*)<\\/Txt>\\s*<Attribute>([^<]*)<\\/Attribute>/g;\n for (let match = linePattern.exec(xml); match; match = linePattern.exec(xml)) {\n if (match[2].length > 0) {\n rows.push({\n line: Number(match[1]),\n text: decodeXmlText(match[2]),\n kind: ROW_KIND_BY_ATTRIBUTE[match[3]] ?? \"item\",\n });\n }\n }\n return {\n ready: !/<Menu_Status>Busy<\\/Menu_Status>/.test(xml),\n menuName: decodeXmlText(/<Menu_Name>([^<]*)<\\/Menu_Name>/.exec(xml)?.[1] ?? \"\"),\n layer: Number(/<Menu_Layer>(\\d+)<\\/Menu_Layer>/.exec(xml)?.[1] ?? 0),\n currentLine: Number(/<Current_Line>(\\d+)<\\/Current_Line>/.exec(xml)?.[1] ?? 1),\n totalItems: Number(/<Max_Line>(\\d+)<\\/Max_Line>/.exec(xml)?.[1] ?? 0),\n rows,\n };\n}\n\n/**\n * The XML/YNC list driver over `<List_Info>` + `<List_Control>` (the predecessor\n * adapter's browsing path). Pull-based: every operation re-reads the window, polling\n * while the device reports the menu as busy (levels come from the catalog service).\n * The cursor line stands in for the window start \u2014 the same approximation rxv makes.\n */\nexport class XmlBrowseDriver implements BrowseDriver {\n private engine: BrowseEngine | undefined;\n private active: { element: string; key: string; input: string } | undefined;\n private lastTotal = 0;\n /**\n * The cursor value this device accepts for \"one level back\". The spec vocabulary is\n * `Return`, but the 2012 generation (RX-V473 class) refuses it and accepts `Left`\n * instead (user-measured on the predecessor adapter, #613). First refusal switches\n * permanently for this device.\n */\n private backCursor: \"Return\" | \"Left\" = \"Return\";\n\n /**\n * @param client the XML client slice (send + getXml)\n * @param available the source keys whose List_Info the start-up probe answered\n * @param delay adapter-managed delay\n */\n public constructor(\n private readonly client: XmlBrowseClient,\n private readonly available: ReadonlySet<string>,\n private readonly delay: (ms: number) => Promise<void>,\n ) {}\n\n /**\n * Attach the engine that renders the windows (set after both are constructed).\n *\n * @param engine the browse engine\n */\n public attach(engine: BrowseEngine): void {\n this.engine = engine;\n }\n\n /** @returns the selectable sources this device offers (state value \u2192 label) */\n public sources(): Record<string, string> {\n const entries = XML_BROWSE_SOURCES.filter(source => this.available.has(source.key));\n return Object.fromEntries(entries.map(source => [source.key, source.label]));\n }\n\n /**\n * Open a source's menu: switch the main-zone input to it and read the window.\n *\n * @param source the source key (from {@link sources})\n */\n public async open(source: string): Promise<void> {\n const entry = XML_BROWSE_SOURCES.find(s => s.key === source && this.available.has(s.key));\n if (!entry) {\n return;\n }\n this.active = entry;\n await this.client.send(\"Main_Zone\", `<Input><Input_Sel>${entry.input}</Input_Sel></Input>`);\n await this.fetch();\n }\n\n /**\n * Select a visible line via Direct_Sel \u2014 a folder opens, a playable item starts.\n *\n * @param line the line number (1\u20138)\n */\n public async select(line: number): Promise<void> {\n await this.control(`<Direct_Sel>Line_${line}</Direct_Sel>`);\n }\n\n /** Show the previous 8 lines (jump the cursor back a page). */\n public async pageUp(): Promise<void> {\n await this.jumpBy(-8);\n }\n\n /** Show the next 8 lines (jump the cursor forward a page). */\n public async pageDown(): Promise<void> {\n await this.jumpBy(8);\n }\n\n /** Go one menu level back \u2014 falling back to `Left` when the device refuses `Return`. */\n public async back(): Promise<void> {\n try {\n await this.control(`<Cursor>${this.backCursor}</Cursor>`);\n } catch (e) {\n // Only a refusal of the DEFAULT vocabulary triggers the generation fallback;\n // a device that also refuses Left (or a transport error) surfaces normally.\n if (this.backCursor !== \"Return\") {\n throw e;\n }\n this.backCursor = \"Left\";\n await this.control(\"<Cursor>Left</Cursor>\");\n }\n }\n\n /** Return to the menu root. */\n public async home(): Promise<void> {\n await this.control(\"<Cursor>Return to Home</Cursor>\");\n }\n\n /**\n * Send a List_Control command to the active source and read the window back.\n *\n * @param inner the List_Control payload (Direct_Sel, Cursor, Jump_Line)\n */\n private async control(inner: string): Promise<void> {\n if (!this.active) {\n return;\n }\n await this.client.send(this.active.element, `<List_Control>${inner}</List_Control>`);\n await this.fetch();\n }\n\n /**\n * Jump the cursor by a page, clamped to the menu bounds.\n *\n * @param delta the line offset (\u00B18)\n */\n private async jumpBy(delta: number): Promise<void> {\n if (!this.active) {\n return;\n }\n const current = await this.readWindow();\n if (!current) {\n return;\n }\n const target = Math.min(Math.max(1, current.currentLine + delta), Math.max(1, this.lastTotal));\n await this.control(`<Jump_Line>${target}</Jump_Line>`);\n }\n\n /** Read the window (polling while busy) and render it to the engine. */\n private async fetch(): Promise<void> {\n const window = await this.readWindow();\n if (window) {\n this.lastTotal = window.totalItems;\n this.engine?.onWindow({\n menuName: window.menuName,\n layer: window.layer,\n totalItems: window.totalItems,\n currentLine: window.currentLine,\n rows: window.rows,\n });\n }\n }\n\n /**\n * Read the active source's List_Info, polling while the device reports Busy.\n *\n * @returns the parsed window, or undefined without an active source / when busy persists\n */\n private async readWindow(): Promise<XmlListInfo | undefined> {\n if (!this.active) {\n return undefined;\n }\n for (let attempt = 0; attempt < MAX_BUSY_POLLS; attempt++) {\n const info = parseXmlListInfo(await this.client.getXml(this.active.element, \"<List_Info>GetParam</List_Info>\"));\n if (info.ready) {\n return info;\n }\n await this.delay(BUSY_POLL_MS);\n }\n return undefined;\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -137,10 +137,6 @@ class YxcBrowseDriver {
137
137
  this.index = 0;
138
138
  }
139
139
  }
140
- /** Re-read the current window. */
141
- async refresh() {
142
- await this.fetch();
143
- }
144
140
  /**
145
141
  * Fetch the current window, render it to the engine, and report the menu layer.
146
142
  *
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/browse/yxc-browse-driver.ts"],
4
- "sourcesContent": ["import type { BrowseDriver, BrowseRow } from \"./types\";\nimport type { BrowseEngine } from \"./browse-engine\";\n\n/** The engine's page size \u2014 YXC serves any window, we keep the device's 8-line form. */\nconst PAGE_SIZE = 8;\n/** Upper bound for the return-to-root loop (menus are never this deep). */\nconst MAX_HOME_STEPS = 16;\n\n/**\n * The netusb inputs the YXC list API can browse (aiomusiccast BROWSABLE_INPUTS),\n * with the transport-neutral source key and display label. The driver offers the\n * intersection with the device's own input list.\n */\nexport const YXC_BROWSE_SOURCES: ReadonlyArray<{ input: string; key: string; label: string }> = [\n { input: \"net_radio\", key: \"netRadio\", label: \"Net Radio\" },\n { input: \"server\", key: \"server\", label: \"Media server\" },\n { input: \"usb\", key: \"usb\", label: \"USB\" },\n { input: \"napster\", key: \"napster\", label: \"Napster\" },\n { input: \"pandora\", key: \"pandora\", label: \"Pandora\" },\n { input: \"rhapsody\", key: \"rhapsody\", label: \"Rhapsody\" },\n { input: \"siriusxm\", key: \"sirius\", label: \"SiriusXM\" },\n { input: \"juke\", key: \"juke\", label: \"JUKE\" },\n { input: \"radiko\", key: \"radiko\", label: \"radiko\" },\n { input: \"qobuz\", key: \"qobuz\", label: \"Qobuz\" },\n { input: \"deezer\", key: \"deezer\", label: \"Deezer\" },\n { input: \"amazon_music\", key: \"amazonMusic\", label: \"Amazon Music\" },\n];\n\n/** The client surface the driver needs (a slice of the YXC client). */\nexport interface YxcBrowseClient {\n /** Read one window of a netusb source's list. */\n getListInfo(input: string, index: number, size?: number): Promise<unknown>;\n /** Drive the netusb list: select/play an absolute index, or go one level back. */\n setListControl(type: \"select\" | \"play\" | \"return\", index?: number, zone?: string): Promise<unknown>;\n}\n\n/** One raw entry of a getListInfo response. */\ninterface RawListEntry {\n /** The entry's text. */\n text?: unknown;\n /** The capability bitmask (b1 selectable, b2 playable). */\n attribute?: unknown;\n /** The entry's thumbnail URL. */\n thumbnail?: unknown;\n}\n\n/**\n * The YXC (MusicCast) list driver over `netusb/getListInfo` + `netusb/setListControl`.\n * Pull-based: every operation fetches the resulting window and hands it to the engine.\n * The absolute entry index is tracked here (the API pages by index, not by cursor);\n * playing an item hands the playback to the netusb input automatically.\n */\nexport class YxcBrowseDriver implements BrowseDriver {\n private engine: BrowseEngine | undefined;\n private active: { input: string; key: string } | undefined;\n /** The absolute (0-based) index of the window's first entry. */\n private index = 0;\n /** The last window's rows, for the select semantics (folder vs playable). */\n private rows: BrowseRow[] = [];\n private totalItems = 0;\n\n /**\n * @param client the YXC client slice (getListInfo + setListControl)\n * @param inputList the device's netusb input list (getFeatures `input_list`)\n */\n public constructor(\n private readonly client: YxcBrowseClient,\n private readonly inputList: readonly string[],\n ) {}\n\n /**\n * Attach the engine that renders the windows (set after both are constructed).\n *\n * @param engine the browse engine\n */\n public attach(engine: BrowseEngine): void {\n this.engine = engine;\n }\n\n /** @returns the selectable sources this device offers (state value \u2192 label) */\n public sources(): Record<string, string> {\n const entries = YXC_BROWSE_SOURCES.filter(source => this.inputList.includes(source.input));\n return Object.fromEntries(entries.map(source => [source.key, source.label]));\n }\n\n /**\n * Open a source's list at the device's current position in it.\n *\n * @param source the source key (from {@link sources})\n */\n public async open(source: string): Promise<void> {\n const entry = YXC_BROWSE_SOURCES.find(s => s.key === source && this.inputList.includes(s.input));\n if (!entry) {\n return;\n }\n this.active = entry;\n this.index = 0;\n await this.fetch();\n }\n\n /**\n * Select a visible line: a playable item starts (the device switches to netusb),\n * a folder opens.\n *\n * @param line the line number (1\u20138)\n */\n public async select(line: number): Promise<void> {\n const row = this.rows.find(r => r.line === line);\n if (!this.active || !row || row.kind === \"unselectable\" || row.kind === \"unplayable\") {\n return;\n }\n const absolute = this.index + line - 1;\n if (row.kind === \"item\") {\n await this.client.setListControl(\"play\", absolute, \"main\");\n await this.fetch();\n return;\n }\n await this.client.setListControl(\"select\", absolute);\n this.index = 0;\n await this.fetch();\n }\n\n /** Show the previous 8 lines. */\n public async pageUp(): Promise<void> {\n this.index = Math.max(0, this.index - PAGE_SIZE);\n await this.fetch();\n }\n\n /** Show the next 8 lines. */\n public async pageDown(): Promise<void> {\n if (this.index + PAGE_SIZE < this.totalItems) {\n this.index += PAGE_SIZE;\n }\n await this.fetch();\n }\n\n /** Go one menu level back. */\n public async back(): Promise<void> {\n if (!this.active) {\n return;\n }\n await this.client.setListControl(\"return\");\n this.index = 0;\n await this.fetch();\n }\n\n /** Return to the menu root: step back until the device reports layer 1. */\n public async home(): Promise<void> {\n if (!this.active) {\n return;\n }\n for (let step = 0; step < MAX_HOME_STEPS; step++) {\n const layer = await this.fetch();\n if (layer === undefined || layer <= 1) {\n return;\n }\n await this.client.setListControl(\"return\");\n this.index = 0;\n }\n }\n\n /** Re-read the current window. */\n private async refresh(): Promise<void> {\n await this.fetch();\n }\n\n /**\n * Fetch the current window, render it to the engine, and report the menu layer.\n *\n * @returns the menu layer, or undefined when the source answered with an error\n */\n private async fetch(): Promise<number | undefined> {\n if (!this.active) {\n return undefined;\n }\n const response = (await this.client.getListInfo(this.active.input, this.index)) as Record<string, unknown> | null;\n if (!response || (typeof response.response_code === \"number\" && response.response_code !== 0)) {\n return undefined;\n }\n const entries = Array.isArray(response.list_info) ? (response.list_info as RawListEntry[]) : [];\n this.rows = entries.slice(0, PAGE_SIZE).map((entry, i) => {\n // b1 = capable of Select (a container to enter), b2 = capable of Play.\n const attribute = typeof entry.attribute === \"number\" ? entry.attribute : 0;\n const playable = (attribute & 0b100) !== 0;\n const selectable = (attribute & 0b10) !== 0;\n const row: BrowseRow = {\n line: i + 1,\n text: typeof entry.text === \"string\" ? entry.text : \"\",\n kind: playable ? \"item\" : selectable ? \"folder\" : \"unselectable\",\n };\n if (typeof entry.thumbnail === \"string\" && entry.thumbnail.length > 0) {\n row.thumbnail = entry.thumbnail;\n }\n return row;\n });\n this.totalItems = typeof response.max_line === \"number\" ? response.max_line : this.rows.length;\n const layer = typeof response.menu_layer === \"number\" ? response.menu_layer : 0;\n this.engine?.onWindow({\n menuName: typeof response.menu_name === \"string\" ? response.menu_name : \"\",\n layer,\n totalItems: this.totalItems,\n currentLine: this.index + 1,\n rows: this.rows,\n });\n return layer;\n }\n}\n"],
5
- "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAIA,MAAM,YAAY;AAElB,MAAM,iBAAiB;AAOhB,MAAM,qBAAmF;AAAA,EAC9F,EAAE,OAAO,aAAa,KAAK,YAAY,OAAO,YAAY;AAAA,EAC1D,EAAE,OAAO,UAAU,KAAK,UAAU,OAAO,eAAe;AAAA,EACxD,EAAE,OAAO,OAAO,KAAK,OAAO,OAAO,MAAM;AAAA,EACzC,EAAE,OAAO,WAAW,KAAK,WAAW,OAAO,UAAU;AAAA,EACrD,EAAE,OAAO,WAAW,KAAK,WAAW,OAAO,UAAU;AAAA,EACrD,EAAE,OAAO,YAAY,KAAK,YAAY,OAAO,WAAW;AAAA,EACxD,EAAE,OAAO,YAAY,KAAK,UAAU,OAAO,WAAW;AAAA,EACtD,EAAE,OAAO,QAAQ,KAAK,QAAQ,OAAO,OAAO;AAAA,EAC5C,EAAE,OAAO,UAAU,KAAK,UAAU,OAAO,SAAS;AAAA,EAClD,EAAE,OAAO,SAAS,KAAK,SAAS,OAAO,QAAQ;AAAA,EAC/C,EAAE,OAAO,UAAU,KAAK,UAAU,OAAO,SAAS;AAAA,EAClD,EAAE,OAAO,gBAAgB,KAAK,eAAe,OAAO,eAAe;AACrE;AA0BO,MAAM,gBAAwC;AAAA;AAAA;AAAA;AAAA;AAAA,EAa5C,YACY,QACA,WACjB;AAFiB;AACA;AAAA,EAChB;AAAA,EAfK;AAAA,EACA;AAAA;AAAA,EAEA,QAAQ;AAAA;AAAA,EAER,OAAoB,CAAC;AAAA,EACrB,aAAa;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAgBd,OAAO,QAA4B;AACxC,SAAK,SAAS;AAAA,EAChB;AAAA;AAAA,EAGO,UAAkC;AACvC,UAAM,UAAU,mBAAmB,OAAO,YAAU,KAAK,UAAU,SAAS,OAAO,KAAK,CAAC;AACzF,WAAO,OAAO,YAAY,QAAQ,IAAI,YAAU,CAAC,OAAO,KAAK,OAAO,KAAK,CAAC,CAAC;AAAA,EAC7E;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,MAAa,KAAK,QAA+B;AAC/C,UAAM,QAAQ,mBAAmB,KAAK,OAAK,EAAE,QAAQ,UAAU,KAAK,UAAU,SAAS,EAAE,KAAK,CAAC;AAC/F,QAAI,CAAC,OAAO;AACV;AAAA,IACF;AACA,SAAK,SAAS;AACd,SAAK,QAAQ;AACb,UAAM,KAAK,MAAM;AAAA,EACnB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,MAAa,OAAO,MAA6B;AAC/C,UAAM,MAAM,KAAK,KAAK,KAAK,OAAK,EAAE,SAAS,IAAI;AAC/C,QAAI,CAAC,KAAK,UAAU,CAAC,OAAO,IAAI,SAAS,kBAAkB,IAAI,SAAS,cAAc;AACpF;AAAA,IACF;AACA,UAAM,WAAW,KAAK,QAAQ,OAAO;AACrC,QAAI,IAAI,SAAS,QAAQ;AACvB,YAAM,KAAK,OAAO,eAAe,QAAQ,UAAU,MAAM;AACzD,YAAM,KAAK,MAAM;AACjB;AAAA,IACF;AACA,UAAM,KAAK,OAAO,eAAe,UAAU,QAAQ;AACnD,SAAK,QAAQ;AACb,UAAM,KAAK,MAAM;AAAA,EACnB;AAAA;AAAA,EAGA,MAAa,SAAwB;AACnC,SAAK,QAAQ,KAAK,IAAI,GAAG,KAAK,QAAQ,SAAS;AAC/C,UAAM,KAAK,MAAM;AAAA,EACnB;AAAA;AAAA,EAGA,MAAa,WAA0B;AACrC,QAAI,KAAK,QAAQ,YAAY,KAAK,YAAY;AAC5C,WAAK,SAAS;AAAA,IAChB;AACA,UAAM,KAAK,MAAM;AAAA,EACnB;AAAA;AAAA,EAGA,MAAa,OAAsB;AACjC,QAAI,CAAC,KAAK,QAAQ;AAChB;AAAA,IACF;AACA,UAAM,KAAK,OAAO,eAAe,QAAQ;AACzC,SAAK,QAAQ;AACb,UAAM,KAAK,MAAM;AAAA,EACnB;AAAA;AAAA,EAGA,MAAa,OAAsB;AACjC,QAAI,CAAC,KAAK,QAAQ;AAChB;AAAA,IACF;AACA,aAAS,OAAO,GAAG,OAAO,gBAAgB,QAAQ;AAChD,YAAM,QAAQ,MAAM,KAAK,MAAM;AAC/B,UAAI,UAAU,UAAa,SAAS,GAAG;AACrC;AAAA,MACF;AACA,YAAM,KAAK,OAAO,eAAe,QAAQ;AACzC,WAAK,QAAQ;AAAA,IACf;AAAA,EACF;AAAA;AAAA,EAGA,MAAc,UAAyB;AACrC,UAAM,KAAK,MAAM;AAAA,EACnB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,MAAc,QAAqC;AA3KrD;AA4KI,QAAI,CAAC,KAAK,QAAQ;AAChB,aAAO;AAAA,IACT;AACA,UAAM,WAAY,MAAM,KAAK,OAAO,YAAY,KAAK,OAAO,OAAO,KAAK,KAAK;AAC7E,QAAI,CAAC,YAAa,OAAO,SAAS,kBAAkB,YAAY,SAAS,kBAAkB,GAAI;AAC7F,aAAO;AAAA,IACT;AACA,UAAM,UAAU,MAAM,QAAQ,SAAS,SAAS,IAAK,SAAS,YAA+B,CAAC;AAC9F,SAAK,OAAO,QAAQ,MAAM,GAAG,SAAS,EAAE,IAAI,CAAC,OAAO,MAAM;AAExD,YAAM,YAAY,OAAO,MAAM,cAAc,WAAW,MAAM,YAAY;AAC1E,YAAM,YAAY,YAAY,OAAW;AACzC,YAAM,cAAc,YAAY,OAAU;AAC1C,YAAM,MAAiB;AAAA,QACrB,MAAM,IAAI;AAAA,QACV,MAAM,OAAO,MAAM,SAAS,WAAW,MAAM,OAAO;AAAA,QACpD,MAAM,WAAW,SAAS,aAAa,WAAW;AAAA,MACpD;AACA,UAAI,OAAO,MAAM,cAAc,YAAY,MAAM,UAAU,SAAS,GAAG;AACrE,YAAI,YAAY,MAAM;AAAA,MACxB;AACA,aAAO;AAAA,IACT,CAAC;AACD,SAAK,aAAa,OAAO,SAAS,aAAa,WAAW,SAAS,WAAW,KAAK,KAAK;AACxF,UAAM,QAAQ,OAAO,SAAS,eAAe,WAAW,SAAS,aAAa;AAC9E,eAAK,WAAL,mBAAa,SAAS;AAAA,MACpB,UAAU,OAAO,SAAS,cAAc,WAAW,SAAS,YAAY;AAAA,MACxE;AAAA,MACA,YAAY,KAAK;AAAA,MACjB,aAAa,KAAK,QAAQ;AAAA,MAC1B,MAAM,KAAK;AAAA,IACb;AACA,WAAO;AAAA,EACT;AACF;",
4
+ "sourcesContent": ["import type { BrowseDriver, BrowseRow } from \"./types\";\nimport type { BrowseEngine } from \"./browse-engine\";\n\n/** The engine's page size \u2014 YXC serves any window, we keep the device's 8-line form. */\nconst PAGE_SIZE = 8;\n/** Upper bound for the return-to-root loop (menus are never this deep). */\nconst MAX_HOME_STEPS = 16;\n\n/**\n * The netusb inputs the YXC list API can browse (aiomusiccast BROWSABLE_INPUTS),\n * with the transport-neutral source key and display label. The driver offers the\n * intersection with the device's own input list.\n */\nexport const YXC_BROWSE_SOURCES: ReadonlyArray<{ input: string; key: string; label: string }> = [\n { input: \"net_radio\", key: \"netRadio\", label: \"Net Radio\" },\n { input: \"server\", key: \"server\", label: \"Media server\" },\n { input: \"usb\", key: \"usb\", label: \"USB\" },\n { input: \"napster\", key: \"napster\", label: \"Napster\" },\n { input: \"pandora\", key: \"pandora\", label: \"Pandora\" },\n { input: \"rhapsody\", key: \"rhapsody\", label: \"Rhapsody\" },\n { input: \"siriusxm\", key: \"sirius\", label: \"SiriusXM\" },\n { input: \"juke\", key: \"juke\", label: \"JUKE\" },\n { input: \"radiko\", key: \"radiko\", label: \"radiko\" },\n { input: \"qobuz\", key: \"qobuz\", label: \"Qobuz\" },\n { input: \"deezer\", key: \"deezer\", label: \"Deezer\" },\n { input: \"amazon_music\", key: \"amazonMusic\", label: \"Amazon Music\" },\n];\n\n/** The client surface the driver needs (a slice of the YXC client). */\nexport interface YxcBrowseClient {\n /** Read one window of a netusb source's list. */\n getListInfo(input: string, index: number, size?: number): Promise<unknown>;\n /** Drive the netusb list: select/play an absolute index, or go one level back. */\n setListControl(type: \"select\" | \"play\" | \"return\", index?: number, zone?: string): Promise<unknown>;\n}\n\n/** One raw entry of a getListInfo response. */\ninterface RawListEntry {\n /** The entry's text. */\n text?: unknown;\n /** The capability bitmask (b1 selectable, b2 playable). */\n attribute?: unknown;\n /** The entry's thumbnail URL. */\n thumbnail?: unknown;\n}\n\n/**\n * The YXC (MusicCast) list driver over `netusb/getListInfo` + `netusb/setListControl`.\n * Pull-based: every operation fetches the resulting window and hands it to the engine.\n * The absolute entry index is tracked here (the API pages by index, not by cursor);\n * playing an item hands the playback to the netusb input automatically.\n */\nexport class YxcBrowseDriver implements BrowseDriver {\n private engine: BrowseEngine | undefined;\n private active: { input: string; key: string } | undefined;\n /** The absolute (0-based) index of the window's first entry. */\n private index = 0;\n /** The last window's rows, for the select semantics (folder vs playable). */\n private rows: BrowseRow[] = [];\n private totalItems = 0;\n\n /**\n * @param client the YXC client slice (getListInfo + setListControl)\n * @param inputList the device's netusb input list (getFeatures `input_list`)\n */\n public constructor(\n private readonly client: YxcBrowseClient,\n private readonly inputList: readonly string[],\n ) {}\n\n /**\n * Attach the engine that renders the windows (set after both are constructed).\n *\n * @param engine the browse engine\n */\n public attach(engine: BrowseEngine): void {\n this.engine = engine;\n }\n\n /** @returns the selectable sources this device offers (state value \u2192 label) */\n public sources(): Record<string, string> {\n const entries = YXC_BROWSE_SOURCES.filter(source => this.inputList.includes(source.input));\n return Object.fromEntries(entries.map(source => [source.key, source.label]));\n }\n\n /**\n * Open a source's list at the device's current position in it.\n *\n * @param source the source key (from {@link sources})\n */\n public async open(source: string): Promise<void> {\n const entry = YXC_BROWSE_SOURCES.find(s => s.key === source && this.inputList.includes(s.input));\n if (!entry) {\n return;\n }\n this.active = entry;\n this.index = 0;\n await this.fetch();\n }\n\n /**\n * Select a visible line: a playable item starts (the device switches to netusb),\n * a folder opens.\n *\n * @param line the line number (1\u20138)\n */\n public async select(line: number): Promise<void> {\n const row = this.rows.find(r => r.line === line);\n if (!this.active || !row || row.kind === \"unselectable\" || row.kind === \"unplayable\") {\n return;\n }\n const absolute = this.index + line - 1;\n if (row.kind === \"item\") {\n await this.client.setListControl(\"play\", absolute, \"main\");\n await this.fetch();\n return;\n }\n await this.client.setListControl(\"select\", absolute);\n this.index = 0;\n await this.fetch();\n }\n\n /** Show the previous 8 lines. */\n public async pageUp(): Promise<void> {\n this.index = Math.max(0, this.index - PAGE_SIZE);\n await this.fetch();\n }\n\n /** Show the next 8 lines. */\n public async pageDown(): Promise<void> {\n if (this.index + PAGE_SIZE < this.totalItems) {\n this.index += PAGE_SIZE;\n }\n await this.fetch();\n }\n\n /** Go one menu level back. */\n public async back(): Promise<void> {\n if (!this.active) {\n return;\n }\n await this.client.setListControl(\"return\");\n this.index = 0;\n await this.fetch();\n }\n\n /** Return to the menu root: step back until the device reports layer 1. */\n public async home(): Promise<void> {\n if (!this.active) {\n return;\n }\n for (let step = 0; step < MAX_HOME_STEPS; step++) {\n const layer = await this.fetch();\n if (layer === undefined || layer <= 1) {\n return;\n }\n await this.client.setListControl(\"return\");\n this.index = 0;\n }\n }\n\n /**\n * Fetch the current window, render it to the engine, and report the menu layer.\n *\n * @returns the menu layer, or undefined when the source answered with an error\n */\n private async fetch(): Promise<number | undefined> {\n if (!this.active) {\n return undefined;\n }\n const response = (await this.client.getListInfo(this.active.input, this.index)) as Record<string, unknown> | null;\n if (!response || (typeof response.response_code === \"number\" && response.response_code !== 0)) {\n return undefined;\n }\n const entries = Array.isArray(response.list_info) ? (response.list_info as RawListEntry[]) : [];\n this.rows = entries.slice(0, PAGE_SIZE).map((entry, i) => {\n // b1 = capable of Select (a container to enter), b2 = capable of Play.\n const attribute = typeof entry.attribute === \"number\" ? entry.attribute : 0;\n const playable = (attribute & 0b100) !== 0;\n const selectable = (attribute & 0b10) !== 0;\n const row: BrowseRow = {\n line: i + 1,\n text: typeof entry.text === \"string\" ? entry.text : \"\",\n kind: playable ? \"item\" : selectable ? \"folder\" : \"unselectable\",\n };\n if (typeof entry.thumbnail === \"string\" && entry.thumbnail.length > 0) {\n row.thumbnail = entry.thumbnail;\n }\n return row;\n });\n this.totalItems = typeof response.max_line === \"number\" ? response.max_line : this.rows.length;\n const layer = typeof response.menu_layer === \"number\" ? response.menu_layer : 0;\n this.engine?.onWindow({\n menuName: typeof response.menu_name === \"string\" ? response.menu_name : \"\",\n layer,\n totalItems: this.totalItems,\n currentLine: this.index + 1,\n rows: this.rows,\n });\n return layer;\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -38,7 +38,7 @@ function groupOf(stateId) {
38
38
  if (seg === "multiroom") {
39
39
  return "multiroom";
40
40
  }
41
- if (seg === "hdmi" || seg === "lipSync") {
41
+ if (seg === "hdmi") {
42
42
  return "hdmi";
43
43
  }
44
44
  if (seg === "player") {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/catalog/groups.ts"],
4
- "sourcesContent": ["/**\n * Datapoint groups: the thematic buckets that are both the object-tree structure and the\n * admin on/off switches. Every state id maps to exactly one group by its (zone-stripped) first\n * path segment (its channel). A disabled group's objects are never created and any existing ones\n * are pruned, the same way beszel gates its metric categories. `amp` is the always-on core \u2014 no\n * switch exists for it, exactly like beszel's unconditional `info.online`/`info.status`.\n *\n * Every group is a real channel prefix in the tree (`player.*`, `tuner.*`, `hdmi.*`,\n * `multiroom.*`, `scene.*`, `sound.*`, `advanced.*`) \u2014 the toggle and the folder a datapoint\n * visually sits in are the same thing, never decoupled. Zone 2/3/4, Zone B, masterPower, party\n * and distribution all live under `multiroom.*`. Legacy flat pre-v0.15.0 ids need no handling\n * here: the start-up cleanup deletes them via `RENAMED_CHANNELS` before anything queries them.\n */\n\n/** The switchable groups. `amp` is the amplifier core and can never be turned off. */\nexport type GroupId = \"amp\" | \"player\" | \"tuner\" | \"multiroom\" | \"hdmi\" | \"scene\" | \"sound\" | \"advanced\" | \"clock\";\n\n/** The groups a user can switch off, in display order (amp is always on and not listed here). */\nexport const SWITCHABLE_GROUPS: readonly GroupId[] = [\n \"player\",\n \"tuner\",\n \"multiroom\",\n \"hdmi\",\n \"scene\",\n \"sound\",\n \"advanced\",\n \"clock\",\n];\n\n/**\n * The datapoint group a state id belongs to, decided by its first path segment. Every group is a\n * real folder prefix in the tree, so the first segment of the id determines the group directly:\n * `multiroom.*` (including zones, masterPower, party), `player.*`, `tuner.*`, `hdmi.*`, `sound.*`,\n * `advanced.*`, `scene.*`. Anything not matched \u2014 the amplifier core (power, volume, input,\n * sleep \u2026), `info.*` \u2014 is the always-on `amp` group.\n *\n * @param stateId the device-relative state id (e.g. \"multiroom.zone2.power\", \"player.spotify.playback\")\n * @returns the group the state belongs to\n */\nexport function groupOf(stateId: string): GroupId {\n const seg = stateId.includes(\".\") ? stateId.slice(0, stateId.indexOf(\".\")) : stateId;\n\n if (seg === \"multiroom\") {\n return \"multiroom\";\n }\n if (seg === \"hdmi\" || seg === \"lipSync\") {\n return \"hdmi\";\n }\n if (seg === \"player\") {\n return \"player\";\n }\n if (seg === \"tuner\") {\n return \"tuner\";\n }\n if (seg === \"sound\") {\n return \"sound\";\n }\n if (seg === \"advanced\") {\n return \"advanced\";\n }\n if (seg === \"scene\") {\n return \"scene\";\n }\n if (seg === \"clock\") {\n return \"clock\";\n }\n return \"amp\";\n}\n\n/**\n * Whether a state's group is switched on. The amplifier core is always on; every other group is\n * on unless its `group_<id>` flag is explicitly false \u2014 default-on, so a fresh install and every\n * existing install keep all groups until the user turns one off.\n *\n * @param stateId the device-relative state id\n * @param config the adapter native config (carries `group_player`, `group_tuner`, \u2026 booleans)\n * @returns true if the state's group is enabled\n */\nexport function isGroupEnabled(stateId: string, config: Record<string, unknown>): boolean {\n const group = groupOf(stateId);\n if (group === \"amp\") {\n return true;\n }\n return config[`group_${group}`] !== false;\n}\n"],
5
- "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAkBO,MAAM,oBAAwC;AAAA,EACnD;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAYO,SAAS,QAAQ,SAA0B;AAChD,QAAM,MAAM,QAAQ,SAAS,GAAG,IAAI,QAAQ,MAAM,GAAG,QAAQ,QAAQ,GAAG,CAAC,IAAI;AAE7E,MAAI,QAAQ,aAAa;AACvB,WAAO;AAAA,EACT;AACA,MAAI,QAAQ,UAAU,QAAQ,WAAW;AACvC,WAAO;AAAA,EACT;AACA,MAAI,QAAQ,UAAU;AACpB,WAAO;AAAA,EACT;AACA,MAAI,QAAQ,SAAS;AACnB,WAAO;AAAA,EACT;AACA,MAAI,QAAQ,SAAS;AACnB,WAAO;AAAA,EACT;AACA,MAAI,QAAQ,YAAY;AACtB,WAAO;AAAA,EACT;AACA,MAAI,QAAQ,SAAS;AACnB,WAAO;AAAA,EACT;AACA,MAAI,QAAQ,SAAS;AACnB,WAAO;AAAA,EACT;AACA,SAAO;AACT;AAWO,SAAS,eAAe,SAAiB,QAA0C;AACxF,QAAM,QAAQ,QAAQ,OAAO;AAC7B,MAAI,UAAU,OAAO;AACnB,WAAO;AAAA,EACT;AACA,SAAO,OAAO,SAAS,KAAK,EAAE,MAAM;AACtC;",
4
+ "sourcesContent": ["/**\n * Datapoint groups: the thematic buckets that are both the object-tree structure and the\n * admin on/off switches. Every state id maps to exactly one group by its (zone-stripped) first\n * path segment (its channel). A disabled group's objects are never created and any existing ones\n * are pruned, the same way beszel gates its metric categories. `amp` is the always-on core \u2014 no\n * switch exists for it, exactly like beszel's unconditional `info.online`/`info.status`.\n *\n * Every group is a real channel prefix in the tree (`player.*`, `tuner.*`, `hdmi.*`,\n * `multiroom.*`, `scene.*`, `sound.*`, `advanced.*`) \u2014 the toggle and the folder a datapoint\n * visually sits in are the same thing, never decoupled. Zone 2/3/4, Zone B, masterPower, party\n * and distribution all live under `multiroom.*`. Legacy flat pre-v0.15.0 ids need no handling\n * here: the start-up cleanup deletes them via `RENAMED_CHANNELS` before anything queries them.\n */\n\n/** The switchable groups. `amp` is the amplifier core and can never be turned off. */\nexport type GroupId = \"amp\" | \"player\" | \"tuner\" | \"multiroom\" | \"hdmi\" | \"scene\" | \"sound\" | \"advanced\" | \"clock\";\n\n/** The groups a user can switch off, in display order (amp is always on and not listed here). */\nexport const SWITCHABLE_GROUPS: readonly GroupId[] = [\n \"player\",\n \"tuner\",\n \"multiroom\",\n \"hdmi\",\n \"scene\",\n \"sound\",\n \"advanced\",\n \"clock\",\n];\n\n/**\n * The datapoint group a state id belongs to, decided by its first path segment. Every group is a\n * real folder prefix in the tree, so the first segment of the id determines the group directly:\n * `multiroom.*` (including zones, masterPower, party), `player.*`, `tuner.*`, `hdmi.*`, `sound.*`,\n * `advanced.*`, `scene.*`. Anything not matched \u2014 the amplifier core (power, volume, input,\n * sleep \u2026), `info.*` \u2014 is the always-on `amp` group.\n *\n * @param stateId the device-relative state id (e.g. \"multiroom.zone2.power\", \"player.spotify.playback\")\n * @returns the group the state belongs to\n */\nexport function groupOf(stateId: string): GroupId {\n const seg = stateId.includes(\".\") ? stateId.slice(0, stateId.indexOf(\".\")) : stateId;\n\n if (seg === \"multiroom\") {\n return \"multiroom\";\n }\n if (seg === \"hdmi\") {\n return \"hdmi\";\n }\n if (seg === \"player\") {\n return \"player\";\n }\n if (seg === \"tuner\") {\n return \"tuner\";\n }\n if (seg === \"sound\") {\n return \"sound\";\n }\n if (seg === \"advanced\") {\n return \"advanced\";\n }\n if (seg === \"scene\") {\n return \"scene\";\n }\n if (seg === \"clock\") {\n return \"clock\";\n }\n return \"amp\";\n}\n\n/**\n * Whether a state's group is switched on. The amplifier core is always on; every other group is\n * on unless its `group_<id>` flag is explicitly false \u2014 default-on, so a fresh install and every\n * existing install keep all groups until the user turns one off.\n *\n * @param stateId the device-relative state id\n * @param config the adapter native config (carries `group_player`, `group_tuner`, \u2026 booleans)\n * @returns true if the state's group is enabled\n */\nexport function isGroupEnabled(stateId: string, config: Record<string, unknown>): boolean {\n const group = groupOf(stateId);\n if (group === \"amp\") {\n return true;\n }\n return config[`group_${group}`] !== false;\n}\n"],
5
+ "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAkBO,MAAM,oBAAwC;AAAA,EACnD;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAYO,SAAS,QAAQ,SAA0B;AAChD,QAAM,MAAM,QAAQ,SAAS,GAAG,IAAI,QAAQ,MAAM,GAAG,QAAQ,QAAQ,GAAG,CAAC,IAAI;AAE7E,MAAI,QAAQ,aAAa;AACvB,WAAO;AAAA,EACT;AACA,MAAI,QAAQ,QAAQ;AAClB,WAAO;AAAA,EACT;AACA,MAAI,QAAQ,UAAU;AACpB,WAAO;AAAA,EACT;AACA,MAAI,QAAQ,SAAS;AACnB,WAAO;AAAA,EACT;AACA,MAAI,QAAQ,SAAS;AACnB,WAAO;AAAA,EACT;AACA,MAAI,QAAQ,YAAY;AACtB,WAAO;AAAA,EACT;AACA,MAAI,QAAQ,SAAS;AACnB,WAAO;AAAA,EACT;AACA,MAAI,QAAQ,SAAS;AACnB,WAAO;AAAA,EACT;AACA,SAAO;AACT;AAWO,SAAS,eAAe,SAAiB,QAA0C;AACxF,QAAM,QAAQ,QAAQ,OAAO;AAC7B,MAAI,UAAU,OAAO;AACnB,WAAO;AAAA,EACT;AACA,SAAO,OAAO,SAAS,KAAK,EAAE,MAAM;AACtC;",
6
6
  "names": []
7
7
  }
@@ -49,7 +49,16 @@ function coordinateObjectTree(contributions) {
49
49
  if (!ownerDef) {
50
50
  throw new Error(`coordinateObjectTree: owner ${owner} has no def for ${canonicalId}`);
51
51
  }
52
- return { ...ownerDef, id: canonicalId };
52
+ const resolvedDef = { ...ownerDef, id: canonicalId };
53
+ if (!resolvedDef.common.states) {
54
+ for (const def of entry.defs.values()) {
55
+ if (def.common.states) {
56
+ resolvedDef.common = { ...resolvedDef.common, states: def.common.states };
57
+ break;
58
+ }
59
+ }
60
+ }
61
+ return resolvedDef;
53
62
  });
54
63
  resolved.sort((a, b) => a.id.split(".").length - b.id.split(".").length);
55
64
  return { objects: resolved, ownerByCanonicalId };
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/catalog/object-tree-coordinator.ts"],
4
- "sourcesContent": ["import type { ObjectDef } from \"./types\";\nimport { canonicalIdOf, capabilityKeyOf, pickOwner, type Transport } from \"./owner-policy\";\n\n/** One transport's contribution: the objects its catalog builds for this device. */\nexport interface TransportObjects {\n /** The transport these objects come from. */\n transport: Transport;\n /** The objects the transport's catalog builds (its own state ids, possibly drifting/zoned). */\n objects: readonly ObjectDef[];\n}\n\n/**\n * Merge the present transports' catalogs into one unified object tree. Each capability appears\n * exactly once, emitted by its owning transport (see {@link pickOwner}) under the canonical id;\n * drifting ids and per-zone duplicates across transports collapse to one node. Objects are\n * ordered parents-before-children so the intermediate channels exist before their states.\n *\n * @param contributions the objects each present transport offers\n * @returns the deduplicated tree and the owner of each canonical id (for write routing)\n */\nexport function coordinateObjectTree(contributions: readonly TransportObjects[]): {\n objects: ObjectDef[];\n ownerByCanonicalId: Map<string, Transport>;\n} {\n const order: string[] = [];\n const byId = new Map<string, { key: string; defs: Map<Transport, ObjectDef> }>();\n for (const { transport, objects } of contributions) {\n for (const obj of objects) {\n const canonicalId = canonicalIdOf(transport, obj.id);\n let entry = byId.get(canonicalId);\n if (!entry) {\n entry = { key: capabilityKeyOf(transport, obj.id), defs: new Map() };\n byId.set(canonicalId, entry);\n order.push(canonicalId);\n }\n entry.defs.set(transport, obj);\n }\n }\n const ownerByCanonicalId = new Map<string, Transport>();\n const resolved: ObjectDef[] = order.map(canonicalId => {\n const entry = byId.get(canonicalId);\n // Both throws below are internal invariants, not reachable states: `order` is\n // built from `byId`'s own keys, and pickOwner always returns one of the\n // candidates it was handed (= this entry's own defs). They exist so a future\n // change to either side fails loudly instead of writing an empty object.\n if (!entry) {\n throw new Error(`coordinateObjectTree: missing entry for ${canonicalId}`);\n }\n const owner = pickOwner(entry.key, [...entry.defs.keys()]);\n ownerByCanonicalId.set(canonicalId, owner);\n const ownerDef = entry.defs.get(owner);\n if (!ownerDef) {\n throw new Error(`coordinateObjectTree: owner ${owner} has no def for ${canonicalId}`);\n }\n return { ...ownerDef, id: canonicalId };\n });\n // Parents before children: shallower id paths (fewer dotted segments) first. Array.sort is\n // stable (ES2019+), so equal-depth objects keep their first-seen order.\n resolved.sort((a, b) => a.id.split(\".\").length - b.id.split(\".\").length);\n return { objects: resolved, ownerByCanonicalId };\n}\n"],
5
- "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AACA,0BAA0E;AAmBnE,SAAS,qBAAqB,eAGnC;AACA,QAAM,QAAkB,CAAC;AACzB,QAAM,OAAO,oBAAI,IAA8D;AAC/E,aAAW,EAAE,WAAW,QAAQ,KAAK,eAAe;AAClD,eAAW,OAAO,SAAS;AACzB,YAAM,kBAAc,mCAAc,WAAW,IAAI,EAAE;AACnD,UAAI,QAAQ,KAAK,IAAI,WAAW;AAChC,UAAI,CAAC,OAAO;AACV,gBAAQ,EAAE,SAAK,qCAAgB,WAAW,IAAI,EAAE,GAAG,MAAM,oBAAI,IAAI,EAAE;AACnE,aAAK,IAAI,aAAa,KAAK;AAC3B,cAAM,KAAK,WAAW;AAAA,MACxB;AACA,YAAM,KAAK,IAAI,WAAW,GAAG;AAAA,IAC/B;AAAA,EACF;AACA,QAAM,qBAAqB,oBAAI,IAAuB;AACtD,QAAM,WAAwB,MAAM,IAAI,iBAAe;AACrD,UAAM,QAAQ,KAAK,IAAI,WAAW;AAKlC,QAAI,CAAC,OAAO;AACV,YAAM,IAAI,MAAM,2CAA2C,WAAW,EAAE;AAAA,IAC1E;AACA,UAAM,YAAQ,+BAAU,MAAM,KAAK,CAAC,GAAG,MAAM,KAAK,KAAK,CAAC,CAAC;AACzD,uBAAmB,IAAI,aAAa,KAAK;AACzC,UAAM,WAAW,MAAM,KAAK,IAAI,KAAK;AACrC,QAAI,CAAC,UAAU;AACb,YAAM,IAAI,MAAM,+BAA+B,KAAK,mBAAmB,WAAW,EAAE;AAAA,IACtF;AACA,WAAO,EAAE,GAAG,UAAU,IAAI,YAAY;AAAA,EACxC,CAAC;AAGD,WAAS,KAAK,CAAC,GAAG,MAAM,EAAE,GAAG,MAAM,GAAG,EAAE,SAAS,EAAE,GAAG,MAAM,GAAG,EAAE,MAAM;AACvE,SAAO,EAAE,SAAS,UAAU,mBAAmB;AACjD;",
4
+ "sourcesContent": ["import type { ObjectDef } from \"./types\";\nimport { canonicalIdOf, capabilityKeyOf, pickOwner, type Transport } from \"./owner-policy\";\n\n/** One transport's contribution: the objects its catalog builds for this device. */\nexport interface TransportObjects {\n /** The transport these objects come from. */\n transport: Transport;\n /** The objects the transport's catalog builds (its own state ids, possibly drifting/zoned). */\n objects: readonly ObjectDef[];\n}\n\n/**\n * Merge the present transports' catalogs into one unified object tree. Each capability appears\n * exactly once, emitted by its owning transport (see {@link pickOwner}) under the canonical id;\n * drifting ids and per-zone duplicates across transports collapse to one node. Objects are\n * ordered parents-before-children so the intermediate channels exist before their states.\n *\n * @param contributions the objects each present transport offers\n * @returns the deduplicated tree and the owner of each canonical id (for write routing)\n */\nexport function coordinateObjectTree(contributions: readonly TransportObjects[]): {\n objects: ObjectDef[];\n ownerByCanonicalId: Map<string, Transport>;\n} {\n const order: string[] = [];\n const byId = new Map<string, { key: string; defs: Map<Transport, ObjectDef> }>();\n for (const { transport, objects } of contributions) {\n for (const obj of objects) {\n const canonicalId = canonicalIdOf(transport, obj.id);\n let entry = byId.get(canonicalId);\n if (!entry) {\n entry = { key: capabilityKeyOf(transport, obj.id), defs: new Map() };\n byId.set(canonicalId, entry);\n order.push(canonicalId);\n }\n entry.defs.set(transport, obj);\n }\n }\n const ownerByCanonicalId = new Map<string, Transport>();\n const resolved: ObjectDef[] = order.map(canonicalId => {\n const entry = byId.get(canonicalId);\n // Both throws below are internal invariants, not reachable states: `order` is\n // built from `byId`'s own keys, and pickOwner always returns one of the\n // candidates it was handed (= this entry's own defs). They exist so a future\n // change to either side fails loudly instead of writing an empty object.\n if (!entry) {\n throw new Error(`coordinateObjectTree: missing entry for ${canonicalId}`);\n }\n const owner = pickOwner(entry.key, [...entry.defs.keys()]);\n ownerByCanonicalId.set(canonicalId, owner);\n const ownerDef = entry.defs.get(owner);\n if (!ownerDef) {\n throw new Error(`coordinateObjectTree: owner ${owner} has no def for ${canonicalId}`);\n }\n // Dropdown borrowing: the owner wins the WRITE PATH, but another transport may know\n // the value labels the owner cannot deliver \u2014 the scene titles come over XML/YNCA\n // while MusicCast owns the recall, the device's own input list comes over XML while\n // YNCA owns the input. The label map is pure presentation, so borrowing it never\n // changes routing; borrowed in modernity-independent claim order (first with one).\n const resolvedDef: ObjectDef = { ...ownerDef, id: canonicalId };\n if (!resolvedDef.common.states) {\n for (const def of entry.defs.values()) {\n if (def.common.states) {\n resolvedDef.common = { ...resolvedDef.common, states: def.common.states };\n break;\n }\n }\n }\n return resolvedDef;\n });\n // Parents before children: shallower id paths (fewer dotted segments) first. Array.sort is\n // stable (ES2019+), so equal-depth objects keep their first-seen order.\n resolved.sort((a, b) => a.id.split(\".\").length - b.id.split(\".\").length);\n return { objects: resolved, ownerByCanonicalId };\n}\n"],
5
+ "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AACA,0BAA0E;AAmBnE,SAAS,qBAAqB,eAGnC;AACA,QAAM,QAAkB,CAAC;AACzB,QAAM,OAAO,oBAAI,IAA8D;AAC/E,aAAW,EAAE,WAAW,QAAQ,KAAK,eAAe;AAClD,eAAW,OAAO,SAAS;AACzB,YAAM,kBAAc,mCAAc,WAAW,IAAI,EAAE;AACnD,UAAI,QAAQ,KAAK,IAAI,WAAW;AAChC,UAAI,CAAC,OAAO;AACV,gBAAQ,EAAE,SAAK,qCAAgB,WAAW,IAAI,EAAE,GAAG,MAAM,oBAAI,IAAI,EAAE;AACnE,aAAK,IAAI,aAAa,KAAK;AAC3B,cAAM,KAAK,WAAW;AAAA,MACxB;AACA,YAAM,KAAK,IAAI,WAAW,GAAG;AAAA,IAC/B;AAAA,EACF;AACA,QAAM,qBAAqB,oBAAI,IAAuB;AACtD,QAAM,WAAwB,MAAM,IAAI,iBAAe;AACrD,UAAM,QAAQ,KAAK,IAAI,WAAW;AAKlC,QAAI,CAAC,OAAO;AACV,YAAM,IAAI,MAAM,2CAA2C,WAAW,EAAE;AAAA,IAC1E;AACA,UAAM,YAAQ,+BAAU,MAAM,KAAK,CAAC,GAAG,MAAM,KAAK,KAAK,CAAC,CAAC;AACzD,uBAAmB,IAAI,aAAa,KAAK;AACzC,UAAM,WAAW,MAAM,KAAK,IAAI,KAAK;AACrC,QAAI,CAAC,UAAU;AACb,YAAM,IAAI,MAAM,+BAA+B,KAAK,mBAAmB,WAAW,EAAE;AAAA,IACtF;AAMA,UAAM,cAAyB,EAAE,GAAG,UAAU,IAAI,YAAY;AAC9D,QAAI,CAAC,YAAY,OAAO,QAAQ;AAC9B,iBAAW,OAAO,MAAM,KAAK,OAAO,GAAG;AACrC,YAAI,IAAI,OAAO,QAAQ;AACrB,sBAAY,SAAS,EAAE,GAAG,YAAY,QAAQ,QAAQ,IAAI,OAAO,OAAO;AACxE;AAAA,QACF;AAAA,MACF;AAAA,IACF;AACA,WAAO;AAAA,EACT,CAAC;AAGD,WAAS,KAAK,CAAC,GAAG,MAAM,EAAE,GAAG,MAAM,GAAG,EAAE,SAAS,EAAE,GAAG,MAAM,GAAG,EAAE,MAAM;AACvE,SAAO,EAAE,SAAS,UAAU,mBAAmB;AACjD;",
6
6
  "names": []
7
7
  }
@@ -30,12 +30,23 @@ const OWNER_OVERRIDES = {
30
30
  volume: ["ynca", "xml", "yxc"],
31
31
  // §3c write loss — YXC is read-only for these, YNCA (and often XML) is writable.
32
32
  "advanced.maxVolume": ["ynca", "xml", "yxc"],
33
- // The DAB preset is a writable recall on YNCA but display-only in the YXC play info.
34
- "tuner.dab.preset": ["ynca", "yxc"],
33
+ // §3c write loss on the unified player block (v2.0.0): YXC reads playback/repeat/
34
+ // shuffle but cannot WRITE them (its API has only toggle/transport endpoints, which
35
+ // stay YXC-owned buttons); YNCA sets all three directly. Zone mirrors collapse to
36
+ // the same template, so this covers multiroom.zoneN.player.* too.
37
+ "player.playback": ["ynca", "yxc"],
38
+ "player.repeat": ["ynca", "yxc"],
39
+ "player.shuffle": ["ynca", "yxc"],
35
40
  "sound.extraBass": ["ynca", "xml", "yxc"],
36
41
  "sound.adaptiveDrc": ["ynca", "xml", "yxc"],
37
42
  "sound.surroundDecoder": ["ynca", "yxc"],
38
43
  "sound.dialogueLift": ["xml", "yxc"],
44
+ // Write-proof beats modernity rank for the scene TRIGGER (#615): YXC declares the
45
+ // recall endpoint per zone (device-verified), XML declares the write value in its
46
+ // Scene_Sel_Item list — but YNCA's claim rests on the scene NAMES being readable,
47
+ // and the 2012 generation (RX-V473/V475) answers the YNCA scene put with
48
+ // @RESTRICTED (ynca-python PRACTICALITIES). The proven writers go first.
49
+ "scene.recall": ["yxc", "xml", "ynca"],
39
50
  // §3d richness loss — YNCA carries an enum dropdown that YXC/XML flatten to a free string.
40
51
  input: ["ynca", "yxc", "xml"],
41
52
  soundProgram: ["ynca", "yxc", "xml"],
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/catalog/owner-policy.ts"],
4
- "sourcesContent": ["/** The transports the adapter speaks. Ordered most-modern-first \u2014 the default ownership rank. */\nexport type Transport = \"yxc\" | \"ynca\" | \"xml\";\n\n/**\n * Ownership preference by modernity, used when a shared capability is equally good on each\n * transport (krobi: \"wenn mehrere Protokolle dasselbe k\u00F6nnen, das modernste nutzen\"). YXC is\n * push + structured JSON, YNCA the text-poll base, XML the pre-2010 fallback.\n */\nconst MODERNITY: readonly Transport[] = [\"yxc\", \"ynca\", \"xml\"];\n\n/**\n * Per-capability ownership preference that OVERRIDES pure modernity \u2014 from the capability\n * census (`Ressourcen/yamaha/capability-census-2026-08-11.md` \u00A73). These are the shared keys\n * where YXC is present but NOT equivalent: wrong scale, read-only, or a poorer type. Each list\n * is the preferred owner order for that key; the first present transport wins.\n */\nconst OWNER_OVERRIDES: Record<string, readonly Transport[]> = {\n // \u00A73a scale conflict \u2014 YXC volume is the raw 0..161 device scale, YNCA/XML are dB. Keep dB.\n volume: [\"ynca\", \"xml\", \"yxc\"],\n // \u00A73c write loss \u2014 YXC is read-only for these, YNCA (and often XML) is writable.\n \"advanced.maxVolume\": [\"ynca\", \"xml\", \"yxc\"],\n // The DAB preset is a writable recall on YNCA but display-only in the YXC play info.\n \"tuner.dab.preset\": [\"ynca\", \"yxc\"],\n \"sound.extraBass\": [\"ynca\", \"xml\", \"yxc\"],\n \"sound.adaptiveDrc\": [\"ynca\", \"xml\", \"yxc\"],\n \"sound.surroundDecoder\": [\"ynca\", \"yxc\"],\n \"sound.dialogueLift\": [\"xml\", \"yxc\"],\n // \u00A73d richness loss \u2014 YNCA carries an enum dropdown that YXC/XML flatten to a free string.\n input: [\"ynca\", \"yxc\", \"xml\"],\n soundProgram: [\"ynca\", \"yxc\", \"xml\"],\n sleep: [\"ynca\", \"xml\", \"yxc\"],\n \"tuner.band\": [\"ynca\", \"yxc\"],\n};\n\n/**\n * Per-transport state-id \u2192 canonical capability key, for the ids that drift between transports\n * (census \u00A73f, verified against the catalogs). The canonical key is the census left column; the\n * transports not listed already use the canonical id. Zone prefixes are stripped separately.\n */\nconst ID_DRIFT: Partial<Record<Transport, Readonly<Record<string, string>>>> = {\n yxc: { subwooferVolume: \"sound.subwooferTrim\", \"multiroom.partyEnable\": \"multiroom.party\" },\n xml: { hdmiOut1: \"hdmi.out1\", hdmiOut2: \"hdmi.out2\" },\n};\n\n/**\n * The transport-neutral capability key for a transport's state id: strip a `zone2/3/4.` prefix\n * to the per-zone template, then resolve any id drift to the canonical key. Two transports that\n * express the same capability under different ids therefore map to the same key.\n *\n * @param transport the transport the state id comes from\n * @param stateId the transport's own state id (may carry a zone prefix)\n * @returns the canonical capability key\n */\nexport function capabilityKeyOf(transport: Transport, stateId: string): string {\n const template = stateId.replace(/^(?:multiroom\\.)?zone[234]\\./, \"\");\n return ID_DRIFT[transport]?.[template] ?? template;\n}\n\n/**\n * The drift-resolved, transport-neutral OBJECT id \u2014 like {@link capabilityKeyOf} but KEEPS the\n * zone prefix. capabilityKeyOf drops the zone to decide ownership per template; canonicalIdOf\n * keeps it to place the per-zone node in the unified tree. Two transports' zoned ids for the\n * same capability therefore collapse to one node id.\n *\n * @param transport the transport the state id comes from\n * @param stateId the transport's own state id\n * @returns the canonical object id (zone prefix kept, drift resolved)\n */\nexport function canonicalIdOf(transport: Transport, stateId: string): string {\n const zone = /^(?:multiroom\\.)?zone[234]\\./.exec(stateId)?.[0] ?? \"\";\n const template = stateId.slice(zone.length);\n const resolved = ID_DRIFT[transport]?.[template] ?? template;\n if (zone && !zone.startsWith(\"multiroom.\")) {\n return `multiroom.${zone}${resolved}`;\n }\n return zone + resolved;\n}\n\n/**\n * Decide which transport owns a capability, given the transports that actually offer it on\n * this device. Default is the most modern; a census-driven override wins where the modern\n * transport would be lossy. An override that lists none of the present candidates falls back\n * to modernity, so any non-empty candidate set resolves.\n *\n * @param key the transport-neutral capability key (the unified state id)\n * @param candidates the present transports that offer this key (non-empty)\n * @returns the owning transport\n */\nexport function pickOwner(key: string, candidates: readonly Transport[]): Transport {\n const preference = OWNER_OVERRIDES[key] ?? MODERNITY;\n // The MODERNITY fallback is defence for a FUTURE override: every entry in the\n // current table lists at least two transports, so an override can never miss all\n // present candidates while more than one is present \u2014 today it is unobservable.\n const owner = preference.find(t => candidates.includes(t)) ?? MODERNITY.find(t => candidates.includes(t));\n return owner ?? candidates[0];\n}\n\n/**\n * Build the owner map for a device: given which capability keys each present transport offers,\n * resolve every key to exactly one owning transport (via {@link pickOwner}). This is the\n * dedup decision behind the unified object tree \u2014 each state is written by one transport only.\n *\n * @param offered the capability keys each present transport offers (absent transport = none)\n * @returns a map from capability key to its owning transport\n */\nexport function resolveOwnership(offered: Partial<Record<Transport, readonly string[]>>): Map<string, Transport> {\n const candidates = new Map<string, Transport[]>();\n // Iterate in modernity order so the candidate lists are deterministic.\n for (const transport of MODERNITY) {\n for (const key of offered[transport] ?? []) {\n const list = candidates.get(key);\n if (list) {\n list.push(transport);\n } else {\n candidates.set(key, [transport]);\n }\n }\n }\n const owners = new Map<string, Transport>();\n for (const [key, cands] of candidates) {\n owners.set(key, pickOwner(key, cands));\n }\n return owners;\n}\n"],
5
- "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAQA,MAAM,YAAkC,CAAC,OAAO,QAAQ,KAAK;AAQ7D,MAAM,kBAAwD;AAAA;AAAA,EAE5D,QAAQ,CAAC,QAAQ,OAAO,KAAK;AAAA;AAAA,EAE7B,sBAAsB,CAAC,QAAQ,OAAO,KAAK;AAAA;AAAA,EAE3C,oBAAoB,CAAC,QAAQ,KAAK;AAAA,EAClC,mBAAmB,CAAC,QAAQ,OAAO,KAAK;AAAA,EACxC,qBAAqB,CAAC,QAAQ,OAAO,KAAK;AAAA,EAC1C,yBAAyB,CAAC,QAAQ,KAAK;AAAA,EACvC,sBAAsB,CAAC,OAAO,KAAK;AAAA;AAAA,EAEnC,OAAO,CAAC,QAAQ,OAAO,KAAK;AAAA,EAC5B,cAAc,CAAC,QAAQ,OAAO,KAAK;AAAA,EACnC,OAAO,CAAC,QAAQ,OAAO,KAAK;AAAA,EAC5B,cAAc,CAAC,QAAQ,KAAK;AAC9B;AAOA,MAAM,WAAyE;AAAA,EAC7E,KAAK,EAAE,iBAAiB,uBAAuB,yBAAyB,kBAAkB;AAAA,EAC1F,KAAK,EAAE,UAAU,aAAa,UAAU,YAAY;AACtD;AAWO,SAAS,gBAAgB,WAAsB,SAAyB;AArD/E;AAsDE,QAAM,WAAW,QAAQ,QAAQ,gCAAgC,EAAE;AACnE,UAAO,oBAAS,SAAS,MAAlB,mBAAsB,cAAtB,YAAmC;AAC5C;AAYO,SAAS,cAAc,WAAsB,SAAyB;AApE7E;AAqEE,QAAM,QAAO,0CAA+B,KAAK,OAAO,MAA3C,mBAA+C,OAA/C,YAAqD;AAClE,QAAM,WAAW,QAAQ,MAAM,KAAK,MAAM;AAC1C,QAAM,YAAW,oBAAS,SAAS,MAAlB,mBAAsB,cAAtB,YAAmC;AACpD,MAAI,QAAQ,CAAC,KAAK,WAAW,YAAY,GAAG;AAC1C,WAAO,aAAa,IAAI,GAAG,QAAQ;AAAA,EACrC;AACA,SAAO,OAAO;AAChB;AAYO,SAAS,UAAU,KAAa,YAA6C;AAxFpF;AAyFE,QAAM,cAAa,qBAAgB,GAAG,MAAnB,YAAwB;AAI3C,QAAM,SAAQ,gBAAW,KAAK,OAAK,WAAW,SAAS,CAAC,CAAC,MAA3C,YAAgD,UAAU,KAAK,OAAK,WAAW,SAAS,CAAC,CAAC;AACxG,SAAO,wBAAS,WAAW,CAAC;AAC9B;AAUO,SAAS,iBAAiB,SAAgF;AAzGjH;AA0GE,QAAM,aAAa,oBAAI,IAAyB;AAEhD,aAAW,aAAa,WAAW;AACjC,eAAW,QAAO,aAAQ,SAAS,MAAjB,YAAsB,CAAC,GAAG;AAC1C,YAAM,OAAO,WAAW,IAAI,GAAG;AAC/B,UAAI,MAAM;AACR,aAAK,KAAK,SAAS;AAAA,MACrB,OAAO;AACL,mBAAW,IAAI,KAAK,CAAC,SAAS,CAAC;AAAA,MACjC;AAAA,IACF;AAAA,EACF;AACA,QAAM,SAAS,oBAAI,IAAuB;AAC1C,aAAW,CAAC,KAAK,KAAK,KAAK,YAAY;AACrC,WAAO,IAAI,KAAK,UAAU,KAAK,KAAK,CAAC;AAAA,EACvC;AACA,SAAO;AACT;",
4
+ "sourcesContent": ["/** The transports the adapter speaks. Ordered most-modern-first \u2014 the default ownership rank. */\nexport type Transport = \"yxc\" | \"ynca\" | \"xml\";\n\n/**\n * Ownership preference by modernity, used when a shared capability is equally good on each\n * transport (krobi: \"wenn mehrere Protokolle dasselbe k\u00F6nnen, das modernste nutzen\"). YXC is\n * push + structured JSON, YNCA the text-poll base, XML the pre-2010 fallback.\n */\nconst MODERNITY: readonly Transport[] = [\"yxc\", \"ynca\", \"xml\"];\n\n/**\n * Per-capability ownership preference that OVERRIDES pure modernity \u2014 from the capability\n * census (`Ressourcen/yamaha/capability-census-2026-08-11.md` \u00A73). These are the shared keys\n * where YXC is present but NOT equivalent: wrong scale, read-only, or a poorer type. Each list\n * is the preferred owner order for that key; the first present transport wins.\n */\nconst OWNER_OVERRIDES: Record<string, readonly Transport[]> = {\n // \u00A73a scale conflict \u2014 YXC volume is the raw 0..161 device scale, YNCA/XML are dB. Keep dB.\n volume: [\"ynca\", \"xml\", \"yxc\"],\n // \u00A73c write loss \u2014 YXC is read-only for these, YNCA (and often XML) is writable.\n \"advanced.maxVolume\": [\"ynca\", \"xml\", \"yxc\"],\n // \u00A73c write loss on the unified player block (v2.0.0): YXC reads playback/repeat/\n // shuffle but cannot WRITE them (its API has only toggle/transport endpoints, which\n // stay YXC-owned buttons); YNCA sets all three directly. Zone mirrors collapse to\n // the same template, so this covers multiroom.zoneN.player.* too.\n \"player.playback\": [\"ynca\", \"yxc\"],\n \"player.repeat\": [\"ynca\", \"yxc\"],\n \"player.shuffle\": [\"ynca\", \"yxc\"],\n \"sound.extraBass\": [\"ynca\", \"xml\", \"yxc\"],\n \"sound.adaptiveDrc\": [\"ynca\", \"xml\", \"yxc\"],\n \"sound.surroundDecoder\": [\"ynca\", \"yxc\"],\n \"sound.dialogueLift\": [\"xml\", \"yxc\"],\n // Write-proof beats modernity rank for the scene TRIGGER (#615): YXC declares the\n // recall endpoint per zone (device-verified), XML declares the write value in its\n // Scene_Sel_Item list \u2014 but YNCA's claim rests on the scene NAMES being readable,\n // and the 2012 generation (RX-V473/V475) answers the YNCA scene put with\n // @RESTRICTED (ynca-python PRACTICALITIES). The proven writers go first.\n \"scene.recall\": [\"yxc\", \"xml\", \"ynca\"],\n // \u00A73d richness loss \u2014 YNCA carries an enum dropdown that YXC/XML flatten to a free string.\n input: [\"ynca\", \"yxc\", \"xml\"],\n soundProgram: [\"ynca\", \"yxc\", \"xml\"],\n sleep: [\"ynca\", \"xml\", \"yxc\"],\n \"tuner.band\": [\"ynca\", \"yxc\"],\n};\n\n/**\n * Per-transport state-id \u2192 canonical capability key, for the ids that drift between transports\n * (census \u00A73f, verified against the catalogs). The canonical key is the census left column; the\n * transports not listed already use the canonical id. Zone prefixes are stripped separately.\n */\nconst ID_DRIFT: Partial<Record<Transport, Readonly<Record<string, string>>>> = {\n yxc: { subwooferVolume: \"sound.subwooferTrim\", \"multiroom.partyEnable\": \"multiroom.party\" },\n xml: { hdmiOut1: \"hdmi.out1\", hdmiOut2: \"hdmi.out2\" },\n};\n\n/**\n * The transport-neutral capability key for a transport's state id: strip a `zone2/3/4.` prefix\n * to the per-zone template, then resolve any id drift to the canonical key. Two transports that\n * express the same capability under different ids therefore map to the same key.\n *\n * @param transport the transport the state id comes from\n * @param stateId the transport's own state id (may carry a zone prefix)\n * @returns the canonical capability key\n */\nexport function capabilityKeyOf(transport: Transport, stateId: string): string {\n const template = stateId.replace(/^(?:multiroom\\.)?zone[234]\\./, \"\");\n return ID_DRIFT[transport]?.[template] ?? template;\n}\n\n/**\n * The drift-resolved, transport-neutral OBJECT id \u2014 like {@link capabilityKeyOf} but KEEPS the\n * zone prefix. capabilityKeyOf drops the zone to decide ownership per template; canonicalIdOf\n * keeps it to place the per-zone node in the unified tree. Two transports' zoned ids for the\n * same capability therefore collapse to one node id.\n *\n * @param transport the transport the state id comes from\n * @param stateId the transport's own state id\n * @returns the canonical object id (zone prefix kept, drift resolved)\n */\nexport function canonicalIdOf(transport: Transport, stateId: string): string {\n const zone = /^(?:multiroom\\.)?zone[234]\\./.exec(stateId)?.[0] ?? \"\";\n const template = stateId.slice(zone.length);\n const resolved = ID_DRIFT[transport]?.[template] ?? template;\n if (zone && !zone.startsWith(\"multiroom.\")) {\n return `multiroom.${zone}${resolved}`;\n }\n return zone + resolved;\n}\n\n/**\n * Decide which transport owns a capability, given the transports that actually offer it on\n * this device. Default is the most modern; a census-driven override wins where the modern\n * transport would be lossy. An override that lists none of the present candidates falls back\n * to modernity, so any non-empty candidate set resolves.\n *\n * @param key the transport-neutral capability key (the unified state id)\n * @param candidates the present transports that offer this key (non-empty)\n * @returns the owning transport\n */\nexport function pickOwner(key: string, candidates: readonly Transport[]): Transport {\n const preference = OWNER_OVERRIDES[key] ?? MODERNITY;\n // The MODERNITY fallback is defence for a FUTURE override: every entry in the\n // current table lists at least two transports, so an override can never miss all\n // present candidates while more than one is present \u2014 today it is unobservable.\n const owner = preference.find(t => candidates.includes(t)) ?? MODERNITY.find(t => candidates.includes(t));\n return owner ?? candidates[0];\n}\n\n/**\n * Build the owner map for a device: given which capability keys each present transport offers,\n * resolve every key to exactly one owning transport (via {@link pickOwner}). This is the\n * dedup decision behind the unified object tree \u2014 each state is written by one transport only.\n *\n * @param offered the capability keys each present transport offers (absent transport = none)\n * @returns a map from capability key to its owning transport\n */\nexport function resolveOwnership(offered: Partial<Record<Transport, readonly string[]>>): Map<string, Transport> {\n const candidates = new Map<string, Transport[]>();\n // Iterate in modernity order so the candidate lists are deterministic.\n for (const transport of MODERNITY) {\n for (const key of offered[transport] ?? []) {\n const list = candidates.get(key);\n if (list) {\n list.push(transport);\n } else {\n candidates.set(key, [transport]);\n }\n }\n }\n const owners = new Map<string, Transport>();\n for (const [key, cands] of candidates) {\n owners.set(key, pickOwner(key, cands));\n }\n return owners;\n}\n"],
5
+ "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAQA,MAAM,YAAkC,CAAC,OAAO,QAAQ,KAAK;AAQ7D,MAAM,kBAAwD;AAAA;AAAA,EAE5D,QAAQ,CAAC,QAAQ,OAAO,KAAK;AAAA;AAAA,EAE7B,sBAAsB,CAAC,QAAQ,OAAO,KAAK;AAAA;AAAA;AAAA;AAAA;AAAA,EAK3C,mBAAmB,CAAC,QAAQ,KAAK;AAAA,EACjC,iBAAiB,CAAC,QAAQ,KAAK;AAAA,EAC/B,kBAAkB,CAAC,QAAQ,KAAK;AAAA,EAChC,mBAAmB,CAAC,QAAQ,OAAO,KAAK;AAAA,EACxC,qBAAqB,CAAC,QAAQ,OAAO,KAAK;AAAA,EAC1C,yBAAyB,CAAC,QAAQ,KAAK;AAAA,EACvC,sBAAsB,CAAC,OAAO,KAAK;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAMnC,gBAAgB,CAAC,OAAO,OAAO,MAAM;AAAA;AAAA,EAErC,OAAO,CAAC,QAAQ,OAAO,KAAK;AAAA,EAC5B,cAAc,CAAC,QAAQ,OAAO,KAAK;AAAA,EACnC,OAAO,CAAC,QAAQ,OAAO,KAAK;AAAA,EAC5B,cAAc,CAAC,QAAQ,KAAK;AAC9B;AAOA,MAAM,WAAyE;AAAA,EAC7E,KAAK,EAAE,iBAAiB,uBAAuB,yBAAyB,kBAAkB;AAAA,EAC1F,KAAK,EAAE,UAAU,aAAa,UAAU,YAAY;AACtD;AAWO,SAAS,gBAAgB,WAAsB,SAAyB;AAhE/E;AAiEE,QAAM,WAAW,QAAQ,QAAQ,gCAAgC,EAAE;AACnE,UAAO,oBAAS,SAAS,MAAlB,mBAAsB,cAAtB,YAAmC;AAC5C;AAYO,SAAS,cAAc,WAAsB,SAAyB;AA/E7E;AAgFE,QAAM,QAAO,0CAA+B,KAAK,OAAO,MAA3C,mBAA+C,OAA/C,YAAqD;AAClE,QAAM,WAAW,QAAQ,MAAM,KAAK,MAAM;AAC1C,QAAM,YAAW,oBAAS,SAAS,MAAlB,mBAAsB,cAAtB,YAAmC;AACpD,MAAI,QAAQ,CAAC,KAAK,WAAW,YAAY,GAAG;AAC1C,WAAO,aAAa,IAAI,GAAG,QAAQ;AAAA,EACrC;AACA,SAAO,OAAO;AAChB;AAYO,SAAS,UAAU,KAAa,YAA6C;AAnGpF;AAoGE,QAAM,cAAa,qBAAgB,GAAG,MAAnB,YAAwB;AAI3C,QAAM,SAAQ,gBAAW,KAAK,OAAK,WAAW,SAAS,CAAC,CAAC,MAA3C,YAAgD,UAAU,KAAK,OAAK,WAAW,SAAS,CAAC,CAAC;AACxG,SAAO,wBAAS,WAAW,CAAC;AAC9B;AAUO,SAAS,iBAAiB,SAAgF;AApHjH;AAqHE,QAAM,aAAa,oBAAI,IAAyB;AAEhD,aAAW,aAAa,WAAW;AACjC,eAAW,QAAO,aAAQ,SAAS,MAAjB,YAAsB,CAAC,GAAG;AAC1C,YAAM,OAAO,WAAW,IAAI,GAAG;AAC/B,UAAI,MAAM;AACR,aAAK,KAAK,SAAS;AAAA,MACrB,OAAO;AACL,mBAAW,IAAI,KAAK,CAAC,SAAS,CAAC;AAAA,MACjC;AAAA,IACF;AAAA,EACF;AACA,QAAM,SAAS,oBAAI,IAAuB;AAC1C,aAAW,CAAC,KAAK,KAAK,KAAK,YAAY;AACrC,WAAO,IAAI,KAAK,UAAU,KAAK,KAAK,CAAC;AAAA,EACvC;AACA,SAAO;AACT;",
6
6
  "names": []
7
7
  }
@@ -0,0 +1,81 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+ var scene_titles_exports = {};
20
+ __export(scene_titles_exports, {
21
+ knownScenes: () => knownScenes,
22
+ resolveSceneNumber: () => resolveSceneNumber,
23
+ sceneStatesMap: () => sceneStatesMap
24
+ });
25
+ module.exports = __toCommonJS(scene_titles_exports);
26
+ var import_protocol = require("../xml/protocol");
27
+ function knownScenes(memory, zoneKey) {
28
+ var _a;
29
+ if (!memory) {
30
+ return [];
31
+ }
32
+ const xml = memory.remembered(`xmlScenes:${zoneKey}`);
33
+ if (typeof xml === "string" && xml.length > 0) {
34
+ const scenes = (0, import_protocol.parseSceneList)(xml);
35
+ if (scenes.length > 0) {
36
+ return scenes;
37
+ }
38
+ }
39
+ if (zoneKey === "main") {
40
+ const statics = memory.remembered("yncaStaticValues");
41
+ const main = (_a = statics == null ? void 0 : statics.MAIN) != null ? _a : {};
42
+ const scenes = [];
43
+ for (let n = 1; n <= 12; n++) {
44
+ const title = main[`SCENE${n}NAME`];
45
+ if (typeof title === "string" && title.length > 0) {
46
+ scenes.push({ num: n, title });
47
+ }
48
+ }
49
+ return scenes;
50
+ }
51
+ return [];
52
+ }
53
+ function resolveSceneNumber(value, memory, zoneKey) {
54
+ if (typeof value === "number" && Number.isFinite(value)) {
55
+ return Math.round(value);
56
+ }
57
+ if (typeof value !== "string") {
58
+ return void 0;
59
+ }
60
+ const trimmed = value.trim();
61
+ if (/^\d+$/.test(trimmed)) {
62
+ return Number(trimmed);
63
+ }
64
+ const needle = trimmed.toLowerCase();
65
+ const match = knownScenes(memory, zoneKey).find((scene) => scene.title.toLowerCase() === needle);
66
+ return match == null ? void 0 : match.num;
67
+ }
68
+ function sceneStatesMap(memory, zoneKey) {
69
+ const scenes = knownScenes(memory, zoneKey);
70
+ if (scenes.length === 0) {
71
+ return void 0;
72
+ }
73
+ return Object.fromEntries(scenes.map((scene) => [scene.num, scene.title]));
74
+ }
75
+ // Annotate the CommonJS export names for ESM import in node:
76
+ 0 && (module.exports = {
77
+ knownScenes,
78
+ resolveSceneNumber,
79
+ sceneStatesMap
80
+ });
81
+ //# sourceMappingURL=scene-titles.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/lib/catalog/scene-titles.ts"],
4
+ "sourcesContent": ["import type { ProbeMemory } from \"../lifecycle/probe-memory\";\nimport { parseSceneList } from \"../xml/protocol\";\n\n/**\n * Scene titles, cross-transport. The device reports its scene titles over XML\n * (`Scene_Sel_Item`, per zone) and/or YNCA (`SCENExNAME`, main zone) \u2014 while the\n * scene RECALL may be owned by a third transport (MusicCast). Both title sources\n * land in the shared per-device probe memory, so every controller can resolve a\n * written title to its number and render the one `scene.list` state, regardless\n * of which transport owns the write.\n */\n\n/** One scene for the `scene.list` JSON state. */\nexport interface SceneListEntry {\n /** The 1-based scene number (the recall value). */\n num: number;\n /** The scene's title as the device reports it. */\n title: string;\n}\n\n/** The probe-memory shape of the YNCA static values (see device-controller STATIC_KEY). */\ntype YncaStatics = Record<string, Record<string, string>>;\n\n/**\n * The known scenes of a zone, from whichever transport reported titles: the XML\n * declaration first (per zone), the YNCA scene names as the fallback (main only).\n *\n * @param memory the device's shared probe memory\n * @param zoneKey the zone (`main`, `zone2`, \u2026)\n * @returns the scenes with titles, empty when no transport reported any\n */\nexport function knownScenes(memory: ProbeMemory | undefined, zoneKey: string): SceneListEntry[] {\n if (!memory) {\n return [];\n }\n const xml = memory.remembered<string>(`xmlScenes:${zoneKey}`);\n if (typeof xml === \"string\" && xml.length > 0) {\n // Parsed lazily from the remembered raw declaration \u2014 ONE stored form, one parser.\n const scenes = parseSceneList(xml);\n if (scenes.length > 0) {\n return scenes;\n }\n }\n if (zoneKey === \"main\") {\n const statics = memory.remembered<YncaStatics>(\"yncaStaticValues\");\n const main = statics?.MAIN ?? {};\n const scenes: SceneListEntry[] = [];\n for (let n = 1; n <= 12; n++) {\n const title = main[`SCENE${n}NAME`];\n if (typeof title === \"string\" && title.length > 0) {\n scenes.push({ num: n, title });\n }\n }\n return scenes;\n }\n return [];\n}\n\n/**\n * Resolve a scene-recall write to its number: a number (or numeric string) passes\n * through, a TITLE is looked up case-insensitively in the zone's known scenes \u2014\n * so `scene.recall = \"Movie Viewing\"` works wherever the device reported titles\n * (the govee dual-write pattern).\n *\n * @param value the written value\n * @param memory the device's shared probe memory\n * @param zoneKey the zone (`main`, `zone2`, \u2026)\n * @returns the scene number, or undefined when unresolvable\n */\nexport function resolveSceneNumber(\n value: unknown,\n memory: ProbeMemory | undefined,\n zoneKey: string,\n): number | undefined {\n if (typeof value === \"number\" && Number.isFinite(value)) {\n return Math.round(value);\n }\n if (typeof value !== \"string\") {\n return undefined;\n }\n const trimmed = value.trim();\n if (/^\\d+$/.test(trimmed)) {\n return Number(trimmed);\n }\n const needle = trimmed.toLowerCase();\n const match = knownScenes(memory, zoneKey).find(scene => scene.title.toLowerCase() === needle);\n return match?.num;\n}\n\n/**\n * The dropdown label map for a zone's scene recall (number \u2192 title), or undefined\n * when no transport reported titles.\n *\n * @param memory the device's shared probe memory\n * @param zoneKey the zone\n * @returns the states map, or undefined\n */\nexport function sceneStatesMap(memory: ProbeMemory | undefined, zoneKey: string): Record<string, string> | undefined {\n const scenes = knownScenes(memory, zoneKey);\n if (scenes.length === 0) {\n return undefined;\n }\n return Object.fromEntries(scenes.map(scene => [scene.num, scene.title]));\n}\n"],
5
+ "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AACA,sBAA+B;AA8BxB,SAAS,YAAY,QAAiC,SAAmC;AA/BhG;AAgCE,MAAI,CAAC,QAAQ;AACX,WAAO,CAAC;AAAA,EACV;AACA,QAAM,MAAM,OAAO,WAAmB,aAAa,OAAO,EAAE;AAC5D,MAAI,OAAO,QAAQ,YAAY,IAAI,SAAS,GAAG;AAE7C,UAAM,aAAS,gCAAe,GAAG;AACjC,QAAI,OAAO,SAAS,GAAG;AACrB,aAAO;AAAA,IACT;AAAA,EACF;AACA,MAAI,YAAY,QAAQ;AACtB,UAAM,UAAU,OAAO,WAAwB,kBAAkB;AACjE,UAAM,QAAO,wCAAS,SAAT,YAAiB,CAAC;AAC/B,UAAM,SAA2B,CAAC;AAClC,aAAS,IAAI,GAAG,KAAK,IAAI,KAAK;AAC5B,YAAM,QAAQ,KAAK,QAAQ,CAAC,MAAM;AAClC,UAAI,OAAO,UAAU,YAAY,MAAM,SAAS,GAAG;AACjD,eAAO,KAAK,EAAE,KAAK,GAAG,MAAM,CAAC;AAAA,MAC/B;AAAA,IACF;AACA,WAAO;AAAA,EACT;AACA,SAAO,CAAC;AACV;AAaO,SAAS,mBACd,OACA,QACA,SACoB;AACpB,MAAI,OAAO,UAAU,YAAY,OAAO,SAAS,KAAK,GAAG;AACvD,WAAO,KAAK,MAAM,KAAK;AAAA,EACzB;AACA,MAAI,OAAO,UAAU,UAAU;AAC7B,WAAO;AAAA,EACT;AACA,QAAM,UAAU,MAAM,KAAK;AAC3B,MAAI,QAAQ,KAAK,OAAO,GAAG;AACzB,WAAO,OAAO,OAAO;AAAA,EACvB;AACA,QAAM,SAAS,QAAQ,YAAY;AACnC,QAAM,QAAQ,YAAY,QAAQ,OAAO,EAAE,KAAK,WAAS,MAAM,MAAM,YAAY,MAAM,MAAM;AAC7F,SAAO,+BAAO;AAChB;AAUO,SAAS,eAAe,QAAiC,SAAqD;AACnH,QAAM,SAAS,YAAY,QAAQ,OAAO;AAC1C,MAAI,OAAO,WAAW,GAAG;AACvB,WAAO;AAAA,EACT;AACA,SAAO,OAAO,YAAY,OAAO,IAAI,WAAS,CAAC,MAAM,KAAK,MAAM,KAAK,CAAC,CAAC;AACzE;",
6
+ "names": []
7
+ }
@@ -35,9 +35,11 @@ const CHANNEL_NAMES = {
35
35
  hdmi: "HDMI",
36
36
  speakers: "Speakers",
37
37
  scene: "Scenes",
38
+ remote: "Remote control",
38
39
  inputNames: "Input names",
39
40
  initialVolume: "Initial volume",
40
- lipSync: "Lip sync",
41
+ equalizer: "Equalizer",
42
+ signal: "Audio signal",
41
43
  // Tuner
42
44
  tuner: "Tuner",
43
45
  dab: "DAB",