iobroker.yamaha 1.6.0 → 1.7.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.
- package/README.md +20 -13
- package/build/lib/browse/xml-browse-driver.js +17 -6
- package/build/lib/browse/xml-browse-driver.js.map +2 -2
- package/build/lib/browse/yxc-browse-driver.js +0 -4
- package/build/lib/browse/yxc-browse-driver.js.map +2 -2
- package/build/lib/catalog/owner-policy.js +6 -0
- package/build/lib/catalog/owner-policy.js.map +2 -2
- package/build/lib/catalog/types.js +1 -0
- package/build/lib/catalog/types.js.map +2 -2
- package/build/lib/device-controller.js +119 -21
- package/build/lib/device-controller.js.map +2 -2
- package/build/lib/lifecycle/probe-memory.js +38 -0
- package/build/lib/lifecycle/probe-memory.js.map +2 -2
- package/build/lib/xml/catalog.js +6 -8
- package/build/lib/xml/catalog.js.map +2 -2
- package/build/lib/xml/device-controller.js +251 -8
- package/build/lib/xml/device-controller.js.map +2 -2
- package/build/lib/xml/protocol.js +74 -2
- package/build/lib/xml/protocol.js.map +2 -2
- package/build/lib/xml/xml-client.js +15 -5
- package/build/lib/xml/xml-client.js.map +2 -2
- package/build/lib/ynca/catalog.js +174 -3
- package/build/lib/ynca/catalog.js.map +2 -2
- package/build/lib/ynca/ynca-client.js +26 -1
- package/build/lib/ynca/ynca-client.js.map +2 -2
- package/build/lib/yxc/capability.js +4 -1
- package/build/lib/yxc/capability.js.map +2 -2
- package/build/lib/yxc/command-mapper.js +67 -0
- package/build/lib/yxc/command-mapper.js.map +2 -2
- package/build/lib/yxc/device-controller.js +68 -9
- package/build/lib/yxc/device-controller.js.map +2 -2
- package/build/lib/yxc/http-client.js +60 -0
- package/build/lib/yxc/http-client.js.map +2 -2
- package/build/lib/yxc/object-mapper.js +85 -3
- package/build/lib/yxc/object-mapper.js.map +2 -2
- package/build/main.js +116 -25
- package/build/main.js.map +2 -2
- package/io-package.json +15 -15
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -21,6 +21,8 @@ legacy XML protocol of the oldest pre-2010 models — behind one object tree.
|
|
|
21
21
|
- **Typed datapoints** — booleans, dropdowns and numbers with unit and range instead of raw text
|
|
22
22
|
- **Presets and favourites** — recall tuner presets and stored network/USB favourites by number, step through presets, save the current station to a preset slot or bookmark it, and read the stored lists with their names (MusicCast); recently-played recall on MusicCast devices
|
|
23
23
|
- **Menu browsing** — page through the Net Radio, media-server and USB menus like with the remote: the visible menu lines as datapoints, select-by-line, and a path datapoint that navigates to a favourite in one write
|
|
24
|
+
- **Scenes with their names** — recall a scene by number or from a dropdown that shows the titles the receiver reports, per zone
|
|
25
|
+
- **On-screen remote** — cursor pad and menu keys as datapoints on MusicCast devices, for driving the receiver's own on-screen menus
|
|
24
26
|
- **Clock & alarm view** — MusicCast desk-audio devices show their clock and alarm settings
|
|
25
27
|
- **Capability-driven** — states are generated from what each device reports, no hardcoded model list
|
|
26
28
|
- **Automatic discovery** — an empty device list finds and sets up MusicCast devices at startup
|
|
@@ -58,13 +60,13 @@ The **Data points** section switches whole groups of datapoints on or off — **
|
|
|
58
60
|
Each receiver becomes one device node with themed groups — the same groups the
|
|
59
61
|
**Data points** switches control. Only what your device reports is created.
|
|
60
62
|
|
|
61
|
-
- **Amplifier core** (always on) — power, volume, mute, input, sound program, sleep, plus the device info with model, firmware and connection.
|
|
62
|
-
- **`player`** — one channel per playback source (Spotify, USB, server, net radio, CD, …) with playback state, artist, album, track, cover art and the transport buttons. The `player.browse` folder mirrors the device's media menu: the eight visible lines (folders and titles marked by symbol), `selectLine` acts like OK on the remote, page/back/root buttons, a `rows` JSON for widgets and a `path` datapoint that walks e.g. `Bookmarks>Radio Paradise` on one write.
|
|
63
|
+
- **Amplifier core** (always on) — power, volume, mute, input, sound program, sleep, plus the device info with model, firmware, IP address and connection; MusicCast devices add an on-screen `remote` (cursor pad, menu keys).
|
|
64
|
+
- **`player`** — one channel per playback source (Spotify, USB, server, net radio, CD, …) with playback state, artist, album, track, cover art and the transport buttons. The `player.browse` folder mirrors the device's media menu: the eight visible lines (folders and titles marked by symbol), `selectLine` acts like OK on the remote, page/back/root buttons, a `rows` JSON for widgets and a `path` datapoint that walks e.g. `Bookmarks>Radio Paradise` on one write. MusicCast devices add their playlists and the current play queue as JSON states.
|
|
63
65
|
- **`tuner`** — AM/FM and DAB radio including RDS texts and frequency.
|
|
64
66
|
- **`multiroom`** — zones 2–4, Zone B, the all-zones switches (master power, party mode) and the MusicCast device group in its own `multiroom.group` folder.
|
|
65
67
|
- **`hdmi`** — the HDMI outputs and lip sync.
|
|
66
|
-
- **`scene`** — the receiver's
|
|
67
|
-
- **`sound`** — tone and sound processing: bass/treble, DSP modes, enhancer, equalizer,
|
|
68
|
+
- **`scene`** — the receiver's scenes with their titles and a recall dropdown; zones with their own scenes carry theirs under `multiroom.zoneN.scene`.
|
|
69
|
+
- **`sound`** — tone and sound processing: bass/treble, DSP modes, enhancer, equalizer, plus the current audio signal (format, sampling rate, bitrate) on MusicCast devices.
|
|
68
70
|
- **`advanced`** — setup-level datapoints: maximum/initial volume, speaker configuration, input names.
|
|
69
71
|
- **`clock`** — the clock and alarm settings of MusicCast desk-audio devices (read-only).
|
|
70
72
|
|
|
@@ -88,7 +90,7 @@ If MusicCast changes only refresh every few minutes, another application is occu
|
|
|
88
90
|
|
|
89
91
|
### First start takes a while
|
|
90
92
|
|
|
91
|
-
On the first
|
|
93
|
+
On the very first contact the adapter asks the receiver which functions it supports — up to half a minute per YNCA device. The answers are remembered per device (and survive restarts), so every later start brings the device online in seconds and refreshes the current values in the background. A firmware update or a different device behind the same address is detected and re-asked automatically.
|
|
92
94
|
|
|
93
95
|
## Changelog
|
|
94
96
|
|
|
@@ -96,6 +98,19 @@ On the first connect the adapter asks the receiver which functions it supports
|
|
|
96
98
|
Placeholder for the next version (at the beginning of the line):
|
|
97
99
|
### **WORK IN PROGRESS**
|
|
98
100
|
-->
|
|
101
|
+
### 1.7.0 (2026-09-01)
|
|
102
|
+
|
|
103
|
+
- (krobipd) Fixed: scene recall now uses the command each device declares itself and the protocol that can deliver it — on 2012-generation receivers a scene write did nothing at all (#615)
|
|
104
|
+
- (krobipd) New: scenes show their titles ("Movie Viewing", …) as datapoints and in the recall dropdown, and zones with their own scenes get their own scene recall
|
|
105
|
+
- (krobipd) Fixed: the menu back button falls back to the older cursor command when a device refuses the standard one, so leaving a folder works on 2012-generation receivers too (#613)
|
|
106
|
+
- (krobipd) New: the adapter now reports when the receiver refuses a command, including the device's own answer — a dead button no longer fails without a trace
|
|
107
|
+
- (krobipd) New: bass and treble on MusicCast-generation receivers over YNCA, dialogue level, DAB signal details, the paired Bluetooth device name and more — the device is asked in its own dialect
|
|
108
|
+
- (krobipd) New: on-screen remote control datapoints (cursor pad and menu keys), the current audio signal info, MusicCast playlists and the play queue on MusicCast devices
|
|
109
|
+
- (krobipd) New: receivers from before 2010 get their tuner back — preset recall, frequency and radio text — and every input dropdown lists exactly the inputs the device really has
|
|
110
|
+
- (krobipd) New: the receiver's IP address is shown as a datapoint, so diagnosis and the device's own web pages are one click away
|
|
111
|
+
- (krobipd) Improved: restarts are fast — the adapter remembers what each device can do, brings it online in seconds and refreshes values in the background; only the first contact asks everything
|
|
112
|
+
- (krobipd) Improved: known devices no longer wait for the network search at startup — it runs in the background and only adds newcomers
|
|
113
|
+
|
|
99
114
|
### 1.6.0 (2026-08-27)
|
|
100
115
|
|
|
101
116
|
- (krobipd) New: three states show how many receivers are set up, how many are connected right now and whether that is all of them — one line to watch instead of every device
|
|
@@ -125,14 +140,6 @@ On the first connect the adapter asks the receiver which functions it supports
|
|
|
125
140
|
- (krobipd) New: save presets from ioBroker — store the current tuner or network station to a preset slot and bookmark the playing Net Radio station on YNCA receivers.
|
|
126
141
|
- (krobipd) New: Bluetooth pairing and connect controls, FM mono mode and tuning indicators on YNCA receivers.
|
|
127
142
|
|
|
128
|
-
### 1.2.0 (2026-08-25)
|
|
129
|
-
|
|
130
|
-
- (krobipd) Fixed: volume writes work again — a written -38 dB reached the receiver as -3.8 dB, so most values were ignored; all numeric controls now send the proper wire format (#612)
|
|
131
|
-
- (krobipd) Fixed: the FM frequency datapoint now shows MHz (it was mislabelled kHz) and accepts direct frequency writes in the form the tuner expects.
|
|
132
|
-
- (krobipd) New: preset selection — recall tuner presets by number with up/down stepping, and recall stored network or USB favourites per source on YNCA receivers (#613)
|
|
133
|
-
- (krobipd) New: MusicCast selection lists — stored favourites and tuner presets with names, a recently-played list with recall by number, and the device's own allowed values as dropdowns.
|
|
134
|
-
- (krobipd) New: more device detail — CD track and drive info, DAB and RDS station data, and a read-only clock and alarm view with its own datapoint group switch in the admin settings.
|
|
135
|
-
|
|
136
143
|
[Older changelogs can be found there](CHANGELOG_OLD.md)
|
|
137
144
|
|
|
138
145
|
## History
|
|
@@ -68,6 +68,13 @@ class XmlBrowseDriver {
|
|
|
68
68
|
engine;
|
|
69
69
|
active;
|
|
70
70
|
lastTotal = 0;
|
|
71
|
+
/**
|
|
72
|
+
* The cursor value this device accepts for "one level back". The spec vocabulary is
|
|
73
|
+
* `Return`, but the 2012 generation (RX-V473 class) refuses it and accepts `Left`
|
|
74
|
+
* instead (user-measured on the predecessor adapter, #613). First refusal switches
|
|
75
|
+
* permanently for this device.
|
|
76
|
+
*/
|
|
77
|
+
backCursor = "Return";
|
|
71
78
|
/**
|
|
72
79
|
* Attach the engine that renders the windows (set after both are constructed).
|
|
73
80
|
*
|
|
@@ -111,18 +118,22 @@ class XmlBrowseDriver {
|
|
|
111
118
|
async pageDown() {
|
|
112
119
|
await this.jumpBy(8);
|
|
113
120
|
}
|
|
114
|
-
/** Go one menu level back
|
|
121
|
+
/** Go one menu level back — falling back to `Left` when the device refuses `Return`. */
|
|
115
122
|
async back() {
|
|
116
|
-
|
|
123
|
+
try {
|
|
124
|
+
await this.control(`<Cursor>${this.backCursor}</Cursor>`);
|
|
125
|
+
} catch (e) {
|
|
126
|
+
if (this.backCursor !== "Return") {
|
|
127
|
+
throw e;
|
|
128
|
+
}
|
|
129
|
+
this.backCursor = "Left";
|
|
130
|
+
await this.control("<Cursor>Left</Cursor>");
|
|
131
|
+
}
|
|
117
132
|
}
|
|
118
133
|
/** Return to the menu root. */
|
|
119
134
|
async home() {
|
|
120
135
|
await this.control("<Cursor>Return to Home</Cursor>");
|
|
121
136
|
}
|
|
122
|
-
/** Re-read the current window. */
|
|
123
|
-
async refresh() {
|
|
124
|
-
await this.fetch();
|
|
125
|
-
}
|
|
126
137
|
/**
|
|
127
138
|
* Send a List_Control command to the active source and read the window back.
|
|
128
139
|
*
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/browse/xml-browse-driver.ts"],
|
|
4
|
-
"sourcesContent": ["import { ROW_KIND_BY_ATTRIBUTE, type BrowseDriver, type BrowseRow } from \"./types\";\nimport type { BrowseEngine } from \"./browse-engine\";\nimport { decodeXmlText } from \"../xml/entities\";\n\n/** How often to re-read while the device reports Menu_Status Busy. */\nconst BUSY_POLL_MS = 500;\n/** How many busy polls before giving up on one read. */\nconst MAX_BUSY_POLLS = 10;\n\n/**\n * The XML/YNC sources with a List_Info menu (the ones the predecessor adapter's\n * users browsed; rxv drives the same three), with the transport-neutral source key\n * and the input name that activates the source.\n */\nexport const XML_BROWSE_SOURCES: ReadonlyArray<{ element: string; key: string; label: string; input: string }> = [\n { element: \"NET_RADIO\", key: \"netRadio\", label: \"Net Radio\", input: \"NET RADIO\" },\n { element: \"SERVER\", key: \"server\", label: \"Media server\", input: \"SERVER\" },\n { element: \"USB\", key: \"usb\", label: \"USB\", input: \"USB\" },\n];\n\n/** The client surface the driver needs (a slice of the XML client). */\nexport interface XmlBrowseClient {\n /** Send an inner PUT command to an element (a zone or a source). */\n send(element: string, inner: string): Promise<void>;\n /** Read an element's inner GET request and return the raw response body. */\n getXml(element: string, inner: string): Promise<string>;\n}\n\n/** A parsed List_Info response. */\nexport interface XmlListInfo {\n /** Whether the menu is ready (false = the device is still fetching it). */\n ready: boolean;\n /** The menu title. */\n menuName: string;\n /** The menu depth (1 = root). */\n layer: number;\n /** The cursor's absolute line number. */\n currentLine: number;\n /** Total entries in the menu. */\n totalItems: number;\n /** The visible rows. */\n rows: BrowseRow[];\n}\n\n/**\n * Parse a `<List_Info>` response (menu status, layer, name, cursor, and the eight\n * `Current_List` lines with text + attribute \u2014 the shape rxv reads).\n *\n * @param xml the List_Info response body\n * @returns the parsed window\n */\nexport function parseXmlListInfo(xml: string): XmlListInfo {\n const rows: BrowseRow[] = [];\n const linePattern = /<Line_([1-8])>\\s*<Txt>([^<]*)<\\/Txt>\\s*<Attribute>([^<]*)<\\/Attribute>/g;\n for (let match = linePattern.exec(xml); match; match = linePattern.exec(xml)) {\n if (match[2].length > 0) {\n rows.push({\n line: Number(match[1]),\n text: decodeXmlText(match[2]),\n kind: ROW_KIND_BY_ATTRIBUTE[match[3]] ?? \"item\",\n });\n }\n }\n return {\n ready: !/<Menu_Status>Busy<\\/Menu_Status>/.test(xml),\n menuName: decodeXmlText(/<Menu_Name>([^<]*)<\\/Menu_Name>/.exec(xml)?.[1] ?? \"\"),\n layer: Number(/<Menu_Layer>(\\d+)<\\/Menu_Layer>/.exec(xml)?.[1] ?? 0),\n currentLine: Number(/<Current_Line>(\\d+)<\\/Current_Line>/.exec(xml)?.[1] ?? 1),\n totalItems: Number(/<Max_Line>(\\d+)<\\/Max_Line>/.exec(xml)?.[1] ?? 0),\n rows,\n };\n}\n\n/**\n * The XML/YNC list driver over `<List_Info>` + `<List_Control>` (the predecessor\n * adapter's browsing path). Pull-based: every operation re-reads the window, polling\n * while the device reports the menu as busy (levels come from the catalog service).\n * The cursor line stands in for the window start \u2014 the same approximation rxv makes.\n */\nexport class XmlBrowseDriver implements BrowseDriver {\n private engine: BrowseEngine | undefined;\n private active: { element: string; key: string; input: string } | undefined;\n private lastTotal = 0;\n\n /**\n * @param client the XML client slice (send + getXml)\n * @param available the source keys whose List_Info the start-up probe answered\n * @param delay adapter-managed delay\n */\n public constructor(\n private readonly client: XmlBrowseClient,\n private readonly available: ReadonlySet<string>,\n private readonly delay: (ms: number) => Promise<void>,\n ) {}\n\n /**\n * Attach the engine that renders the windows (set after both are constructed).\n *\n * @param engine the browse engine\n */\n public attach(engine: BrowseEngine): void {\n this.engine = engine;\n }\n\n /** @returns the selectable sources this device offers (state value \u2192 label) */\n public sources(): Record<string, string> {\n const entries = XML_BROWSE_SOURCES.filter(source => this.available.has(source.key));\n return Object.fromEntries(entries.map(source => [source.key, source.label]));\n }\n\n /**\n * Open a source's menu: switch the main-zone input to it and read the window.\n *\n * @param source the source key (from {@link sources})\n */\n public async open(source: string): Promise<void> {\n const entry = XML_BROWSE_SOURCES.find(s => s.key === source && this.available.has(s.key));\n if (!entry) {\n return;\n }\n this.active = entry;\n await this.client.send(\"Main_Zone\", `<Input><Input_Sel>${entry.input}</Input_Sel></Input>`);\n await this.fetch();\n }\n\n /**\n * Select a visible line via Direct_Sel \u2014 a folder opens, a playable item starts.\n *\n * @param line the line number (1\u20138)\n */\n public async select(line: number): Promise<void> {\n await this.control(`<Direct_Sel>Line_${line}</Direct_Sel>`);\n }\n\n /** Show the previous 8 lines (jump the cursor back a page). */\n public async pageUp(): Promise<void> {\n await this.jumpBy(-8);\n }\n\n /** Show the next 8 lines (jump the cursor forward a page). */\n public async pageDown(): Promise<void> {\n await this.jumpBy(8);\n }\n\n /** Go one menu level back
|
|
5
|
-
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,mBAAyE;AAEzE,sBAA8B;AAG9B,MAAM,eAAe;AAErB,MAAM,iBAAiB;AAOhB,MAAM,qBAAoG;AAAA,EAC/G,EAAE,SAAS,aAAa,KAAK,YAAY,OAAO,aAAa,OAAO,YAAY;AAAA,EAChF,EAAE,SAAS,UAAU,KAAK,UAAU,OAAO,gBAAgB,OAAO,SAAS;AAAA,EAC3E,EAAE,SAAS,OAAO,KAAK,OAAO,OAAO,OAAO,OAAO,MAAM;AAC3D;AAiCO,SAAS,iBAAiB,KAA0B;AAnD3D;AAoDE,QAAM,OAAoB,CAAC;AAC3B,QAAM,cAAc;AACpB,WAAS,QAAQ,YAAY,KAAK,GAAG,GAAG,OAAO,QAAQ,YAAY,KAAK,GAAG,GAAG;AAC5E,QAAI,MAAM,CAAC,EAAE,SAAS,GAAG;AACvB,WAAK,KAAK;AAAA,QACR,MAAM,OAAO,MAAM,CAAC,CAAC;AAAA,QACrB,UAAM,+BAAc,MAAM,CAAC,CAAC;AAAA,QAC5B,OAAM,wCAAsB,MAAM,CAAC,CAAC,MAA9B,YAAmC;AAAA,MAC3C,CAAC;AAAA,IACH;AAAA,EACF;AACA,SAAO;AAAA,IACL,OAAO,CAAC,mCAAmC,KAAK,GAAG;AAAA,IACnD,cAAU,gCAAc,6CAAkC,KAAK,GAAG,MAA1C,mBAA8C,OAA9C,YAAoD,EAAE;AAAA,IAC9E,OAAO,QAAO,6CAAkC,KAAK,GAAG,MAA1C,mBAA8C,OAA9C,YAAoD,CAAC;AAAA,IACnE,aAAa,QAAO,iDAAsC,KAAK,GAAG,MAA9C,mBAAkD,OAAlD,YAAwD,CAAC;AAAA,IAC7E,YAAY,QAAO,yCAA8B,KAAK,GAAG,MAAtC,mBAA0C,OAA1C,YAAgD,CAAC;AAAA,IACpE;AAAA,EACF;AACF;AAQO,MAAM,gBAAwC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,
|
|
4
|
+
"sourcesContent": ["import { ROW_KIND_BY_ATTRIBUTE, type BrowseDriver, type BrowseRow } from \"./types\";\nimport type { BrowseEngine } from \"./browse-engine\";\nimport { decodeXmlText } from \"../xml/entities\";\n\n/** How often to re-read while the device reports Menu_Status Busy. */\nconst BUSY_POLL_MS = 500;\n/** How many busy polls before giving up on one read. */\nconst MAX_BUSY_POLLS = 10;\n\n/**\n * The XML/YNC sources with a List_Info menu (the ones the predecessor adapter's\n * users browsed; rxv drives the same three), with the transport-neutral source key\n * and the input name that activates the source.\n */\nexport const XML_BROWSE_SOURCES: ReadonlyArray<{ element: string; key: string; label: string; input: string }> = [\n { element: \"NET_RADIO\", key: \"netRadio\", label: \"Net Radio\", input: \"NET RADIO\" },\n { element: \"SERVER\", key: \"server\", label: \"Media server\", input: \"SERVER\" },\n { element: \"USB\", key: \"usb\", label: \"USB\", input: \"USB\" },\n];\n\n/** The client surface the driver needs (a slice of the XML client). */\nexport interface XmlBrowseClient {\n /** Send an inner PUT command to an element (a zone or a source). */\n send(element: string, inner: string): Promise<void>;\n /** Read an element's inner GET request and return the raw response body. */\n getXml(element: string, inner: string): Promise<string>;\n}\n\n/** A parsed List_Info response. */\nexport interface XmlListInfo {\n /** Whether the menu is ready (false = the device is still fetching it). */\n ready: boolean;\n /** The menu title. */\n menuName: string;\n /** The menu depth (1 = root). */\n layer: number;\n /** The cursor's absolute line number. */\n currentLine: number;\n /** Total entries in the menu. */\n totalItems: number;\n /** The visible rows. */\n rows: BrowseRow[];\n}\n\n/**\n * Parse a `<List_Info>` response (menu status, layer, name, cursor, and the eight\n * `Current_List` lines with text + attribute \u2014 the shape rxv reads).\n *\n * @param xml the List_Info response body\n * @returns the parsed window\n */\nexport function parseXmlListInfo(xml: string): XmlListInfo {\n const rows: BrowseRow[] = [];\n const linePattern = /<Line_([1-8])>\\s*<Txt>([^<]*)<\\/Txt>\\s*<Attribute>([^<]*)<\\/Attribute>/g;\n for (let match = linePattern.exec(xml); match; match = linePattern.exec(xml)) {\n if (match[2].length > 0) {\n rows.push({\n line: Number(match[1]),\n text: decodeXmlText(match[2]),\n kind: ROW_KIND_BY_ATTRIBUTE[match[3]] ?? \"item\",\n });\n }\n }\n return {\n ready: !/<Menu_Status>Busy<\\/Menu_Status>/.test(xml),\n menuName: decodeXmlText(/<Menu_Name>([^<]*)<\\/Menu_Name>/.exec(xml)?.[1] ?? \"\"),\n layer: Number(/<Menu_Layer>(\\d+)<\\/Menu_Layer>/.exec(xml)?.[1] ?? 0),\n currentLine: Number(/<Current_Line>(\\d+)<\\/Current_Line>/.exec(xml)?.[1] ?? 1),\n totalItems: Number(/<Max_Line>(\\d+)<\\/Max_Line>/.exec(xml)?.[1] ?? 0),\n rows,\n };\n}\n\n/**\n * The XML/YNC list driver over `<List_Info>` + `<List_Control>` (the predecessor\n * adapter's browsing path). Pull-based: every operation re-reads the window, polling\n * while the device reports the menu as busy (levels come from the catalog service).\n * The cursor line stands in for the window start \u2014 the same approximation rxv makes.\n */\nexport class XmlBrowseDriver implements BrowseDriver {\n private engine: BrowseEngine | undefined;\n private active: { element: string; key: string; input: string } | undefined;\n private lastTotal = 0;\n /**\n * The cursor value this device accepts for \"one level back\". The spec vocabulary is\n * `Return`, but the 2012 generation (RX-V473 class) refuses it and accepts `Left`\n * instead (user-measured on the predecessor adapter, #613). First refusal switches\n * permanently for this device.\n */\n private backCursor: \"Return\" | \"Left\" = \"Return\";\n\n /**\n * @param client the XML client slice (send + getXml)\n * @param available the source keys whose List_Info the start-up probe answered\n * @param delay adapter-managed delay\n */\n public constructor(\n private readonly client: XmlBrowseClient,\n private readonly available: ReadonlySet<string>,\n private readonly delay: (ms: number) => Promise<void>,\n ) {}\n\n /**\n * Attach the engine that renders the windows (set after both are constructed).\n *\n * @param engine the browse engine\n */\n public attach(engine: BrowseEngine): void {\n this.engine = engine;\n }\n\n /** @returns the selectable sources this device offers (state value \u2192 label) */\n public sources(): Record<string, string> {\n const entries = XML_BROWSE_SOURCES.filter(source => this.available.has(source.key));\n return Object.fromEntries(entries.map(source => [source.key, source.label]));\n }\n\n /**\n * Open a source's menu: switch the main-zone input to it and read the window.\n *\n * @param source the source key (from {@link sources})\n */\n public async open(source: string): Promise<void> {\n const entry = XML_BROWSE_SOURCES.find(s => s.key === source && this.available.has(s.key));\n if (!entry) {\n return;\n }\n this.active = entry;\n await this.client.send(\"Main_Zone\", `<Input><Input_Sel>${entry.input}</Input_Sel></Input>`);\n await this.fetch();\n }\n\n /**\n * Select a visible line via Direct_Sel \u2014 a folder opens, a playable item starts.\n *\n * @param line the line number (1\u20138)\n */\n public async select(line: number): Promise<void> {\n await this.control(`<Direct_Sel>Line_${line}</Direct_Sel>`);\n }\n\n /** Show the previous 8 lines (jump the cursor back a page). */\n public async pageUp(): Promise<void> {\n await this.jumpBy(-8);\n }\n\n /** Show the next 8 lines (jump the cursor forward a page). */\n public async pageDown(): Promise<void> {\n await this.jumpBy(8);\n }\n\n /** Go one menu level back \u2014 falling back to `Left` when the device refuses `Return`. */\n public async back(): Promise<void> {\n try {\n await this.control(`<Cursor>${this.backCursor}</Cursor>`);\n } catch (e) {\n // Only a refusal of the DEFAULT vocabulary triggers the generation fallback;\n // a device that also refuses Left (or a transport error) surfaces normally.\n if (this.backCursor !== \"Return\") {\n throw e;\n }\n this.backCursor = \"Left\";\n await this.control(\"<Cursor>Left</Cursor>\");\n }\n }\n\n /** Return to the menu root. */\n public async home(): Promise<void> {\n await this.control(\"<Cursor>Return to Home</Cursor>\");\n }\n\n /**\n * Send a List_Control command to the active source and read the window back.\n *\n * @param inner the List_Control payload (Direct_Sel, Cursor, Jump_Line)\n */\n private async control(inner: string): Promise<void> {\n if (!this.active) {\n return;\n }\n await this.client.send(this.active.element, `<List_Control>${inner}</List_Control>`);\n await this.fetch();\n }\n\n /**\n * Jump the cursor by a page, clamped to the menu bounds.\n *\n * @param delta the line offset (\u00B18)\n */\n private async jumpBy(delta: number): Promise<void> {\n if (!this.active) {\n return;\n }\n const current = await this.readWindow();\n if (!current) {\n return;\n }\n const target = Math.min(Math.max(1, current.currentLine + delta), Math.max(1, this.lastTotal));\n await this.control(`<Jump_Line>${target}</Jump_Line>`);\n }\n\n /** Read the window (polling while busy) and render it to the engine. */\n private async fetch(): Promise<void> {\n const window = await this.readWindow();\n if (window) {\n this.lastTotal = window.totalItems;\n this.engine?.onWindow({\n menuName: window.menuName,\n layer: window.layer,\n totalItems: window.totalItems,\n currentLine: window.currentLine,\n rows: window.rows,\n });\n }\n }\n\n /**\n * Read the active source's List_Info, polling while the device reports Busy.\n *\n * @returns the parsed window, or undefined without an active source / when busy persists\n */\n private async readWindow(): Promise<XmlListInfo | undefined> {\n if (!this.active) {\n return undefined;\n }\n for (let attempt = 0; attempt < MAX_BUSY_POLLS; attempt++) {\n const info = parseXmlListInfo(await this.client.getXml(this.active.element, \"<List_Info>GetParam</List_Info>\"));\n if (info.ready) {\n return info;\n }\n await this.delay(BUSY_POLL_MS);\n }\n return undefined;\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/browse/yxc-browse-driver.ts"],
|
|
4
|
-
"sourcesContent": ["import type { BrowseDriver, BrowseRow } from \"./types\";\nimport type { BrowseEngine } from \"./browse-engine\";\n\n/** The engine's page size \u2014 YXC serves any window, we keep the device's 8-line form. */\nconst PAGE_SIZE = 8;\n/** Upper bound for the return-to-root loop (menus are never this deep). */\nconst MAX_HOME_STEPS = 16;\n\n/**\n * The netusb inputs the YXC list API can browse (aiomusiccast BROWSABLE_INPUTS),\n * with the transport-neutral source key and display label. The driver offers the\n * intersection with the device's own input list.\n */\nexport const YXC_BROWSE_SOURCES: ReadonlyArray<{ input: string; key: string; label: string }> = [\n { input: \"net_radio\", key: \"netRadio\", label: \"Net Radio\" },\n { input: \"server\", key: \"server\", label: \"Media server\" },\n { input: \"usb\", key: \"usb\", label: \"USB\" },\n { input: \"napster\", key: \"napster\", label: \"Napster\" },\n { input: \"pandora\", key: \"pandora\", label: \"Pandora\" },\n { input: \"rhapsody\", key: \"rhapsody\", label: \"Rhapsody\" },\n { input: \"siriusxm\", key: \"sirius\", label: \"SiriusXM\" },\n { input: \"juke\", key: \"juke\", label: \"JUKE\" },\n { input: \"radiko\", key: \"radiko\", label: \"radiko\" },\n { input: \"qobuz\", key: \"qobuz\", label: \"Qobuz\" },\n { input: \"deezer\", key: \"deezer\", label: \"Deezer\" },\n { input: \"amazon_music\", key: \"amazonMusic\", label: \"Amazon Music\" },\n];\n\n/** The client surface the driver needs (a slice of the YXC client). */\nexport interface YxcBrowseClient {\n /** Read one window of a netusb source's list. */\n getListInfo(input: string, index: number, size?: number): Promise<unknown>;\n /** Drive the netusb list: select/play an absolute index, or go one level back. */\n setListControl(type: \"select\" | \"play\" | \"return\", index?: number, zone?: string): Promise<unknown>;\n}\n\n/** One raw entry of a getListInfo response. */\ninterface RawListEntry {\n /** The entry's text. */\n text?: unknown;\n /** The capability bitmask (b1 selectable, b2 playable). */\n attribute?: unknown;\n /** The entry's thumbnail URL. */\n thumbnail?: unknown;\n}\n\n/**\n * The YXC (MusicCast) list driver over `netusb/getListInfo` + `netusb/setListControl`.\n * Pull-based: every operation fetches the resulting window and hands it to the engine.\n * The absolute entry index is tracked here (the API pages by index, not by cursor);\n * playing an item hands the playback to the netusb input automatically.\n */\nexport class YxcBrowseDriver implements BrowseDriver {\n private engine: BrowseEngine | undefined;\n private active: { input: string; key: string } | undefined;\n /** The absolute (0-based) index of the window's first entry. */\n private index = 0;\n /** The last window's rows, for the select semantics (folder vs playable). */\n private rows: BrowseRow[] = [];\n private totalItems = 0;\n\n /**\n * @param client the YXC client slice (getListInfo + setListControl)\n * @param inputList the device's netusb input list (getFeatures `input_list`)\n */\n public constructor(\n private readonly client: YxcBrowseClient,\n private readonly inputList: readonly string[],\n ) {}\n\n /**\n * Attach the engine that renders the windows (set after both are constructed).\n *\n * @param engine the browse engine\n */\n public attach(engine: BrowseEngine): void {\n this.engine = engine;\n }\n\n /** @returns the selectable sources this device offers (state value \u2192 label) */\n public sources(): Record<string, string> {\n const entries = YXC_BROWSE_SOURCES.filter(source => this.inputList.includes(source.input));\n return Object.fromEntries(entries.map(source => [source.key, source.label]));\n }\n\n /**\n * Open a source's list at the device's current position in it.\n *\n * @param source the source key (from {@link sources})\n */\n public async open(source: string): Promise<void> {\n const entry = YXC_BROWSE_SOURCES.find(s => s.key === source && this.inputList.includes(s.input));\n if (!entry) {\n return;\n }\n this.active = entry;\n this.index = 0;\n await this.fetch();\n }\n\n /**\n * Select a visible line: a playable item starts (the device switches to netusb),\n * a folder opens.\n *\n * @param line the line number (1\u20138)\n */\n public async select(line: number): Promise<void> {\n const row = this.rows.find(r => r.line === line);\n if (!this.active || !row || row.kind === \"unselectable\" || row.kind === \"unplayable\") {\n return;\n }\n const absolute = this.index + line - 1;\n if (row.kind === \"item\") {\n await this.client.setListControl(\"play\", absolute, \"main\");\n await this.fetch();\n return;\n }\n await this.client.setListControl(\"select\", absolute);\n this.index = 0;\n await this.fetch();\n }\n\n /** Show the previous 8 lines. */\n public async pageUp(): Promise<void> {\n this.index = Math.max(0, this.index - PAGE_SIZE);\n await this.fetch();\n }\n\n /** Show the next 8 lines. */\n public async pageDown(): Promise<void> {\n if (this.index + PAGE_SIZE < this.totalItems) {\n this.index += PAGE_SIZE;\n }\n await this.fetch();\n }\n\n /** Go one menu level back. */\n public async back(): Promise<void> {\n if (!this.active) {\n return;\n }\n await this.client.setListControl(\"return\");\n this.index = 0;\n await this.fetch();\n }\n\n /** Return to the menu root: step back until the device reports layer 1. */\n public async home(): Promise<void> {\n if (!this.active) {\n return;\n }\n for (let step = 0; step < MAX_HOME_STEPS; step++) {\n const layer = await this.fetch();\n if (layer === undefined || layer <= 1) {\n return;\n }\n await this.client.setListControl(\"return\");\n this.index = 0;\n }\n }\n\n
|
|
5
|
-
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAIA,MAAM,YAAY;AAElB,MAAM,iBAAiB;AAOhB,MAAM,qBAAmF;AAAA,EAC9F,EAAE,OAAO,aAAa,KAAK,YAAY,OAAO,YAAY;AAAA,EAC1D,EAAE,OAAO,UAAU,KAAK,UAAU,OAAO,eAAe;AAAA,EACxD,EAAE,OAAO,OAAO,KAAK,OAAO,OAAO,MAAM;AAAA,EACzC,EAAE,OAAO,WAAW,KAAK,WAAW,OAAO,UAAU;AAAA,EACrD,EAAE,OAAO,WAAW,KAAK,WAAW,OAAO,UAAU;AAAA,EACrD,EAAE,OAAO,YAAY,KAAK,YAAY,OAAO,WAAW;AAAA,EACxD,EAAE,OAAO,YAAY,KAAK,UAAU,OAAO,WAAW;AAAA,EACtD,EAAE,OAAO,QAAQ,KAAK,QAAQ,OAAO,OAAO;AAAA,EAC5C,EAAE,OAAO,UAAU,KAAK,UAAU,OAAO,SAAS;AAAA,EAClD,EAAE,OAAO,SAAS,KAAK,SAAS,OAAO,QAAQ;AAAA,EAC/C,EAAE,OAAO,UAAU,KAAK,UAAU,OAAO,SAAS;AAAA,EAClD,EAAE,OAAO,gBAAgB,KAAK,eAAe,OAAO,eAAe;AACrE;AA0BO,MAAM,gBAAwC;AAAA;AAAA;AAAA;AAAA;AAAA,EAa5C,YACY,QACA,WACjB;AAFiB;AACA;AAAA,EAChB;AAAA,EAfK;AAAA,EACA;AAAA;AAAA,EAEA,QAAQ;AAAA;AAAA,EAER,OAAoB,CAAC;AAAA,EACrB,aAAa;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAgBd,OAAO,QAA4B;AACxC,SAAK,SAAS;AAAA,EAChB;AAAA;AAAA,EAGO,UAAkC;AACvC,UAAM,UAAU,mBAAmB,OAAO,YAAU,KAAK,UAAU,SAAS,OAAO,KAAK,CAAC;AACzF,WAAO,OAAO,YAAY,QAAQ,IAAI,YAAU,CAAC,OAAO,KAAK,OAAO,KAAK,CAAC,CAAC;AAAA,EAC7E;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,MAAa,KAAK,QAA+B;AAC/C,UAAM,QAAQ,mBAAmB,KAAK,OAAK,EAAE,QAAQ,UAAU,KAAK,UAAU,SAAS,EAAE,KAAK,CAAC;AAC/F,QAAI,CAAC,OAAO;AACV;AAAA,IACF;AACA,SAAK,SAAS;AACd,SAAK,QAAQ;AACb,UAAM,KAAK,MAAM;AAAA,EACnB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,MAAa,OAAO,MAA6B;AAC/C,UAAM,MAAM,KAAK,KAAK,KAAK,OAAK,EAAE,SAAS,IAAI;AAC/C,QAAI,CAAC,KAAK,UAAU,CAAC,OAAO,IAAI,SAAS,kBAAkB,IAAI,SAAS,cAAc;AACpF;AAAA,IACF;AACA,UAAM,WAAW,KAAK,QAAQ,OAAO;AACrC,QAAI,IAAI,SAAS,QAAQ;AACvB,YAAM,KAAK,OAAO,eAAe,QAAQ,UAAU,MAAM;AACzD,YAAM,KAAK,MAAM;AACjB;AAAA,IACF;AACA,UAAM,KAAK,OAAO,eAAe,UAAU,QAAQ;AACnD,SAAK,QAAQ;AACb,UAAM,KAAK,MAAM;AAAA,EACnB;AAAA;AAAA,EAGA,MAAa,SAAwB;AACnC,SAAK,QAAQ,KAAK,IAAI,GAAG,KAAK,QAAQ,SAAS;AAC/C,UAAM,KAAK,MAAM;AAAA,EACnB;AAAA;AAAA,EAGA,MAAa,WAA0B;AACrC,QAAI,KAAK,QAAQ,YAAY,KAAK,YAAY;AAC5C,WAAK,SAAS;AAAA,IAChB;AACA,UAAM,KAAK,MAAM;AAAA,EACnB;AAAA;AAAA,EAGA,MAAa,OAAsB;AACjC,QAAI,CAAC,KAAK,QAAQ;AAChB;AAAA,IACF;AACA,UAAM,KAAK,OAAO,eAAe,QAAQ;AACzC,SAAK,QAAQ;AACb,UAAM,KAAK,MAAM;AAAA,EACnB;AAAA;AAAA,EAGA,MAAa,OAAsB;AACjC,QAAI,CAAC,KAAK,QAAQ;AAChB;AAAA,IACF;AACA,aAAS,OAAO,GAAG,OAAO,gBAAgB,QAAQ;AAChD,YAAM,QAAQ,MAAM,KAAK,MAAM;AAC/B,UAAI,UAAU,UAAa,SAAS,GAAG;AACrC;AAAA,MACF;AACA,YAAM,KAAK,OAAO,eAAe,QAAQ;AACzC,WAAK,QAAQ;AAAA,IACf;AAAA,EACF;AAAA;AAAA
|
|
4
|
+
"sourcesContent": ["import type { BrowseDriver, BrowseRow } from \"./types\";\nimport type { BrowseEngine } from \"./browse-engine\";\n\n/** The engine's page size \u2014 YXC serves any window, we keep the device's 8-line form. */\nconst PAGE_SIZE = 8;\n/** Upper bound for the return-to-root loop (menus are never this deep). */\nconst MAX_HOME_STEPS = 16;\n\n/**\n * The netusb inputs the YXC list API can browse (aiomusiccast BROWSABLE_INPUTS),\n * with the transport-neutral source key and display label. The driver offers the\n * intersection with the device's own input list.\n */\nexport const YXC_BROWSE_SOURCES: ReadonlyArray<{ input: string; key: string; label: string }> = [\n { input: \"net_radio\", key: \"netRadio\", label: \"Net Radio\" },\n { input: \"server\", key: \"server\", label: \"Media server\" },\n { input: \"usb\", key: \"usb\", label: \"USB\" },\n { input: \"napster\", key: \"napster\", label: \"Napster\" },\n { input: \"pandora\", key: \"pandora\", label: \"Pandora\" },\n { input: \"rhapsody\", key: \"rhapsody\", label: \"Rhapsody\" },\n { input: \"siriusxm\", key: \"sirius\", label: \"SiriusXM\" },\n { input: \"juke\", key: \"juke\", label: \"JUKE\" },\n { input: \"radiko\", key: \"radiko\", label: \"radiko\" },\n { input: \"qobuz\", key: \"qobuz\", label: \"Qobuz\" },\n { input: \"deezer\", key: \"deezer\", label: \"Deezer\" },\n { input: \"amazon_music\", key: \"amazonMusic\", label: \"Amazon Music\" },\n];\n\n/** The client surface the driver needs (a slice of the YXC client). */\nexport interface YxcBrowseClient {\n /** Read one window of a netusb source's list. */\n getListInfo(input: string, index: number, size?: number): Promise<unknown>;\n /** Drive the netusb list: select/play an absolute index, or go one level back. */\n setListControl(type: \"select\" | \"play\" | \"return\", index?: number, zone?: string): Promise<unknown>;\n}\n\n/** One raw entry of a getListInfo response. */\ninterface RawListEntry {\n /** The entry's text. */\n text?: unknown;\n /** The capability bitmask (b1 selectable, b2 playable). */\n attribute?: unknown;\n /** The entry's thumbnail URL. */\n thumbnail?: unknown;\n}\n\n/**\n * The YXC (MusicCast) list driver over `netusb/getListInfo` + `netusb/setListControl`.\n * Pull-based: every operation fetches the resulting window and hands it to the engine.\n * The absolute entry index is tracked here (the API pages by index, not by cursor);\n * playing an item hands the playback to the netusb input automatically.\n */\nexport class YxcBrowseDriver implements BrowseDriver {\n private engine: BrowseEngine | undefined;\n private active: { input: string; key: string } | undefined;\n /** The absolute (0-based) index of the window's first entry. */\n private index = 0;\n /** The last window's rows, for the select semantics (folder vs playable). */\n private rows: BrowseRow[] = [];\n private totalItems = 0;\n\n /**\n * @param client the YXC client slice (getListInfo + setListControl)\n * @param inputList the device's netusb input list (getFeatures `input_list`)\n */\n public constructor(\n private readonly client: YxcBrowseClient,\n private readonly inputList: readonly string[],\n ) {}\n\n /**\n * Attach the engine that renders the windows (set after both are constructed).\n *\n * @param engine the browse engine\n */\n public attach(engine: BrowseEngine): void {\n this.engine = engine;\n }\n\n /** @returns the selectable sources this device offers (state value \u2192 label) */\n public sources(): Record<string, string> {\n const entries = YXC_BROWSE_SOURCES.filter(source => this.inputList.includes(source.input));\n return Object.fromEntries(entries.map(source => [source.key, source.label]));\n }\n\n /**\n * Open a source's list at the device's current position in it.\n *\n * @param source the source key (from {@link sources})\n */\n public async open(source: string): Promise<void> {\n const entry = YXC_BROWSE_SOURCES.find(s => s.key === source && this.inputList.includes(s.input));\n if (!entry) {\n return;\n }\n this.active = entry;\n this.index = 0;\n await this.fetch();\n }\n\n /**\n * Select a visible line: a playable item starts (the device switches to netusb),\n * a folder opens.\n *\n * @param line the line number (1\u20138)\n */\n public async select(line: number): Promise<void> {\n const row = this.rows.find(r => r.line === line);\n if (!this.active || !row || row.kind === \"unselectable\" || row.kind === \"unplayable\") {\n return;\n }\n const absolute = this.index + line - 1;\n if (row.kind === \"item\") {\n await this.client.setListControl(\"play\", absolute, \"main\");\n await this.fetch();\n return;\n }\n await this.client.setListControl(\"select\", absolute);\n this.index = 0;\n await this.fetch();\n }\n\n /** Show the previous 8 lines. */\n public async pageUp(): Promise<void> {\n this.index = Math.max(0, this.index - PAGE_SIZE);\n await this.fetch();\n }\n\n /** Show the next 8 lines. */\n public async pageDown(): Promise<void> {\n if (this.index + PAGE_SIZE < this.totalItems) {\n this.index += PAGE_SIZE;\n }\n await this.fetch();\n }\n\n /** Go one menu level back. */\n public async back(): Promise<void> {\n if (!this.active) {\n return;\n }\n await this.client.setListControl(\"return\");\n this.index = 0;\n await this.fetch();\n }\n\n /** Return to the menu root: step back until the device reports layer 1. */\n public async home(): Promise<void> {\n if (!this.active) {\n return;\n }\n for (let step = 0; step < MAX_HOME_STEPS; step++) {\n const layer = await this.fetch();\n if (layer === undefined || layer <= 1) {\n return;\n }\n await this.client.setListControl(\"return\");\n this.index = 0;\n }\n }\n\n /**\n * Fetch the current window, render it to the engine, and report the menu layer.\n *\n * @returns the menu layer, or undefined when the source answered with an error\n */\n private async fetch(): Promise<number | undefined> {\n if (!this.active) {\n return undefined;\n }\n const response = (await this.client.getListInfo(this.active.input, this.index)) as Record<string, unknown> | null;\n if (!response || (typeof response.response_code === \"number\" && response.response_code !== 0)) {\n return undefined;\n }\n const entries = Array.isArray(response.list_info) ? (response.list_info as RawListEntry[]) : [];\n this.rows = entries.slice(0, PAGE_SIZE).map((entry, i) => {\n // b1 = capable of Select (a container to enter), b2 = capable of Play.\n const attribute = typeof entry.attribute === \"number\" ? entry.attribute : 0;\n const playable = (attribute & 0b100) !== 0;\n const selectable = (attribute & 0b10) !== 0;\n const row: BrowseRow = {\n line: i + 1,\n text: typeof entry.text === \"string\" ? entry.text : \"\",\n kind: playable ? \"item\" : selectable ? \"folder\" : \"unselectable\",\n };\n if (typeof entry.thumbnail === \"string\" && entry.thumbnail.length > 0) {\n row.thumbnail = entry.thumbnail;\n }\n return row;\n });\n this.totalItems = typeof response.max_line === \"number\" ? response.max_line : this.rows.length;\n const layer = typeof response.menu_layer === \"number\" ? response.menu_layer : 0;\n this.engine?.onWindow({\n menuName: typeof response.menu_name === \"string\" ? response.menu_name : \"\",\n layer,\n totalItems: this.totalItems,\n currentLine: this.index + 1,\n rows: this.rows,\n });\n return layer;\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -36,6 +36,12 @@ const OWNER_OVERRIDES = {
|
|
|
36
36
|
"sound.adaptiveDrc": ["ynca", "xml", "yxc"],
|
|
37
37
|
"sound.surroundDecoder": ["ynca", "yxc"],
|
|
38
38
|
"sound.dialogueLift": ["xml", "yxc"],
|
|
39
|
+
// Write-proof beats modernity rank for the scene TRIGGER (#615): YXC declares the
|
|
40
|
+
// recall endpoint per zone (device-verified), XML declares the write value in its
|
|
41
|
+
// Scene_Sel_Item list — but YNCA's claim rests on the scene NAMES being readable,
|
|
42
|
+
// and the 2012 generation (RX-V473/V475) answers the YNCA scene put with
|
|
43
|
+
// @RESTRICTED (ynca-python PRACTICALITIES). The proven writers go first.
|
|
44
|
+
"scene.recall": ["yxc", "xml", "ynca"],
|
|
39
45
|
// §3d richness loss — YNCA carries an enum dropdown that YXC/XML flatten to a free string.
|
|
40
46
|
input: ["ynca", "yxc", "xml"],
|
|
41
47
|
soundProgram: ["ynca", "yxc", "xml"],
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/catalog/owner-policy.ts"],
|
|
4
|
-
"sourcesContent": ["/** The transports the adapter speaks. Ordered most-modern-first \u2014 the default ownership rank. */\nexport type Transport = \"yxc\" | \"ynca\" | \"xml\";\n\n/**\n * Ownership preference by modernity, used when a shared capability is equally good on each\n * transport (krobi: \"wenn mehrere Protokolle dasselbe k\u00F6nnen, das modernste nutzen\"). YXC is\n * push + structured JSON, YNCA the text-poll base, XML the pre-2010 fallback.\n */\nconst MODERNITY: readonly Transport[] = [\"yxc\", \"ynca\", \"xml\"];\n\n/**\n * Per-capability ownership preference that OVERRIDES pure modernity \u2014 from the capability\n * census (`Ressourcen/yamaha/capability-census-2026-08-11.md` \u00A73). These are the shared keys\n * where YXC is present but NOT equivalent: wrong scale, read-only, or a poorer type. Each list\n * is the preferred owner order for that key; the first present transport wins.\n */\nconst OWNER_OVERRIDES: Record<string, readonly Transport[]> = {\n // \u00A73a scale conflict \u2014 YXC volume is the raw 0..161 device scale, YNCA/XML are dB. Keep dB.\n volume: [\"ynca\", \"xml\", \"yxc\"],\n // \u00A73c write loss \u2014 YXC is read-only for these, YNCA (and often XML) is writable.\n \"advanced.maxVolume\": [\"ynca\", \"xml\", \"yxc\"],\n // The DAB preset is a writable recall on YNCA but display-only in the YXC play info.\n \"tuner.dab.preset\": [\"ynca\", \"yxc\"],\n \"sound.extraBass\": [\"ynca\", \"xml\", \"yxc\"],\n \"sound.adaptiveDrc\": [\"ynca\", \"xml\", \"yxc\"],\n \"sound.surroundDecoder\": [\"ynca\", \"yxc\"],\n \"sound.dialogueLift\": [\"xml\", \"yxc\"],\n // \u00A73d richness loss \u2014 YNCA carries an enum dropdown that YXC/XML flatten to a free string.\n input: [\"ynca\", \"yxc\", \"xml\"],\n soundProgram: [\"ynca\", \"yxc\", \"xml\"],\n sleep: [\"ynca\", \"xml\", \"yxc\"],\n \"tuner.band\": [\"ynca\", \"yxc\"],\n};\n\n/**\n * Per-transport state-id \u2192 canonical capability key, for the ids that drift between transports\n * (census \u00A73f, verified against the catalogs). The canonical key is the census left column; the\n * transports not listed already use the canonical id. Zone prefixes are stripped separately.\n */\nconst ID_DRIFT: Partial<Record<Transport, Readonly<Record<string, string>>>> = {\n yxc: { subwooferVolume: \"sound.subwooferTrim\", \"multiroom.partyEnable\": \"multiroom.party\" },\n xml: { hdmiOut1: \"hdmi.out1\", hdmiOut2: \"hdmi.out2\" },\n};\n\n/**\n * The transport-neutral capability key for a transport's state id: strip a `zone2/3/4.` prefix\n * to the per-zone template, then resolve any id drift to the canonical key. Two transports that\n * express the same capability under different ids therefore map to the same key.\n *\n * @param transport the transport the state id comes from\n * @param stateId the transport's own state id (may carry a zone prefix)\n * @returns the canonical capability key\n */\nexport function capabilityKeyOf(transport: Transport, stateId: string): string {\n const template = stateId.replace(/^(?:multiroom\\.)?zone[234]\\./, \"\");\n return ID_DRIFT[transport]?.[template] ?? template;\n}\n\n/**\n * The drift-resolved, transport-neutral OBJECT id \u2014 like {@link capabilityKeyOf} but KEEPS the\n * zone prefix. capabilityKeyOf drops the zone to decide ownership per template; canonicalIdOf\n * keeps it to place the per-zone node in the unified tree. Two transports' zoned ids for the\n * same capability therefore collapse to one node id.\n *\n * @param transport the transport the state id comes from\n * @param stateId the transport's own state id\n * @returns the canonical object id (zone prefix kept, drift resolved)\n */\nexport function canonicalIdOf(transport: Transport, stateId: string): string {\n const zone = /^(?:multiroom\\.)?zone[234]\\./.exec(stateId)?.[0] ?? \"\";\n const template = stateId.slice(zone.length);\n const resolved = ID_DRIFT[transport]?.[template] ?? template;\n if (zone && !zone.startsWith(\"multiroom.\")) {\n return `multiroom.${zone}${resolved}`;\n }\n return zone + resolved;\n}\n\n/**\n * Decide which transport owns a capability, given the transports that actually offer it on\n * this device. Default is the most modern; a census-driven override wins where the modern\n * transport would be lossy. An override that lists none of the present candidates falls back\n * to modernity, so any non-empty candidate set resolves.\n *\n * @param key the transport-neutral capability key (the unified state id)\n * @param candidates the present transports that offer this key (non-empty)\n * @returns the owning transport\n */\nexport function pickOwner(key: string, candidates: readonly Transport[]): Transport {\n const preference = OWNER_OVERRIDES[key] ?? MODERNITY;\n // The MODERNITY fallback is defence for a FUTURE override: every entry in the\n // current table lists at least two transports, so an override can never miss all\n // present candidates while more than one is present \u2014 today it is unobservable.\n const owner = preference.find(t => candidates.includes(t)) ?? MODERNITY.find(t => candidates.includes(t));\n return owner ?? candidates[0];\n}\n\n/**\n * Build the owner map for a device: given which capability keys each present transport offers,\n * resolve every key to exactly one owning transport (via {@link pickOwner}). This is the\n * dedup decision behind the unified object tree \u2014 each state is written by one transport only.\n *\n * @param offered the capability keys each present transport offers (absent transport = none)\n * @returns a map from capability key to its owning transport\n */\nexport function resolveOwnership(offered: Partial<Record<Transport, readonly string[]>>): Map<string, Transport> {\n const candidates = new Map<string, Transport[]>();\n // Iterate in modernity order so the candidate lists are deterministic.\n for (const transport of MODERNITY) {\n for (const key of offered[transport] ?? []) {\n const list = candidates.get(key);\n if (list) {\n list.push(transport);\n } else {\n candidates.set(key, [transport]);\n }\n }\n }\n const owners = new Map<string, Transport>();\n for (const [key, cands] of candidates) {\n owners.set(key, pickOwner(key, cands));\n }\n return owners;\n}\n"],
|
|
5
|
-
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAQA,MAAM,YAAkC,CAAC,OAAO,QAAQ,KAAK;AAQ7D,MAAM,kBAAwD;AAAA;AAAA,EAE5D,QAAQ,CAAC,QAAQ,OAAO,KAAK;AAAA;AAAA,EAE7B,sBAAsB,CAAC,QAAQ,OAAO,KAAK;AAAA;AAAA,EAE3C,oBAAoB,CAAC,QAAQ,KAAK;AAAA,EAClC,mBAAmB,CAAC,QAAQ,OAAO,KAAK;AAAA,EACxC,qBAAqB,CAAC,QAAQ,OAAO,KAAK;AAAA,EAC1C,yBAAyB,CAAC,QAAQ,KAAK;AAAA,EACvC,sBAAsB,CAAC,OAAO,KAAK;AAAA;AAAA,
|
|
4
|
+
"sourcesContent": ["/** The transports the adapter speaks. Ordered most-modern-first \u2014 the default ownership rank. */\nexport type Transport = \"yxc\" | \"ynca\" | \"xml\";\n\n/**\n * Ownership preference by modernity, used when a shared capability is equally good on each\n * transport (krobi: \"wenn mehrere Protokolle dasselbe k\u00F6nnen, das modernste nutzen\"). YXC is\n * push + structured JSON, YNCA the text-poll base, XML the pre-2010 fallback.\n */\nconst MODERNITY: readonly Transport[] = [\"yxc\", \"ynca\", \"xml\"];\n\n/**\n * Per-capability ownership preference that OVERRIDES pure modernity \u2014 from the capability\n * census (`Ressourcen/yamaha/capability-census-2026-08-11.md` \u00A73). These are the shared keys\n * where YXC is present but NOT equivalent: wrong scale, read-only, or a poorer type. Each list\n * is the preferred owner order for that key; the first present transport wins.\n */\nconst OWNER_OVERRIDES: Record<string, readonly Transport[]> = {\n // \u00A73a scale conflict \u2014 YXC volume is the raw 0..161 device scale, YNCA/XML are dB. Keep dB.\n volume: [\"ynca\", \"xml\", \"yxc\"],\n // \u00A73c write loss \u2014 YXC is read-only for these, YNCA (and often XML) is writable.\n \"advanced.maxVolume\": [\"ynca\", \"xml\", \"yxc\"],\n // The DAB preset is a writable recall on YNCA but display-only in the YXC play info.\n \"tuner.dab.preset\": [\"ynca\", \"yxc\"],\n \"sound.extraBass\": [\"ynca\", \"xml\", \"yxc\"],\n \"sound.adaptiveDrc\": [\"ynca\", \"xml\", \"yxc\"],\n \"sound.surroundDecoder\": [\"ynca\", \"yxc\"],\n \"sound.dialogueLift\": [\"xml\", \"yxc\"],\n // Write-proof beats modernity rank for the scene TRIGGER (#615): YXC declares the\n // recall endpoint per zone (device-verified), XML declares the write value in its\n // Scene_Sel_Item list \u2014 but YNCA's claim rests on the scene NAMES being readable,\n // and the 2012 generation (RX-V473/V475) answers the YNCA scene put with\n // @RESTRICTED (ynca-python PRACTICALITIES). The proven writers go first.\n \"scene.recall\": [\"yxc\", \"xml\", \"ynca\"],\n // \u00A73d richness loss \u2014 YNCA carries an enum dropdown that YXC/XML flatten to a free string.\n input: [\"ynca\", \"yxc\", \"xml\"],\n soundProgram: [\"ynca\", \"yxc\", \"xml\"],\n sleep: [\"ynca\", \"xml\", \"yxc\"],\n \"tuner.band\": [\"ynca\", \"yxc\"],\n};\n\n/**\n * Per-transport state-id \u2192 canonical capability key, for the ids that drift between transports\n * (census \u00A73f, verified against the catalogs). The canonical key is the census left column; the\n * transports not listed already use the canonical id. Zone prefixes are stripped separately.\n */\nconst ID_DRIFT: Partial<Record<Transport, Readonly<Record<string, string>>>> = {\n yxc: { subwooferVolume: \"sound.subwooferTrim\", \"multiroom.partyEnable\": \"multiroom.party\" },\n xml: { hdmiOut1: \"hdmi.out1\", hdmiOut2: \"hdmi.out2\" },\n};\n\n/**\n * The transport-neutral capability key for a transport's state id: strip a `zone2/3/4.` prefix\n * to the per-zone template, then resolve any id drift to the canonical key. Two transports that\n * express the same capability under different ids therefore map to the same key.\n *\n * @param transport the transport the state id comes from\n * @param stateId the transport's own state id (may carry a zone prefix)\n * @returns the canonical capability key\n */\nexport function capabilityKeyOf(transport: Transport, stateId: string): string {\n const template = stateId.replace(/^(?:multiroom\\.)?zone[234]\\./, \"\");\n return ID_DRIFT[transport]?.[template] ?? template;\n}\n\n/**\n * The drift-resolved, transport-neutral OBJECT id \u2014 like {@link capabilityKeyOf} but KEEPS the\n * zone prefix. capabilityKeyOf drops the zone to decide ownership per template; canonicalIdOf\n * keeps it to place the per-zone node in the unified tree. Two transports' zoned ids for the\n * same capability therefore collapse to one node id.\n *\n * @param transport the transport the state id comes from\n * @param stateId the transport's own state id\n * @returns the canonical object id (zone prefix kept, drift resolved)\n */\nexport function canonicalIdOf(transport: Transport, stateId: string): string {\n const zone = /^(?:multiroom\\.)?zone[234]\\./.exec(stateId)?.[0] ?? \"\";\n const template = stateId.slice(zone.length);\n const resolved = ID_DRIFT[transport]?.[template] ?? template;\n if (zone && !zone.startsWith(\"multiroom.\")) {\n return `multiroom.${zone}${resolved}`;\n }\n return zone + resolved;\n}\n\n/**\n * Decide which transport owns a capability, given the transports that actually offer it on\n * this device. Default is the most modern; a census-driven override wins where the modern\n * transport would be lossy. An override that lists none of the present candidates falls back\n * to modernity, so any non-empty candidate set resolves.\n *\n * @param key the transport-neutral capability key (the unified state id)\n * @param candidates the present transports that offer this key (non-empty)\n * @returns the owning transport\n */\nexport function pickOwner(key: string, candidates: readonly Transport[]): Transport {\n const preference = OWNER_OVERRIDES[key] ?? MODERNITY;\n // The MODERNITY fallback is defence for a FUTURE override: every entry in the\n // current table lists at least two transports, so an override can never miss all\n // present candidates while more than one is present \u2014 today it is unobservable.\n const owner = preference.find(t => candidates.includes(t)) ?? MODERNITY.find(t => candidates.includes(t));\n return owner ?? candidates[0];\n}\n\n/**\n * Build the owner map for a device: given which capability keys each present transport offers,\n * resolve every key to exactly one owning transport (via {@link pickOwner}). This is the\n * dedup decision behind the unified object tree \u2014 each state is written by one transport only.\n *\n * @param offered the capability keys each present transport offers (absent transport = none)\n * @returns a map from capability key to its owning transport\n */\nexport function resolveOwnership(offered: Partial<Record<Transport, readonly string[]>>): Map<string, Transport> {\n const candidates = new Map<string, Transport[]>();\n // Iterate in modernity order so the candidate lists are deterministic.\n for (const transport of MODERNITY) {\n for (const key of offered[transport] ?? []) {\n const list = candidates.get(key);\n if (list) {\n list.push(transport);\n } else {\n candidates.set(key, [transport]);\n }\n }\n }\n const owners = new Map<string, Transport>();\n for (const [key, cands] of candidates) {\n owners.set(key, pickOwner(key, cands));\n }\n return owners;\n}\n"],
|
|
5
|
+
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAQA,MAAM,YAAkC,CAAC,OAAO,QAAQ,KAAK;AAQ7D,MAAM,kBAAwD;AAAA;AAAA,EAE5D,QAAQ,CAAC,QAAQ,OAAO,KAAK;AAAA;AAAA,EAE7B,sBAAsB,CAAC,QAAQ,OAAO,KAAK;AAAA;AAAA,EAE3C,oBAAoB,CAAC,QAAQ,KAAK;AAAA,EAClC,mBAAmB,CAAC,QAAQ,OAAO,KAAK;AAAA,EACxC,qBAAqB,CAAC,QAAQ,OAAO,KAAK;AAAA,EAC1C,yBAAyB,CAAC,QAAQ,KAAK;AAAA,EACvC,sBAAsB,CAAC,OAAO,KAAK;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAMnC,gBAAgB,CAAC,OAAO,OAAO,MAAM;AAAA;AAAA,EAErC,OAAO,CAAC,QAAQ,OAAO,KAAK;AAAA,EAC5B,cAAc,CAAC,QAAQ,OAAO,KAAK;AAAA,EACnC,OAAO,CAAC,QAAQ,OAAO,KAAK;AAAA,EAC5B,cAAc,CAAC,QAAQ,KAAK;AAC9B;AAOA,MAAM,WAAyE;AAAA,EAC7E,KAAK,EAAE,iBAAiB,uBAAuB,yBAAyB,kBAAkB;AAAA,EAC1F,KAAK,EAAE,UAAU,aAAa,UAAU,YAAY;AACtD;AAWO,SAAS,gBAAgB,WAAsB,SAAyB;AA3D/E;AA4DE,QAAM,WAAW,QAAQ,QAAQ,gCAAgC,EAAE;AACnE,UAAO,oBAAS,SAAS,MAAlB,mBAAsB,cAAtB,YAAmC;AAC5C;AAYO,SAAS,cAAc,WAAsB,SAAyB;AA1E7E;AA2EE,QAAM,QAAO,0CAA+B,KAAK,OAAO,MAA3C,mBAA+C,OAA/C,YAAqD;AAClE,QAAM,WAAW,QAAQ,MAAM,KAAK,MAAM;AAC1C,QAAM,YAAW,oBAAS,SAAS,MAAlB,mBAAsB,cAAtB,YAAmC;AACpD,MAAI,QAAQ,CAAC,KAAK,WAAW,YAAY,GAAG;AAC1C,WAAO,aAAa,IAAI,GAAG,QAAQ;AAAA,EACrC;AACA,SAAO,OAAO;AAChB;AAYO,SAAS,UAAU,KAAa,YAA6C;AA9FpF;AA+FE,QAAM,cAAa,qBAAgB,GAAG,MAAnB,YAAwB;AAI3C,QAAM,SAAQ,gBAAW,KAAK,OAAK,WAAW,SAAS,CAAC,CAAC,MAA3C,YAAgD,UAAU,KAAK,OAAK,WAAW,SAAS,CAAC,CAAC;AACxG,SAAO,wBAAS,WAAW,CAAC;AAC9B;AAUO,SAAS,iBAAiB,SAAgF;AA/GjH;AAgHE,QAAM,aAAa,oBAAI,IAAyB;AAEhD,aAAW,aAAa,WAAW;AACjC,eAAW,QAAO,aAAQ,SAAS,MAAjB,YAAsB,CAAC,GAAG;AAC1C,YAAM,OAAO,WAAW,IAAI,GAAG;AAC/B,UAAI,MAAM;AACR,aAAK,KAAK,SAAS;AAAA,MACrB,OAAO;AACL,mBAAW,IAAI,KAAK,CAAC,SAAS,CAAC;AAAA,MACjC;AAAA,IACF;AAAA,EACF;AACA,QAAM,SAAS,oBAAI,IAAuB;AAC1C,aAAW,CAAC,KAAK,KAAK,KAAK,YAAY;AACrC,WAAO,IAAI,KAAK,UAAU,KAAK,KAAK,CAAC;AAAA,EACvC;AACA,SAAO;AACT;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/catalog/types.ts"],
|
|
4
|
-
"sourcesContent": ["import type { ValueSpec } from \"./value-coerce\";\n\n/**\n * One catalogued device function \u2192 one ioBroker state. The catalog is\n * device-agnostic: it lists every function a protocol can expose; the\n * per-device mapper picks the entries the device actually reports.\n *\n * **Single source rule.** Each protocol catalog extends this with its own\n * protocol key (YNCA: the subunit function name; YXC: the getStatus field +\n * write method; XML: the Basic_Status field + PUT builder). The init sweep, the\n * device\u2192state read-back and the user-write encode are all derived from that one\n * extended list \u2014 never a second state\u2194function table beside the catalog (the\n * `AMP_STATES` vs `STATE_MAPPINGS` split this rewrite removes). `catalogToObjects`\n * reads only the object fields below and ignores the protocol key.\n */\nexport interface CatalogEntry {\n /** State id relative to the device \u2014 dotted for a channel (e.g. `power`, `sound.bass`, `zone2.power`). */\n id: string;\n /** Display name shown in the object browser. */\n name: string;\n /** Value semantics \u2014 drives type/role/states/range via {@link ValueSpec}. */\n spec: ValueSpec;\n /** Whether the user can write this state. */\n write: boolean;\n /** Explicit ioBroker role; when omitted it is derived from the spec kind. */\n role?: string;\n}\n\n/** An object to create in the device tree: a state or a channel. */\nexport interface ObjectDef {\n /** Object id relative to the device. */\n id: string;\n /** Object kind. */\n type: \"state\" | \"channel\";\n /** ioBroker common part. */\n common: {\n name: string;\n type?: \"boolean\" | \"number\" | \"string\";\n role?: string;\n read?: boolean;\n write?: boolean;\n unit?: string;\n min?: number;\n max?: number;\n step?: number;\n states?: Record<string, string>;\n };\n}\n\n/**\n * Display names for the channel ids the catalogs use. A channel id not listed\n * here falls back to its capitalised segment, so a new channel still renders \u2014 but\n * every channel a catalog actually creates is named here, so nothing shows a raw id\n * like \"Pc\" or \"Ipod\" in the object browser. Keys match the real channel segments\n * (verified against the built catalogs); do not add speculative entries.\n */\nexport const CHANNEL_NAMES: Record<string, string> = {\n // Device info (metadata beside the per-device connection indicator)\n info: \"Info\",\n // Zones\n zone2: \"Zone 2\",\n zone3: \"Zone 3\",\n zone4: \"Zone 4\",\n zoneB: \"Zone B\",\n // Amplifier groups\n sound: \"Sound\",\n advanced: \"Advanced\",\n hdmi: \"HDMI\",\n speakers: \"Speakers\",\n scene: \"Scenes\",\n inputNames: \"Input names\",\n initialVolume: \"Initial volume\",\n lipSync: \"Lip sync\",\n // Tuner\n tuner: \"Tuner\",\n dab: \"DAB\",\n // Media player container + multiroom\n player: \"Media player\",\n multiroom: \"Multiroom\",\n // The MusicCast-Link folder under multiroom \u2014 a group of linked DEVICES, not zones.\n group: \"MusicCast group (linked devices)\",\n // Media player sources\n netRadio: \"Net radio\",\n server: \"Media server\",\n usb: \"USB\",\n spotify: \"Spotify\",\n deezer: \"Deezer\",\n tidal: \"Tidal\",\n napster: \"Napster\",\n pandora: \"Pandora\",\n rhapsody: \"Rhapsody\",\n sirius: \"SiriusXM\",\n airplay: \"AirPlay\",\n bluetooth: \"Bluetooth\",\n pc: \"PC\",\n musicCastLink: \"MusicCast Link\",\n ipod: \"iPod\",\n ipodUsb: \"iPod (USB)\",\n // YXC/XML media channels\n cd: \"CD\",\n netPlayer: \"Network player\",\n clock: \"Clock\",\n};\n"],
|
|
5
|
-
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAwDO,MAAM,gBAAwC;AAAA;AAAA,EAEnD,MAAM;AAAA;AAAA,EAEN,OAAO;AAAA,EACP,OAAO;AAAA,EACP,OAAO;AAAA,EACP,OAAO;AAAA;AAAA,EAEP,OAAO;AAAA,EACP,UAAU;AAAA,EACV,MAAM;AAAA,EACN,UAAU;AAAA,EACV,OAAO;AAAA,EACP,YAAY;AAAA,EACZ,eAAe;AAAA,EACf,SAAS;AAAA;AAAA,EAET,OAAO;AAAA,EACP,KAAK;AAAA;AAAA,EAEL,QAAQ;AAAA,EACR,WAAW;AAAA;AAAA,EAEX,OAAO;AAAA;AAAA,EAEP,UAAU;AAAA,EACV,QAAQ;AAAA,EACR,KAAK;AAAA,EACL,SAAS;AAAA,EACT,QAAQ;AAAA,EACR,OAAO;AAAA,EACP,SAAS;AAAA,EACT,SAAS;AAAA,EACT,UAAU;AAAA,EACV,QAAQ;AAAA,EACR,SAAS;AAAA,EACT,WAAW;AAAA,EACX,IAAI;AAAA,EACJ,eAAe;AAAA,EACf,MAAM;AAAA,EACN,SAAS;AAAA;AAAA,EAET,IAAI;AAAA,EACJ,WAAW;AAAA,EACX,OAAO;AACT;",
|
|
4
|
+
"sourcesContent": ["import type { ValueSpec } from \"./value-coerce\";\n\n/**\n * One catalogued device function \u2192 one ioBroker state. The catalog is\n * device-agnostic: it lists every function a protocol can expose; the\n * per-device mapper picks the entries the device actually reports.\n *\n * **Single source rule.** Each protocol catalog extends this with its own\n * protocol key (YNCA: the subunit function name; YXC: the getStatus field +\n * write method; XML: the Basic_Status field + PUT builder). The init sweep, the\n * device\u2192state read-back and the user-write encode are all derived from that one\n * extended list \u2014 never a second state\u2194function table beside the catalog (the\n * `AMP_STATES` vs `STATE_MAPPINGS` split this rewrite removes). `catalogToObjects`\n * reads only the object fields below and ignores the protocol key.\n */\nexport interface CatalogEntry {\n /** State id relative to the device \u2014 dotted for a channel (e.g. `power`, `sound.bass`, `zone2.power`). */\n id: string;\n /** Display name shown in the object browser. */\n name: string;\n /** Value semantics \u2014 drives type/role/states/range via {@link ValueSpec}. */\n spec: ValueSpec;\n /** Whether the user can write this state. */\n write: boolean;\n /** Explicit ioBroker role; when omitted it is derived from the spec kind. */\n role?: string;\n}\n\n/** An object to create in the device tree: a state or a channel. */\nexport interface ObjectDef {\n /** Object id relative to the device. */\n id: string;\n /** Object kind. */\n type: \"state\" | \"channel\";\n /** ioBroker common part. */\n common: {\n name: string;\n type?: \"boolean\" | \"number\" | \"string\";\n role?: string;\n read?: boolean;\n write?: boolean;\n unit?: string;\n min?: number;\n max?: number;\n step?: number;\n states?: Record<string, string>;\n };\n}\n\n/**\n * Display names for the channel ids the catalogs use. A channel id not listed\n * here falls back to its capitalised segment, so a new channel still renders \u2014 but\n * every channel a catalog actually creates is named here, so nothing shows a raw id\n * like \"Pc\" or \"Ipod\" in the object browser. Keys match the real channel segments\n * (verified against the built catalogs); do not add speculative entries.\n */\nexport const CHANNEL_NAMES: Record<string, string> = {\n // Device info (metadata beside the per-device connection indicator)\n info: \"Info\",\n // Zones\n zone2: \"Zone 2\",\n zone3: \"Zone 3\",\n zone4: \"Zone 4\",\n zoneB: \"Zone B\",\n // Amplifier groups\n sound: \"Sound\",\n advanced: \"Advanced\",\n hdmi: \"HDMI\",\n speakers: \"Speakers\",\n scene: \"Scenes\",\n remote: \"Remote control\",\n inputNames: \"Input names\",\n initialVolume: \"Initial volume\",\n lipSync: \"Lip sync\",\n // Tuner\n tuner: \"Tuner\",\n dab: \"DAB\",\n // Media player container + multiroom\n player: \"Media player\",\n multiroom: \"Multiroom\",\n // The MusicCast-Link folder under multiroom \u2014 a group of linked DEVICES, not zones.\n group: \"MusicCast group (linked devices)\",\n // Media player sources\n netRadio: \"Net radio\",\n server: \"Media server\",\n usb: \"USB\",\n spotify: \"Spotify\",\n deezer: \"Deezer\",\n tidal: \"Tidal\",\n napster: \"Napster\",\n pandora: \"Pandora\",\n rhapsody: \"Rhapsody\",\n sirius: \"SiriusXM\",\n airplay: \"AirPlay\",\n bluetooth: \"Bluetooth\",\n pc: \"PC\",\n musicCastLink: \"MusicCast Link\",\n ipod: \"iPod\",\n ipodUsb: \"iPod (USB)\",\n // YXC/XML media channels\n cd: \"CD\",\n netPlayer: \"Network player\",\n clock: \"Clock\",\n};\n"],
|
|
5
|
+
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAwDO,MAAM,gBAAwC;AAAA;AAAA,EAEnD,MAAM;AAAA;AAAA,EAEN,OAAO;AAAA,EACP,OAAO;AAAA,EACP,OAAO;AAAA,EACP,OAAO;AAAA;AAAA,EAEP,OAAO;AAAA,EACP,UAAU;AAAA,EACV,MAAM;AAAA,EACN,UAAU;AAAA,EACV,OAAO;AAAA,EACP,QAAQ;AAAA,EACR,YAAY;AAAA,EACZ,eAAe;AAAA,EACf,SAAS;AAAA;AAAA,EAET,OAAO;AAAA,EACP,KAAK;AAAA;AAAA,EAEL,QAAQ;AAAA,EACR,WAAW;AAAA;AAAA,EAEX,OAAO;AAAA;AAAA,EAEP,UAAU;AAAA,EACV,QAAQ;AAAA,EACR,KAAK;AAAA,EACL,SAAS;AAAA,EACT,QAAQ;AAAA,EACR,OAAO;AAAA,EACP,SAAS;AAAA,EACT,SAAS;AAAA,EACT,UAAU;AAAA,EACV,QAAQ;AAAA,EACR,SAAS;AAAA,EACT,WAAW;AAAA,EACX,IAAI;AAAA,EACJ,eAAe;AAAA,EACf,MAAM;AAAA,EACN,SAAS;AAAA;AAAA,EAET,IAAI;AAAA,EACJ,WAAW;AAAA,EACX,OAAO;AACT;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -29,6 +29,11 @@ const ID_MAP = (0, import_catalog.idToEntry)(import_catalog.YNCA_CATALOG);
|
|
|
29
29
|
const AVAIL_PROBE = (0, import_catalog.availGets)(import_catalog.YNCA_CATALOG);
|
|
30
30
|
const STATIC_FUNC = /^(INPNAME|SCENE\d+NAME$)/;
|
|
31
31
|
const STATIC_KEY = "yncaStaticValues";
|
|
32
|
+
const CAPS_KEY = "yncaCapabilities";
|
|
33
|
+
function isCachedCapabilities(value) {
|
|
34
|
+
const candidate = value;
|
|
35
|
+
return typeof candidate === "object" && candidate !== null && typeof candidate.model === "string" && typeof candidate.firmware === "string" && typeof candidate.subunits === "object" && candidate.subunits !== null;
|
|
36
|
+
}
|
|
32
37
|
const LIST_PROOF = /^(LISTLAYER|LISTLAYERNAME|CURRLINE|MAXLINE|LINE[1-8](TXT|ATRIB))$/;
|
|
33
38
|
class YncaDeviceController {
|
|
34
39
|
/**
|
|
@@ -41,6 +46,12 @@ class YncaDeviceController {
|
|
|
41
46
|
}
|
|
42
47
|
browseDriver;
|
|
43
48
|
browseEngine;
|
|
49
|
+
/**
|
|
50
|
+
* Write map filtered to the entries THIS device reported — claim-with-proof for
|
|
51
|
+
* writes: a command is only sent with a wire function the device answered in the
|
|
52
|
+
* sweep. Until the sweep ran, the unfiltered static map answers.
|
|
53
|
+
*/
|
|
54
|
+
writeMap;
|
|
44
55
|
/**
|
|
45
56
|
* Connect, sweep the device from the catalog, and create its object tree; wire
|
|
46
57
|
* up push updates. The catalog is the single source: it drives the sweep, the
|
|
@@ -49,38 +60,128 @@ class YncaDeviceController {
|
|
|
49
60
|
* @returns true if the device reported capabilities and its tree was created
|
|
50
61
|
*/
|
|
51
62
|
async start() {
|
|
63
|
+
var _a, _b;
|
|
52
64
|
await this.deps.client.connect();
|
|
53
65
|
const catalog = this.deps.isEntryEnabled ? import_catalog.YNCA_CATALOG.filter((entry) => this.deps.isEntryEnabled(entry.id)) : import_catalog.YNCA_CATALOG;
|
|
54
|
-
const
|
|
66
|
+
const resolved = await this.resolveCapabilities(catalog);
|
|
67
|
+
const { capabilities, fromCache } = resolved;
|
|
68
|
+
const present = (0, import_catalog.presentYncaEntries)(capabilities, catalog);
|
|
69
|
+
this.writeMap = (0, import_catalog.idToEntry)(present);
|
|
55
70
|
const objects = (0, import_catalog.yncaObjectsFor)(capabilities, catalog);
|
|
56
71
|
if (objects.length === 0) {
|
|
57
72
|
this.deps.log.warn(`${this.deviceId}: no capabilities reported \u2014 creating no objects`);
|
|
58
73
|
return false;
|
|
59
74
|
}
|
|
75
|
+
(_b = (_a = this.deps.client).onRefusal) == null ? void 0 : _b.call(
|
|
76
|
+
_a,
|
|
77
|
+
(command, verdict) => this.deps.log.warn(`${this.deviceId}: device refused "${command}" (@${verdict.toUpperCase()})`)
|
|
78
|
+
);
|
|
60
79
|
for (const object of objects) {
|
|
61
80
|
await this.deps.upsertObject(`${this.deviceId}.${object.id}`, object);
|
|
62
81
|
}
|
|
63
|
-
|
|
64
|
-
for (const [
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
82
|
+
if (!fromCache) {
|
|
83
|
+
for (const [subunit, funcs] of Object.entries(capabilities.subunits)) {
|
|
84
|
+
for (const [func, value] of Object.entries(funcs)) {
|
|
85
|
+
const update = (0, import_catalog.yncaStateUpdate)({ subunit, func, value }, FUNC_MAP);
|
|
86
|
+
if (update) {
|
|
87
|
+
this.deps.setStateAck(`${this.deviceId}.${update.id}`, update.value);
|
|
88
|
+
}
|
|
68
89
|
}
|
|
69
90
|
}
|
|
70
91
|
}
|
|
71
92
|
await this.setupBrowse(capabilities);
|
|
72
93
|
this.deps.client.onMessage((message) => {
|
|
73
|
-
var
|
|
74
|
-
(
|
|
94
|
+
var _a2;
|
|
95
|
+
(_a2 = this.browseDriver) == null ? void 0 : _a2.handleMessage(message);
|
|
75
96
|
const update = (0, import_catalog.yncaStateUpdate)(message, FUNC_MAP);
|
|
76
97
|
if (update) {
|
|
77
98
|
this.deps.setStateAck(`${this.deviceId}.${update.id}`, update.value);
|
|
78
99
|
}
|
|
79
100
|
});
|
|
80
101
|
this.deps.client.startKeepalive();
|
|
102
|
+
if (fromCache) {
|
|
103
|
+
void this.refreshInBackground(catalog);
|
|
104
|
+
}
|
|
81
105
|
this.deps.log.debug(`${this.deviceId}: ${capabilities.model || "device"} ready (YNCA)`);
|
|
82
106
|
return true;
|
|
83
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* The device's capabilities — from the persisted fast-restart layer when the LIVE
|
|
110
|
+
* identity (model + firmware, two paced reads, ~0.2 s) matches what the layer was
|
|
111
|
+
* captured from, else from the full two-pass sweep. The identity read doubles as the
|
|
112
|
+
* liveness proof the ready line rests on: a cached shape alone must never present a
|
|
113
|
+
* dead device as connected (the v1.5.0 honesty rule).
|
|
114
|
+
*
|
|
115
|
+
* @param catalog the (group-filtered) catalog
|
|
116
|
+
* @returns the capabilities and whether they came from the persisted layer
|
|
117
|
+
*/
|
|
118
|
+
async resolveCapabilities(catalog) {
|
|
119
|
+
var _a, _b, _c, _d, _e, _f, _g;
|
|
120
|
+
const identity = await this.deps.client.readCapabilities([
|
|
121
|
+
{ subunit: "SYS", func: "MODELNAME" },
|
|
122
|
+
{ subunit: "SYS", func: "VERSION" }
|
|
123
|
+
]);
|
|
124
|
+
const model = identity.model;
|
|
125
|
+
const firmware = (_b = (_a = identity.subunits.SYS) == null ? void 0 : _a.VERSION) != null ? _b : "";
|
|
126
|
+
const remembered = (_c = this.deps.probeMemory) == null ? void 0 : _c.remembered(CAPS_KEY);
|
|
127
|
+
if (model && isCachedCapabilities(remembered) && remembered.model === model && remembered.firmware === firmware) {
|
|
128
|
+
return { capabilities: { model, subunits: remembered.subunits }, fromCache: true };
|
|
129
|
+
}
|
|
130
|
+
if (remembered !== void 0) {
|
|
131
|
+
(_d = this.deps.probeMemory) == null ? void 0 : _d.drop((key) => key === CAPS_KEY || key === STATIC_KEY);
|
|
132
|
+
}
|
|
133
|
+
const capabilities = await this.sweepDevice(catalog, model, firmware);
|
|
134
|
+
if (capabilities.model) {
|
|
135
|
+
(_g = this.deps.probeMemory) == null ? void 0 : _g.set(CAPS_KEY, {
|
|
136
|
+
model: capabilities.model,
|
|
137
|
+
firmware: (_f = (_e = capabilities.subunits.SYS) == null ? void 0 : _e.VERSION) != null ? _f : firmware,
|
|
138
|
+
subunits: capabilities.subunits
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
return { capabilities, fromCache: false };
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* The fast path's second half: re-ask every catalogued function of the present
|
|
145
|
+
* subunits — the answers stream into the states through the live message handler,
|
|
146
|
+
* so current values arrive within the usual sweep time WITHOUT having gated the
|
|
147
|
+
* ready line. Completion refreshes the persisted layers (capabilities, statics)
|
|
148
|
+
* and the write map; a SHAPE change (a function newly answered) is only persisted —
|
|
149
|
+
* its object appears on the next start, because the unified tree is coordinated
|
|
150
|
+
* once per connect and a late upsert would not materialize.
|
|
151
|
+
*
|
|
152
|
+
* @param catalog the (group-filtered) catalog
|
|
153
|
+
*/
|
|
154
|
+
async refreshInBackground(catalog) {
|
|
155
|
+
var _a, _b, _c, _d, _e, _f;
|
|
156
|
+
try {
|
|
157
|
+
const cached = (_a = this.deps.subunitCache) == null ? void 0 : _a.get();
|
|
158
|
+
const gets = (0, import_catalog.sweepGets)(catalog).filter(
|
|
159
|
+
(get) => get.subunit === "SYS" || !cached || cached.subunits.includes(get.subunit)
|
|
160
|
+
);
|
|
161
|
+
const fresh = await this.deps.client.readCapabilities(gets);
|
|
162
|
+
if (!fresh.model) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const statics = {};
|
|
166
|
+
for (const [subunit, funcs] of Object.entries(fresh.subunits)) {
|
|
167
|
+
for (const [func, value] of Object.entries(funcs)) {
|
|
168
|
+
if (STATIC_FUNC.test(func)) {
|
|
169
|
+
((_b = statics[subunit]) != null ? _b : statics[subunit] = {})[func] = value;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
(_c = this.deps.probeMemory) == null ? void 0 : _c.set(STATIC_KEY, statics);
|
|
174
|
+
(_f = this.deps.probeMemory) == null ? void 0 : _f.set(CAPS_KEY, {
|
|
175
|
+
model: fresh.model,
|
|
176
|
+
firmware: (_e = (_d = fresh.subunits.SYS) == null ? void 0 : _d.VERSION) != null ? _e : "",
|
|
177
|
+
subunits: fresh.subunits
|
|
178
|
+
});
|
|
179
|
+
this.writeMap = (0, import_catalog.idToEntry)((0, import_catalog.presentYncaEntries)(fresh, catalog));
|
|
180
|
+
this.deps.log.debug(`${this.deviceId}: background value refresh done (YNCA)`);
|
|
181
|
+
} catch (e) {
|
|
182
|
+
this.deps.log.debug(`${this.deviceId}: background value refresh failed: ${String(e)}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
84
185
|
/**
|
|
85
186
|
* Read the device's capabilities with the two-pass sweep. Pass 1 probes each
|
|
86
187
|
* catalogued subunit with `AVAIL=?` (~2 s); pass 2 sweeps only the subunits that
|
|
@@ -92,22 +193,19 @@ class YncaDeviceController {
|
|
|
92
193
|
* firmware loses speed, never features.
|
|
93
194
|
*
|
|
94
195
|
* @param catalog the (group-filtered) catalog whose functions to sweep
|
|
196
|
+
* @param model the live-read SYS model name (from resolveCapabilities)
|
|
197
|
+
* @param firmware the live-read SYS firmware version
|
|
95
198
|
* @returns the assembled capabilities
|
|
96
199
|
*/
|
|
97
|
-
async sweepDevice(catalog) {
|
|
98
|
-
var _a, _b, _c, _d, _e
|
|
200
|
+
async sweepDevice(catalog, model, firmware) {
|
|
201
|
+
var _a, _b, _c, _d, _e;
|
|
99
202
|
const cached = (_a = this.deps.subunitCache) == null ? void 0 : _a.get();
|
|
100
203
|
if (cached) {
|
|
101
|
-
|
|
102
|
-
{ subunit: "SYS", func: "MODELNAME" },
|
|
103
|
-
{ subunit: "SYS", func: "VERSION" }
|
|
104
|
-
]);
|
|
105
|
-
const firmware = (_c = (_b = identity.subunits.SYS) == null ? void 0 : _b.VERSION) != null ? _c : "";
|
|
106
|
-
if (identity.model === cached.model && firmware === cached.firmware) {
|
|
204
|
+
if (model === cached.model && firmware === cached.firmware) {
|
|
107
205
|
return await this.targetedSweep(catalog, new Set(cached.subunits));
|
|
108
206
|
}
|
|
109
207
|
this.deps.log.debug(`${this.deviceId}: cached subunit set is stale (model/firmware changed), re-probing`);
|
|
110
|
-
(
|
|
208
|
+
(_b = this.deps.subunitCache) == null ? void 0 : _b.clear();
|
|
111
209
|
}
|
|
112
210
|
const probe = await this.deps.client.readCapabilities(AVAIL_PROBE);
|
|
113
211
|
const present = new Set(Object.keys(probe.subunits));
|
|
@@ -116,10 +214,10 @@ class YncaDeviceController {
|
|
|
116
214
|
}
|
|
117
215
|
const capabilities = await this.targetedSweep(catalog, present);
|
|
118
216
|
if (capabilities.model) {
|
|
119
|
-
(
|
|
217
|
+
(_e = this.deps.subunitCache) == null ? void 0 : _e.set({
|
|
120
218
|
subunits: [...present],
|
|
121
219
|
model: capabilities.model,
|
|
122
|
-
firmware: (
|
|
220
|
+
firmware: (_d = (_c = capabilities.subunits.SYS) == null ? void 0 : _c.VERSION) != null ? _d : ""
|
|
123
221
|
});
|
|
124
222
|
}
|
|
125
223
|
return capabilities;
|
|
@@ -165,7 +263,7 @@ class YncaDeviceController {
|
|
|
165
263
|
* @param value the new value
|
|
166
264
|
*/
|
|
167
265
|
handleStateChange(fullStateId, ack, value) {
|
|
168
|
-
var _a;
|
|
266
|
+
var _a, _b;
|
|
169
267
|
if (ack) {
|
|
170
268
|
return;
|
|
171
269
|
}
|
|
@@ -178,7 +276,7 @@ class YncaDeviceController {
|
|
|
178
276
|
(_a = this.browseEngine) == null ? void 0 : _a.handleWrite(stateId, value);
|
|
179
277
|
return;
|
|
180
278
|
}
|
|
181
|
-
const triple = (0, import_catalog.yncaCommand)(stateId, value, ID_MAP);
|
|
279
|
+
const triple = (0, import_catalog.yncaCommand)(stateId, value, (_b = this.writeMap) != null ? _b : ID_MAP);
|
|
182
280
|
if (triple) {
|
|
183
281
|
this.deps.client.send(triple.subunit, triple.func, triple.value);
|
|
184
282
|
}
|