iobroker.yamaha 1.3.0 → 1.5.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 (63) hide show
  1. package/README.md +25 -18
  2. package/build/device-management.js +4 -2
  3. package/build/device-management.js.map +2 -2
  4. package/build/lib/attempt-device.js +19 -10
  5. package/build/lib/attempt-device.js.map +2 -2
  6. package/build/lib/browse/browse-engine.js +3 -0
  7. package/build/lib/browse/browse-engine.js.map +2 -2
  8. package/build/lib/browse/surface.js +47 -0
  9. package/build/lib/browse/surface.js.map +7 -0
  10. package/build/lib/browse/types.js +17 -0
  11. package/build/lib/browse/types.js.map +2 -2
  12. package/build/lib/browse/xml-browse-driver.js +8 -8
  13. package/build/lib/browse/xml-browse-driver.js.map +2 -2
  14. package/build/lib/browse/ynca-browse-driver.js +17 -26
  15. package/build/lib/browse/ynca-browse-driver.js.map +2 -2
  16. package/build/lib/browse/yxc-browse-driver.js.map +2 -2
  17. package/build/lib/device-controller.js +75 -20
  18. package/build/lib/device-controller.js.map +3 -3
  19. package/build/lib/discovery.js +15 -15
  20. package/build/lib/discovery.js.map +2 -2
  21. package/build/lib/lifecycle/command-gate.js +165 -0
  22. package/build/lib/lifecycle/command-gate.js.map +7 -0
  23. package/build/lib/lifecycle/multi-transport-handle.js +7 -0
  24. package/build/lib/lifecycle/multi-transport-handle.js.map +2 -2
  25. package/build/lib/lifecycle/poll-drop-detector.js +73 -0
  26. package/build/lib/lifecycle/poll-drop-detector.js.map +7 -0
  27. package/build/lib/lifecycle/probe-memory.js +69 -0
  28. package/build/lib/lifecycle/probe-memory.js.map +7 -0
  29. package/build/lib/lifecycle/reconnect-strategy.js +5 -2
  30. package/build/lib/lifecycle/reconnect-strategy.js.map +2 -2
  31. package/build/lib/xml/catalog.js +5 -4
  32. package/build/lib/xml/catalog.js.map +2 -2
  33. package/build/lib/xml/device-controller.js +47 -56
  34. package/build/lib/xml/device-controller.js.map +2 -2
  35. package/build/lib/xml/entities.js +50 -0
  36. package/build/lib/xml/entities.js.map +7 -0
  37. package/build/lib/xml/protocol.js +6 -5
  38. package/build/lib/xml/protocol.js.map +2 -2
  39. package/build/lib/xml/xml-client.js +10 -6
  40. package/build/lib/xml/xml-client.js.map +2 -2
  41. package/build/lib/ynca/__fixtures__/capabilities-from-lines.js +40 -0
  42. package/build/lib/ynca/__fixtures__/capabilities-from-lines.js.map +7 -0
  43. package/build/lib/ynca/capability.js +2 -15
  44. package/build/lib/ynca/capability.js.map +2 -2
  45. package/build/lib/ynca/ynca-client.js +78 -23
  46. package/build/lib/ynca/ynca-client.js.map +2 -2
  47. package/build/lib/yxc/client-contract.js.map +1 -1
  48. package/build/lib/yxc/command-mapper.js +5 -11
  49. package/build/lib/yxc/command-mapper.js.map +2 -2
  50. package/build/lib/yxc/device-controller.js +136 -79
  51. package/build/lib/yxc/device-controller.js.map +2 -2
  52. package/build/lib/yxc/http-client.js +46 -25
  53. package/build/lib/yxc/http-client.js.map +2 -2
  54. package/build/lib/yxc/object-mapper.js +5 -6
  55. package/build/lib/yxc/object-mapper.js.map +2 -2
  56. package/build/lib/yxc/push-receiver.js +10 -0
  57. package/build/lib/yxc/push-receiver.js.map +2 -2
  58. package/build/lib/yxc/zones.js +43 -0
  59. package/build/lib/yxc/zones.js.map +7 -0
  60. package/build/main.js +115 -9
  61. package/build/main.js.map +2 -2
  62. package/io-package.json +32 -27
  63. package/package.json +1 -1
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  **Release:** [![npm version](https://img.shields.io/npm/v/iobroker.yamaha)](https://www.npmjs.com/package/iobroker.yamaha) ![stable](https://iobroker.live/badges/yamaha-stable.svg) ![Installations](https://iobroker.live/badges/yamaha-installed.svg) [![npm downloads](https://img.shields.io/npm/dt/iobroker.yamaha)](https://www.npmjs.com/package/iobroker.yamaha)
4
4
 
5
- **Build:** [![Test and Release](https://github.com/iobroker-community-adapters/ioBroker.yamaha/actions/workflows/test-and-release.yml/badge.svg)](https://github.com/iobroker-community-adapters/ioBroker.yamaha/actions/workflows/test-and-release.yml) ![Node](https://img.shields.io/badge/node-%3E%3D22-brightgreen) ![TypeScript](https://img.shields.io/badge/TypeScript-strict-blue) [![License](https://img.shields.io/badge/license-MIT-green)](LICENSE)
5
+ **Build:** [![Test and Release](https://github.com/iobroker-community-adapters/ioBroker.yamaha/actions/workflows/test-and-release.yml/badge.svg)](https://github.com/iobroker-community-adapters/ioBroker.yamaha/actions/workflows/test-and-release.yml) ![Node](https://img.shields.io/badge/node-%3E%3D22-brightgreen) ![TypeScript](https://img.shields.io/badge/TypeScript-strict-blue) [![License](https://img.shields.io/badge/license-MIT-green)](LICENSE) [![Sentry](https://img.shields.io/badge/error%20reporting-Sentry-362d59?logo=sentry&logoColor=white)](https://github.com/ioBroker/plugin-sentry#plugin-sentry)
6
6
 
7
7
  **Support:** [![Ko-fi](https://img.shields.io/badge/Ko--fi-Support-ff5e5b?logo=ko-fi)](https://ko-fi.com/krobipd) [![PayPal](https://img.shields.io/badge/Donate-PayPal-blue.svg)](https://paypal.me/krobipd)
8
8
 
@@ -26,6 +26,12 @@ legacy XML protocol of the oldest pre-2010 models — behind one object tree.
26
26
  - **Automatic discovery** — an empty device list finds and sets up MusicCast devices at startup
27
27
  - **Device manager** — receivers as admin cards with model, address, live protocol indicators and a device-type icon (receiver, stereo, speaker, soundbar, CD)
28
28
 
29
+ ## Sentry / Error reporting
30
+
31
+ **This adapter uses Sentry libraries to automatically report exceptions and code errors to the developers.** Reporting only happens if you have enabled error reporting in the ioBroker diagnostics (**System settings → Diagnostics and error reporting**). Only an anonymous installation ID is transmitted — no name, e-mail address or IP address.
32
+
33
+ For details and how to disable it, see the [Sentry plugin documentation](https://github.com/ioBroker/plugin-sentry#plugin-sentry). Error reporting requires js-controller 3.0 or newer.
34
+
29
35
  ## Requirements
30
36
 
31
37
  - Node.js >= 22
@@ -90,6 +96,24 @@ On the first connect the adapter asks the receiver which functions it supports
90
96
  Placeholder for the next version (at the beginning of the line):
91
97
  ### **WORK IN PROGRESS**
92
98
  -->
99
+ ### 1.5.0 (2026-08-27)
100
+
101
+ - (krobipd) Fixed: menu browsing now works on receivers that serve their menus over the old XML protocol — the adapter no longer claims the menu datapoints on devices that cannot deliver them (#613)
102
+ - (krobipd) Fixed: a MusicCast device that loses power is now reported as offline instead of still showing a connection, so the ready message and the connection indicator tell the truth
103
+ - (krobipd) New: switching a datapoint group on or off in the settings now shows how many datapoints appeared or disappeared, so you no longer have to click through the object tree to check
104
+ - (krobipd) New: optional error reporting via Sentry — only active if you enabled diagnostics in ioBroker, and it transmits no personal data
105
+
106
+ ### 1.4.0 (2026-08-26)
107
+
108
+ - (krobipd) Fixed: commands sent in quick succession all arrive — a scene switching power, input and volume in one go used to lose everything after the first command
109
+ - (krobipd) Fixed: a command the device rejects is now reported instead of counting as success, so a MusicCast device that stops answering is reconnected rather than silently freezing
110
+ - (krobipd) Fixed: names and menu entries containing "&" or other special characters now read and write correctly on the older XML protocol
111
+ - (krobipd) Fixed: writing one equalizer band no longer resets the other two when the device has not reported its bands yet
112
+ - (krobipd) Fixed: switching the tuner band and setting a frequency right after each other now applies the frequency to the new band
113
+ - (krobipd) Improved: startup with automatic discovery is much faster on networks with many devices, and a reconnect no longer re-asks what the device already told us
114
+ - (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
115
+ - (krobipd) Improved: stopping or restarting the adapter no longer leaves requests running that write to datapoints afterwards
116
+
93
117
  ### 1.3.0 (2026-08-26)
94
118
 
95
119
  - (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)
@@ -108,23 +132,6 @@ On the first connect the adapter asks the receiver which functions it supports
108
132
 
109
133
  - (krobipd) Changed: Internal cleanup. No user-facing changes.
110
134
 
111
- ### 1.1.0 (2026-08-22)
112
-
113
- - (krobipd) Fixed: a device carried over from the old adapter is no longer called by its IP — the object folder and the admin card now show the name the device reports, or its model.
114
- - (krobipd) Improved: a device that has not reported a model yet already carries its device-class symbol instead of none.
115
-
116
- ### 1.0.1 (2026-08-22)
117
-
118
- - (krobipd) Complete rebuild: one adapter now speaks YNCA, MusicCast and the legacy XML protocol — every protocol a device answers runs in parallel on one object tree.
119
- - (krobipd) New object tree with typed datapoints built from what your device reports. Old datapoints are removed automatically, the address is carried over — point scripts at the new paths.
120
- - (krobipd) Instant updates: MusicCast push events and the live YNCA connection replace polling; connections heal themselves, and one protocol's hiccup reconnects just that protocol.
121
- - (krobipd) Auto-discovery sets up MusicCast devices by itself when the device list is empty, and the admin shows every receiver as a card with model, address and protocol indicators.
122
- - (krobipd) Whole datapoint groups such as playback sources, tuner, multiroom or scenes can be switched off in the admin — and are then not even queried from the device.
123
- - (krobipd) The multiroom folder tells the scope at a glance: switches that affect all zones say so in their name, and the MusicCast device group has its own `multiroom.group` folder.
124
- - (krobipd) Every device shows a type icon — receiver, stereo receiver, speaker, soundbar or CD system, detected from the reported model — in the object tree and on its admin card; the adapter logo now stays readable in light and dark mode.
125
- - (krobipd) Upgrading from 0.5.x shows a one-time notice explaining the new object tree before the update installs.
126
- - (mcm1957) version has been rebuilt due to deploy problems
127
-
128
135
  [Older changelogs can be found there](CHANGELOG_OLD.md)
129
136
 
130
137
  ## History
@@ -84,8 +84,10 @@ class YamahaDeviceManagement extends import_dm_utils.DeviceManagement {
84
84
  async loadDevices(context) {
85
85
  var _a;
86
86
  for (const card of await this.cards()) {
87
- const model = await this.adapter.getForeignStateAsync(`${this.adapter.namespace}.${card.id}.info.model`);
88
- const node = await this.adapter.getForeignObjectAsync(`${this.adapter.namespace}.${card.id}`);
87
+ const [model, node] = await Promise.all([
88
+ this.adapter.getForeignStateAsync(`${this.adapter.namespace}.${card.id}.info.model`),
89
+ this.adapter.getForeignObjectAsync(`${this.adapter.namespace}.${card.id}`)
90
+ ]);
89
91
  const label = typeof ((_a = node == null ? void 0 : node.common) == null ? void 0 : _a.name) === "string" ? node.common.name : void 0;
90
92
  context.addDevice(
91
93
  this.toDeviceInfo(
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/device-management.ts"],
4
- "sourcesContent": ["import {\n DeviceManagement,\n type ActionContext,\n type DeviceInfo,\n type DeviceLoadContext,\n type InstanceDetails,\n} from \"@iobroker/dm-utils\";\nimport { t } from \"./lib/i18n\";\nimport { iconForModel } from \"./lib/device-type\";\nimport { readDiscovered, writeDiscovered } from \"./lib/discovered-store\";\nimport { discoveredStoreDeps } from \"./lib/discovered-store-deps\";\nimport type { DeviceRecord } from \"./lib/types\";\nimport {\n TRANSPORTS,\n buildDeviceForm,\n findClash,\n rowId,\n type CardDevice,\n type ManualRow,\n} from \"./device-management-helpers\";\n\n/**\n * ioBroker device-manager backend: the Yamaha receivers as cards showing the live model,\n * the IP, and which protocols (YNCA/MusicCast/XML) are connected right now, with a manual\n * add-by-IP dialog. The card list follows the running set \u2014 the manual `native.devices`\n * table when it is filled, otherwise the auto-discovered devices \u2014 so it matches exactly\n * what the adapter runs. \"Yamaha\" is never a card line: it is the whole adapter.\n */\nexport class YamahaDeviceManagement extends DeviceManagement {\n /** The instance object id whose `native` holds the manual device table. */\n private get objId(): string {\n return `system.adapter.${this.adapter.namespace}`;\n }\n\n /** Read the manual device table (`native.devices`) as raw rows, keeping the name. */\n private async readManual(): Promise<ManualRow[]> {\n const obj = await this.adapter.getForeignObjectAsync(this.objId);\n const devices = (obj?.native as { devices?: unknown } | undefined)?.devices;\n if (!Array.isArray(devices)) {\n return [];\n }\n return devices.filter(\n (d): d is ManualRow => !!d && typeof (d as ManualRow).ip === \"string\" && (d as ManualRow).ip.length > 0,\n );\n }\n\n /**\n * Persist the manual device table; writing `native.*` restarts the adapter with the new set.\n *\n * @param rows the manual rows to store\n */\n private async writeManual(rows: ManualRow[]): Promise<void> {\n await this.adapter.extendForeignObjectAsync(this.objId, { native: { devices: rows } });\n }\n\n /**\n * The running device set as cards: the manual table when filled (manual mode), otherwise\n * the auto-discovered devices (auto mode) \u2014 the same either/or the adapter itself runs.\n *\n * @returns the cards with their source\n */\n private async cards(): Promise<CardDevice[]> {\n const manual = await this.readManual();\n if (manual.length > 0) {\n const taken = new Set<string>([\"info\"]);\n const cards: CardDevice[] = [];\n for (const row of manual) {\n const id = rowId(row);\n if (taken.has(id)) {\n continue;\n }\n taken.add(id);\n cards.push({ id, ip: row.ip, name: row.name && row.name.length > 0 ? row.name : row.ip, source: \"manual\" });\n }\n return cards;\n }\n const discovered = await readDiscovered(discoveredStoreDeps(this.adapter));\n return discovered.map(d => ({ id: d.id, ip: d.ip, name: d.id, source: \"discovered\" as const }));\n }\n\n /**\n * Populate the manager with one card per running device.\n *\n * @param context the load context\n */\n protected async loadDevices(context: DeviceLoadContext<string>): Promise<void> {\n for (const card of await this.cards()) {\n // The reported model picks the device-class silhouette shown on the card.\n const model = await this.adapter.getForeignStateAsync(`${this.adapter.namespace}.${card.id}.info.model`);\n // The card title follows the device object's name, not the table entry. On an\n // instance upgraded from the previous adapter the table entry is the receiver's\n // ip \u2014 the object carries the readable name the adapter learned from the device.\n // The table entry itself must stay put: the object id is derived from it, and\n // changing that would move the whole tree.\n const node = await this.adapter.getForeignObjectAsync(`${this.adapter.namespace}.${card.id}`);\n const label = typeof node?.common?.name === \"string\" ? node.common.name : undefined;\n context.addDevice(\n this.toDeviceInfo(\n label && label !== card.id ? { ...card, name: label } : card,\n typeof model?.val === \"string\" ? model.val : undefined,\n ),\n );\n }\n }\n\n /**\n * Build one device card: the live model, the IP as the identifier line, a connection\n * status, and one indicator per connected protocol (hidden while that protocol is not\n * connected). All live values read from the device's own `info.*` states. A discovered\n * card has no edit action \u2014 it is auto-found and re-found; it can only be removed.\n *\n * @param card the running device\n * @param model the device's reported model name, for the device-class icon\n * @returns the card descriptor\n */\n private toDeviceInfo(card: CardDevice, model?: string): DeviceInfo<string> {\n const base = `${this.adapter.namespace}.${card.id}`;\n const del = {\n id: \"delete\",\n icon: \"delete\",\n description: t(\"dmDelete\"),\n handler: async (id: string, ctx: ActionContext): Promise<{ refresh: \"devices\" }> => this.deleteDevice(id, ctx),\n };\n const edit = {\n id: \"edit\",\n icon: \"edit\",\n description: t(\"dmEdit\"),\n handler: async (id: string, ctx: ActionContext): Promise<{ refresh: \"devices\" }> => this.editDevice(id, ctx),\n };\n return {\n id: card.id,\n name: card.name,\n icon: iconForModel(model),\n model: { stateId: `${base}.info.model` },\n identifier: card.ip,\n status: {\n connection: { stateId: `${base}.info.connection`, mapping: { true: \"connected\", false: \"disconnected\" } },\n },\n // No icon: the indicator icon accepts only a reserved/`fa-*`/`data:`/URL name, so a plain\n // \"wifi\" rendered as a \"?\". The transport label as text plus a green \"on\" colour carries it;\n // `hideIfEmpty` shows only the protocols this device is actually connected over.\n indicators: TRANSPORTS.map(tr => ({\n id: `transport-${tr.id}`,\n value: { stateId: `${base}.info.transports.${tr.id}` },\n text: tr.label,\n colorOn: \"ok\",\n hideIfEmpty: true,\n })),\n actions: card.source === \"manual\" ? [edit, del] : [del],\n };\n }\n\n /**\n * The \"+ add\" action above the list and the label of the identifier line (the IP).\n *\n * @returns the instance action descriptor\n */\n protected getInstanceInfo(): InstanceDetails {\n return {\n apiVersion: \"v3\",\n identifierLabel: t(\"ipLabel\"),\n actions: [{ id: \"add\", icon: \"add\", description: t(\"dmAdd\"), handler: async ctx => this.addDevice(ctx) }],\n };\n }\n\n /**\n * Manual add: show the name+IP form, then append the device to `native.devices` (which\n * restarts the adapter and switches it to manual mode).\n *\n * @param context the action context\n * @returns a directive to reload the manager\n */\n private async addDevice(context: ActionContext): Promise<{ refresh: boolean }> {\n const manual = await this.readManual();\n const data = await context.showForm(buildDeviceForm(manual.map(r => r.ip)), { title: t(\"dmAdd\") });\n if (data && typeof data.ip === \"string\" && data.ip.trim()) {\n const row: ManualRow = { name: typeof data.name === \"string\" ? data.name.trim() : \"\", ip: data.ip.trim() };\n const clash = findClash(manual, row, -1);\n if (clash) {\n await context.showMessage(clash);\n return { refresh: true };\n }\n manual.push(row);\n await this.writeManual(manual);\n }\n return { refresh: true };\n }\n\n /**\n * Edit a manual device via the pre-filled form (edit is offered on manual cards only).\n *\n * @param cardId the card id (= the object-tree device id)\n * @param context the action context\n * @returns a directive to reload the list\n */\n private async editDevice(cardId: string, context: ActionContext): Promise<{ refresh: \"devices\" }> {\n const manual = await this.readManual();\n const index = manual.findIndex(r => rowId(r) === cardId);\n if (index < 0) {\n return { refresh: \"devices\" };\n }\n const current = manual[index];\n const usedIps = manual.filter((_, i) => i !== index).map(r => r.ip);\n const data = await context.showForm(buildDeviceForm(usedIps), {\n title: t(\"dmEditTitle\"),\n data: { name: current.name ?? \"\", ip: current.ip },\n });\n if (data && typeof data.ip === \"string\" && data.ip.trim()) {\n const row: ManualRow = { name: typeof data.name === \"string\" ? data.name.trim() : \"\", ip: data.ip.trim() };\n const clash = findClash(manual, row, index);\n if (clash) {\n await context.showMessage(clash);\n return { refresh: \"devices\" };\n }\n manual[index] = row;\n await this.writeManual(manual);\n }\n return { refresh: \"devices\" };\n }\n\n /**\n * Delete a device after confirmation, from whichever source it came from: a manual card\n * from `native.devices`, a discovered card from the discovery store \u2014 the latter so the\n * standby-protection merge does not resurrect it on the next start.\n *\n * @param cardId the card id (= the object-tree device id)\n * @param context the action context\n * @returns a directive to reload the list\n */\n private async deleteDevice(cardId: string, context: ActionContext): Promise<{ refresh: \"devices\" }> {\n const manual = await this.readManual();\n if (manual.length > 0) {\n const index = manual.findIndex(r => rowId(r) === cardId);\n if (index < 0) {\n return { refresh: \"devices\" };\n }\n const confirmed = await context.showConfirmation(t(\"dmDeleteConfirm\", manual[index].name || manual[index].ip));\n if (confirmed) {\n manual.splice(index, 1);\n await this.writeManual(manual);\n }\n return { refresh: \"devices\" };\n }\n const store = discoveredStoreDeps(this.adapter);\n const discovered = await readDiscovered(store);\n const remaining = discovered.filter((d: DeviceRecord) => d.id !== cardId);\n if (remaining.length !== discovered.length) {\n const confirmed = await context.showConfirmation(t(\"dmDeleteConfirm\", cardId));\n if (confirmed) {\n await writeDiscovered(store, remaining);\n }\n }\n return { refresh: \"devices\" };\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import {\n DeviceManagement,\n type ActionContext,\n type DeviceInfo,\n type DeviceLoadContext,\n type InstanceDetails,\n} from \"@iobroker/dm-utils\";\nimport { t } from \"./lib/i18n\";\nimport { iconForModel } from \"./lib/device-type\";\nimport { readDiscovered, writeDiscovered } from \"./lib/discovered-store\";\nimport { discoveredStoreDeps } from \"./lib/discovered-store-deps\";\nimport type { DeviceRecord } from \"./lib/types\";\nimport {\n TRANSPORTS,\n buildDeviceForm,\n findClash,\n rowId,\n type CardDevice,\n type ManualRow,\n} from \"./device-management-helpers\";\n\n/**\n * ioBroker device-manager backend: the Yamaha receivers as cards showing the live model,\n * the IP, and which protocols (YNCA/MusicCast/XML) are connected right now, with a manual\n * add-by-IP dialog. The card list follows the running set \u2014 the manual `native.devices`\n * table when it is filled, otherwise the auto-discovered devices \u2014 so it matches exactly\n * what the adapter runs. \"Yamaha\" is never a card line: it is the whole adapter.\n */\nexport class YamahaDeviceManagement extends DeviceManagement {\n /** The instance object id whose `native` holds the manual device table. */\n private get objId(): string {\n return `system.adapter.${this.adapter.namespace}`;\n }\n\n /** Read the manual device table (`native.devices`) as raw rows, keeping the name. */\n private async readManual(): Promise<ManualRow[]> {\n const obj = await this.adapter.getForeignObjectAsync(this.objId);\n const devices = (obj?.native as { devices?: unknown } | undefined)?.devices;\n if (!Array.isArray(devices)) {\n return [];\n }\n return devices.filter(\n (d): d is ManualRow => !!d && typeof (d as ManualRow).ip === \"string\" && (d as ManualRow).ip.length > 0,\n );\n }\n\n /**\n * Persist the manual device table; writing `native.*` restarts the adapter with the new set.\n *\n * @param rows the manual rows to store\n */\n private async writeManual(rows: ManualRow[]): Promise<void> {\n await this.adapter.extendForeignObjectAsync(this.objId, { native: { devices: rows } });\n }\n\n /**\n * The running device set as cards: the manual table when filled (manual mode), otherwise\n * the auto-discovered devices (auto mode) \u2014 the same either/or the adapter itself runs.\n *\n * @returns the cards with their source\n */\n private async cards(): Promise<CardDevice[]> {\n const manual = await this.readManual();\n if (manual.length > 0) {\n const taken = new Set<string>([\"info\"]);\n const cards: CardDevice[] = [];\n for (const row of manual) {\n const id = rowId(row);\n if (taken.has(id)) {\n continue;\n }\n taken.add(id);\n cards.push({ id, ip: row.ip, name: row.name && row.name.length > 0 ? row.name : row.ip, source: \"manual\" });\n }\n return cards;\n }\n const discovered = await readDiscovered(discoveredStoreDeps(this.adapter));\n return discovered.map(d => ({ id: d.id, ip: d.ip, name: d.id, source: \"discovered\" as const }));\n }\n\n /**\n * Populate the manager with one card per running device.\n *\n * @param context the load context\n */\n protected async loadDevices(context: DeviceLoadContext<string>): Promise<void> {\n for (const card of await this.cards()) {\n // Model and object are independent reads \u2014 fetch them together so a card with\n // several devices does not add up their round-trips.\n const [model, node] = await Promise.all([\n this.adapter.getForeignStateAsync(`${this.adapter.namespace}.${card.id}.info.model`),\n this.adapter.getForeignObjectAsync(`${this.adapter.namespace}.${card.id}`),\n ]);\n // The card title follows the device object's name, not the table entry. On an\n // instance upgraded from the previous adapter the table entry is the receiver's\n // ip \u2014 the object carries the readable name the adapter learned from the device.\n // The table entry itself must stay put: the object id is derived from it, and\n // changing that would move the whole tree.\n const label = typeof node?.common?.name === \"string\" ? node.common.name : undefined;\n context.addDevice(\n this.toDeviceInfo(\n label && label !== card.id ? { ...card, name: label } : card,\n typeof model?.val === \"string\" ? model.val : undefined,\n ),\n );\n }\n }\n\n /**\n * Build one device card: the live model, the IP as the identifier line, a connection\n * status, and one indicator per connected protocol (hidden while that protocol is not\n * connected). All live values read from the device's own `info.*` states. A discovered\n * card has no edit action \u2014 it is auto-found and re-found; it can only be removed.\n *\n * @param card the running device\n * @param model the device's reported model name, for the device-class icon\n * @returns the card descriptor\n */\n private toDeviceInfo(card: CardDevice, model?: string): DeviceInfo<string> {\n const base = `${this.adapter.namespace}.${card.id}`;\n const del = {\n id: \"delete\",\n icon: \"delete\",\n description: t(\"dmDelete\"),\n handler: async (id: string, ctx: ActionContext): Promise<{ refresh: \"devices\" }> => this.deleteDevice(id, ctx),\n };\n const edit = {\n id: \"edit\",\n icon: \"edit\",\n description: t(\"dmEdit\"),\n handler: async (id: string, ctx: ActionContext): Promise<{ refresh: \"devices\" }> => this.editDevice(id, ctx),\n };\n return {\n id: card.id,\n name: card.name,\n icon: iconForModel(model),\n model: { stateId: `${base}.info.model` },\n identifier: card.ip,\n status: {\n connection: { stateId: `${base}.info.connection`, mapping: { true: \"connected\", false: \"disconnected\" } },\n },\n // No icon: the indicator icon accepts only a reserved/`fa-*`/`data:`/URL name, so a plain\n // \"wifi\" rendered as a \"?\". The transport label as text plus a green \"on\" colour carries it;\n // `hideIfEmpty` shows only the protocols this device is actually connected over.\n indicators: TRANSPORTS.map(tr => ({\n id: `transport-${tr.id}`,\n value: { stateId: `${base}.info.transports.${tr.id}` },\n text: tr.label,\n colorOn: \"ok\",\n hideIfEmpty: true,\n })),\n actions: card.source === \"manual\" ? [edit, del] : [del],\n };\n }\n\n /**\n * The \"+ add\" action above the list and the label of the identifier line (the IP).\n *\n * @returns the instance action descriptor\n */\n protected getInstanceInfo(): InstanceDetails {\n return {\n apiVersion: \"v3\",\n identifierLabel: t(\"ipLabel\"),\n actions: [{ id: \"add\", icon: \"add\", description: t(\"dmAdd\"), handler: async ctx => this.addDevice(ctx) }],\n };\n }\n\n /**\n * Manual add: show the name+IP form, then append the device to `native.devices` (which\n * restarts the adapter and switches it to manual mode).\n *\n * @param context the action context\n * @returns a directive to reload the manager\n */\n private async addDevice(context: ActionContext): Promise<{ refresh: boolean }> {\n const manual = await this.readManual();\n const data = await context.showForm(buildDeviceForm(manual.map(r => r.ip)), { title: t(\"dmAdd\") });\n if (data && typeof data.ip === \"string\" && data.ip.trim()) {\n const row: ManualRow = { name: typeof data.name === \"string\" ? data.name.trim() : \"\", ip: data.ip.trim() };\n const clash = findClash(manual, row, -1);\n if (clash) {\n await context.showMessage(clash);\n return { refresh: true };\n }\n manual.push(row);\n await this.writeManual(manual);\n }\n return { refresh: true };\n }\n\n /**\n * Edit a manual device via the pre-filled form (edit is offered on manual cards only).\n *\n * @param cardId the card id (= the object-tree device id)\n * @param context the action context\n * @returns a directive to reload the list\n */\n private async editDevice(cardId: string, context: ActionContext): Promise<{ refresh: \"devices\" }> {\n const manual = await this.readManual();\n const index = manual.findIndex(r => rowId(r) === cardId);\n if (index < 0) {\n return { refresh: \"devices\" };\n }\n const current = manual[index];\n const usedIps = manual.filter((_, i) => i !== index).map(r => r.ip);\n const data = await context.showForm(buildDeviceForm(usedIps), {\n title: t(\"dmEditTitle\"),\n data: { name: current.name ?? \"\", ip: current.ip },\n });\n if (data && typeof data.ip === \"string\" && data.ip.trim()) {\n const row: ManualRow = { name: typeof data.name === \"string\" ? data.name.trim() : \"\", ip: data.ip.trim() };\n const clash = findClash(manual, row, index);\n if (clash) {\n await context.showMessage(clash);\n return { refresh: \"devices\" };\n }\n manual[index] = row;\n await this.writeManual(manual);\n }\n return { refresh: \"devices\" };\n }\n\n /**\n * Delete a device after confirmation, from whichever source it came from: a manual card\n * from `native.devices`, a discovered card from the discovery store \u2014 the latter so the\n * standby-protection merge does not resurrect it on the next start.\n *\n * @param cardId the card id (= the object-tree device id)\n * @param context the action context\n * @returns a directive to reload the list\n */\n private async deleteDevice(cardId: string, context: ActionContext): Promise<{ refresh: \"devices\" }> {\n const manual = await this.readManual();\n if (manual.length > 0) {\n const index = manual.findIndex(r => rowId(r) === cardId);\n if (index < 0) {\n return { refresh: \"devices\" };\n }\n const confirmed = await context.showConfirmation(t(\"dmDeleteConfirm\", manual[index].name || manual[index].ip));\n if (confirmed) {\n manual.splice(index, 1);\n await this.writeManual(manual);\n }\n return { refresh: \"devices\" };\n }\n const store = discoveredStoreDeps(this.adapter);\n const discovered = await readDiscovered(store);\n const remaining = discovered.filter((d: DeviceRecord) => d.id !== cardId);\n if (remaining.length !== discovered.length) {\n const confirmed = await context.showConfirmation(t(\"dmDeleteConfirm\", cardId));\n if (confirmed) {\n await writeDiscovered(store, remaining);\n }\n }\n return { refresh: \"devices\" };\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -31,10 +31,12 @@ var import_xml_client = require("./xml/xml-client");
31
31
  var import_multi_transport_handle = require("./lifecycle/multi-transport-handle");
32
32
  var import_transport_connection_adapter = require("./lifecycle/transport-connection-adapter");
33
33
  var import_reconnect_strategy = require("./lifecycle/reconnect-strategy");
34
+ var import_command_gate = require("./lifecycle/command-gate");
34
35
  var import_ready_line = require("./ready-line");
35
36
  var import_util = require("./util");
36
37
  const TRANSPORT_RECONNECT_BASE_MS = 1e3;
37
38
  const TRANSPORT_RECONNECT_MAX_MS = 6e4;
39
+ const COMMAND_SPACING_MS = { ynca: 100, yxc: 0, xml: 0 };
38
40
  async function connectTransports(deviceId, attempts, deps) {
39
41
  var _a, _b, _c;
40
42
  const results = await Promise.all(
@@ -84,54 +86,61 @@ async function connectTransports(deviceId, attempts, deps) {
84
86
  }
85
87
  function attemptDevice(device, deps) {
86
88
  const { log, upsertObject, setStateAck, timers } = deps;
87
- const delay = (ms) => new Promise((resolve) => {
88
- timers.schedule(resolve, ms);
89
- });
89
+ const gateFor = (transport) => new import_command_gate.CommandGate({ minSpacingMs: COMMAND_SPACING_MS[transport], timers });
90
90
  const buildYnca = () => {
91
91
  const ynca = new import_transport_connection_adapter.TransportConnectionAdapter("ynca", device.id, setStateAck);
92
+ const gate = gateFor("ynca");
92
93
  ynca.bind(
93
94
  new import_device_controller.YncaDeviceController(device.id, {
94
- client: new import_ynca_client.YncaClient(device.ip, timers),
95
+ client: new import_ynca_client.YncaClient(device.ip, timers, gate),
96
+ gate,
95
97
  upsertObject: ynca.interceptUpsert,
96
98
  setStateAck: ynca.interceptSetStateAck,
97
99
  log,
98
100
  isEntryEnabled: deps.isEntryEnabled,
99
101
  subunitCache: deps.yncaSubunitCache,
100
- delay
102
+ probeMemory: deps.probeMemory
101
103
  })
102
104
  );
103
105
  return ynca;
104
106
  };
105
107
  const buildYxc = () => {
106
108
  const yxc = new import_transport_connection_adapter.TransportConnectionAdapter("yxc", device.id, setStateAck);
109
+ const gate = gateFor("yxc");
107
110
  yxc.bind(
108
111
  new import_device_controller2.YxcDeviceController(device.id, {
109
- client: new import_http_client.YamahaYxcClient(device.ip),
110
- // Resolve another configured device's client for a multiroom link — never this device itself.
112
+ client: new import_http_client.YamahaYxcClient(device.ip, void 0, gate),
113
+ // Resolve another configured device's client for a multiroom link — never this device
114
+ // itself. The partner's own gate belongs to its own connection, so this one-off client
115
+ // stays ungated (a single link call, not a stream of commands).
111
116
  clientFor: (ip) => ip !== device.ip && deps.knownDeviceIps.has(ip) ? new import_http_client.YamahaYxcClient(ip) : void 0,
112
117
  registerPush: (onPush) => deps.registerPush(device.ip, onPush),
118
+ pushActive: deps.pushActive,
119
+ probeMemory: deps.probeMemory,
113
120
  scheduleKeepalive: deps.scheduleKeepalive,
114
121
  upsertObject: yxc.interceptUpsert,
115
122
  setStateAck: yxc.interceptSetStateAck,
116
123
  reportDeviceName: deps.onDeviceName,
117
124
  log,
118
- delay
125
+ gate
119
126
  })
120
127
  );
121
128
  return yxc;
122
129
  };
123
130
  const buildXml = () => {
124
131
  const xml = new import_transport_connection_adapter.TransportConnectionAdapter("xml", device.id, setStateAck);
132
+ const gate = gateFor("xml");
125
133
  xml.bind(
126
134
  new import_device_controller3.XmlDeviceController(
127
135
  device.id,
128
136
  {
129
- client: new import_xml_client.XmlClient(device.ip),
137
+ client: new import_xml_client.XmlClient(device.ip, void 0, gate),
130
138
  scheduleKeepalive: deps.scheduleKeepalive,
131
139
  upsertObject: xml.interceptUpsert,
132
140
  setStateAck: xml.interceptSetStateAck,
133
141
  log,
134
- delay
142
+ gate,
143
+ probeMemory: deps.probeMemory
135
144
  },
136
145
  deps.xmlPollIntervalMs
137
146
  )
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/lib/attempt-device.ts"],
4
- "sourcesContent": ["import { YncaClient } from \"./ynca/ynca-client\";\nimport { YncaDeviceController } from \"./device-controller\";\nimport { YxcDeviceController } from \"./yxc/device-controller\";\nimport { YamahaYxcClient } from \"./yxc/http-client\";\nimport { XmlDeviceController } from \"./xml/device-controller\";\nimport { XmlClient } from \"./xml/xml-client\";\nimport { MultiTransportHandle, type ConnectableTransport } from \"./lifecycle/multi-transport-handle\";\nimport { TransportConnectionAdapter } from \"./lifecycle/transport-connection-adapter\";\nimport { ReconnectStrategy } from \"./lifecycle/reconnect-strategy\";\nimport type { ReachabilityDedup } from \"./lifecycle/reachability-dedup\";\nimport type { YncaSubunitCache } from \"./ynca/subunit-cache\";\nimport type { Transport } from \"./catalog/owner-policy\";\nimport { readyLine } from \"./ready-line\";\nimport { errorMessage } from \"./util\";\nimport type { ConnectionHandle, ControllerLog } from \"./controller\";\nimport type { ObjectDef } from \"./catalog/types\";\nimport type { DeviceRecord } from \"./types\";\n\n// Re-exported so existing importers (tests) keep resolving it from here.\nexport type { ConnectableTransport };\n\n/** Per-transport reconnect backoff bounds \u2014 same shape as the device supervisor's. */\nconst TRANSPORT_RECONNECT_BASE_MS = 1000;\nconst TRANSPORT_RECONNECT_MAX_MS = 60000;\n\n/** The adapter-bound callbacks {@link attemptDevice} drives \u2014 injected so it needs no adapter. */\nexport interface AttemptDeps {\n /** Adapter log. */\n log: ControllerLog;\n /** Create or update an object in the device tree. */\n upsertObject(id: string, def: ObjectDef): Promise<void>;\n /** Write a state value with ack (device-originated). */\n setStateAck(id: string, value: boolean | number | string): void;\n /** Adapter-managed timers (YNCA pacing + per-transport reconnects). */\n timers: {\n /** Schedule a one-shot timer. */\n schedule(handler: () => void, ms: number): ioBroker.Timeout | undefined;\n /** Cancel a scheduled timer. */\n cancel(handle: ioBroker.Timeout | undefined): void;\n };\n /** Register a YXC push handler for a device IP; returns a function that unregisters it. */\n registerPush(ip: string, onPush: (event: unknown) => void): () => void;\n /** Schedule a repeating keepalive; returns a function that cancels it. */\n scheduleKeepalive(handler: () => void, ms: number): () => void;\n /** How often to poll an XML/YNC device for state (ms). */\n xmlPollIntervalMs: number;\n /** Report the transports that are live after every change \u2014 the id-safe names (\"ynca\"/\"yxc\"/\"xml\"). */\n onTransports?(names: string[]): void;\n /** Report the name the device carries for itself (MusicCast), for the device object's label. */\n onDeviceName?(name: string): void;\n /** IPs of all configured devices, so a MusicCast group can resolve a client device by IP. */\n knownDeviceIps: Set<string>;\n /** Dedup for the \"no reachable transport\" warning \u2014 see {@link ConnectDeps.reachability}. */\n reachability?: ReachabilityDedup;\n /** Datapoint-group gate for the YNCA sweep \u2014 a disabled group's functions are never fetched. */\n isEntryEnabled?(id: string): boolean;\n /** Per-device cache of the YNCA AVAIL probe, held by the caller across reconnects. */\n yncaSubunitCache?: YncaSubunitCache;\n}\n\n/** One transport to try: its name and a factory building a FRESH connectable (also for reconnects). */\nexport interface TransportAttempt {\n /** The transport this attempt stands for. */\n transport: Transport;\n /** Build a fresh connectable transport (a controller behind its adapter). */\n build(): ConnectableTransport;\n}\n\n/** The adapter callbacks {@link connectTransports} drives to build and hold the unified tree. */\nexport interface ConnectDeps {\n /** Adapter log. */\n log: ControllerLog;\n /** Create or update an object in the device tree. */\n upsertObject(id: string, def: ObjectDef): Promise<void>;\n /** Report the transports that are live after every change \u2014 the id-safe names (\"ynca\"/\"yxc\"/\"xml\"). */\n onTransports?(names: string[]): void;\n /**\n * Dedup for the \"no reachable transport\" warning: without it every retry warns\n * again for as long as the device stays offline (nut2 `failedUps` pattern \u2014 first\n * failure warns, repeats stay at debug until the device answers again).\n */\n reachability?: ReachabilityDedup;\n /** Timers for the per-transport reconnect loops (absent in tests \u2192 no per-transport retry). */\n timers?: {\n /** Schedule a one-shot timer. */\n schedule(handler: () => void, ms: number): ioBroker.Timeout | undefined;\n /** Cancel a scheduled timer. */\n cancel(handle: ioBroker.Timeout | undefined): void;\n };\n}\n\n/**\n * Bring every answering transport online on ONE object tree. All candidates connect IN\n * PARALLEL (a YNCA connect timeout or long sweep no longer delays YXC/XML); the ones that\n * answer are handed to a single {@link MultiTransportHandle}, which unifies their catalogs\n * (each capability owned by exactly one transport \u2014 most-modern-but-lossless, see the\n * object-tree coordinator), routes user writes to the owner, and reconnects a single\n * dropped transport on its own while the others keep running. Ownership is resolved by\n * rank over the connected set, so the connect order/timing never changes it. A transport\n * that does not answer, or whose connect throws, is closed and left out. Returns the\n * handle over the live set, or null when no transport answered (the device is offline\n * this attempt).\n *\n * @param deviceId the id-safe device id\n * @param attempts the transports to try\n * @param deps the adapter callbacks (upsert + log + timers)\n * @returns a connection handle over the live transports, or null when none connected\n */\nexport async function connectTransports(\n deviceId: string,\n attempts: readonly TransportAttempt[],\n deps: ConnectDeps,\n): Promise<ConnectionHandle | null> {\n const results = await Promise.all(\n attempts.map(async attempt => {\n const conn = attempt.build();\n try {\n if (await conn.connect()) {\n return conn;\n }\n } catch (e) {\n deps.log.debug(`${deviceId}/${conn.transport}: transport did not connect (${errorMessage(e)})`);\n }\n conn.close();\n return null;\n }),\n );\n const live = results.filter((conn): conn is ConnectableTransport => conn !== null);\n if (live.length === 0) {\n const level = deps.reachability?.reportUnreachable() ?? \"warn\";\n deps.log[level](`${deviceId}: no reachable transport (YNCA/YXC/XML)`);\n return null;\n }\n deps.reachability?.reportReachable();\n const rebuilds = new Map(attempts.map(attempt => [attempt.transport, attempt.build] as const));\n const handle = new MultiTransportHandle(deviceId, live, {\n upsertObject: deps.upsertObject,\n log: deps.log,\n onTransports: deps.onTransports,\n rebuild: deps.timers ? transport => rebuilds.get(transport)!() : undefined,\n schedule: deps.timers ? (cb, ms) => deps.timers!.schedule(cb, ms) : undefined,\n cancel: deps.timers ? handle_ => deps.timers!.cancel(handle_ as ioBroker.Timeout | undefined) : undefined,\n backoffFactory: () => new ReconnectStrategy(TRANSPORT_RECONNECT_BASE_MS, TRANSPORT_RECONNECT_MAX_MS),\n });\n try {\n await handle.start();\n } catch (e) {\n // Building the unified tree failed (e.g. object creation errored): close every live\n // transport before rethrowing, or the supervisor's retry would leak sockets and timers \u2014\n // fatal for YNCA, where the receiver allows only ONE connection and a zombie socket\n // would block every future attempt until the adapter restarts.\n handle.close();\n throw e;\n }\n // One summary line instead of three per-transport \"ready\" lines; each controller logs its\n // own readiness at debug level for diagnostics.\n deps.log.info(\n readyLine(\n deviceId,\n live.map(conn => conn.transport),\n ),\n );\n return handle;\n}\n\n/**\n * Bring one device online across ALL its transports. Every transport that answers \u2014 YNCA (amp\n * control over a held TCP connection), YXC (MusicCast, push + poll), XML/YNC (pre-2010) \u2014 is built\n * behind a {@link TransportConnectionAdapter} and connected in parallel on one object tree, so a\n * MusicCast AVR gets YNCA base control AND the YXC-exclusive richness (multiroom, equalizer, album\n * art) instead of one transport winning and hiding the rest. Each transport is described by a\n * factory, so the handle can rebuild and reconnect a single dropped transport while the others\n * keep running. Returns a {@link ConnectionHandle} over the live set, or null when no transport\n * answers this attempt.\n *\n * @param device the configured device record\n * @param deps the adapter-bound callbacks\n * @returns a connection handle, or null when no transport connected\n */\nexport function attemptDevice(device: DeviceRecord, deps: AttemptDeps): Promise<ConnectionHandle | null> {\n const { log, upsertObject, setStateAck, timers } = deps;\n // Adapter-managed delay for the browse engine/drivers (pacing, busy polls, path walk).\n const delay = (ms: number): Promise<void> =>\n new Promise(resolve => {\n timers.schedule(resolve, ms);\n });\n\n // 1) YNCA \u2014 amp control over a held TCP connection; a socket drop is the genuine gone-signal.\n const buildYnca = (): ConnectableTransport => {\n const ynca = new TransportConnectionAdapter(\"ynca\", device.id, setStateAck);\n ynca.bind(\n new YncaDeviceController(device.id, {\n client: new YncaClient(device.ip, timers),\n upsertObject: ynca.interceptUpsert,\n setStateAck: ynca.interceptSetStateAck,\n log,\n isEntryEnabled: deps.isEntryEnabled,\n subunitCache: deps.yncaSubunitCache,\n delay,\n }),\n );\n return ynca;\n };\n\n // 2) YXC \u2014 MusicCast; polled + push. Drop reported after a run of failed keepalive polls.\n const buildYxc = (): ConnectableTransport => {\n const yxc = new TransportConnectionAdapter(\"yxc\", device.id, setStateAck);\n yxc.bind(\n new YxcDeviceController(device.id, {\n client: new YamahaYxcClient(device.ip),\n // Resolve another configured device's client for a multiroom link \u2014 never this device itself.\n clientFor: ip => (ip !== device.ip && deps.knownDeviceIps.has(ip) ? new YamahaYxcClient(ip) : undefined),\n registerPush: onPush => deps.registerPush(device.ip, onPush),\n scheduleKeepalive: deps.scheduleKeepalive,\n upsertObject: yxc.interceptUpsert,\n setStateAck: yxc.interceptSetStateAck,\n reportDeviceName: deps.onDeviceName,\n log,\n delay,\n }),\n );\n return yxc;\n };\n\n // 3) XML/YNC \u2014 pre-2010 receivers; polled. A drop is reported after a run of failed polls.\n const buildXml = (): ConnectableTransport => {\n const xml = new TransportConnectionAdapter(\"xml\", device.id, setStateAck);\n xml.bind(\n new XmlDeviceController(\n device.id,\n {\n client: new XmlClient(device.ip),\n scheduleKeepalive: deps.scheduleKeepalive,\n upsertObject: xml.interceptUpsert,\n setStateAck: xml.interceptSetStateAck,\n log,\n delay,\n },\n deps.xmlPollIntervalMs,\n ),\n );\n return xml;\n };\n\n return connectTransports(\n device.id,\n [\n { transport: \"ynca\", build: buildYnca },\n { transport: \"yxc\", build: buildYxc },\n { transport: \"xml\", build: buildXml },\n ],\n {\n upsertObject,\n log,\n onTransports: deps.onTransports,\n reachability: deps.reachability,\n timers: deps.timers,\n },\n );\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { YncaClient } from \"./ynca/ynca-client\";\nimport { YncaDeviceController } from \"./device-controller\";\nimport { YxcDeviceController } from \"./yxc/device-controller\";\nimport { YamahaYxcClient } from \"./yxc/http-client\";\nimport { XmlDeviceController } from \"./xml/device-controller\";\nimport { XmlClient } from \"./xml/xml-client\";\nimport { MultiTransportHandle, type ConnectableTransport } from \"./lifecycle/multi-transport-handle\";\nimport { TransportConnectionAdapter } from \"./lifecycle/transport-connection-adapter\";\nimport { ReconnectStrategy } from \"./lifecycle/reconnect-strategy\";\nimport { CommandGate } from \"./lifecycle/command-gate\";\nimport type { ReachabilityDedup } from \"./lifecycle/reachability-dedup\";\nimport type { YncaSubunitCache } from \"./ynca/subunit-cache\";\nimport type { ProbeMemory } from \"./lifecycle/probe-memory\";\nimport type { Transport } from \"./catalog/owner-policy\";\nimport { readyLine } from \"./ready-line\";\nimport { errorMessage } from \"./util\";\nimport type { ConnectionHandle, ControllerLog } from \"./controller\";\nimport type { ObjectDef } from \"./catalog/types\";\nimport type { DeviceRecord } from \"./types\";\n\n// Re-exported so existing importers (tests) keep resolving it from here.\nexport type { ConnectableTransport };\n\n/** Per-transport reconnect backoff bounds \u2014 same shape as the device supervisor's. */\nconst TRANSPORT_RECONNECT_BASE_MS = 1000;\nconst TRANSPORT_RECONNECT_MAX_MS = 60000;\n\n/**\n * Minimum spacing between two commands, per transport. YNCA's 100 ms is Yamaha's\n * specification (`ynca-python` protocol.py: \"YNCA spec specifies that there should be at\n * least 100 milliseconds between commands\"). The HTTP transports have no documented\n * spacing \u2014 0 ms, but they still run through a gate, which serialises them so an embedded\n * device never faces a burst of parallel requests.\n */\nconst COMMAND_SPACING_MS: Readonly<Record<Transport, number>> = { ynca: 100, yxc: 0, xml: 0 };\n\n/** The adapter-bound callbacks {@link attemptDevice} drives \u2014 injected so it needs no adapter. */\nexport interface AttemptDeps {\n /** Adapter log. */\n log: ControllerLog;\n /** Create or update an object in the device tree. */\n upsertObject(id: string, def: ObjectDef): Promise<void>;\n /** Write a state value with ack (device-originated). */\n setStateAck(id: string, value: boolean | number | string): void;\n /** Adapter-managed timers (YNCA pacing + per-transport reconnects). */\n timers: {\n /** Schedule a one-shot timer. */\n schedule(handler: () => void, ms: number): ioBroker.Timeout | undefined;\n /** Cancel a scheduled timer. */\n cancel(handle: ioBroker.Timeout | undefined): void;\n };\n /** Register a YXC push handler for a device IP; returns a function that unregisters it. */\n registerPush(ip: string, onPush: (event: unknown) => void): () => void;\n /** Whether the shared push receiver is listening (decides how much the keepalive polls). */\n pushActive?(): boolean;\n /** Schedule a repeating keepalive; returns a function that cancels it. */\n scheduleKeepalive(handler: () => void, ms: number): () => void;\n /** How often to poll an XML/YNC device for state (ms). */\n xmlPollIntervalMs: number;\n /** Report the transports that are live after every change \u2014 the id-safe names (\"ynca\"/\"yxc\"/\"xml\"). */\n onTransports?(names: string[]): void;\n /** Report the name the device carries for itself (MusicCast), for the device object's label. */\n onDeviceName?(name: string): void;\n /** IPs of all configured devices, so a MusicCast group can resolve a client device by IP. */\n knownDeviceIps: Set<string>;\n /** Dedup for the \"no reachable transport\" warning \u2014 see {@link ConnectDeps.reachability}. */\n reachability?: ReachabilityDedup;\n /** Datapoint-group gate for the YNCA sweep \u2014 a disabled group's functions are never fetched. */\n isEntryEnabled?(id: string): boolean;\n /** Per-device cache of the YNCA AVAIL probe, held by the caller across reconnects. */\n yncaSubunitCache?: YncaSubunitCache;\n /** Per-device memory for device answers that stay constant while it runs (held by the caller). */\n probeMemory?: ProbeMemory;\n}\n\n/** One transport to try: its name and a factory building a FRESH connectable (also for reconnects). */\nexport interface TransportAttempt {\n /** The transport this attempt stands for. */\n transport: Transport;\n /** Build a fresh connectable transport (a controller behind its adapter). */\n build(): ConnectableTransport;\n}\n\n/** The adapter callbacks {@link connectTransports} drives to build and hold the unified tree. */\nexport interface ConnectDeps {\n /** Adapter log. */\n log: ControllerLog;\n /** Create or update an object in the device tree. */\n upsertObject(id: string, def: ObjectDef): Promise<void>;\n /** Report the transports that are live after every change \u2014 the id-safe names (\"ynca\"/\"yxc\"/\"xml\"). */\n onTransports?(names: string[]): void;\n /**\n * Dedup for the \"no reachable transport\" warning: without it every retry warns\n * again for as long as the device stays offline (nut2 `failedUps` pattern \u2014 first\n * failure warns, repeats stay at debug until the device answers again).\n */\n reachability?: ReachabilityDedup;\n /** Timers for the per-transport reconnect loops (absent in tests \u2192 no per-transport retry). */\n timers?: {\n /** Schedule a one-shot timer. */\n schedule(handler: () => void, ms: number): ioBroker.Timeout | undefined;\n /** Cancel a scheduled timer. */\n cancel(handle: ioBroker.Timeout | undefined): void;\n };\n}\n\n/**\n * Bring every answering transport online on ONE object tree. All candidates connect IN\n * PARALLEL (a YNCA connect timeout or long sweep no longer delays YXC/XML); the ones that\n * answer are handed to a single {@link MultiTransportHandle}, which unifies their catalogs\n * (each capability owned by exactly one transport \u2014 most-modern-but-lossless, see the\n * object-tree coordinator), routes user writes to the owner, and reconnects a single\n * dropped transport on its own while the others keep running. Ownership is resolved by\n * rank over the connected set, so the connect order/timing never changes it. A transport\n * that does not answer, or whose connect throws, is closed and left out. Returns the\n * handle over the live set, or null when no transport answered (the device is offline\n * this attempt).\n *\n * @param deviceId the id-safe device id\n * @param attempts the transports to try\n * @param deps the adapter callbacks (upsert + log + timers)\n * @returns a connection handle over the live transports, or null when none connected\n */\nexport async function connectTransports(\n deviceId: string,\n attempts: readonly TransportAttempt[],\n deps: ConnectDeps,\n): Promise<ConnectionHandle | null> {\n const results = await Promise.all(\n attempts.map(async attempt => {\n const conn = attempt.build();\n try {\n if (await conn.connect()) {\n return conn;\n }\n } catch (e) {\n deps.log.debug(`${deviceId}/${conn.transport}: transport did not connect (${errorMessage(e)})`);\n }\n conn.close();\n return null;\n }),\n );\n const live = results.filter((conn): conn is ConnectableTransport => conn !== null);\n if (live.length === 0) {\n const level = deps.reachability?.reportUnreachable() ?? \"warn\";\n deps.log[level](`${deviceId}: no reachable transport (YNCA/YXC/XML)`);\n return null;\n }\n deps.reachability?.reportReachable();\n const rebuilds = new Map(attempts.map(attempt => [attempt.transport, attempt.build] as const));\n const handle = new MultiTransportHandle(deviceId, live, {\n upsertObject: deps.upsertObject,\n log: deps.log,\n onTransports: deps.onTransports,\n rebuild: deps.timers ? transport => rebuilds.get(transport)!() : undefined,\n schedule: deps.timers ? (cb, ms) => deps.timers!.schedule(cb, ms) : undefined,\n cancel: deps.timers ? handle_ => deps.timers!.cancel(handle_ as ioBroker.Timeout | undefined) : undefined,\n backoffFactory: () => new ReconnectStrategy(TRANSPORT_RECONNECT_BASE_MS, TRANSPORT_RECONNECT_MAX_MS),\n });\n try {\n await handle.start();\n } catch (e) {\n // Building the unified tree failed (e.g. object creation errored): close every live\n // transport before rethrowing, or the supervisor's retry would leak sockets and timers \u2014\n // fatal for YNCA, where the receiver allows only ONE connection and a zombie socket\n // would block every future attempt until the adapter restarts.\n handle.close();\n throw e;\n }\n // One summary line instead of three per-transport \"ready\" lines; each controller logs its\n // own readiness at debug level for diagnostics.\n deps.log.info(\n readyLine(\n deviceId,\n live.map(conn => conn.transport),\n ),\n );\n return handle;\n}\n\n/**\n * Bring one device online across ALL its transports. Every transport that answers \u2014 YNCA (amp\n * control over a held TCP connection), YXC (MusicCast, push + poll), XML/YNC (pre-2010) \u2014 is built\n * behind a {@link TransportConnectionAdapter} and connected in parallel on one object tree, so a\n * MusicCast AVR gets YNCA base control AND the YXC-exclusive richness (multiroom, equalizer, album\n * art) instead of one transport winning and hiding the rest. Each transport is described by a\n * factory, so the handle can rebuild and reconnect a single dropped transport while the others\n * keep running. Returns a {@link ConnectionHandle} over the live set, or null when no transport\n * answers this attempt.\n *\n * @param device the configured device record\n * @param deps the adapter-bound callbacks\n * @returns a connection handle, or null when no transport connected\n */\nexport function attemptDevice(device: DeviceRecord, deps: AttemptDeps): Promise<ConnectionHandle | null> {\n const { log, upsertObject, setStateAck, timers } = deps;\n /**\n * A fresh command gate for one transport connection. EVERY command of that transport\n * goes through it: user writes, the init sweep, the keepalive and browsing alike. It is\n * also the connection's shutdown signal \u2014 closing it empties the queue and ends every\n * pending wait, so a stopped adapter leaves nothing running.\n *\n * One gate per device AND transport, not one for the adapter: the spacing is a property\n * of the device connection, so a shared gate would let one receiver's 19-second sweep\n * block another receiver's button press.\n *\n * @param transport the transport the gate belongs to\n * @returns the gate\n */\n const gateFor = (transport: Transport): CommandGate =>\n new CommandGate({ minSpacingMs: COMMAND_SPACING_MS[transport], timers });\n\n // 1) YNCA \u2014 amp control over a held TCP connection; a socket drop is the genuine gone-signal.\n const buildYnca = (): ConnectableTransport => {\n const ynca = new TransportConnectionAdapter(\"ynca\", device.id, setStateAck);\n const gate = gateFor(\"ynca\");\n ynca.bind(\n new YncaDeviceController(device.id, {\n client: new YncaClient(device.ip, timers, gate),\n gate,\n upsertObject: ynca.interceptUpsert,\n setStateAck: ynca.interceptSetStateAck,\n log,\n isEntryEnabled: deps.isEntryEnabled,\n subunitCache: deps.yncaSubunitCache,\n probeMemory: deps.probeMemory,\n }),\n );\n return ynca;\n };\n\n // 2) YXC \u2014 MusicCast; polled + push. Drop reported after a run of failed keepalive polls.\n const buildYxc = (): ConnectableTransport => {\n const yxc = new TransportConnectionAdapter(\"yxc\", device.id, setStateAck);\n const gate = gateFor(\"yxc\");\n yxc.bind(\n new YxcDeviceController(device.id, {\n client: new YamahaYxcClient(device.ip, undefined, gate),\n // Resolve another configured device's client for a multiroom link \u2014 never this device\n // itself. The partner's own gate belongs to its own connection, so this one-off client\n // stays ungated (a single link call, not a stream of commands).\n clientFor: ip => (ip !== device.ip && deps.knownDeviceIps.has(ip) ? new YamahaYxcClient(ip) : undefined),\n registerPush: onPush => deps.registerPush(device.ip, onPush),\n pushActive: deps.pushActive,\n probeMemory: deps.probeMemory,\n scheduleKeepalive: deps.scheduleKeepalive,\n upsertObject: yxc.interceptUpsert,\n setStateAck: yxc.interceptSetStateAck,\n reportDeviceName: deps.onDeviceName,\n log,\n gate,\n }),\n );\n return yxc;\n };\n\n // 3) XML/YNC \u2014 pre-2010 receivers; polled. A drop is reported after a run of failed polls.\n const buildXml = (): ConnectableTransport => {\n const xml = new TransportConnectionAdapter(\"xml\", device.id, setStateAck);\n const gate = gateFor(\"xml\");\n xml.bind(\n new XmlDeviceController(\n device.id,\n {\n client: new XmlClient(device.ip, undefined, gate),\n scheduleKeepalive: deps.scheduleKeepalive,\n upsertObject: xml.interceptUpsert,\n setStateAck: xml.interceptSetStateAck,\n log,\n gate,\n probeMemory: deps.probeMemory,\n },\n deps.xmlPollIntervalMs,\n ),\n );\n return xml;\n };\n\n return connectTransports(\n device.id,\n [\n { transport: \"ynca\", build: buildYnca },\n { transport: \"yxc\", build: buildYxc },\n { transport: \"xml\", build: buildXml },\n ],\n {\n upsertObject,\n log,\n onTransports: deps.onTransports,\n reachability: deps.reachability,\n timers: deps.timers,\n },\n );\n}\n"],
5
+ "mappings": "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",
6
6
  "names": ["import_device_controller"]
7
7
  }
@@ -66,6 +66,9 @@ class BrowseEngine {
66
66
  * @param window the window snapshot
67
67
  */
68
68
  onWindow(window) {
69
+ if (this.closed) {
70
+ return;
71
+ }
69
72
  this.window = window;
70
73
  this.windowVersion++;
71
74
  this.deps.emit("player.browse.menuName", window.menuName);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/browse/browse-engine.ts"],
4
- "sourcesContent": ["import type { BrowseDriver, BrowseRow, BrowseWindow } from \"./types\";\nimport type { ControllerLog } from \"../controller\";\nimport { errorMessage } from \"../util\";\n\n/** Poll interval while waiting for the device to deliver a fresh window. */\nconst WAIT_POLL_MS = 250;\n/** How long one navigation step (select/page/back/home) may take before the walk aborts. */\nconst STEP_TIMEOUT_MS = 15000;\n/** Hard page cap while searching one path segment, beyond the totalItems-derived bound. */\nconst MAX_SEARCH_PAGES = 32;\n\n/** The adapter-bound callbacks the engine drives. */\nexport interface BrowseEngineDeps {\n /** Write a browse state (id relative to the device, e.g. `player.browse.line1`) with ack. */\n emit(id: string, value: boolean | number | string): void;\n /** Adapter log. */\n log: ControllerLog;\n /** Adapter-managed delay (so no native timer outlives onUnload). */\n delay(ms: number): Promise<void>;\n}\n\n/**\n * Prefix a row's text with its kind symbol, so a plain VIS button caption already\n * shows whether the line is a folder or a playable title.\n *\n * @param row the row to label\n * @returns the display text\n */\nexport function rowLabel(row: BrowseRow): string {\n if (row.kind === \"folder\") {\n return `\\u{1F4C1} ${row.text}`;\n }\n if (row.kind === \"item\") {\n return `\u266A ${row.text}`;\n }\n return row.text;\n}\n\n/**\n * The transport-neutral browsing engine: it owns the `player.browse.*` states, turns\n * user writes into driver operations, and renders every window the driver reports.\n * Exactly one engine is active per device \u2014 the one inside the owning transport's\n * controller (writes only ever reach the owner, and a non-owner's state writes are\n * filtered by the transport adapter).\n */\nexport class BrowseEngine {\n private window: BrowseWindow | undefined;\n /** Bumped on every window report \u2014 the path walk waits on it. */\n private windowVersion = 0;\n /** Serializes operations: a write while an operation runs is dropped with a log line. */\n private running = false;\n private closed = false;\n\n /**\n * @param driver the transport's list operations\n * @param deps the adapter-bound callbacks\n */\n public constructor(\n private readonly driver: BrowseDriver,\n private readonly deps: BrowseEngineDeps,\n ) {}\n\n /** Seed the surface: the source dropdown exists via the objects; states start empty. */\n public seed(): void {\n this.deps.emit(\"player.browse.busy\", false);\n this.deps.emit(\"player.browse.path\", \"\");\n }\n\n /** Stop accepting operations (the controller is closing). */\n public close(): void {\n this.closed = true;\n }\n\n /**\n * Render a fresh window the driver reports (a fetch result or, on YNCA, an\n * unsolicited auto-feedback push).\n *\n * @param window the window snapshot\n */\n public onWindow(window: BrowseWindow): void {\n this.window = window;\n this.windowVersion++;\n this.deps.emit(\"player.browse.menuName\", window.menuName);\n this.deps.emit(\"player.browse.layer\", window.layer);\n this.deps.emit(\"player.browse.totalItems\", window.totalItems);\n this.deps.emit(\"player.browse.currentLine\", window.currentLine);\n for (let line = 1; line <= 8; line++) {\n const row = window.rows.find(r => r.line === line);\n this.deps.emit(`player.browse.line${line}`, row ? rowLabel(row) : \"\");\n }\n this.deps.emit(\"player.browse.rows\", JSON.stringify(window.rows));\n }\n\n /**\n * Handle a user write to one of the browse states.\n *\n * @param stateId the state id relative to the device (`player.browse.\u2026`)\n * @param value the written value\n */\n public handleWrite(stateId: string, value: unknown): void {\n const sub = stateId.slice(\"player.browse.\".length);\n switch (sub) {\n case \"source\":\n if (typeof value === \"string\" && value in this.driver.sources()) {\n void this.run(`open ${value}`, async () => {\n await this.driver.open(value);\n this.deps.emit(\"player.browse.source\", value);\n });\n }\n return;\n case \"selectLine\": {\n const line = Number(value);\n if (Number.isInteger(line) && line >= 1 && line <= 8) {\n void this.run(`select line ${line}`, () => this.driver.select(line));\n }\n return;\n }\n case \"pageUp\":\n void this.run(\"page up\", () => this.driver.pageUp());\n return;\n case \"pageDown\":\n void this.run(\"page down\", () => this.driver.pageDown());\n return;\n case \"back\":\n void this.run(\"back\", () => this.driver.back());\n return;\n case \"home\":\n void this.run(\"home\", () => this.driver.home());\n return;\n case \"path\":\n if (typeof value === \"string\" && value.trim().length > 0) {\n void this.run(`path ${value}`, () => this.walkPath(value));\n }\n return;\n default:\n return;\n }\n }\n\n /**\n * Run one operation, serialized: while one runs, further writes are dropped (the\n * busy state shows why). Failures land in the log as warnings \u2014 a user action\n * failing must be visible.\n *\n * @param what the operation, for the log line\n * @param op the operation to run\n */\n private async run(what: string, op: () => Promise<void> | void): Promise<void> {\n if (this.closed) {\n return;\n }\n if (this.running) {\n this.deps.log.debug(`browse: \"${what}\" dropped \u2014 another browse operation is still running`);\n return;\n }\n this.running = true;\n this.deps.emit(\"player.browse.busy\", true);\n try {\n await op();\n } catch (e) {\n this.deps.log.warn(`browse: ${what} failed: ${errorMessage(e)}`);\n } finally {\n this.running = false;\n if (!this.closed) {\n this.deps.emit(\"player.browse.busy\", false);\n }\n }\n }\n\n /**\n * Wait until the driver reports a window fresher than `sinceVersion` (or the step\n * timeout passes \u2014 menu levels are served by the device/catalog service and can be\n * slow, so this polls rather than racing).\n *\n * @param sinceVersion the window version before the operation\n * @returns true if a fresh window arrived, false on timeout\n */\n private async waitForWindow(sinceVersion: number): Promise<boolean> {\n const deadline = STEP_TIMEOUT_MS / WAIT_POLL_MS;\n for (let i = 0; i < deadline; i++) {\n if (this.closed) {\n return false;\n }\n if (this.windowVersion > sinceVersion) {\n return true;\n }\n await this.deps.delay(WAIT_POLL_MS);\n }\n return this.windowVersion > sinceVersion;\n }\n\n /**\n * Walk a `>`-separated path from the menu root, selecting each segment by its text\n * (paging forward when it is not in the visible window) \u2014 the one-write navigation\n * for scripts and scenes. The final segment behaves like any selection: a folder\n * opens, a playable item starts.\n *\n * @param path the path, e.g. `Bookmarks>Radio Paradise`\n */\n private async walkPath(path: string): Promise<void> {\n const segments = path\n .split(\">\")\n .map(segment => segment.trim())\n .filter(segment => segment.length > 0);\n if (segments.length === 0) {\n return;\n }\n let version = this.windowVersion;\n await this.driver.home();\n if (!(await this.waitForWindow(version))) {\n this.deps.log.warn(`browse: path \"${path}\" aborted \u2014 the menu root did not load`);\n return;\n }\n for (const segment of segments) {\n if (!(await this.findAndSelect(segment))) {\n this.deps.log.warn(`browse: path \"${path}\" aborted \u2014 \"${segment}\" not found in this menu`);\n return;\n }\n version = this.windowVersion;\n // Give the device a chance to deliver the next level; a playable final segment\n // may not change the window at all, so a timeout here is not an error.\n await this.waitForWindow(version);\n }\n }\n\n /**\n * Find a row by its text in the current menu, paging forward as needed, and select it.\n *\n * @param text the row text to find (without the symbol prefix)\n * @returns true when the row was found and selected\n */\n private async findAndSelect(text: string): Promise<boolean> {\n for (let page = 0; page < MAX_SEARCH_PAGES; page++) {\n const window = this.window;\n if (!window) {\n return false;\n }\n const row = window.rows.find(r => r.text === text && r.kind !== \"unselectable\");\n if (row) {\n await this.driver.select(row.line);\n return true;\n }\n // Stop at the last page: the window already shows the menu's tail.\n if (window.currentLine - 1 + window.rows.length >= window.totalItems) {\n return false;\n }\n const version = this.windowVersion;\n await this.driver.pageDown();\n if (!(await this.waitForWindow(version))) {\n return false;\n }\n }\n return false;\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type { BrowseDriver, BrowseRow, BrowseWindow } from \"./types\";\nimport type { ControllerLog } from \"../controller\";\nimport { errorMessage } from \"../util\";\n\n/** Poll interval while waiting for the device to deliver a fresh window. */\nconst WAIT_POLL_MS = 250;\n/** How long one navigation step (select/page/back/home) may take before the walk aborts. */\nconst STEP_TIMEOUT_MS = 15000;\n/** Hard page cap while searching one path segment, beyond the totalItems-derived bound. */\nconst MAX_SEARCH_PAGES = 32;\n\n/** The adapter-bound callbacks the engine drives. */\nexport interface BrowseEngineDeps {\n /** Write a browse state (id relative to the device, e.g. `player.browse.line1`) with ack. */\n emit(id: string, value: boolean | number | string): void;\n /** Adapter log. */\n log: ControllerLog;\n /** Adapter-managed delay (so no native timer outlives onUnload). */\n delay(ms: number): Promise<void>;\n}\n\n/**\n * Prefix a row's text with its kind symbol, so a plain VIS button caption already\n * shows whether the line is a folder or a playable title.\n *\n * @param row the row to label\n * @returns the display text\n */\nexport function rowLabel(row: BrowseRow): string {\n if (row.kind === \"folder\") {\n return `\\u{1F4C1} ${row.text}`;\n }\n if (row.kind === \"item\") {\n return `\u266A ${row.text}`;\n }\n return row.text;\n}\n\n/**\n * The transport-neutral browsing engine: it owns the `player.browse.*` states, turns\n * user writes into driver operations, and renders every window the driver reports.\n * Exactly one engine is active per device \u2014 the one inside the owning transport's\n * controller (writes only ever reach the owner, and a non-owner's state writes are\n * filtered by the transport adapter).\n */\nexport class BrowseEngine {\n private window: BrowseWindow | undefined;\n /** Bumped on every window report \u2014 the path walk waits on it. */\n private windowVersion = 0;\n /** Serializes operations: a write while an operation runs is dropped with a log line. */\n private running = false;\n private closed = false;\n\n /**\n * @param driver the transport's list operations\n * @param deps the adapter-bound callbacks\n */\n public constructor(\n private readonly driver: BrowseDriver,\n private readonly deps: BrowseEngineDeps,\n ) {}\n\n /** Seed the surface: the source dropdown exists via the objects; states start empty. */\n public seed(): void {\n this.deps.emit(\"player.browse.busy\", false);\n this.deps.emit(\"player.browse.path\", \"\");\n }\n\n /** Stop accepting operations (the controller is closing). */\n public close(): void {\n this.closed = true;\n }\n\n /**\n * Render a fresh window the driver reports (a fetch result or, on YNCA, an\n * unsolicited auto-feedback push).\n *\n * @param window the window snapshot\n */\n public onWindow(window: BrowseWindow): void {\n // A fetch that was already in flight when the connection closed must not paint stale\n // rows into a tree that is being torn down (the XML driver polls a busy menu for up\n // to five seconds).\n if (this.closed) {\n return;\n }\n this.window = window;\n this.windowVersion++;\n this.deps.emit(\"player.browse.menuName\", window.menuName);\n this.deps.emit(\"player.browse.layer\", window.layer);\n this.deps.emit(\"player.browse.totalItems\", window.totalItems);\n this.deps.emit(\"player.browse.currentLine\", window.currentLine);\n for (let line = 1; line <= 8; line++) {\n const row = window.rows.find(r => r.line === line);\n this.deps.emit(`player.browse.line${line}`, row ? rowLabel(row) : \"\");\n }\n this.deps.emit(\"player.browse.rows\", JSON.stringify(window.rows));\n }\n\n /**\n * Handle a user write to one of the browse states.\n *\n * @param stateId the state id relative to the device (`player.browse.\u2026`)\n * @param value the written value\n */\n public handleWrite(stateId: string, value: unknown): void {\n const sub = stateId.slice(\"player.browse.\".length);\n switch (sub) {\n case \"source\":\n if (typeof value === \"string\" && value in this.driver.sources()) {\n void this.run(`open ${value}`, async () => {\n await this.driver.open(value);\n this.deps.emit(\"player.browse.source\", value);\n });\n }\n return;\n case \"selectLine\": {\n const line = Number(value);\n if (Number.isInteger(line) && line >= 1 && line <= 8) {\n void this.run(`select line ${line}`, () => this.driver.select(line));\n }\n return;\n }\n case \"pageUp\":\n void this.run(\"page up\", () => this.driver.pageUp());\n return;\n case \"pageDown\":\n void this.run(\"page down\", () => this.driver.pageDown());\n return;\n case \"back\":\n void this.run(\"back\", () => this.driver.back());\n return;\n case \"home\":\n void this.run(\"home\", () => this.driver.home());\n return;\n case \"path\":\n if (typeof value === \"string\" && value.trim().length > 0) {\n void this.run(`path ${value}`, () => this.walkPath(value));\n }\n return;\n default:\n return;\n }\n }\n\n /**\n * Run one operation, serialized: while one runs, further writes are dropped (the\n * busy state shows why). Failures land in the log as warnings \u2014 a user action\n * failing must be visible.\n *\n * @param what the operation, for the log line\n * @param op the operation to run\n */\n private async run(what: string, op: () => Promise<void> | void): Promise<void> {\n if (this.closed) {\n return;\n }\n if (this.running) {\n this.deps.log.debug(`browse: \"${what}\" dropped \u2014 another browse operation is still running`);\n return;\n }\n this.running = true;\n this.deps.emit(\"player.browse.busy\", true);\n try {\n await op();\n } catch (e) {\n this.deps.log.warn(`browse: ${what} failed: ${errorMessage(e)}`);\n } finally {\n this.running = false;\n if (!this.closed) {\n this.deps.emit(\"player.browse.busy\", false);\n }\n }\n }\n\n /**\n * Wait until the driver reports a window fresher than `sinceVersion` (or the step\n * timeout passes \u2014 menu levels are served by the device/catalog service and can be\n * slow, so this polls rather than racing).\n *\n * @param sinceVersion the window version before the operation\n * @returns true if a fresh window arrived, false on timeout\n */\n private async waitForWindow(sinceVersion: number): Promise<boolean> {\n const deadline = STEP_TIMEOUT_MS / WAIT_POLL_MS;\n for (let i = 0; i < deadline; i++) {\n if (this.closed) {\n return false;\n }\n if (this.windowVersion > sinceVersion) {\n return true;\n }\n await this.deps.delay(WAIT_POLL_MS);\n }\n return this.windowVersion > sinceVersion;\n }\n\n /**\n * Walk a `>`-separated path from the menu root, selecting each segment by its text\n * (paging forward when it is not in the visible window) \u2014 the one-write navigation\n * for scripts and scenes. The final segment behaves like any selection: a folder\n * opens, a playable item starts.\n *\n * @param path the path, e.g. `Bookmarks>Radio Paradise`\n */\n private async walkPath(path: string): Promise<void> {\n const segments = path\n .split(\">\")\n .map(segment => segment.trim())\n .filter(segment => segment.length > 0);\n if (segments.length === 0) {\n return;\n }\n let version = this.windowVersion;\n await this.driver.home();\n if (!(await this.waitForWindow(version))) {\n this.deps.log.warn(`browse: path \"${path}\" aborted \u2014 the menu root did not load`);\n return;\n }\n for (const segment of segments) {\n if (!(await this.findAndSelect(segment))) {\n this.deps.log.warn(`browse: path \"${path}\" aborted \u2014 \"${segment}\" not found in this menu`);\n return;\n }\n version = this.windowVersion;\n // Give the device a chance to deliver the next level; a playable final segment\n // may not change the window at all, so a timeout here is not an error.\n await this.waitForWindow(version);\n }\n }\n\n /**\n * Find a row by its text in the current menu, paging forward as needed, and select it.\n *\n * @param text the row text to find (without the symbol prefix)\n * @returns true when the row was found and selected\n */\n private async findAndSelect(text: string): Promise<boolean> {\n for (let page = 0; page < MAX_SEARCH_PAGES; page++) {\n const window = this.window;\n if (!window) {\n return false;\n }\n const row = window.rows.find(r => r.text === text && r.kind !== \"unselectable\");\n if (row) {\n await this.driver.select(row.line);\n return true;\n }\n // Stop at the last page: the window already shows the menu's tail.\n if (window.currentLine - 1 + window.rows.length >= window.totalItems) {\n return false;\n }\n const version = this.windowVersion;\n await this.driver.pageDown();\n if (!(await this.waitForWindow(version))) {\n return false;\n }\n }\n return false;\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -0,0 +1,47 @@
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 surface_exports = {};
20
+ __export(surface_exports, {
21
+ createBrowseSurface: () => createBrowseSurface
22
+ });
23
+ module.exports = __toCommonJS(surface_exports);
24
+ var import_browse_engine = require("./browse-engine");
25
+ var import_objects = require("./objects");
26
+ async function createBrowseSurface(driver, deviceId, deps) {
27
+ const sources = driver.sources();
28
+ if (Object.keys(sources).length === 0) {
29
+ return void 0;
30
+ }
31
+ for (const def of (0, import_objects.browseObjectDefs)(sources)) {
32
+ await deps.upsertObject(`${deviceId}.${def.id}`, def);
33
+ }
34
+ const engine = new import_browse_engine.BrowseEngine(driver, {
35
+ emit: (id, value) => deps.emit(id, value),
36
+ log: deps.log,
37
+ delay: deps.delay
38
+ });
39
+ driver.attach(engine);
40
+ engine.seed();
41
+ return engine;
42
+ }
43
+ // Annotate the CommonJS export names for ESM import in node:
44
+ 0 && (module.exports = {
45
+ createBrowseSurface
46
+ });
47
+ //# sourceMappingURL=surface.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/lib/browse/surface.ts"],
4
+ "sourcesContent": ["import { BrowseEngine } from \"./browse-engine\";\nimport { browseObjectDefs } from \"./objects\";\nimport type { BrowseDriver } from \"./types\";\nimport type { ObjectDef } from \"../catalog/types\";\nimport type { ControllerLog } from \"../controller\";\n\n/** What creating a browsing surface needs from its controller. */\nexport interface BrowseSurfaceDeps {\n /** Create or update an object in the device tree. */\n upsertObject(id: string, def: ObjectDef): Promise<void>;\n /** Write a browse state with ack (already guarded against a closed connection). */\n emit(relativeId: string, value: boolean | number | string): void;\n /** Adapter log. */\n log: ControllerLog;\n /** Adapter-managed delay that ends when the connection closes. */\n delay(ms: number): Promise<void>;\n}\n\n/**\n * Create the `player.browse.*` surface for one transport: its objects, the engine that\n * owns the states, and the wiring between them. All three transports need exactly this\n * sequence, so it lives here once instead of being copied into each controller.\n *\n * Returns undefined when the device offers nothing browsable \u2014 then no objects are created\n * at all, which is what keeps the folder off devices that cannot browse.\n *\n * @param driver the transport's list driver\n * @param deviceId the id-safe device id\n * @param deps the controller callbacks\n * @returns the engine driving the surface, or undefined when there is nothing to browse\n */\nexport async function createBrowseSurface(\n driver: BrowseDriver & { attach(engine: BrowseEngine): void },\n deviceId: string,\n deps: BrowseSurfaceDeps,\n): Promise<BrowseEngine | undefined> {\n const sources = driver.sources();\n if (Object.keys(sources).length === 0) {\n return undefined;\n }\n for (const def of browseObjectDefs(sources)) {\n await deps.upsertObject(`${deviceId}.${def.id}`, def);\n }\n const engine = new BrowseEngine(driver, {\n emit: (id, value) => deps.emit(id, value),\n log: deps.log,\n delay: deps.delay,\n });\n driver.attach(engine);\n engine.seed();\n return engine;\n}\n"],
5
+ "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,2BAA6B;AAC7B,qBAAiC;AA8BjC,eAAsB,oBACpB,QACA,UACA,MACmC;AACnC,QAAM,UAAU,OAAO,QAAQ;AAC/B,MAAI,OAAO,KAAK,OAAO,EAAE,WAAW,GAAG;AACrC,WAAO;AAAA,EACT;AACA,aAAW,WAAO,iCAAiB,OAAO,GAAG;AAC3C,UAAM,KAAK,aAAa,GAAG,QAAQ,IAAI,IAAI,EAAE,IAAI,GAAG;AAAA,EACtD;AACA,QAAM,SAAS,IAAI,kCAAa,QAAQ;AAAA,IACtC,MAAM,CAAC,IAAI,UAAU,KAAK,KAAK,IAAI,KAAK;AAAA,IACxC,KAAK,KAAK;AAAA,IACV,OAAO,KAAK;AAAA,EACd,CAAC;AACD,SAAO,OAAO,MAAM;AACpB,SAAO,KAAK;AACZ,SAAO;AACT;",
6
+ "names": []
7
+ }
@@ -3,6 +3,10 @@ var __defProp = Object.defineProperty;
3
3
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
4
  var __getOwnPropNames = Object.getOwnPropertyNames;
5
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
+ };
6
10
  var __copyProps = (to, from, except, desc) => {
7
11
  if (from && typeof from === "object" || typeof from === "function") {
8
12
  for (let key of __getOwnPropNames(from))
@@ -13,5 +17,18 @@ var __copyProps = (to, from, except, desc) => {
13
17
  };
14
18
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
15
19
  var types_exports = {};
20
+ __export(types_exports, {
21
+ ROW_KIND_BY_ATTRIBUTE: () => ROW_KIND_BY_ATTRIBUTE
22
+ });
16
23
  module.exports = __toCommonJS(types_exports);
24
+ const ROW_KIND_BY_ATTRIBUTE = {
25
+ Container: "folder",
26
+ Item: "item",
27
+ "Unplayable Item": "unplayable",
28
+ Unselectable: "unselectable"
29
+ };
30
+ // Annotate the CommonJS export names for ESM import in node:
31
+ 0 && (module.exports = {
32
+ ROW_KIND_BY_ATTRIBUTE
33
+ });
17
34
  //# sourceMappingURL=types.js.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/browse/types.ts"],
4
- "sourcesContent": ["/**\n * Shared contracts for the media-menu browsing surface (`player.browse.*`, issue #613).\n * One transport-neutral engine drives the states; each transport contributes a thin\n * {@link BrowseDriver} over its own list protocol (YNCA LISTINFO/LISTSEL, YXC\n * netusb getListInfo/setListControl, XML List_Info/List_Control). The surface mirrors\n * the device's own menu: an 8-line window, select-by-line, page/back/home, plus a\n * path auto-walk for scripts \u2014 the predecessor adapter's proven VIS workflow.\n */\n\n/** The kind of a menu row, as the device reports it. */\nexport type BrowseRowKind = \"folder\" | \"item\" | \"unplayable\" | \"unselectable\";\n\n/** One visible menu row. */\nexport interface BrowseRow {\n /** The row's line number within the window (1\u20138). */\n line: number;\n /** The row's raw text (no symbol prefix). */\n text: string;\n /** Whether the row is a folder, a playable item, or not selectable. */\n kind: BrowseRowKind;\n /** Cover/thumbnail URL, where the protocol carries one (YXC only). */\n thumbnail?: string;\n}\n\n/** A snapshot of the device's current menu window. */\nexport interface BrowseWindow {\n /** The menu title (the current folder's name). */\n menuName: string;\n /** The menu depth (1 = root). */\n layer: number;\n /** Total entries in the current menu. */\n totalItems: number;\n /** The absolute number of the window's first line (1-based). */\n currentLine: number;\n /** The visible rows (up to 8). */\n rows: BrowseRow[];\n}\n\n/**\n * The per-transport list operations behind the engine. Every method may be async;\n * the driver reports fresh windows through the engine's `onWindow` (pull transports\n * fetch after each operation, YNCA also receives unsolicited auto-feedback).\n */\nexport interface BrowseDriver {\n /** The selectable sources as state-value \u2192 display-label entries. */\n sources(): Record<string, string>;\n /** Open a source's menu (the device keeps the session position). */\n open(source: string): Promise<void> | void;\n /** Select a visible line (1\u20138) \u2014 a folder opens, a playable item starts. */\n select(line: number): Promise<void> | void;\n /** Show the previous 8 lines. */\n pageUp(): Promise<void> | void;\n /** Show the next 8 lines. */\n pageDown(): Promise<void> | void;\n /** Go one menu level back. */\n back(): Promise<void> | void;\n /** Return to the menu root. */\n home(): Promise<void> | void;\n /** Re-read the current window. */\n refresh(): Promise<void> | void;\n}\n"],
5
- "mappings": ";;;;;;;;;;;;;;AAAA;AAAA;",
4
+ "sourcesContent": ["/**\n * Shared contracts for the media-menu browsing surface (`player.browse.*`, issue #613).\n * One transport-neutral engine drives the states; each transport contributes a thin\n * {@link BrowseDriver} over its own list protocol (YNCA LISTINFO/LISTSEL, YXC\n * netusb getListInfo/setListControl, XML List_Info/List_Control). The surface mirrors\n * the device's own menu: an 8-line window, select-by-line, page/back/home, plus a\n * path auto-walk for scripts \u2014 the predecessor adapter's proven VIS workflow.\n */\n\n/** The kind of a menu row, as the device reports it. */\nexport type BrowseRowKind = \"folder\" | \"item\" | \"unplayable\" | \"unselectable\";\n\n/** One visible menu row. */\nexport interface BrowseRow {\n /** The row's line number within the window (1\u20138). */\n line: number;\n /** The row's raw text (no symbol prefix). */\n text: string;\n /** Whether the row is a folder, a playable item, or not selectable. */\n kind: BrowseRowKind;\n /** Cover/thumbnail URL, where the protocol carries one (YXC only). */\n thumbnail?: string;\n}\n\n/** A snapshot of the device's current menu window. */\nexport interface BrowseWindow {\n /** The menu title (the current folder's name). */\n menuName: string;\n /** The menu depth (1 = root). */\n layer: number;\n /** Total entries in the current menu. */\n totalItems: number;\n /**\n * The device's cursor position within the whole list (1-based) \u2014 that is what the\n * official YNCA command list defines CURRLINE as (\"absolute position number of current\n * cursor position\"), and the XML `Current_Line` matches it. It is NOT necessarily the\n * window's first line: after stepping back out of a submenu the cursor sits on the row\n * that was entered. The YXC driver has a real window index and reports that.\n */\n currentLine: number;\n /** The visible rows (up to 8). */\n rows: BrowseRow[];\n}\n\n/**\n * The per-transport list operations behind the engine. Every method may be async;\n * the driver reports fresh windows through the engine's `onWindow` (pull transports\n * fetch after each operation, YNCA also receives unsolicited auto-feedback).\n */\nexport interface BrowseDriver {\n /** The selectable sources as state-value \u2192 display-label entries. */\n sources(): Record<string, string>;\n /** Open a source's menu (the device keeps the session position). */\n open(source: string): Promise<void> | void;\n /** Select a visible line (1\u20138) \u2014 a folder opens, a playable item starts. */\n select(line: number): Promise<void> | void;\n /** Show the previous 8 lines. */\n pageUp(): Promise<void> | void;\n /** Show the next 8 lines. */\n pageDown(): Promise<void> | void;\n /** Go one menu level back. */\n back(): Promise<void> | void;\n /** Return to the menu root. */\n home(): Promise<void> | void;\n}\n\n/**\n * How the two text protocols name a row's kind. YNCA (`LINE1ATRIB`) and XML\n * (`Current_List` attribute) use the SAME four words \u2014 one table for both, so a new\n * attribute value is added once instead of in two drivers that then drift apart.\n */\nexport const ROW_KIND_BY_ATTRIBUTE: Readonly<Record<string, BrowseRowKind>> = {\n Container: \"folder\",\n Item: \"item\",\n \"Unplayable Item\": \"unplayable\",\n Unselectable: \"unselectable\",\n};\n"],
5
+ "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAuEO,MAAM,wBAAiE;AAAA,EAC5E,WAAW;AAAA,EACX,MAAM;AAAA,EACN,mBAAmB;AAAA,EACnB,cAAc;AAChB;",
6
6
  "names": []
7
7
  }
@@ -23,6 +23,8 @@ __export(xml_browse_driver_exports, {
23
23
  parseXmlListInfo: () => parseXmlListInfo
24
24
  });
25
25
  module.exports = __toCommonJS(xml_browse_driver_exports);
26
+ var import_types = require("./types");
27
+ var import_entities = require("../xml/entities");
26
28
  const BUSY_POLL_MS = 500;
27
29
  const MAX_BUSY_POLLS = 10;
28
30
  const XML_BROWSE_SOURCES = [
@@ -30,24 +32,22 @@ const XML_BROWSE_SOURCES = [
30
32
  { element: "SERVER", key: "server", label: "Media server", input: "SERVER" },
31
33
  { element: "USB", key: "usb", label: "USB", input: "USB" }
32
34
  ];
33
- const ATTRIBUTE_KINDS = {
34
- Container: "folder",
35
- Item: "item",
36
- "Unplayable Item": "unplayable",
37
- Unselectable: "unselectable"
38
- };
39
35
  function parseXmlListInfo(xml) {
40
36
  var _a, _b, _c, _d, _e, _f, _g, _h, _i;
41
37
  const rows = [];
42
38
  const linePattern = /<Line_([1-8])>\s*<Txt>([^<]*)<\/Txt>\s*<Attribute>([^<]*)<\/Attribute>/g;
43
39
  for (let match = linePattern.exec(xml); match; match = linePattern.exec(xml)) {
44
40
  if (match[2].length > 0) {
45
- rows.push({ line: Number(match[1]), text: match[2], kind: (_a = ATTRIBUTE_KINDS[match[3]]) != null ? _a : "item" });
41
+ rows.push({
42
+ line: Number(match[1]),
43
+ text: (0, import_entities.decodeXmlText)(match[2]),
44
+ kind: (_a = import_types.ROW_KIND_BY_ATTRIBUTE[match[3]]) != null ? _a : "item"
45
+ });
46
46
  }
47
47
  }
48
48
  return {
49
49
  ready: !/<Menu_Status>Busy<\/Menu_Status>/.test(xml),
50
- menuName: (_c = (_b = /<Menu_Name>([^<]*)<\/Menu_Name>/.exec(xml)) == null ? void 0 : _b[1]) != null ? _c : "",
50
+ menuName: (0, import_entities.decodeXmlText)((_c = (_b = /<Menu_Name>([^<]*)<\/Menu_Name>/.exec(xml)) == null ? void 0 : _b[1]) != null ? _c : ""),
51
51
  layer: Number((_e = (_d = /<Menu_Layer>(\d+)<\/Menu_Layer>/.exec(xml)) == null ? void 0 : _d[1]) != null ? _e : 0),
52
52
  currentLine: Number((_g = (_f = /<Current_Line>(\d+)<\/Current_Line>/.exec(xml)) == null ? void 0 : _f[1]) != null ? _g : 1),
53
53
  totalItems: Number((_i = (_h = /<Max_Line>(\d+)<\/Max_Line>/.exec(xml)) == null ? void 0 : _h[1]) != null ? _i : 0),