iobroker.yamaha 2.0.3 → 2.0.4

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 (42) hide show
  1. package/README.md +10 -13
  2. package/admin/i18n/de.json +0 -2
  3. package/admin/i18n/en.json +0 -2
  4. package/admin/i18n/es.json +0 -2
  5. package/admin/i18n/fr.json +0 -2
  6. package/admin/i18n/it.json +0 -2
  7. package/admin/i18n/nl.json +0 -2
  8. package/admin/i18n/pl.json +0 -2
  9. package/admin/i18n/pt.json +0 -2
  10. package/admin/i18n/ru.json +0 -2
  11. package/admin/i18n/uk.json +0 -2
  12. package/admin/i18n/zh-cn.json +0 -2
  13. package/build/lib/browse/yxc-browse-driver.js +1 -1
  14. package/build/lib/browse/yxc-browse-driver.js.map +2 -2
  15. package/build/lib/catalog/object-tree-coordinator.js +1 -7
  16. package/build/lib/catalog/object-tree-coordinator.js.map +2 -2
  17. package/build/lib/catalog/owner-policy.js +7 -0
  18. package/build/lib/catalog/owner-policy.js.map +2 -2
  19. package/build/lib/lifecycle/reconnect-strategy.js +1 -1
  20. package/build/lib/lifecycle/reconnect-strategy.js.map +2 -2
  21. package/build/lib/util.js +3 -0
  22. package/build/lib/util.js.map +2 -2
  23. package/build/lib/xml/device-controller.js +54 -39
  24. package/build/lib/xml/device-controller.js.map +2 -2
  25. package/build/lib/xml/protocol.js +18 -0
  26. package/build/lib/xml/protocol.js.map +2 -2
  27. package/build/lib/xml/xml-client.js +11 -2
  28. package/build/lib/xml/xml-client.js.map +2 -2
  29. package/build/lib/ynca/ynca-client.js +7 -3
  30. package/build/lib/ynca/ynca-client.js.map +2 -2
  31. package/build/lib/yxc/device-controller.js +13 -8
  32. package/build/lib/yxc/device-controller.js.map +2 -2
  33. package/build/lib/yxc/http-client.js +10 -1
  34. package/build/lib/yxc/http-client.js.map +2 -2
  35. package/build/lib/yxc/object-mapper.js +12 -40
  36. package/build/lib/yxc/object-mapper.js.map +2 -2
  37. package/build/lib/yxc/zones.js +0 -9
  38. package/build/lib/yxc/zones.js.map +2 -2
  39. package/build/main.js +90 -12
  40. package/build/main.js.map +2 -2
  41. package/io-package.json +14 -14
  42. package/package.json +3 -3
package/README.md CHANGED
@@ -103,6 +103,16 @@ On the very first contact the adapter asks the receiver which functions it suppo
103
103
  Placeholder for the next version (at the beginning of the line):
104
104
  ### **WORK IN PROGRESS**
105
105
  -->
106
+ ### 2.0.4 (2026-09-02)
107
+
108
+ - (krobipd) Fixed: a short outage of the ioBroker database no longer restarts the adapter — a failed write is logged once and the next value is written normally
109
+ - (krobipd) Fixed: the protocol badges on the device card are reset when a device starts and when the adapter stops, so a crash no longer leaves a green badge next to a red dot
110
+ - (krobipd) Fixed: stopping or restarting the adapter while it is still searching the network now stops it completely — it no longer keeps working half-started until the next restart
111
+ - (krobipd) Fixed: the scene list keeps its titles on MusicCast receivers that also answer over XML or YNCA — the title-less MusicCast list no longer replaces it at startup
112
+ - (krobipd) Fixed: a timeout while probing an older receiver for scenes or menus is asked again on the next connect instead of being remembered as "none" until the next restart
113
+ - (krobipd) Fixed: writing one equalizer band before the receiver reported all three no longer sends zeros for the other two — the zone status is fetched first
114
+ - (krobipd) Improved: a receiver that is off for a while is retried no later than the configured maximum wait, and an oversized answer from a wrong host can no longer eat the adapter's memory
115
+
106
116
  ### 2.0.3 (2026-09-01)
107
117
 
108
118
  - (krobipd) Fixed: the update cleanup now removes every never-filled leftover datapoint — a history recording setting no longer shields it, because nothing was ever recorded there and nothing is lost
@@ -128,19 +138,6 @@ On the very first contact the adapter asks the receiver which functions it suppo
128
138
  - (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
129
139
  - (krobipd) Fixed: restarting or updating the adapter while a receiver was still connecting no longer produces shutdown warnings about timers in the log
130
140
 
131
- ### 1.7.0 (2026-09-01)
132
-
133
- - (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)
134
- - (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
135
- - (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)
136
- - (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
137
- - (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
138
- - (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
139
- - (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
140
- - (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
141
- - (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
142
- - (krobipd) Improved: known devices no longer wait for the network search at startup — it runs in the background and only adds newcomers
143
-
144
141
  [Older changelogs can be found there](CHANGELOG_OLD.md)
145
142
 
146
143
  ## History
@@ -1,7 +1,5 @@
1
1
  {
2
2
  "header_devices": "Geräte",
3
- "introText": "Lass die Tabelle leer, dann findet der Adapter MusicCast-Geräte beim Start selbst im Netzwerk — nichts einzutragen. Trage Geräte (Name + IP) ein, um stattdessen nur diese zu nutzen. Ältere Receiver (vor ~2010, XML-Protokoll) melden sich nicht im Netzwerk und müssen hier immer manuell eingetragen werden.",
4
- "devicesTable": "Yamaha-Geräte",
5
3
  "columnName": "Name",
6
4
  "columnIp": "IP-Adresse",
7
5
  "label_xmlPollInterval": "XML-Abfrageintervall (Sekunden)",
@@ -1,7 +1,5 @@
1
1
  {
2
2
  "header_devices": "Devices",
3
- "introText": "Leave the table empty and the adapter finds MusicCast devices on the network by itself at startup — nothing to enter. Add devices (name + IP) to use only those instead. Older receivers (before ~2010, XML protocol) do not announce themselves on the network and must always be added here manually.",
4
- "devicesTable": "Yamaha devices",
5
3
  "columnName": "Name",
6
4
  "columnIp": "IP address",
7
5
  "label_xmlPollInterval": "XML query interval (seconds)",
@@ -1,7 +1,5 @@
1
1
  {
2
2
  "header_devices": "Dispositivos",
3
- "introText": "Deja la tabla vacía y el adaptador encuentra por sí mismo los dispositivos MusicCast en la red al iniciar — nada que introducir. Añade dispositivos (nombre + IP) para usar solo esos. Los receptores antiguos (antes de ~2010, protocolo XML) no se anuncian en la red y siempre se añaden aquí manualmente.",
4
- "devicesTable": "Dispositivos Yamaha",
5
3
  "columnName": "Nombre",
6
4
  "columnIp": "Dirección IP",
7
5
  "label_xmlPollInterval": "Intervalo de consulta XML (segundos)",
@@ -1,7 +1,5 @@
1
1
  {
2
2
  "header_devices": "Appareils",
3
- "introText": "Laissez le tableau vide et l'adaptateur trouve lui-même les appareils MusicCast sur le réseau au démarrage — rien à saisir. Ajoutez des appareils (nom + IP) pour n'utiliser que ceux-là. Les récepteurs anciens (avant ~2010, protocole XML) ne s'annoncent pas sur le réseau et doivent toujours être ajoutés ici manuellement.",
4
- "devicesTable": "Appareils Yamaha",
5
3
  "columnName": "Nom",
6
4
  "columnIp": "Adresse IP",
7
5
  "label_xmlPollInterval": "Intervalle d'interrogation XML (secondes)",
@@ -1,7 +1,5 @@
1
1
  {
2
2
  "header_devices": "Dispositivi",
3
- "introText": "Lascia la tabella vuota e l'adattatore trova da solo i dispositivi MusicCast sulla rete all'avvio — niente da inserire. Aggiungi dispositivi (nome + IP) per usare solo quelli. I ricevitori più vecchi (prima del ~2010, protocollo XML) non si annunciano sulla rete e vanno sempre aggiunti qui manualmente.",
4
- "devicesTable": "Dispositivi Yamaha",
5
3
  "columnName": "Nome",
6
4
  "columnIp": "Indirizzo IP",
7
5
  "label_xmlPollInterval": "Intervallo di interrogazione XML (secondi)",
@@ -1,7 +1,5 @@
1
1
  {
2
2
  "header_devices": "Apparaten",
3
- "introText": "Laat de tabel leeg en de adapter vindt bij het opstarten zelf MusicCast-apparaten op het netwerk — niets in te voeren. Voeg apparaten (naam + IP) toe om alleen die te gebruiken. Oudere receivers (van vóór ~2010, XML-protocol) melden zich niet op het netwerk en moeten hier altijd handmatig worden toegevoegd.",
4
- "devicesTable": "Yamaha-apparaten",
5
3
  "columnName": "Naam",
6
4
  "columnIp": "IP-adres",
7
5
  "label_xmlPollInterval": "XML-opvraaginterval (seconden)",
@@ -1,7 +1,5 @@
1
1
  {
2
2
  "header_devices": "Urządzenia",
3
- "introText": "Pozostaw tabelę pustą, a adapter sam znajdzie urządzenia MusicCast w sieci przy starcie — nic nie trzeba wpisywać. Dodaj urządzenia (nazwa + IP), aby używać tylko ich. Starsze amplitunery (sprzed ~2010, protokół XML) nie zgłaszają się w sieci i zawsze dodaje się je tutaj ręcznie.",
4
- "devicesTable": "Urządzenia Yamaha",
5
3
  "columnName": "Nazwa",
6
4
  "columnIp": "Adres IP",
7
5
  "label_xmlPollInterval": "Interwał odpytywania XML (sekundy)",
@@ -1,7 +1,5 @@
1
1
  {
2
2
  "header_devices": "Dispositivos",
3
- "introText": "Deixe a tabela vazia e o adaptador encontra sozinho os dispositivos MusicCast na rede ao arrancar — nada a introduzir. Adicione dispositivos (nome + IP) para usar apenas esses. Recetores antigos (antes de ~2010, protocolo XML) não se anunciam na rede e devem ser sempre adicionados aqui manualmente.",
4
- "devicesTable": "Dispositivos Yamaha",
5
3
  "columnName": "Nome",
6
4
  "columnIp": "Endereço IP",
7
5
  "label_xmlPollInterval": "Intervalo de consulta XML (segundos)",
@@ -1,7 +1,5 @@
1
1
  {
2
2
  "header_devices": "Устройства",
3
- "introText": "Оставьте таблицу пустой, и адаптер при запуске сам найдёт устройства MusicCast в сети — ничего вводить не нужно. Добавьте устройства (имя + IP), чтобы использовать только их. Старые ресиверы (до ~2010, протокол XML) не объявляют себя в сети и всегда добавляются здесь вручную.",
4
- "devicesTable": "Устройства Yamaha",
5
3
  "columnName": "Имя",
6
4
  "columnIp": "IP-адрес",
7
5
  "label_xmlPollInterval": "Интервал опроса XML (секунды)",
@@ -1,7 +1,5 @@
1
1
  {
2
2
  "header_devices": "Пристрої",
3
- "introText": "Залиште таблицю порожньою, і адаптер під час запуску сам знайде пристрої MusicCast у мережі — нічого вводити не потрібно. Додайте пристрої (ім'я + IP), щоб використовувати лише їх. Старіші ресивери (до ~2010, протокол XML) не оголошують себе в мережі й завжди додаються тут вручну.",
4
- "devicesTable": "Пристрої Yamaha",
5
3
  "columnName": "Назва",
6
4
  "columnIp": "IP-адреса",
7
5
  "label_xmlPollInterval": "Інтервал опитування XML (секунди)",
@@ -1,7 +1,5 @@
1
1
  {
2
2
  "header_devices": "设备",
3
- "introText": "将表格留空,适配器会在启动时自动在网络中查找 MusicCast 设备——无需输入。添加设备(名称 + IP)则只使用这些设备。较旧的接收机(约 2010 年之前,XML 协议)不会在网络中通告自己,必须始终在此手动添加。",
4
- "devicesTable": "Yamaha 设备",
5
3
  "columnName": "名称",
6
4
  "columnIp": "IP 地址",
7
5
  "label_xmlPollInterval": "XML 查询间隔(秒)",
@@ -148,7 +148,7 @@ class YxcBrowseDriver {
148
148
  return void 0;
149
149
  }
150
150
  const response = await this.client.getListInfo(this.active.input, this.index);
151
- if (!response || typeof response.response_code === "number" && response.response_code !== 0) {
151
+ if (!response) {
152
152
  return void 0;
153
153
  }
154
154
  const entries = Array.isArray(response.list_info) ? response.list_info : [];
@@ -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 /**\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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",
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 // A refusal (response_code != 0) never arrives here: the client's transport already\n // turns it into an error, which the engine reports. Only a null body is left to guard.\n const response = (await this.client.getListInfo(this.active.input, this.index)) as Record<string, unknown> | null;\n if (!response) {\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
  }
@@ -23,7 +23,6 @@ __export(object_tree_coordinator_exports, {
23
23
  module.exports = __toCommonJS(object_tree_coordinator_exports);
24
24
  var import_owner_policy = require("./owner-policy");
25
25
  function coordinateObjectTree(contributions) {
26
- const order = [];
27
26
  const byId = /* @__PURE__ */ new Map();
28
27
  for (const { transport, objects } of contributions) {
29
28
  for (const obj of objects) {
@@ -32,17 +31,12 @@ function coordinateObjectTree(contributions) {
32
31
  if (!entry) {
33
32
  entry = { key: (0, import_owner_policy.capabilityKeyOf)(transport, obj.id), defs: /* @__PURE__ */ new Map() };
34
33
  byId.set(canonicalId, entry);
35
- order.push(canonicalId);
36
34
  }
37
35
  entry.defs.set(transport, obj);
38
36
  }
39
37
  }
40
38
  const ownerByCanonicalId = /* @__PURE__ */ new Map();
41
- const resolved = order.map((canonicalId) => {
42
- const entry = byId.get(canonicalId);
43
- if (!entry) {
44
- throw new Error(`coordinateObjectTree: missing entry for ${canonicalId}`);
45
- }
39
+ const resolved = [...byId].map(([canonicalId, entry]) => {
46
40
  const owner = (0, import_owner_policy.pickOwner)(entry.key, [...entry.defs.keys()]);
47
41
  ownerByCanonicalId.set(canonicalId, owner);
48
42
  const ownerDef = entry.defs.get(owner);
@@ -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 // 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;",
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 // Map iteration follows insertion order, so first-seen order needs no side list.\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 }\n entry.defs.set(transport, obj);\n }\n }\n const ownerByCanonicalId = new Map<string, Transport>();\n const resolved: ObjectDef[] = [...byId].map(([canonicalId, entry]) => {\n const owner = pickOwner(entry.key, [...entry.defs.keys()]);\n ownerByCanonicalId.set(canonicalId, owner);\n const ownerDef = entry.defs.get(owner);\n // Internal invariant, not a reachable state: pickOwner always returns one of the\n // candidates it was handed (= this entry's own defs). It exists so a future change\n // to pickOwner fails loudly instead of writing an empty object.\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;AAEA,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;AAAA,MAC7B;AACA,YAAM,KAAK,IAAI,WAAW,GAAG;AAAA,IAC/B;AAAA,EACF;AACA,QAAM,qBAAqB,oBAAI,IAAuB;AACtD,QAAM,WAAwB,CAAC,GAAG,IAAI,EAAE,IAAI,CAAC,CAAC,aAAa,KAAK,MAAM;AACpE,UAAM,YAAQ,+BAAU,MAAM,KAAK,CAAC,GAAG,MAAM,KAAK,KAAK,CAAC,CAAC;AACzD,uBAAmB,IAAI,aAAa,KAAK;AACzC,UAAM,WAAW,MAAM,KAAK,IAAI,KAAK;AAIrC,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
  }
@@ -47,6 +47,13 @@ const OWNER_OVERRIDES = {
47
47
  // and the 2012 generation (RX-V473/V475) answers the YNCA scene put with
48
48
  // @RESTRICTED (ynca-python PRACTICALITIES). The proven writers go first.
49
49
  "scene.recall": ["yxc", "xml", "ynca"],
50
+ // The scene LIST is presentation: number + title per slot. MusicCast knows the slot
51
+ // COUNT but no titles; XML declares the titles per zone, YNCA the main zone's names.
52
+ // By modernity MusicCast would own it and — connecting in seconds while the YNCA
53
+ // names ride a 19 s sweep — publish a title-less list on the first contact that then
54
+ // stands until the next restart. The title sources come first; a MusicCast-only
55
+ // device still owns its list alone.
56
+ "scene.list": ["xml", "ynca", "yxc"],
50
57
  // §3d richness loss — YNCA carries an enum dropdown that YXC/XML flatten to a free string.
51
58
  input: ["ynca", "yxc", "xml"],
52
59
  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 // \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;",
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 // The scene LIST is presentation: number + title per slot. MusicCast knows the slot\n // COUNT but no titles; XML declares the titles per zone, YNCA the main zone's names.\n // By modernity MusicCast would own it and \u2014 connecting in seconds while the YNCA\n // names ride a 19 s sweep \u2014 publish a title-less list on the first contact that then\n // stands until the next restart. The title sources come first; a MusicCast-only\n // device still owns its list alone.\n \"scene.list\": [\"xml\", \"ynca\", \"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;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;AAAA;AAAA;AAAA;AAAA;AAAA,EAOrC,cAAc,CAAC,OAAO,QAAQ,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;AAvE/E;AAwEE,QAAM,WAAW,QAAQ,QAAQ,gCAAgC,EAAE;AACnE,UAAO,oBAAS,SAAS,MAAlB,mBAAsB,cAAtB,YAAmC;AAC5C;AAYO,SAAS,cAAc,WAAsB,SAAyB;AAtF7E;AAuFE,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;AA1GpF;AA2GE,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;AA3HjH;AA4HE,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
  }
@@ -42,7 +42,7 @@ class ReconnectStrategy {
42
42
  nextDelay() {
43
43
  const delay = Math.min(this.baseMs * 2 ** this.attempt, this.maxMs);
44
44
  this.attempt++;
45
- return Math.round(delay * (1 + this.jitter * Math.random()));
45
+ return Math.round(delay * (1 - this.jitter * Math.random()));
46
46
  }
47
47
  /** Reset the backoff to the base delay after a successful connection. */
48
48
  reset() {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/lifecycle/reconnect-strategy.ts"],
4
- "sourcesContent": ["/**\n * Exponential backoff for reconnect attempts, capped at a maximum delay and spread by a\n * small random offset.\n *\n * The offset matters once more than one device is configured: a router reboot or a switch\n * losing power takes every receiver \u2014 and every one of their transports \u2014 down at the same\n * moment, so without it they all retry in lockstep for as long as the outage lasts, each\n * wave hitting the network together. Up to 20 % jitter breaks the convoy apart while\n * keeping the backoff's shape.\n */\nexport class ReconnectStrategy {\n private attempt = 0;\n\n /**\n * @param baseMs the first delay in milliseconds\n * @param maxMs the maximum delay in milliseconds\n * @param jitter fraction of the delay to spread randomly (0 disables it \u2014 used by tests\n * that assert exact delays)\n */\n public constructor(\n private readonly baseMs: number,\n private readonly maxMs: number,\n private readonly jitter = 0.2,\n ) {}\n\n /**\n * Get the next backoff delay and advance the attempt counter.\n *\n * @returns the delay in milliseconds for the next reconnect attempt\n */\n public nextDelay(): number {\n const delay = Math.min(this.baseMs * 2 ** this.attempt, this.maxMs);\n this.attempt++;\n return Math.round(delay * (1 + this.jitter * Math.random()));\n }\n\n /** Reset the backoff to the base delay after a successful connection. */\n public reset(): void {\n this.attempt = 0;\n }\n}\n"],
5
- "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAUO,MAAM,kBAAkB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAStB,YACY,QACA,OACA,SAAS,KAC1B;AAHiB;AACA;AACA;AAAA,EAChB;AAAA,EAZK,UAAU;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAmBX,YAAoB;AACzB,UAAM,QAAQ,KAAK,IAAI,KAAK,SAAS,KAAK,KAAK,SAAS,KAAK,KAAK;AAClE,SAAK;AACL,WAAO,KAAK,MAAM,SAAS,IAAI,KAAK,SAAS,KAAK,OAAO,EAAE;AAAA,EAC7D;AAAA;AAAA,EAGO,QAAc;AACnB,SAAK,UAAU;AAAA,EACjB;AACF;",
4
+ "sourcesContent": ["/**\n * Exponential backoff for reconnect attempts, capped at a maximum delay and spread by a\n * small random offset BELOW it.\n *\n * The offset matters once more than one device is configured: a router reboot or a switch\n * losing power takes every receiver \u2014 and every one of their transports \u2014 down at the same\n * moment, so without it they all retry in lockstep for as long as the outage lasts, each\n * wave hitting the network together. Up to 20 % jitter breaks the convoy apart while\n * keeping the backoff's shape. It spreads downward so the cap stays a cap (an upward\n * spread let the \"60 s ceiling\" reach 72 s) \u2014 and so the spread still works AT the\n * ceiling, where every device of a long outage ends up.\n */\nexport class ReconnectStrategy {\n private attempt = 0;\n\n /**\n * @param baseMs the first delay in milliseconds\n * @param maxMs the maximum delay in milliseconds\n * @param jitter fraction of the delay to spread randomly (0 disables it \u2014 used by tests\n * that assert exact delays)\n */\n public constructor(\n private readonly baseMs: number,\n private readonly maxMs: number,\n private readonly jitter = 0.2,\n ) {}\n\n /**\n * Get the next backoff delay and advance the attempt counter.\n *\n * @returns the delay in milliseconds for the next reconnect attempt\n */\n public nextDelay(): number {\n const delay = Math.min(this.baseMs * 2 ** this.attempt, this.maxMs);\n this.attempt++;\n return Math.round(delay * (1 - this.jitter * Math.random()));\n }\n\n /** Reset the backoff to the base delay after a successful connection. */\n public reset(): void {\n this.attempt = 0;\n }\n}\n"],
5
+ "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAYO,MAAM,kBAAkB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAStB,YACY,QACA,OACA,SAAS,KAC1B;AAHiB;AACA;AACA;AAAA,EAChB;AAAA,EAZK,UAAU;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAmBX,YAAoB;AACzB,UAAM,QAAQ,KAAK,IAAI,KAAK,SAAS,KAAK,KAAK,SAAS,KAAK,KAAK;AAClE,SAAK;AACL,WAAO,KAAK,MAAM,SAAS,IAAI,KAAK,SAAS,KAAK,OAAO,EAAE;AAAA,EAC7D;AAAA;AAAA,EAGO,QAAc;AACnB,SAAK,UAAU;AAAA,EACjB;AACF;",
6
6
  "names": []
7
7
  }
package/build/lib/util.js CHANGED
@@ -18,14 +18,17 @@ var __copyProps = (to, from, except, desc) => {
18
18
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
19
  var util_exports = {};
20
20
  __export(util_exports, {
21
+ MAX_HTTP_BODY_BYTES: () => MAX_HTTP_BODY_BYTES,
21
22
  errorMessage: () => errorMessage
22
23
  });
23
24
  module.exports = __toCommonJS(util_exports);
24
25
  function errorMessage(e) {
25
26
  return e instanceof Error ? e.message : String(e);
26
27
  }
28
+ const MAX_HTTP_BODY_BYTES = 1024 * 1024;
27
29
  // Annotate the CommonJS export names for ESM import in node:
28
30
  0 && (module.exports = {
31
+ MAX_HTTP_BODY_BYTES,
29
32
  errorMessage
30
33
  });
31
34
  //# sourceMappingURL=util.js.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/lib/util.ts"],
4
- "sourcesContent": ["/**\n * The message of an unknown thrown value \u2014 `Error.message` when it is an Error, else\n * its string form. One helper for the `e instanceof Error ? e.message : String(e)`\n * idiom the transports would otherwise repeat in every catch.\n *\n * @param e the caught value\n * @returns a human-readable message\n */\nexport function errorMessage(e: unknown): string {\n return e instanceof Error ? e.message : String(e);\n}\n"],
5
- "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAQO,SAAS,aAAa,GAAoB;AAC/C,SAAO,aAAa,QAAQ,EAAE,UAAU,OAAO,CAAC;AAClD;",
4
+ "sourcesContent": ["/**\n * The message of an unknown thrown value \u2014 `Error.message` when it is an Error, else\n * its string form. One helper for the `e instanceof Error ? e.message : String(e)`\n * idiom the transports would otherwise repeat in every catch.\n *\n * @param e the caught value\n * @returns a human-readable message\n */\nexport function errorMessage(e: unknown): string {\n return e instanceof Error ? e.message : String(e);\n}\n\n/**\n * Cap for every HTTP response body the adapter reads from a device: MusicCast JSON, XML\n * answers, the UPnP description of the network search. Real answers are a few KB (the\n * largest observed, the RX-V6A getFeatures map, is ~5 KB). A body growing past this is\n * not a Yamaha answering \u2014 a misbehaving device or a foreign host at that address\n * streaming without end would otherwise grow the process's memory without bound.\n */\nexport const MAX_HTTP_BODY_BYTES = 1024 * 1024;\n"],
5
+ "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAQO,SAAS,aAAa,GAAoB;AAC/C,SAAO,aAAa,QAAQ,EAAE,UAAU,OAAO,CAAC;AAClD;AASO,MAAM,sBAAsB,OAAO;",
6
6
  "names": []
7
7
  }
@@ -32,9 +32,9 @@ var import_xml_browse_driver = require("../browse/xml-browse-driver");
32
32
  const DEFAULT_POLL_INTERVAL_MS = 60 * 1e3;
33
33
  const XML_ZONES = [
34
34
  { key: "main", element: "Main_Zone", prefix: "" },
35
- { key: "zone2", element: "Zone_2", prefix: "multiroom.zone2.", channel: "multiroom.zone2", channelName: "Zone 2" },
36
- { key: "zone3", element: "Zone_3", prefix: "multiroom.zone3.", channel: "multiroom.zone3", channelName: "Zone 3" },
37
- { key: "zone4", element: "Zone_4", prefix: "multiroom.zone4.", channel: "multiroom.zone4", channelName: "Zone 4" }
35
+ { key: "zone2", element: "Zone_2", prefix: "multiroom.zone2." },
36
+ { key: "zone3", element: "Zone_3", prefix: "multiroom.zone3." },
37
+ { key: "zone4", element: "Zone_4", prefix: "multiroom.zone4." }
38
38
  ];
39
39
  class XmlDeviceController {
40
40
  /**
@@ -66,7 +66,7 @@ class XmlDeviceController {
66
66
  * @returns true if the main zone answered and the tree was created
67
67
  */
68
68
  async start() {
69
- var _a, _b, _c, _d, _e, _f;
69
+ var _a, _b, _c, _d;
70
70
  const probes = await Promise.all(
71
71
  XML_ZONES.map(async (zone) => ({ zone, status: await this.tryGetStatus(zone.element) }))
72
72
  );
@@ -109,32 +109,11 @@ class XmlDeviceController {
109
109
  }
110
110
  const createdChannels = /* @__PURE__ */ new Set();
111
111
  for (const zone of this.zones) {
112
- if (zone.channel) {
113
- const chSegments = zone.channel.split(".");
114
- for (let i = 1; i < chSegments.length; i++) {
115
- const parentId = chSegments.slice(0, i).join(".");
116
- if (!createdChannels.has(parentId)) {
117
- createdChannels.add(parentId);
118
- const seg = chSegments[i - 1];
119
- await this.deps.upsertObject(`${this.deviceId}.${parentId}`, {
120
- id: parentId,
121
- type: "channel",
122
- common: { name: (_c = import_types.CHANNEL_NAMES[seg]) != null ? _c : seg.charAt(0).toUpperCase() + seg.slice(1) }
123
- });
124
- }
125
- }
126
- createdChannels.add(zone.channel);
127
- await this.deps.upsertObject(`${this.deviceId}.${zone.channel}`, {
128
- id: zone.channel,
129
- type: "channel",
130
- common: { name: (_d = zone.channelName) != null ? _d : zone.channel }
131
- });
132
- }
133
112
  for (const entry of import_catalog.XML_AMP_CATALOG) {
134
113
  if (entry.mainOnly && zone.key !== "main") {
135
114
  continue;
136
115
  }
137
- if (entry.statusField !== void 0 && !((_e = this.zoneFields.get(zone.key)) == null ? void 0 : _e.has(entry.statusField))) {
116
+ if (entry.statusField !== void 0 && !((_c = this.zoneFields.get(zone.key)) == null ? void 0 : _c.has(entry.statusField))) {
138
117
  continue;
139
118
  }
140
119
  const stateId = `${zone.prefix}${entry.state}`;
@@ -146,7 +125,7 @@ class XmlDeviceController {
146
125
  await this.deps.upsertObject(`${this.deviceId}.${channelId}`, {
147
126
  id: channelId,
148
127
  type: "channel",
149
- common: { name: (_f = import_types.CHANNEL_NAMES[segments[i - 1]]) != null ? _f : segments[i - 1] }
128
+ common: { name: (_d = import_types.CHANNEL_NAMES[segments[i - 1]]) != null ? _d : segments[i - 1] }
150
129
  });
151
130
  }
152
131
  }
@@ -181,22 +160,47 @@ class XmlDeviceController {
181
160
  /**
182
161
  * Read one element's list once per device: the answer is a property of the MODEL
183
162
  * (input lists, scene declarations), so reconnects reuse it via the probe memory.
184
- * A refusal (RC/HTTP) or transport error yields an empty body — "declares none".
163
+ *
164
+ * Only a DEFINITE answer is remembered: a body, or the model's own "no such node"
165
+ * (bodyless HTTP 400 / return code 2, both captured on the RX-V6A) as "declares none".
166
+ * A transient failure — timeout, connection error, HTTP 5xx, or a state-dependent
167
+ * refusal (return code 3/4, "not now") — is NOT remembered: before this, one busy
168
+ * moment during the first contact recorded "no scenes" for that device for good, until
169
+ * the model changed. Now it is simply asked again on the next connect.
185
170
  *
186
171
  * @param key the probe-memory key
187
172
  * @param element the XML element to ask
188
173
  * @param inner the inner GET request
189
- * @returns the raw response body, or "" when the device refused
174
+ * @returns the raw response body, or "" when the device (definitely or for now) has none
190
175
  */
191
- probeXml(key, element, inner) {
176
+ async probeXml(key, element, inner) {
192
177
  const probe = async () => {
178
+ let body;
193
179
  try {
194
- return await this.deps.client.getXml(element, inner);
195
- } catch {
196
- return "";
180
+ body = await this.deps.client.getXml(element, inner);
181
+ } catch (e) {
182
+ if ((0, import_protocol.isPermanentXmlRefusal)(e)) {
183
+ return "";
184
+ }
185
+ throw e;
186
+ }
187
+ const rc = (0, import_protocol.parseReturnCode)(body);
188
+ if (rc !== void 0 && rc !== 0) {
189
+ if (rc === 2) {
190
+ return "";
191
+ }
192
+ throw new Error(`device refused ${element} probe (RC=${rc})`);
197
193
  }
194
+ return body;
198
195
  };
199
- return this.deps.probeMemory ? this.deps.probeMemory.once(key, probe) : probe();
196
+ try {
197
+ return this.deps.probeMemory ? await this.deps.probeMemory.once(key, probe) : await probe();
198
+ } catch (e) {
199
+ this.deps.log.debug(
200
+ `${this.deviceId}: ${key} probe failed, asking again on the next connect (${(0, import_util.errorMessage)(e)})`
201
+ );
202
+ return "";
203
+ }
200
204
  }
201
205
  /**
202
206
  * Build the scene surface from the device's OWN declaration (#615): each zone's
@@ -406,16 +410,27 @@ class XmlDeviceController {
406
410
  try {
407
411
  const body = await this.deps.client.getXml(source.element, "<List_Info>GetParam</List_Info>");
408
412
  return body.includes("<Menu_Status>") ? source.key : void 0;
409
- } catch {
410
- return void 0;
413
+ } catch (e) {
414
+ if ((0, import_protocol.isPermanentXmlRefusal)(e)) {
415
+ return void 0;
416
+ }
417
+ throw e;
411
418
  }
412
419
  })
413
420
  );
414
421
  return probes.filter((key) => key !== void 0);
415
422
  };
416
- const available = new Set(
417
- this.deps.probeMemory ? await this.deps.probeMemory.once("xmlBrowseSources", probe) : await probe()
418
- );
423
+ let available;
424
+ try {
425
+ available = new Set(
426
+ this.deps.probeMemory ? await this.deps.probeMemory.once("xmlBrowseSources", probe) : await probe()
427
+ );
428
+ } catch (e) {
429
+ this.deps.log.debug(
430
+ `${this.deviceId}: browse probe failed, asking again on the next connect (${(0, import_util.errorMessage)(e)})`
431
+ );
432
+ return;
433
+ }
419
434
  if (available.size === 0) {
420
435
  return;
421
436
  }