iobroker.yamaha 2.0.0 → 2.0.2

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 CHANGED
@@ -19,9 +19,10 @@ legacy XML protocol of the oldest pre-2010 models — behind one object tree.
19
19
  - **Instant updates** — MusicCast pushes its changes, YNCA reports over its live connection
20
20
  - **Self-healing connections** — an offline receiver joins once it answers; a single protocol reconnects on its own while the others keep running
21
21
  - **Typed datapoints** — booleans, dropdowns and numbers with unit and range instead of raw text
22
+ - **Now playing, per zone** — one player block per zone shows source, playback state, title, artist, cover art and the transport buttons for whatever that zone is playing; zones 2–4 carry their own block
22
23
  - **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
24
  - **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
+ - **Scenes with their names** — recall a scene by number or by its title from a dropdown that shows the names the receiver reports, per zone — plus a scene list for visualizations
25
26
  - **On-screen remote** — cursor pad and menu keys as datapoints on MusicCast devices, for driving the receiver's own on-screen menus
26
27
  - **Clock & alarm view** — MusicCast desk-audio devices show their clock and alarm settings
27
28
  - **Capability-driven** — states are generated from what each device reports, no hardcoded model list
@@ -102,6 +103,17 @@ On the very first contact the adapter asks the receiver which functions it suppo
102
103
  Placeholder for the next version (at the beginning of the line):
103
104
  ### **WORK IN PROGRESS**
104
105
  -->
106
+ ### 2.0.2 (2026-09-01)
107
+
108
+ - (krobipd) Fixed: a restart while the receiver stands by no longer forgets abilities the device proved while awake — the remembered capability map only ever grows for the same device and firmware
109
+
110
+ ### 2.0.1 (2026-09-01)
111
+
112
+ - (krobipd) Fixed: devices on the oldest protocol no longer get datapoints their status never delivers — states are only created for what the device really answers, like on the other two protocols
113
+ - (krobipd) Fixed: leftover datapoints that never carried a value — zone sound settings or a second HDMI output the receiver does not have — are cleaned away once when updating the adapter
114
+ - (krobipd) Fixed: the player block of a zone that is not playing a media source starts with cleared values (empty texts, playback on stop) instead of staying without any value until the first playback
115
+ - (krobipd) Fixed: the two DAB station-scan datapoints (stations found, scan progress) start at zero instead of staying without a value until the first station scan
116
+
105
117
  ### 2.0.0 (2026-09-01)
106
118
 
107
119
  - (krobipd) Changed: one player block per zone now shows what is playing — source, title, artist, cover art and the transport buttons — instead of an identical, mostly empty block under every source
@@ -130,24 +142,6 @@ On the very first contact the adapter asks the receiver which functions it suppo
130
142
  - (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
131
143
  - (krobipd) Fixed: a receiver kept showing as connected while the adapter was stopped, and after a crash it stayed that way until it answered again — both now show the truth
132
144
 
133
- ### 1.5.0 (2026-08-27)
134
-
135
- - (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)
136
- - (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
137
- - (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
138
- - (krobipd) New: optional error reporting via Sentry — only active if you enabled diagnostics in ioBroker, and it transmits no personal data
139
-
140
- ### 1.4.0 (2026-08-26)
141
-
142
- - (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
143
- - (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
144
- - (krobipd) Fixed: names and menu entries containing "&" or other special characters now read and write correctly on the older XML protocol
145
- - (krobipd) Fixed: writing one equalizer band no longer resets the other two when the device has not reported its bands yet
146
- - (krobipd) Fixed: switching the tuner band and setting a frequency right after each other now applies the frequency to the new band
147
- - (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
148
- - (krobipd) Fixed: recalling a favourite, a recently played item or a tuner preset now goes to the zone that is actually listening instead of always switching the main zone
149
- - (krobipd) Improved: stopping or restarting the adapter no longer leaves requests running that write to datapoints afterwards
150
-
151
145
  [Older changelogs can be found there](CHANGELOG_OLD.md)
152
146
 
153
147
  ## History
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/catalog/groups.ts"],
4
- "sourcesContent": ["/**\n * Datapoint groups: the thematic buckets that are both the object-tree structure and the\n * admin on/off switches. Every state id maps to exactly one group by its (zone-stripped) first\n * path segment (its channel). A disabled group's objects are never created and any existing ones\n * are pruned, the same way beszel gates its metric categories. `amp` is the always-on core \u2014 no\n * switch exists for it, exactly like beszel's unconditional `info.online`/`info.status`.\n *\n * Every group is a real channel prefix in the tree (`player.*`, `tuner.*`, `hdmi.*`,\n * `multiroom.*`, `scene.*`, `sound.*`, `advanced.*`) \u2014 the toggle and the folder a datapoint\n * visually sits in are the same thing, never decoupled. Zone 2/3/4, Zone B, masterPower, party\n * and distribution all live under `multiroom.*`. Legacy flat pre-v0.15.0 ids need no handling\n * here: the start-up cleanup deletes them via `RENAMED_CHANNELS` before anything queries them.\n */\n\n/** The switchable groups. `amp` is the amplifier core and can never be turned off. */\nexport type GroupId = \"amp\" | \"player\" | \"tuner\" | \"multiroom\" | \"hdmi\" | \"scene\" | \"sound\" | \"advanced\" | \"clock\";\n\n/** The groups a user can switch off, in display order (amp is always on and not listed here). */\nexport const SWITCHABLE_GROUPS: readonly GroupId[] = [\n \"player\",\n \"tuner\",\n \"multiroom\",\n \"hdmi\",\n \"scene\",\n \"sound\",\n \"advanced\",\n \"clock\",\n];\n\n/**\n * The datapoint group a state id belongs to, decided by its first path segment. Every group is a\n * real folder prefix in the tree, so the first segment of the id determines the group directly:\n * `multiroom.*` (including zones, masterPower, party), `player.*`, `tuner.*`, `hdmi.*`, `sound.*`,\n * `advanced.*`, `scene.*`. Anything not matched \u2014 the amplifier core (power, volume, input,\n * sleep \u2026), `info.*` \u2014 is the always-on `amp` group.\n *\n * @param stateId the device-relative state id (e.g. \"multiroom.zone2.power\", \"player.spotify.playback\")\n * @returns the group the state belongs to\n */\nexport function groupOf(stateId: string): GroupId {\n const seg = stateId.includes(\".\") ? stateId.slice(0, stateId.indexOf(\".\")) : stateId;\n\n if (seg === \"multiroom\") {\n return \"multiroom\";\n }\n if (seg === \"hdmi\") {\n return \"hdmi\";\n }\n if (seg === \"player\") {\n return \"player\";\n }\n if (seg === \"tuner\") {\n return \"tuner\";\n }\n if (seg === \"sound\") {\n return \"sound\";\n }\n if (seg === \"advanced\") {\n return \"advanced\";\n }\n if (seg === \"scene\") {\n return \"scene\";\n }\n if (seg === \"clock\") {\n return \"clock\";\n }\n return \"amp\";\n}\n\n/**\n * Whether a state's group is switched on. The amplifier core is always on; every other group is\n * on unless its `group_<id>` flag is explicitly false \u2014 default-on, so a fresh install and every\n * existing install keep all groups until the user turns one off.\n *\n * @param stateId the device-relative state id\n * @param config the adapter native config (carries `group_player`, `group_tuner`, \u2026 booleans)\n * @returns true if the state's group is enabled\n */\nexport function isGroupEnabled(stateId: string, config: Record<string, unknown>): boolean {\n const group = groupOf(stateId);\n if (group === \"amp\") {\n return true;\n }\n return config[`group_${group}`] !== false;\n}\n"],
4
+ "sourcesContent": ["/**\n * Datapoint groups: the thematic buckets that are both the object-tree structure and the\n * admin on/off switches. Every state id maps to exactly one group by its (zone-stripped) first\n * path segment (its channel). A disabled group's objects are never created and any existing ones\n * are pruned, the same way beszel gates its metric categories. `amp` is the always-on core \u2014 no\n * switch exists for it, exactly like beszel's unconditional `info.online`/`info.status`.\n *\n * Every group is a real channel prefix in the tree (`player.*`, `tuner.*`, `hdmi.*`,\n * `multiroom.*`, `scene.*`, `sound.*`, `advanced.*`) \u2014 the toggle and the folder a datapoint\n * visually sits in are the same thing, never decoupled. Zone 2/3/4, Zone B, masterPower, party\n * and distribution all live under `multiroom.*`. Legacy flat pre-v0.15.0 ids need no handling\n * here: the start-up cleanup deletes them via `RENAMED_CHANNELS` before anything queries them.\n */\n\n/** The switchable groups. `amp` is the amplifier core and can never be turned off. */\nexport type GroupId = \"amp\" | \"player\" | \"tuner\" | \"multiroom\" | \"hdmi\" | \"scene\" | \"sound\" | \"advanced\" | \"clock\";\n\n/** The groups a user can switch off, in display order (amp is always on and not listed here). */\nexport const SWITCHABLE_GROUPS: readonly GroupId[] = [\n \"player\",\n \"tuner\",\n \"multiroom\",\n \"hdmi\",\n \"scene\",\n \"sound\",\n \"advanced\",\n \"clock\",\n];\n\n/**\n * The datapoint group a state id belongs to, decided by its first path segment. Every group is a\n * real folder prefix in the tree, so the first segment of the id determines the group directly:\n * `multiroom.*` (including zones, masterPower, party), `player.*`, `tuner.*`, `hdmi.*`, `sound.*`,\n * `advanced.*`, `scene.*`. Anything not matched \u2014 the amplifier core (power, volume, input,\n * sleep \u2026), `info.*` \u2014 is the always-on `amp` group.\n *\n * @param stateId the device-relative state id (e.g. \"multiroom.zone2.power\", \"player.playback\")\n * @returns the group the state belongs to\n */\nexport function groupOf(stateId: string): GroupId {\n const seg = stateId.includes(\".\") ? stateId.slice(0, stateId.indexOf(\".\")) : stateId;\n\n if (seg === \"multiroom\") {\n return \"multiroom\";\n }\n if (seg === \"hdmi\") {\n return \"hdmi\";\n }\n if (seg === \"player\") {\n return \"player\";\n }\n if (seg === \"tuner\") {\n return \"tuner\";\n }\n if (seg === \"sound\") {\n return \"sound\";\n }\n if (seg === \"advanced\") {\n return \"advanced\";\n }\n if (seg === \"scene\") {\n return \"scene\";\n }\n if (seg === \"clock\") {\n return \"clock\";\n }\n return \"amp\";\n}\n\n/**\n * Whether a state's group is switched on. The amplifier core is always on; every other group is\n * on unless its `group_<id>` flag is explicitly false \u2014 default-on, so a fresh install and every\n * existing install keep all groups until the user turns one off.\n *\n * @param stateId the device-relative state id\n * @param config the adapter native config (carries `group_player`, `group_tuner`, \u2026 booleans)\n * @returns true if the state's group is enabled\n */\nexport function isGroupEnabled(stateId: string, config: Record<string, unknown>): boolean {\n const group = groupOf(stateId);\n if (group === \"amp\") {\n return true;\n }\n return config[`group_${group}`] !== false;\n}\n"],
5
5
  "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAkBO,MAAM,oBAAwC;AAAA,EACnD;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAYO,SAAS,QAAQ,SAA0B;AAChD,QAAM,MAAM,QAAQ,SAAS,GAAG,IAAI,QAAQ,MAAM,GAAG,QAAQ,QAAQ,GAAG,CAAC,IAAI;AAE7E,MAAI,QAAQ,aAAa;AACvB,WAAO;AAAA,EACT;AACA,MAAI,QAAQ,QAAQ;AAClB,WAAO;AAAA,EACT;AACA,MAAI,QAAQ,UAAU;AACpB,WAAO;AAAA,EACT;AACA,MAAI,QAAQ,SAAS;AACnB,WAAO;AAAA,EACT;AACA,MAAI,QAAQ,SAAS;AACnB,WAAO;AAAA,EACT;AACA,MAAI,QAAQ,YAAY;AACtB,WAAO;AAAA,EACT;AACA,MAAI,QAAQ,SAAS;AACnB,WAAO;AAAA,EACT;AACA,MAAI,QAAQ,SAAS;AACnB,WAAO;AAAA,EACT;AACA,SAAO;AACT;AAWO,SAAS,eAAe,SAAiB,QAA0C;AACxF,QAAM,QAAQ,QAAQ,OAAO;AAC7B,MAAI,UAAU,OAAO;AACnB,WAAO;AAAA,EACT;AACA,SAAO,OAAO,SAAS,KAAK,EAAE,MAAM;AACtC;",
6
6
  "names": []
7
7
  }
@@ -21,6 +21,7 @@ __export(device_controller_exports, {
21
21
  YncaDeviceController: () => YncaDeviceController
22
22
  });
23
23
  module.exports = __toCommonJS(device_controller_exports);
24
+ var import_capability = require("./ynca/capability");
24
25
  var import_value_coerce = require("./catalog/value-coerce");
25
26
  var import_catalog = require("./ynca/catalog");
26
27
  var import_surface = require("./browse/surface");
@@ -256,7 +257,7 @@ class YncaDeviceController {
256
257
  * @param catalog the (group-filtered) catalog
257
258
  */
258
259
  async refreshInBackground(catalog) {
259
- var _a, _b, _c, _d, _e, _f;
260
+ var _a, _b, _c, _d, _e, _f, _g;
260
261
  try {
261
262
  const cached = (_a = this.deps.subunitCache) == null ? void 0 : _a.get();
262
263
  const gets = (0, import_catalog.sweepGets)(catalog).filter(
@@ -275,12 +276,14 @@ class YncaDeviceController {
275
276
  }
276
277
  }
277
278
  (_c = this.deps.probeMemory) == null ? void 0 : _c.set(STATIC_KEY, statics);
278
- (_f = this.deps.probeMemory) == null ? void 0 : _f.set(CAPS_KEY, {
279
+ const remembered = (_d = this.deps.probeMemory) == null ? void 0 : _d.remembered(CAPS_KEY);
280
+ const subunits = isCachedCapabilities(remembered) ? (0, import_capability.mergeYncaSubunits)(remembered.subunits, fresh.subunits) : fresh.subunits;
281
+ (_g = this.deps.probeMemory) == null ? void 0 : _g.set(CAPS_KEY, {
279
282
  model: fresh.model,
280
- firmware: (_e = (_d = fresh.subunits.SYS) == null ? void 0 : _d.VERSION) != null ? _e : "",
281
- subunits: fresh.subunits
283
+ firmware: (_f = (_e = fresh.subunits.SYS) == null ? void 0 : _e.VERSION) != null ? _f : "",
284
+ subunits
282
285
  });
283
- this.presentEntries = (0, import_catalog.presentYncaEntries)(fresh, catalog);
286
+ this.presentEntries = (0, import_catalog.presentYncaEntries)({ model: fresh.model, subunits }, catalog);
284
287
  this.writeMap = (0, import_catalog.idToEntry)(this.presentEntries);
285
288
  this.deps.log.debug(`${this.deviceId}: background value refresh done (YNCA)`);
286
289
  } catch (e) {
@@ -441,14 +444,25 @@ class YncaDeviceController {
441
444
  sourceDef(`${zone.prefix}player.source`)
442
445
  );
443
446
  }
447
+ const presentFlat = new Set(
448
+ this.presentEntries.filter((entry) => FLAT_PLAYER_ID.test(entry.id)).map((entry) => entry.id)
449
+ );
444
450
  for (const zone of YNCA_ZONES) {
445
- if (this.playerZones.includes(zone.key)) {
446
- const input = this.zoneInputs.get(zone.key);
447
- const playing = playerSubunitForInput(input) !== void 0;
448
- this.deps.setStateAck(
449
- `${this.deviceId}.${zone.prefix}player.source`,
450
- playing && input !== void 0 ? input : ""
451
- );
451
+ if (!this.playerZones.includes(zone.key)) {
452
+ continue;
453
+ }
454
+ const input = this.zoneInputs.get(zone.key);
455
+ const playing = playerSubunitForInput(input) !== void 0;
456
+ this.deps.setStateAck(
457
+ `${this.deviceId}.${zone.prefix}player.source`,
458
+ playing && input !== void 0 ? input : ""
459
+ );
460
+ if (!playing) {
461
+ for (const clear of YNCA_PLAYER_CLEAR) {
462
+ if (presentFlat.has(clear.id)) {
463
+ this.deps.setStateAck(`${this.deviceId}.${zone.prefix}${clear.id}`, clear.value);
464
+ }
465
+ }
452
466
  }
453
467
  }
454
468
  }
@@ -557,28 +571,45 @@ class YncaDeviceController {
557
571
  }
558
572
  if (this.hasDab) {
559
573
  if (this.tunerBand === "FM") {
560
- this.deps.client.send("DAB", "FMFREQ", (0, import_value_coerce.formatWireNumber)(khz / 1e3, 2));
574
+ this.sendProven("DAB", "FMFREQ", (0, import_value_coerce.formatWireNumber)(khz / 1e3, 2));
561
575
  } else {
562
576
  this.deps.log.debug(`${this.deviceId}: DAB tunes by service \u2014 frequency write ignored`);
563
577
  }
564
578
  return true;
565
579
  }
566
580
  if (this.tunerBand === "AM") {
567
- this.deps.client.send("TUN", "AMFREQ", String(Math.round(khz)));
581
+ this.sendProven("TUN", "AMFREQ", String(Math.round(khz)));
568
582
  } else {
569
- this.deps.client.send("TUN", "FMFREQ", (0, import_value_coerce.formatWireNumber)(khz / 1e3, 2));
583
+ this.sendProven("TUN", "FMFREQ", (0, import_value_coerce.formatWireNumber)(khz / 1e3, 2));
570
584
  }
571
585
  return true;
572
586
  }
573
587
  if (stateId === "tuner.preset" && this.hasDab) {
574
588
  const slot = Math.round(Number(value));
575
589
  if (Number.isFinite(slot) && slot >= 1) {
576
- this.deps.client.send("DAB", this.tunerBand === "DAB" ? "DABPRESET" : "FMPRESET", String(slot));
590
+ this.sendProven("DAB", this.tunerBand === "DAB" ? "DABPRESET" : "FMPRESET", String(slot));
577
591
  }
578
592
  return true;
579
593
  }
580
594
  return false;
581
595
  }
596
+ /**
597
+ * Send a band-routed write ONLY with a function THIS device reported in its sweep —
598
+ * the same claim-with-proof rule every generic write obeys (#615 class). Without it
599
+ * the router put a blind TUN:FMFREQ on the wire for any device, tuner or not, and
600
+ * each write surfaced as a "device refused" warning (test-audit finding).
601
+ *
602
+ * @param subunit the target subunit
603
+ * @param func the wire function
604
+ * @param wire the encoded wire value
605
+ */
606
+ sendProven(subunit, func, wire) {
607
+ if (!this.presentEntries.some((entry) => entry.subunit === subunit && entry.func === func)) {
608
+ this.deps.log.debug(`${this.deviceId}: ${subunit}:${func} not reported by this device \u2014 write dropped`);
609
+ return;
610
+ }
611
+ this.deps.client.send(subunit, func, wire);
612
+ }
582
613
  /**
583
614
  * Create the browsing surface (#613) when the device reports a browsable media
584
615
  * subunit: the official YNCA list vocabulary (LISTINFO/LISTSEL/LISTPAGE/LISTCURSOR)
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/lib/device-controller.ts"],
4
- "sourcesContent": ["import type { YncaCapabilities } from \"./ynca/capability\";\nimport { formatWireNumber } from \"./catalog/value-coerce\";\nimport type { ObjectDef } from \"./catalog/types\";\nimport type { ConnectionHandle, ControllerLog } from \"./controller\";\nimport {\n YNCA_CATALOG,\n availGets,\n funcToEntry,\n idToEntry,\n presentYncaEntries,\n sweepGets,\n yncaCommand,\n yncaObjectsFor,\n yncaStateUpdate,\n type YncaEntry,\n} from \"./ynca/catalog\";\nimport type { YncaSubunitCache } from \"./ynca/subunit-cache\";\nimport type { CommandGate } from \"./lifecycle/command-gate\";\nimport type { ProbeMemory } from \"./lifecycle/probe-memory\";\nimport type { BrowseEngine } from \"./browse/browse-engine\";\nimport { createBrowseSurface } from \"./browse/surface\";\nimport { YNCA_BROWSE_SOURCES, YncaBrowseDriver } from \"./browse/ynca-browse-driver\";\n\n// The YNCA catalog and its lookup maps are static \u2014 built once for all devices.\n// SYS:MODELNAME is part of the catalog (info.model), so the sweep already covers it.\n// The AVAIL probe always covers the FULL catalog (not the group-filtered one), so the\n// cached subunit set reflects the device, never the current group configuration.\nconst FUNC_MAP = funcToEntry(YNCA_CATALOG);\nconst ID_MAP = idToEntry(YNCA_CATALOG);\nconst AVAIL_PROBE = availGets(YNCA_CATALOG);\n\n/**\n * Functions whose VALUE cannot change while the device runs: the 23 assignable input names\n * and the 12 scene names. They cost 35 of the ~187 paced reads of a targeted sweep (3.5 s\n * at the specification's mandatory 100 ms spacing) and answer the same thing every time, so\n * a reconnect reuses what the first connect learned. Deliberately per adapter RUN, not\n * persisted: renaming an input at the receiver shows up after the next adapter restart\n * rather than needing anyone to invalidate a stored file.\n */\nconst STATIC_FUNC = /^(INPNAME|SCENE\\d+NAME$)/;\n\n/** The zones whose player/input surface the controller routes (v2.0.0 player unification). */\nconst YNCA_ZONES: Array<{ key: string; subunit: string; prefix: string }> = [\n { key: \"main\", subunit: \"MAIN\", prefix: \"\" },\n { key: \"zone2\", subunit: \"ZONE2\", prefix: \"multiroom.zone2.\" },\n { key: \"zone3\", subunit: \"ZONE3\", prefix: \"multiroom.zone3.\" },\n { key: \"zone4\", subunit: \"ZONE4\", prefix: \"multiroom.zone4.\" },\n];\n\n/**\n * INP value \u2192 the player subunit it selects (normalized: uppercase, alphanumerics only).\n * The wire values come from the RX-V6A INPNAME/INP capture and ynca-python's input map;\n * inputs that are no media player (HDMI, AV, TUNER, \u2026) deliberately map to nothing.\n */\nconst INPUT_SUBUNITS: Record<string, string> = {\n NETRADIO: \"NETRADIO\",\n SERVER: \"SERVER\",\n USB: \"USB\",\n SPOTIFY: \"SPOTIFY\",\n DEEZER: \"DEEZER\",\n TIDAL: \"TIDAL\",\n NAPSTER: \"NAPSTER\",\n PANDORA: \"PANDORA\",\n RHAPSODY: \"RHAP\",\n SIRIUS: \"SIRIUS\",\n SIRIUSXM: \"SIRIUS\",\n SIRIUSIR: \"SIRIUS\",\n AIRPLAY: \"AIRPLAY\",\n BLUETOOTH: \"BT\",\n PC: \"PC\",\n MUSICCASTLINK: \"MCLINK\",\n IPOD: \"IPOD\",\n IPODUSB: \"IPODUSB\",\n};\n\n/**\n * The player subunit an input selection feeds, or undefined when the input is no\n * media player.\n *\n * @param input the zone's INP value (e.g. \"NET RADIO\", \"iPod (USB)\")\n * @returns the subunit (e.g. NETRADIO), or undefined\n */\nfunction playerSubunitForInput(input: string | undefined): string | undefined {\n if (typeof input !== \"string\" || input.length === 0) {\n return undefined;\n }\n return INPUT_SUBUNITS[input.toUpperCase().replace(/[^A-Z0-9]/g, \"\")];\n}\n\n/**\n * A state of the flat per-zone player block: exactly one segment below `player.`.\n * ONLY these are zone-routed \u2014 multi-segment `player.*` ids (bluetooth pairing\n * status, airplay volume interlock, per-source presets) are device-global and go\n * straight to their state (2.0.0 review finding: the broad `player.` prefix check\n * silently dropped BT/AirPlay status while no zone listened to that source).\n */\nconst FLAT_PLAYER_ID = /^player\\.[^.]+$/;\n\n/**\n * What a zone's player block is reset to when the zone leaves its playing source\n * (clear-on-switch): metadata empty, playback Stop. Filtered to the states the\n * device actually has before emitting.\n */\nconst YNCA_PLAYER_CLEAR: Array<{ id: string; value: number | string | boolean }> = [\n { id: \"player.playback\", value: 1 },\n { id: \"player.artist\", value: \"\" },\n { id: \"player.album\", value: \"\" },\n { id: \"player.track\", value: \"\" },\n { id: \"player.station\", value: \"\" },\n { id: \"player.channelName\", value: \"\" },\n { id: \"player.elapsedTime\", value: \"\" },\n { id: \"player.totalTime\", value: \"\" },\n { id: \"player.repeat\", value: 0 },\n { id: \"player.shuffle\", value: false },\n];\n\n/** Memory key for the remembered static values. */\nconst STATIC_KEY = \"yncaStaticValues\";\n\n/** Memory key for the persisted capability shape (the fast-restart layer). */\nconst CAPS_KEY = \"yncaCapabilities\";\n\n/** The persisted capability shape, keyed by the device identity that validated it. */\ninterface CachedCapabilities {\n /** SYS MODELNAME at capture time \u2014 freshness key half 1. */\n model: string;\n /** SYS VERSION at capture time \u2014 freshness key half 2. */\n firmware: string;\n /** The captured subunit\u2192function map (values are last-known, used for SHAPE only). */\n subunits: Record<string, Record<string, string>>;\n}\n\n/**\n * Whether a remembered value carries the cached-capabilities shape (API boundary \u2014\n * the persisted probe memory is untrusted storage).\n *\n * @param value the remembered value\n * @returns true when usable\n */\nfunction isCachedCapabilities(value: unknown): value is CachedCapabilities {\n const candidate = value as Partial<CachedCapabilities> | null;\n return (\n typeof candidate === \"object\" &&\n candidate !== null &&\n typeof candidate.model === \"string\" &&\n typeof candidate.firmware === \"string\" &&\n typeof candidate.subunits === \"object\" &&\n candidate.subunits !== null\n );\n}\n\n/**\n * The functions whose presence proves a subunit really serves menus \u2014 the fields a real\n * `LISTINFO=?` answer is made of (RX-A810 reference log). See {@link YncaDeviceController.probeBrowseSubunits}.\n */\nconst LIST_PROOF = /^(LISTLAYER|LISTLAYERNAME|CURRLINE|MAXLINE|LINE[1-8](TXT|ATRIB))$/;\n\n/** The subset of the YNCA client the controller uses (so tests can inject a fake). */\nexport interface YncaClientLike {\n /** Open the connection. */\n connect(): Promise<void>;\n /** Run the init sweep and return the device's capabilities. */\n readCapabilities(gets: Array<{ subunit: string; func: string }>): Promise<YncaCapabilities>;\n /** Send a PUT command. */\n send(subunit: string, func: string, value: string): void;\n /** Send a GET request (the browse driver reads LISTINFO with it). */\n get(subunit: string, func: string): void;\n /** Register a handler for pushed messages. */\n onMessage(handler: (message: { subunit: string; func: string; value: string }) => void): void;\n /** Register the socket-drop handler the supervisor reconnects on. */\n onDrop(handler: (reason?: Error) => void): void;\n /** Register the refusal handler for user commands the device rejects (optional in older tests). */\n onRefusal?(handler: (command: string, verdict: \"restricted\" | \"undefined\") => void): void;\n /** Start the keepalive poll \u2014 called after the init sweep, not on connect. */\n startKeepalive(): void;\n /** Close the connection synchronously. */\n close(): void;\n}\n\nexport type { ControllerLog };\n\n/** The adapter callbacks the controller drives \u2014 narrow, so no adapter mock is needed in tests. */\nexport interface ControllerDeps {\n /** The YNCA client for this device. */\n client: YncaClientLike;\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 log. */\n log: ControllerLog;\n /**\n * Whether a catalog entry's datapoint group is enabled. A disabled group's entries\n * are dropped BEFORE the sweep, so their GETs are never sent (previously only the\n * object/state writes were gated and the answers thrown away). Absent = all enabled.\n */\n isEntryEnabled?(id: string): boolean;\n /**\n * Per-device cache of the AVAIL probe result, held across reconnects and restarts.\n * With a valid cache the probe phase is skipped and the targeted sweep runs directly;\n * a model/firmware mismatch after the sweep invalidates it and re-probes.\n */\n subunitCache?: YncaSubunitCache;\n /**\n * The device's command gate: every line this controller puts on the wire is already\n * paced through it, and its signal is the connection's shutdown flag (a closed gate\n * ends pending waits and stops state writes). Absent in older tests \u2192 no browsing.\n */\n gate?: CommandGate;\n /** Per-device memory for answers that stay constant while the device runs (see ProbeMemory). */\n probeMemory?: ProbeMemory;\n}\n\n/**\n * Drives one YNCA device: connect, init sweep, build the object tree, and route\n * commands both ways. Create-only \u2014 orphan cleanup and legacy migration are\n * separate, gated steps.\n */\nexport class YncaDeviceController implements ConnectionHandle {\n private browseDriver: YncaBrowseDriver | undefined;\n private browseEngine: BrowseEngine | undefined;\n /**\n * Write map filtered to the entries THIS device reported \u2014 claim-with-proof for\n * writes: a command is only sent with a wire function the device answered in the\n * sweep. Until the sweep ran, the unfiltered static map answers.\n */\n private writeMap: Map<string, YncaEntry> | undefined;\n /** The device's scene titles (SCENExNAME), for the recall dropdown, the list state and title writes. */\n private sceneTitles: Array<{ num: number; title: string }> = [];\n /** The tuner's current band (AM/FM/DAB), for the band-dependent frequency/preset writes. */\n private tunerBand = \"\";\n /** Whether the device carries the DAB subunit (its FM half shares the flat tuner ids). */\n private hasDab = false;\n /** The entries THIS device reported \u2014 the per-subunit lookup behind the player routing. */\n private presentEntries: YncaEntry[] = [];\n /** Each zone's currently selected input (INP), for the player routing (v2.0.0). */\n private readonly zoneInputs = new Map<string, string>();\n /** The zones that got a player block (main plus every present ZONEn, when sources exist). */\n private playerZones: string[] = [];\n\n /**\n * @param deviceId the id-safe device id (object-tree path segment)\n * @param deps the client and adapter callbacks\n */\n public constructor(\n private readonly deviceId: string,\n private readonly deps: ControllerDeps,\n ) {}\n\n /**\n * Connect, sweep the device from the catalog, and create its object tree; wire\n * up push updates. The catalog is the single source: it drives the sweep, the\n * device\u2192state read-back and (in handleStateChange) the state\u2192wire encode.\n *\n * @returns true if the device reported capabilities and its tree was created\n */\n public async start(): Promise<boolean> {\n await this.deps.client.connect();\n // Group-filtered catalog: disabled groups are excluded from the sweep AND the objects.\n const catalog = this.deps.isEntryEnabled\n ? YNCA_CATALOG.filter(entry => this.deps.isEntryEnabled!(entry.id))\n : YNCA_CATALOG;\n const resolved = await this.resolveCapabilities(catalog);\n const { capabilities, fromCache } = resolved;\n const present = presentYncaEntries(capabilities, catalog);\n this.writeMap = idToEntry(present);\n this.presentEntries = present;\n // Seed each zone's input from the sweep \u2014 the player routing needs to know what\n // every zone is listening to before the first INP push arrives.\n for (const zone of YNCA_ZONES) {\n const input = capabilities.subunits[zone.subunit]?.INP;\n if (typeof input === \"string\") {\n this.zoneInputs.set(zone.key, input);\n }\n }\n const objects = yncaObjectsFor(capabilities, catalog);\n if (objects.length === 0) {\n this.deps.log.warn(`${this.deviceId}: no capabilities reported \u2014 creating no objects`);\n return false;\n }\n // A user command the device rejects must leave a trace: @RESTRICTED (not allowed /\n // not possible right now) and @UNDEFINED (unknown on this model) were silently\n // dropped before \u2014 the class of invisible failures behind #615.\n this.deps.client.onRefusal?.((command, verdict) =>\n this.deps.log.warn(`${this.deviceId}: device refused \"${command}\" (@${verdict.toUpperCase()})`),\n );\n // The scene titles ride the sweep as SCENExNAME answers; they become the recall\n // dropdown's labels and the one scene.list state (v2.0.0 \u2014 no per-name datapoints).\n const main = capabilities.subunits.MAIN ?? {};\n this.sceneTitles = [];\n for (let n = 1; n <= 12; n++) {\n const title = main[`SCENE${n}NAME`];\n if (typeof title === \"string\" && title.length > 0) {\n this.sceneTitles.push({ num: n, title });\n }\n }\n // Parents before children (channels before their states) \u2014 created in order.\n for (const object of objects) {\n if (object.id === \"scene.recall\" && this.sceneTitles.length > 0) {\n object.common.states = Object.fromEntries(this.sceneTitles.map(scene => [scene.num, scene.title]));\n }\n await this.deps.upsertObject(`${this.deviceId}.${object.id}`, object);\n }\n if (this.sceneTitles.length > 0) {\n await this.deps.upsertObject(`${this.deviceId}.scene.list`, {\n id: \"scene.list\",\n type: \"state\",\n common: { name: \"Scenes (number + title)\", type: \"string\", role: \"json\", read: true, write: false },\n });\n this.deps.setStateAck(`${this.deviceId}.scene.list`, JSON.stringify(this.sceneTitles));\n }\n await this.setupZonePlayers(capabilities, objects);\n // Seed the states with the values read during the init sweep. On the fast path the\n // cached values are last-run leftovers \u2014 the states already hold exactly those, and\n // the background refresh streams the fresh ones in \u2014 so nothing is seeded there.\n if (!fromCache) {\n for (const [subunit, funcs] of Object.entries(capabilities.subunits)) {\n for (const [func, value] of Object.entries(funcs)) {\n const update = yncaStateUpdate({ subunit, func, value }, FUNC_MAP);\n if (update) {\n if (FLAT_PLAYER_ID.test(update.id)) {\n // Player-block values feed only the zones LISTENING to their source\n // (v2.0.0) \u2014 an idle source's leftover metadata must not seed the block.\n this.routePlayerUpdate(subunit, update.id, update.value);\n } else {\n this.deps.setStateAck(`${this.deviceId}.${update.id}`, update.value);\n }\n }\n }\n }\n }\n // The band decides which wire function a tuner.frequency/preset write goes to\n // (v2.0.0 unification) \u2014 seeded from the sweep, kept fresh from the live pushes.\n this.hasDab = capabilities.subunits.DAB !== undefined;\n this.tunerBand = (capabilities.subunits.DAB?.BAND ?? capabilities.subunits.TUN?.BAND ?? \"\").toUpperCase();\n await this.setupBrowse(capabilities);\n this.deps.client.onMessage(message => {\n // The browse driver sees every line first: list lines (LINE1TXT\u2026, LISTINFO\n // bursts, auto-feedback) are not catalogued and would otherwise be dropped.\n this.browseDriver?.handleMessage(message);\n if (message.func === \"BAND\" && (message.subunit === \"TUN\" || message.subunit === \"DAB\")) {\n this.tunerBand = message.value.toUpperCase();\n }\n if (message.func === \"INP\") {\n const zone = YNCA_ZONES.find(z => z.subunit === message.subunit);\n if (zone) {\n this.handleInputSwitch(zone.key, message.value);\n }\n }\n const update = yncaStateUpdate(message, FUNC_MAP);\n if (update) {\n if (FLAT_PLAYER_ID.test(update.id)) {\n this.routePlayerUpdate(message.subunit, update.id, update.value);\n } else {\n this.deps.setStateAck(`${this.deviceId}.${update.id}`, update.value);\n }\n }\n });\n // Start the keepalive only now the (fast-path) init is done; on the slow path the\n // sweep already ran, on the fast path the background refresh paces itself through\n // the same gate, so the 30 s poll cannot break the spacing either way.\n this.deps.client.startKeepalive();\n if (fromCache) {\n // The whole point of the persisted capability layer: the tree stood in ~1 s from\n // the remembered shape (validated by the LIVE identity answer above), and the\n // 15\u201320 s question round now runs behind the ready line as a pure value refresh \u2014\n // its answers stream into the states through the live handler just registered.\n void this.refreshInBackground(catalog);\n }\n // The adapter logs one combined \"ready\" line across all transports; this per-transport line\n // stays at debug for diagnostics.\n this.deps.log.debug(`${this.deviceId}: ${capabilities.model || \"device\"} ready (YNCA)`);\n return true;\n }\n\n /**\n * The device's capabilities \u2014 from the persisted fast-restart layer when the LIVE\n * identity (model + firmware, two paced reads, ~0.2 s) matches what the layer was\n * captured from, else from the full two-pass sweep. The identity read doubles as the\n * liveness proof the ready line rests on: a cached shape alone must never present a\n * dead device as connected (the v1.5.0 honesty rule).\n *\n * @param catalog the (group-filtered) catalog\n * @returns the capabilities and whether they came from the persisted layer\n */\n private async resolveCapabilities(\n catalog: readonly YncaEntry[],\n ): Promise<{ capabilities: YncaCapabilities; fromCache: boolean }> {\n const identity = await this.deps.client.readCapabilities([\n { subunit: \"SYS\", func: \"MODELNAME\" },\n { subunit: \"SYS\", func: \"VERSION\" },\n ]);\n const model = identity.model;\n const firmware = identity.subunits.SYS?.VERSION ?? \"\";\n const remembered = this.deps.probeMemory?.remembered(CAPS_KEY);\n if (model && isCachedCapabilities(remembered) && remembered.model === model && remembered.firmware === firmware) {\n return { capabilities: { model, subunits: remembered.subunits }, fromCache: true };\n }\n if (remembered !== undefined) {\n // A different (or updated) device behind this address: its remembered YNCA\n // answers are void. The other transports guard their own portions.\n this.deps.probeMemory?.drop(key => key === CAPS_KEY || key === STATIC_KEY);\n }\n const capabilities = await this.sweepDevice(catalog, model, firmware);\n if (capabilities.model) {\n this.deps.probeMemory?.set(CAPS_KEY, {\n model: capabilities.model,\n firmware: capabilities.subunits.SYS?.VERSION ?? firmware,\n subunits: capabilities.subunits,\n } satisfies CachedCapabilities);\n }\n return { capabilities, fromCache: false };\n }\n\n /**\n * The fast path's second half: re-ask every catalogued function of the present\n * subunits \u2014 the answers stream into the states through the live message handler,\n * so current values arrive within the usual sweep time WITHOUT having gated the\n * ready line. Completion refreshes the persisted layers (capabilities, statics)\n * and the write map; a SHAPE change (a function newly answered) is only persisted \u2014\n * its object appears on the next start, because the unified tree is coordinated\n * once per connect and a late upsert would not materialize.\n *\n * @param catalog the (group-filtered) catalog\n */\n private async refreshInBackground(catalog: readonly YncaEntry[]): Promise<void> {\n try {\n const cached = this.deps.subunitCache?.get();\n const gets = sweepGets(catalog).filter(\n get => get.subunit === \"SYS\" || !cached || cached.subunits.includes(get.subunit),\n );\n const fresh = await this.deps.client.readCapabilities(gets);\n if (!fresh.model) {\n // The refresh ran into a drop \u2014 the supervisor handles the reconnect.\n return;\n }\n const statics: Record<string, Record<string, string>> = {};\n for (const [subunit, funcs] of Object.entries(fresh.subunits)) {\n for (const [func, value] of Object.entries(funcs)) {\n if (STATIC_FUNC.test(func)) {\n (statics[subunit] ??= {})[func] = value;\n }\n }\n }\n this.deps.probeMemory?.set(STATIC_KEY, statics);\n this.deps.probeMemory?.set(CAPS_KEY, {\n model: fresh.model,\n firmware: fresh.subunits.SYS?.VERSION ?? \"\",\n subunits: fresh.subunits,\n } satisfies CachedCapabilities);\n this.presentEntries = presentYncaEntries(fresh, catalog);\n this.writeMap = idToEntry(this.presentEntries);\n this.deps.log.debug(`${this.deviceId}: background value refresh done (YNCA)`);\n } catch (e) {\n this.deps.log.debug(`${this.deviceId}: background value refresh failed: ${String(e)}`);\n }\n }\n\n /**\n * Read the device's capabilities with the two-pass sweep. Pass 1 probes each\n * catalogued subunit with `AVAIL=?` (~2 s); pass 2 sweeps only the subunits that\n * answered, plus SYS (which never answers AVAIL) \u2014 on a typical receiver that\n * saves a third or more of the ~39 s blind sweep. A cached probe result (per\n * device, surviving reconnects and restarts) skips pass 1 entirely; a device\n * whose model or firmware no longer matches the cache re-probes. A device that\n * answers no AVAIL at all falls back to the full blind sweep, so an unknown\n * firmware loses speed, never features.\n *\n * @param catalog the (group-filtered) catalog whose functions to sweep\n * @param model the live-read SYS model name (from resolveCapabilities)\n * @param firmware the live-read SYS firmware version\n * @returns the assembled capabilities\n */\n private async sweepDevice(catalog: readonly YncaEntry[], model: string, firmware: string): Promise<YncaCapabilities> {\n const cached = this.deps.subunitCache?.get();\n if (cached) {\n // The device's IDENTITY was already read by resolveCapabilities (two reads,\n // ~0.2 s) \u2014 checking it BEFORE sweeping is what keeps a stale cache from costing\n // a full targeted sweep, then the probe, then a second sweep (~40 s).\n if (model === cached.model && firmware === cached.firmware) {\n return await this.targetedSweep(catalog, new Set(cached.subunits));\n }\n // The device behind this IP changed (swap or firmware update) \u2014 re-probe.\n this.deps.log.debug(`${this.deviceId}: cached subunit set is stale (model/firmware changed), re-probing`);\n this.deps.subunitCache?.clear();\n }\n const probe = await this.deps.client.readCapabilities(AVAIL_PROBE);\n const present = new Set(Object.keys(probe.subunits));\n if (present.size === 0) {\n // Device ignores AVAIL \u2014 sweep blind so no function is lost.\n return await this.deps.client.readCapabilities(sweepGets(catalog));\n }\n const capabilities = await this.targetedSweep(catalog, present);\n if (capabilities.model) {\n this.deps.subunitCache?.set({\n subunits: [...present],\n model: capabilities.model,\n firmware: capabilities.subunits.SYS?.VERSION ?? \"\",\n });\n }\n return capabilities;\n }\n\n /**\n * Sweep only the present subunits' functions (SYS always included \u2014 it answers no\n * AVAIL but carries model/firmware/master power).\n *\n * @param catalog the (group-filtered) catalog whose functions to sweep\n * @param present the subunits that answered the AVAIL probe\n * @returns the assembled capabilities\n */\n private async targetedSweep(catalog: readonly YncaEntry[], present: ReadonlySet<string>): Promise<YncaCapabilities> {\n const gets = sweepGets(catalog).filter(get => get.subunit === \"SYS\" || present.has(get.subunit));\n const remembered = this.deps.probeMemory?.remembered<Record<string, Record<string, string>>>(STATIC_KEY);\n // Second connect onwards: skip those reads and put the remembered answers back in, so\n // the objects are built exactly as if the device had answered them again.\n const capabilities = await this.deps.client.readCapabilities(\n remembered ? gets.filter(get => !STATIC_FUNC.test(get.func)) : gets,\n );\n if (remembered) {\n for (const [subunit, funcs] of Object.entries(remembered)) {\n capabilities.subunits[subunit] = { ...funcs, ...capabilities.subunits[subunit] };\n }\n return capabilities;\n }\n const statics: Record<string, Record<string, string>> = {};\n for (const [subunit, funcs] of Object.entries(capabilities.subunits)) {\n for (const [func, value] of Object.entries(funcs)) {\n if (STATIC_FUNC.test(func)) {\n (statics[subunit] ??= {})[func] = value;\n }\n }\n }\n this.deps.probeMemory?.set(STATIC_KEY, statics);\n return capabilities;\n }\n\n /**\n * Handle a state change: a user write (ack false) becomes a YNCA command; an\n * acked change (the device's own echo) is ignored to avoid a resend loop.\n *\n * @param fullStateId the full state id (device id + \".\" + state)\n * @param ack whether the change is acked (device-originated)\n * @param value the new value\n */\n public handleStateChange(fullStateId: string, ack: boolean, value: unknown): void {\n if (ack) {\n return;\n }\n const prefix = `${this.deviceId}.`;\n if (!fullStateId.startsWith(prefix)) {\n return;\n }\n const stateId = fullStateId.slice(prefix.length);\n if (stateId.startsWith(\"player.browse.\")) {\n this.browseEngine?.handleWrite(stateId, value);\n return;\n }\n // A scene TITLE is as valid a recall write as its number (\"Movie Viewing\" \u2192 1).\n if (stateId === \"scene.recall\" && typeof value === \"string\" && !/^\\d+$/.test(value.trim())) {\n const needle = value.trim().toLowerCase();\n const match = this.sceneTitles.find(scene => scene.title.toLowerCase() === needle);\n if (match === undefined) {\n return;\n }\n value = match.num;\n }\n // The unified player writes go to the subunit the ZONE is listening to (v2.0.0) \u2014\n // routed here, BEFORE the generic path.\n const playerWrite = /^(?:multiroom\\.(zone[234])\\.)?player\\.(playback|repeat|shuffle|next|prev)$/.exec(stateId);\n if (playerWrite) {\n this.handlePlayerWrite(playerWrite[1] ?? \"main\", `player.${playerWrite[2]}`, value);\n return;\n }\n // The unified tuner writes are band-dependent (v2.0.0) and routed here, BEFORE the\n // generic path \u2014 one state, the right wire function for the active band.\n if (this.handleTunerWrite(stateId, value)) {\n return;\n }\n const triple = yncaCommand(stateId, value, this.writeMap ?? ID_MAP);\n if (triple) {\n this.deps.client.send(triple.subunit, triple.func, triple.value);\n }\n }\n\n /**\n * Create the per-zone player mirrors (v2.0.0): every present ZONEn gets its own\n * \"now playing\" block under its multiroom folder \u2014 zones can play different\n * sources, one shared block could not show that. The defs are clones of the flat\n * block the catalog built for the main zone, plus the `source` display each zone\n * (main included) gets.\n *\n * @param capabilities the device's swept capabilities\n * @param objects the main tree's object definitions (source of the block's shape)\n */\n private async setupZonePlayers(capabilities: YncaCapabilities, objects: ObjectDef[]): Promise<void> {\n const playerObjects = objects.filter(\n object => object.id === \"player\" || (object.type === \"state\" && FLAT_PLAYER_ID.test(object.id)),\n );\n if (!playerObjects.some(object => object.type === \"state\")) {\n this.playerZones = [];\n return;\n }\n const sourceDef = (id: string): ObjectDef => ({\n id,\n type: \"state\",\n common: { name: \"Playing source\", type: \"string\", role: \"text\", read: true, write: false },\n });\n await this.deps.upsertObject(`${this.deviceId}.player.source`, sourceDef(\"player.source\"));\n this.playerZones = [\"main\"];\n for (const zone of YNCA_ZONES) {\n if (zone.key === \"main\" || capabilities.subunits[zone.subunit] === undefined) {\n continue;\n }\n this.playerZones.push(zone.key);\n for (const object of playerObjects) {\n const id = `${zone.prefix}${object.id}`;\n await this.deps.upsertObject(`${this.deviceId}.${id}`, { ...object, id });\n }\n await this.deps.upsertObject(\n `${this.deviceId}.${zone.prefix}player.source`,\n sourceDef(`${zone.prefix}player.source`),\n );\n }\n // Seed the source display: a device already playing at adapter start must not\n // show an empty source until its first input switch (2.0.0 review finding \u2014 the\n // sweep seeds the metadata, but `player.source` is controller-derived).\n for (const zone of YNCA_ZONES) {\n if (this.playerZones.includes(zone.key)) {\n const input = this.zoneInputs.get(zone.key);\n const playing = playerSubunitForInput(input) !== undefined;\n this.deps.setStateAck(\n `${this.deviceId}.${zone.prefix}player.source`,\n playing && input !== undefined ? input : \"\",\n );\n }\n }\n }\n\n /**\n * Feed one player-subunit value into the block of every zone listening to that\n * source \u2014 and only those: an idle source's answer (the sweep reads them all)\n * must not overwrite what the active source shows.\n *\n * @param subunit the source subunit the value came from\n * @param id the flat player state id\n * @param value the decoded value\n */\n private routePlayerUpdate(subunit: string, id: string, value: boolean | number | string): void {\n for (const zone of YNCA_ZONES) {\n if (!this.playerZones.includes(zone.key)) {\n continue;\n }\n if (playerSubunitForInput(this.zoneInputs.get(zone.key)) === subunit) {\n this.deps.setStateAck(`${this.deviceId}.${zone.prefix}${id}`, value);\n }\n }\n }\n\n /**\n * Track a zone's input switch: remember the input, and when the zone changed its\n * player source, clear the block (stale metadata must not linger) and ask the new\n * source for its current state \u2014 YNCA pushes changes, but a source that was already\n * playing has nothing new to push.\n *\n * @param zoneKey the zone (`main`, `zone2`, \u2026)\n * @param input the new INP value\n */\n private handleInputSwitch(zoneKey: string, input: string): void {\n const before = playerSubunitForInput(this.zoneInputs.get(zoneKey));\n this.zoneInputs.set(zoneKey, input);\n const after = playerSubunitForInput(input);\n if (before === after || !this.playerZones.includes(zoneKey)) {\n return;\n }\n const zone = YNCA_ZONES.find(z => z.key === zoneKey);\n if (!zone) {\n return;\n }\n const presentFlat = new Set(\n this.presentEntries.filter(entry => FLAT_PLAYER_ID.test(entry.id)).map(entry => entry.id),\n );\n for (const clear of YNCA_PLAYER_CLEAR) {\n if (presentFlat.has(clear.id)) {\n this.deps.setStateAck(`${this.deviceId}.${zone.prefix}${clear.id}`, clear.value);\n }\n }\n this.deps.setStateAck(`${this.deviceId}.${zone.prefix}player.source`, after === undefined ? \"\" : input);\n if (after !== undefined) {\n // Fresh reads for the newly selected source, streamed back through the live handler.\n const funcs = new Set<string>();\n for (const entry of this.presentEntries) {\n if (entry.subunit === after && FLAT_PLAYER_ID.test(entry.id) && !entry.writeOnly) {\n funcs.add(entry.readFunc ?? entry.func);\n }\n }\n for (const func of funcs) {\n this.deps.client.get(after, func);\n }\n }\n }\n\n /**\n * Route a unified player write (playback/repeat/shuffle/next/prev) to the source\n * subunit the ZONE is listening to \u2014 with the entry that subunit itself reported\n * (claim-with-proof, like every other write).\n *\n * @param zoneKey the zone the write belongs to\n * @param flatId the flat player state id\n * @param value the written value\n */\n private handlePlayerWrite(zoneKey: string, flatId: string, value: unknown): void {\n const subunit = playerSubunitForInput(this.zoneInputs.get(zoneKey));\n if (subunit === undefined) {\n this.deps.log.debug(`${this.deviceId}: ${flatId} ignored \u2014 ${zoneKey} is not playing a media source`);\n return;\n }\n const entry = this.presentEntries.find(e => e.id === flatId && e.subunit === subunit);\n if (entry === undefined) {\n this.deps.log.debug(`${this.deviceId}: ${flatId} ignored \u2014 ${subunit} did not report it`);\n return;\n }\n const triple = yncaCommand(flatId, value, new Map([[flatId, entry]]));\n if (triple) {\n this.deps.client.send(triple.subunit, triple.func, triple.value);\n }\n }\n\n /**\n * Route the band-dependent tuner writes (v2.0.0 unification): ONE frequency state\n * in kHz and ONE preset state, sent to the wire function of the ACTIVE band \u2014\n * AM/FM on the classic TUN subunit, FM/DAB on the DAB subunit (whose FM half\n * shares the flat ids). A DAB frequency write is dropped: DAB tunes by service,\n * the device has no frequency command there.\n *\n * @param stateId the state id relative to the device\n * @param value the written value\n * @returns true when the id was a band-routed tuner write (handled here)\n */\n private handleTunerWrite(stateId: string, value: unknown): boolean {\n if (stateId === \"tuner.frequency\") {\n const khz = Number(value);\n if (!Number.isFinite(khz)) {\n return true;\n }\n if (this.hasDab) {\n if (this.tunerBand === \"FM\") {\n this.deps.client.send(\"DAB\", \"FMFREQ\", formatWireNumber(khz / 1000, 2));\n } else {\n this.deps.log.debug(`${this.deviceId}: DAB tunes by service \u2014 frequency write ignored`);\n }\n return true;\n }\n if (this.tunerBand === \"AM\") {\n this.deps.client.send(\"TUN\", \"AMFREQ\", String(Math.round(khz)));\n } else {\n this.deps.client.send(\"TUN\", \"FMFREQ\", formatWireNumber(khz / 1000, 2));\n }\n return true;\n }\n if (stateId === \"tuner.preset\" && this.hasDab) {\n const slot = Math.round(Number(value));\n if (Number.isFinite(slot) && slot >= 1) {\n this.deps.client.send(\"DAB\", this.tunerBand === \"DAB\" ? \"DABPRESET\" : \"FMPRESET\", String(slot));\n }\n return true;\n }\n return false;\n }\n\n /**\n * Create the browsing surface (#613) when the device reports a browsable media\n * subunit: the official YNCA list vocabulary (LISTINFO/LISTSEL/LISTPAGE/LISTCURSOR)\n * drives an 8-line window under `player.browse.*`. Skipped without a delay dep\n * (older tests) and when the playback group is switched off.\n *\n * @param capabilities the device's swept capabilities\n */\n private async setupBrowse(capabilities: YncaCapabilities): Promise<void> {\n const gate = this.deps.gate;\n if (!gate || this.deps.isEntryEnabled?.(\"player.browse.source\") === false) {\n return;\n }\n const delay = (ms: number): Promise<void> => gate.delay(ms);\n const present = await this.probeBrowseSubunits(capabilities);\n if (present.size === 0) {\n // Leaving the states uncreated is what hands browsing to another transport: the owner\n // policy ranks by modernity (yxc > ynca > xml), so an unproven YNCA claim would beat a\n // PROVEN xml one and the user would get an empty menu on a device that can browse over\n // XML \u2014 exactly issue #613's RX-V473.\n this.deps.log.debug(`${this.deviceId}: no YNCA source answers LISTINFO \u2014 leaving menus to another transport`);\n return;\n }\n const driver = new YncaBrowseDriver(this.deps.client, present, delay);\n this.browseEngine = await createBrowseSurface(driver, this.deviceId, {\n upsertObject: this.deps.upsertObject,\n emit: (id, value) => this.deps.setStateAck(`${this.deviceId}.${id}`, value),\n log: this.deps.log,\n delay,\n });\n if (this.browseEngine) {\n this.browseDriver = driver;\n }\n }\n\n /**\n * Which browsable subunits actually SERVE menus, proven by asking them.\n *\n * Carrying the subunit is NOT proof: the RX-A810 reference log answers `@SERVER:LISTINFO=?`\n * with `@UNDEFINED` while NETRADIO/PC/USB on the very same device return a full window. The\n * XML driver has always probed (`List_Info` \u2192 `<Menu_Status>`); YNCA claimed the states on\n * presence alone and, ranking higher, silently displaced the transport that could deliver.\n *\n * @param capabilities the device's swept capabilities\n * @returns the subunits that answered with list data\n */\n private async probeBrowseSubunits(capabilities: YncaCapabilities): Promise<ReadonlySet<string>> {\n const candidates = YNCA_BROWSE_SOURCES.filter(source => source.subunit in capabilities.subunits);\n if (candidates.length === 0) {\n return new Set();\n }\n // A receiver in standby answers @RESTRICTED for its media subunits, which is\n // indistinguishable from \"cannot browse\" and would strip the menus off a device that\n // serves them perfectly once it is on. Nobody browses a sleeping receiver, so keep the\n // claim and let the next connect \u2014 with the device awake \u2014 do the real probe.\n if (capabilities.subunits.MAIN?.PWR !== \"On\") {\n return new Set(candidates.map(source => source.subunit));\n }\n const answer = await this.deps.client.readCapabilities(\n candidates.map(source => ({ subunit: source.subunit, func: \"LISTINFO\" })),\n );\n // Only a real list answer counts. Both refusals \u2014 `@UNDEFINED` (function unknown) and\n // `@RESTRICTED` (source not usable right now) \u2014 carry no subunit, so they cannot be\n // attributed to one request; it is the ABSENCE of an answer that excludes a subunit.\n return new Set(\n candidates\n .map(source => source.subunit)\n .filter(subunit => Object.keys(answer.subunits[subunit] ?? {}).some(func => LIST_PROOF.test(func))),\n );\n }\n\n /**\n * Register the supervisor's drop handler \u2014 delegated to the client's socket drop,\n * which is YNCA's genuine connection-lost signal.\n *\n * @param cb invoked once when the connection drops, with the reason if known\n */\n public onDrop(cb: (reason?: Error) => void): void {\n this.deps.client.onDrop(cb);\n }\n\n /** Close the client. Synchronous \u2014 safe to call from onUnload. */\n public close(): void {\n this.browseEngine?.close();\n this.browseDriver?.close();\n this.deps.client.close();\n }\n}\n"],
5
- "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AACA,0BAAiC;AAGjC,qBAWO;AAKP,qBAAoC;AACpC,gCAAsD;AAMtD,MAAM,eAAW,4BAAY,2BAAY;AACzC,MAAM,aAAS,0BAAU,2BAAY;AACrC,MAAM,kBAAc,0BAAU,2BAAY;AAU1C,MAAM,cAAc;AAGpB,MAAM,aAAsE;AAAA,EAC1E,EAAE,KAAK,QAAQ,SAAS,QAAQ,QAAQ,GAAG;AAAA,EAC3C,EAAE,KAAK,SAAS,SAAS,SAAS,QAAQ,mBAAmB;AAAA,EAC7D,EAAE,KAAK,SAAS,SAAS,SAAS,QAAQ,mBAAmB;AAAA,EAC7D,EAAE,KAAK,SAAS,SAAS,SAAS,QAAQ,mBAAmB;AAC/D;AAOA,MAAM,iBAAyC;AAAA,EAC7C,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,UAAU;AAAA,EACV,UAAU;AAAA,EACV,SAAS;AAAA,EACT,WAAW;AAAA,EACX,IAAI;AAAA,EACJ,eAAe;AAAA,EACf,MAAM;AAAA,EACN,SAAS;AACX;AASA,SAAS,sBAAsB,OAA+C;AAC5E,MAAI,OAAO,UAAU,YAAY,MAAM,WAAW,GAAG;AACnD,WAAO;AAAA,EACT;AACA,SAAO,eAAe,MAAM,YAAY,EAAE,QAAQ,cAAc,EAAE,CAAC;AACrE;AASA,MAAM,iBAAiB;AAOvB,MAAM,oBAA6E;AAAA,EACjF,EAAE,IAAI,mBAAmB,OAAO,EAAE;AAAA,EAClC,EAAE,IAAI,iBAAiB,OAAO,GAAG;AAAA,EACjC,EAAE,IAAI,gBAAgB,OAAO,GAAG;AAAA,EAChC,EAAE,IAAI,gBAAgB,OAAO,GAAG;AAAA,EAChC,EAAE,IAAI,kBAAkB,OAAO,GAAG;AAAA,EAClC,EAAE,IAAI,sBAAsB,OAAO,GAAG;AAAA,EACtC,EAAE,IAAI,sBAAsB,OAAO,GAAG;AAAA,EACtC,EAAE,IAAI,oBAAoB,OAAO,GAAG;AAAA,EACpC,EAAE,IAAI,iBAAiB,OAAO,EAAE;AAAA,EAChC,EAAE,IAAI,kBAAkB,OAAO,MAAM;AACvC;AAGA,MAAM,aAAa;AAGnB,MAAM,WAAW;AAmBjB,SAAS,qBAAqB,OAA6C;AACzE,QAAM,YAAY;AAClB,SACE,OAAO,cAAc,YACrB,cAAc,QACd,OAAO,UAAU,UAAU,YAC3B,OAAO,UAAU,aAAa,YAC9B,OAAO,UAAU,aAAa,YAC9B,UAAU,aAAa;AAE3B;AAMA,MAAM,aAAa;AA+DZ,MAAM,qBAAiD;AAAA;AAAA;AAAA;AAAA;AAAA,EA0BrD,YACY,UACA,MACjB;AAFiB;AACA;AAAA,EAChB;AAAA,EA5BK;AAAA,EACA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA;AAAA;AAAA,EAEA,cAAqD,CAAC;AAAA;AAAA,EAEtD,YAAY;AAAA;AAAA,EAEZ,SAAS;AAAA;AAAA,EAET,iBAA8B,CAAC;AAAA;AAAA,EAEtB,aAAa,oBAAI,IAAoB;AAAA;AAAA,EAE9C,cAAwB,CAAC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAkBjC,MAAa,QAA0B;AAhQzC;AAiQI,UAAM,KAAK,KAAK,OAAO,QAAQ;AAE/B,UAAM,UAAU,KAAK,KAAK,iBACtB,4BAAa,OAAO,WAAS,KAAK,KAAK,eAAgB,MAAM,EAAE,CAAC,IAChE;AACJ,UAAM,WAAW,MAAM,KAAK,oBAAoB,OAAO;AACvD,UAAM,EAAE,cAAc,UAAU,IAAI;AACpC,UAAM,cAAU,mCAAmB,cAAc,OAAO;AACxD,SAAK,eAAW,0BAAU,OAAO;AACjC,SAAK,iBAAiB;AAGtB,eAAW,QAAQ,YAAY;AAC7B,YAAM,SAAQ,kBAAa,SAAS,KAAK,OAAO,MAAlC,mBAAqC;AACnD,UAAI,OAAO,UAAU,UAAU;AAC7B,aAAK,WAAW,IAAI,KAAK,KAAK,KAAK;AAAA,MACrC;AAAA,IACF;AACA,UAAM,cAAU,+BAAe,cAAc,OAAO;AACpD,QAAI,QAAQ,WAAW,GAAG;AACxB,WAAK,KAAK,IAAI,KAAK,GAAG,KAAK,QAAQ,uDAAkD;AACrF,aAAO;AAAA,IACT;AAIA,qBAAK,KAAK,QAAO,cAAjB;AAAA;AAAA,MAA6B,CAAC,SAAS,YACrC,KAAK,KAAK,IAAI,KAAK,GAAG,KAAK,QAAQ,qBAAqB,OAAO,OAAO,QAAQ,YAAY,CAAC,GAAG;AAAA;AAIhG,UAAM,QAAO,kBAAa,SAAS,SAAtB,YAA8B,CAAC;AAC5C,SAAK,cAAc,CAAC;AACpB,aAAS,IAAI,GAAG,KAAK,IAAI,KAAK;AAC5B,YAAM,QAAQ,KAAK,QAAQ,CAAC,MAAM;AAClC,UAAI,OAAO,UAAU,YAAY,MAAM,SAAS,GAAG;AACjD,aAAK,YAAY,KAAK,EAAE,KAAK,GAAG,MAAM,CAAC;AAAA,MACzC;AAAA,IACF;AAEA,eAAW,UAAU,SAAS;AAC5B,UAAI,OAAO,OAAO,kBAAkB,KAAK,YAAY,SAAS,GAAG;AAC/D,eAAO,OAAO,SAAS,OAAO,YAAY,KAAK,YAAY,IAAI,WAAS,CAAC,MAAM,KAAK,MAAM,KAAK,CAAC,CAAC;AAAA,MACnG;AACA,YAAM,KAAK,KAAK,aAAa,GAAG,KAAK,QAAQ,IAAI,OAAO,EAAE,IAAI,MAAM;AAAA,IACtE;AACA,QAAI,KAAK,YAAY,SAAS,GAAG;AAC/B,YAAM,KAAK,KAAK,aAAa,GAAG,KAAK,QAAQ,eAAe;AAAA,QAC1D,IAAI;AAAA,QACJ,MAAM;AAAA,QACN,QAAQ,EAAE,MAAM,2BAA2B,MAAM,UAAU,MAAM,QAAQ,MAAM,MAAM,OAAO,MAAM;AAAA,MACpG,CAAC;AACD,WAAK,KAAK,YAAY,GAAG,KAAK,QAAQ,eAAe,KAAK,UAAU,KAAK,WAAW,CAAC;AAAA,IACvF;AACA,UAAM,KAAK,iBAAiB,cAAc,OAAO;AAIjD,QAAI,CAAC,WAAW;AACd,iBAAW,CAAC,SAAS,KAAK,KAAK,OAAO,QAAQ,aAAa,QAAQ,GAAG;AACpE,mBAAW,CAAC,MAAM,KAAK,KAAK,OAAO,QAAQ,KAAK,GAAG;AACjD,gBAAM,aAAS,gCAAgB,EAAE,SAAS,MAAM,MAAM,GAAG,QAAQ;AACjE,cAAI,QAAQ;AACV,gBAAI,eAAe,KAAK,OAAO,EAAE,GAAG;AAGlC,mBAAK,kBAAkB,SAAS,OAAO,IAAI,OAAO,KAAK;AAAA,YACzD,OAAO;AACL,mBAAK,KAAK,YAAY,GAAG,KAAK,QAAQ,IAAI,OAAO,EAAE,IAAI,OAAO,KAAK;AAAA,YACrE;AAAA,UACF;AAAA,QACF;AAAA,MACF;AAAA,IACF;AAGA,SAAK,SAAS,aAAa,SAAS,QAAQ;AAC5C,SAAK,cAAa,8BAAa,SAAS,QAAtB,mBAA2B,SAA3B,aAAmC,kBAAa,SAAS,QAAtB,mBAA2B,SAA9D,YAAsE,IAAI,YAAY;AACxG,UAAM,KAAK,YAAY,YAAY;AACnC,SAAK,KAAK,OAAO,UAAU,aAAW;AAhV1C,UAAAA;AAmVM,OAAAA,MAAA,KAAK,iBAAL,gBAAAA,IAAmB,cAAc;AACjC,UAAI,QAAQ,SAAS,WAAW,QAAQ,YAAY,SAAS,QAAQ,YAAY,QAAQ;AACvF,aAAK,YAAY,QAAQ,MAAM,YAAY;AAAA,MAC7C;AACA,UAAI,QAAQ,SAAS,OAAO;AAC1B,cAAM,OAAO,WAAW,KAAK,OAAK,EAAE,YAAY,QAAQ,OAAO;AAC/D,YAAI,MAAM;AACR,eAAK,kBAAkB,KAAK,KAAK,QAAQ,KAAK;AAAA,QAChD;AAAA,MACF;AACA,YAAM,aAAS,gCAAgB,SAAS,QAAQ;AAChD,UAAI,QAAQ;AACV,YAAI,eAAe,KAAK,OAAO,EAAE,GAAG;AAClC,eAAK,kBAAkB,QAAQ,SAAS,OAAO,IAAI,OAAO,KAAK;AAAA,QACjE,OAAO;AACL,eAAK,KAAK,YAAY,GAAG,KAAK,QAAQ,IAAI,OAAO,EAAE,IAAI,OAAO,KAAK;AAAA,QACrE;AAAA,MACF;AAAA,IACF,CAAC;AAID,SAAK,KAAK,OAAO,eAAe;AAChC,QAAI,WAAW;AAKb,WAAK,KAAK,oBAAoB,OAAO;AAAA,IACvC;AAGA,SAAK,KAAK,IAAI,MAAM,GAAG,KAAK,QAAQ,KAAK,aAAa,SAAS,QAAQ,eAAe;AACtF,WAAO;AAAA,EACT;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAYA,MAAc,oBACZ,SACiE;AAnYrE;AAoYI,UAAM,WAAW,MAAM,KAAK,KAAK,OAAO,iBAAiB;AAAA,MACvD,EAAE,SAAS,OAAO,MAAM,YAAY;AAAA,MACpC,EAAE,SAAS,OAAO,MAAM,UAAU;AAAA,IACpC,CAAC;AACD,UAAM,QAAQ,SAAS;AACvB,UAAM,YAAW,oBAAS,SAAS,QAAlB,mBAAuB,YAAvB,YAAkC;AACnD,UAAM,cAAa,UAAK,KAAK,gBAAV,mBAAuB,WAAW;AACrD,QAAI,SAAS,qBAAqB,UAAU,KAAK,WAAW,UAAU,SAAS,WAAW,aAAa,UAAU;AAC/G,aAAO,EAAE,cAAc,EAAE,OAAO,UAAU,WAAW,SAAS,GAAG,WAAW,KAAK;AAAA,IACnF;AACA,QAAI,eAAe,QAAW;AAG5B,iBAAK,KAAK,gBAAV,mBAAuB,KAAK,SAAO,QAAQ,YAAY,QAAQ;AAAA,IACjE;AACA,UAAM,eAAe,MAAM,KAAK,YAAY,SAAS,OAAO,QAAQ;AACpE,QAAI,aAAa,OAAO;AACtB,iBAAK,KAAK,gBAAV,mBAAuB,IAAI,UAAU;AAAA,QACnC,OAAO,aAAa;AAAA,QACpB,WAAU,wBAAa,SAAS,QAAtB,mBAA2B,YAA3B,YAAsC;AAAA,QAChD,UAAU,aAAa;AAAA,MACzB;AAAA,IACF;AACA,WAAO,EAAE,cAAc,WAAW,MAAM;AAAA,EAC1C;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAaA,MAAc,oBAAoB,SAA8C;AAzalF;AA0aI,QAAI;AACF,YAAM,UAAS,UAAK,KAAK,iBAAV,mBAAwB;AACvC,YAAM,WAAO,0BAAU,OAAO,EAAE;AAAA,QAC9B,SAAO,IAAI,YAAY,SAAS,CAAC,UAAU,OAAO,SAAS,SAAS,IAAI,OAAO;AAAA,MACjF;AACA,YAAM,QAAQ,MAAM,KAAK,KAAK,OAAO,iBAAiB,IAAI;AAC1D,UAAI,CAAC,MAAM,OAAO;AAEhB;AAAA,MACF;AACA,YAAM,UAAkD,CAAC;AACzD,iBAAW,CAAC,SAAS,KAAK,KAAK,OAAO,QAAQ,MAAM,QAAQ,GAAG;AAC7D,mBAAW,CAAC,MAAM,KAAK,KAAK,OAAO,QAAQ,KAAK,GAAG;AACjD,cAAI,YAAY,KAAK,IAAI,GAAG;AAC1B,cAAC,yDAAqB,CAAC,GAAG,IAAI,IAAI;AAAA,UACpC;AAAA,QACF;AAAA,MACF;AACA,iBAAK,KAAK,gBAAV,mBAAuB,IAAI,YAAY;AACvC,iBAAK,KAAK,gBAAV,mBAAuB,IAAI,UAAU;AAAA,QACnC,OAAO,MAAM;AAAA,QACb,WAAU,iBAAM,SAAS,QAAf,mBAAoB,YAApB,YAA+B;AAAA,QACzC,UAAU,MAAM;AAAA,MAClB;AACA,WAAK,qBAAiB,mCAAmB,OAAO,OAAO;AACvD,WAAK,eAAW,0BAAU,KAAK,cAAc;AAC7C,WAAK,KAAK,IAAI,MAAM,GAAG,KAAK,QAAQ,wCAAwC;AAAA,IAC9E,SAAS,GAAG;AACV,WAAK,KAAK,IAAI,MAAM,GAAG,KAAK,QAAQ,sCAAsC,OAAO,CAAC,CAAC,EAAE;AAAA,IACvF;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAiBA,MAAc,YAAY,SAA+B,OAAe,UAA6C;AAzdvH;AA0dI,UAAM,UAAS,UAAK,KAAK,iBAAV,mBAAwB;AACvC,QAAI,QAAQ;AAIV,UAAI,UAAU,OAAO,SAAS,aAAa,OAAO,UAAU;AAC1D,eAAO,MAAM,KAAK,cAAc,SAAS,IAAI,IAAI,OAAO,QAAQ,CAAC;AAAA,MACnE;AAEA,WAAK,KAAK,IAAI,MAAM,GAAG,KAAK,QAAQ,oEAAoE;AACxG,iBAAK,KAAK,iBAAV,mBAAwB;AAAA,IAC1B;AACA,UAAM,QAAQ,MAAM,KAAK,KAAK,OAAO,iBAAiB,WAAW;AACjE,UAAM,UAAU,IAAI,IAAI,OAAO,KAAK,MAAM,QAAQ,CAAC;AACnD,QAAI,QAAQ,SAAS,GAAG;AAEtB,aAAO,MAAM,KAAK,KAAK,OAAO,qBAAiB,0BAAU,OAAO,CAAC;AAAA,IACnE;AACA,UAAM,eAAe,MAAM,KAAK,cAAc,SAAS,OAAO;AAC9D,QAAI,aAAa,OAAO;AACtB,iBAAK,KAAK,iBAAV,mBAAwB,IAAI;AAAA,QAC1B,UAAU,CAAC,GAAG,OAAO;AAAA,QACrB,OAAO,aAAa;AAAA,QACpB,WAAU,wBAAa,SAAS,QAAtB,mBAA2B,YAA3B,YAAsC;AAAA,MAClD;AAAA,IACF;AACA,WAAO;AAAA,EACT;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAUA,MAAc,cAAc,SAA+B,SAAyD;AA/ftH;AAggBI,UAAM,WAAO,0BAAU,OAAO,EAAE,OAAO,SAAO,IAAI,YAAY,SAAS,QAAQ,IAAI,IAAI,OAAO,CAAC;AAC/F,UAAM,cAAa,UAAK,KAAK,gBAAV,mBAAuB,WAAmD;AAG7F,UAAM,eAAe,MAAM,KAAK,KAAK,OAAO;AAAA,MAC1C,aAAa,KAAK,OAAO,SAAO,CAAC,YAAY,KAAK,IAAI,IAAI,CAAC,IAAI;AAAA,IACjE;AACA,QAAI,YAAY;AACd,iBAAW,CAAC,SAAS,KAAK,KAAK,OAAO,QAAQ,UAAU,GAAG;AACzD,qBAAa,SAAS,OAAO,IAAI,EAAE,GAAG,OAAO,GAAG,aAAa,SAAS,OAAO,EAAE;AAAA,MACjF;AACA,aAAO;AAAA,IACT;AACA,UAAM,UAAkD,CAAC;AACzD,eAAW,CAAC,SAAS,KAAK,KAAK,OAAO,QAAQ,aAAa,QAAQ,GAAG;AACpE,iBAAW,CAAC,MAAM,KAAK,KAAK,OAAO,QAAQ,KAAK,GAAG;AACjD,YAAI,YAAY,KAAK,IAAI,GAAG;AAC1B,YAAC,yDAAqB,CAAC,GAAG,IAAI,IAAI;AAAA,QACpC;AAAA,MACF;AAAA,IACF;AACA,eAAK,KAAK,gBAAV,mBAAuB,IAAI,YAAY;AACvC,WAAO;AAAA,EACT;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAUO,kBAAkB,aAAqB,KAAc,OAAsB;AAjiBpF;AAkiBI,QAAI,KAAK;AACP;AAAA,IACF;AACA,UAAM,SAAS,GAAG,KAAK,QAAQ;AAC/B,QAAI,CAAC,YAAY,WAAW,MAAM,GAAG;AACnC;AAAA,IACF;AACA,UAAM,UAAU,YAAY,MAAM,OAAO,MAAM;AAC/C,QAAI,QAAQ,WAAW,gBAAgB,GAAG;AACxC,iBAAK,iBAAL,mBAAmB,YAAY,SAAS;AACxC;AAAA,IACF;AAEA,QAAI,YAAY,kBAAkB,OAAO,UAAU,YAAY,CAAC,QAAQ,KAAK,MAAM,KAAK,CAAC,GAAG;AAC1F,YAAM,SAAS,MAAM,KAAK,EAAE,YAAY;AACxC,YAAM,QAAQ,KAAK,YAAY,KAAK,WAAS,MAAM,MAAM,YAAY,MAAM,MAAM;AACjF,UAAI,UAAU,QAAW;AACvB;AAAA,MACF;AACA,cAAQ,MAAM;AAAA,IAChB;AAGA,UAAM,cAAc,6EAA6E,KAAK,OAAO;AAC7G,QAAI,aAAa;AACf,WAAK,mBAAkB,iBAAY,CAAC,MAAb,YAAkB,QAAQ,UAAU,YAAY,CAAC,CAAC,IAAI,KAAK;AAClF;AAAA,IACF;AAGA,QAAI,KAAK,iBAAiB,SAAS,KAAK,GAAG;AACzC;AAAA,IACF;AACA,UAAM,aAAS,4BAAY,SAAS,QAAO,UAAK,aAAL,YAAiB,MAAM;AAClE,QAAI,QAAQ;AACV,WAAK,KAAK,OAAO,KAAK,OAAO,SAAS,OAAO,MAAM,OAAO,KAAK;AAAA,IACjE;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAYA,MAAc,iBAAiB,cAAgC,SAAqC;AAClG,UAAM,gBAAgB,QAAQ;AAAA,MAC5B,YAAU,OAAO,OAAO,YAAa,OAAO,SAAS,WAAW,eAAe,KAAK,OAAO,EAAE;AAAA,IAC/F;AACA,QAAI,CAAC,cAAc,KAAK,YAAU,OAAO,SAAS,OAAO,GAAG;AAC1D,WAAK,cAAc,CAAC;AACpB;AAAA,IACF;AACA,UAAM,YAAY,CAAC,QAA2B;AAAA,MAC5C;AAAA,MACA,MAAM;AAAA,MACN,QAAQ,EAAE,MAAM,kBAAkB,MAAM,UAAU,MAAM,QAAQ,MAAM,MAAM,OAAO,MAAM;AAAA,IAC3F;AACA,UAAM,KAAK,KAAK,aAAa,GAAG,KAAK,QAAQ,kBAAkB,UAAU,eAAe,CAAC;AACzF,SAAK,cAAc,CAAC,MAAM;AAC1B,eAAW,QAAQ,YAAY;AAC7B,UAAI,KAAK,QAAQ,UAAU,aAAa,SAAS,KAAK,OAAO,MAAM,QAAW;AAC5E;AAAA,MACF;AACA,WAAK,YAAY,KAAK,KAAK,GAAG;AAC9B,iBAAW,UAAU,eAAe;AAClC,cAAM,KAAK,GAAG,KAAK,MAAM,GAAG,OAAO,EAAE;AACrC,cAAM,KAAK,KAAK,aAAa,GAAG,KAAK,QAAQ,IAAI,EAAE,IAAI,EAAE,GAAG,QAAQ,GAAG,CAAC;AAAA,MAC1E;AACA,YAAM,KAAK,KAAK;AAAA,QACd,GAAG,KAAK,QAAQ,IAAI,KAAK,MAAM;AAAA,QAC/B,UAAU,GAAG,KAAK,MAAM,eAAe;AAAA,MACzC;AAAA,IACF;AAIA,eAAW,QAAQ,YAAY;AAC7B,UAAI,KAAK,YAAY,SAAS,KAAK,GAAG,GAAG;AACvC,cAAM,QAAQ,KAAK,WAAW,IAAI,KAAK,GAAG;AAC1C,cAAM,UAAU,sBAAsB,KAAK,MAAM;AACjD,aAAK,KAAK;AAAA,UACR,GAAG,KAAK,QAAQ,IAAI,KAAK,MAAM;AAAA,UAC/B,WAAW,UAAU,SAAY,QAAQ;AAAA,QAC3C;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAWQ,kBAAkB,SAAiB,IAAY,OAAwC;AAC7F,eAAW,QAAQ,YAAY;AAC7B,UAAI,CAAC,KAAK,YAAY,SAAS,KAAK,GAAG,GAAG;AACxC;AAAA,MACF;AACA,UAAI,sBAAsB,KAAK,WAAW,IAAI,KAAK,GAAG,CAAC,MAAM,SAAS;AACpE,aAAK,KAAK,YAAY,GAAG,KAAK,QAAQ,IAAI,KAAK,MAAM,GAAG,EAAE,IAAI,KAAK;AAAA,MACrE;AAAA,IACF;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAWQ,kBAAkB,SAAiB,OAAqB;AA5pBlE;AA6pBI,UAAM,SAAS,sBAAsB,KAAK,WAAW,IAAI,OAAO,CAAC;AACjE,SAAK,WAAW,IAAI,SAAS,KAAK;AAClC,UAAM,QAAQ,sBAAsB,KAAK;AACzC,QAAI,WAAW,SAAS,CAAC,KAAK,YAAY,SAAS,OAAO,GAAG;AAC3D;AAAA,IACF;AACA,UAAM,OAAO,WAAW,KAAK,OAAK,EAAE,QAAQ,OAAO;AACnD,QAAI,CAAC,MAAM;AACT;AAAA,IACF;AACA,UAAM,cAAc,IAAI;AAAA,MACtB,KAAK,eAAe,OAAO,WAAS,eAAe,KAAK,MAAM,EAAE,CAAC,EAAE,IAAI,WAAS,MAAM,EAAE;AAAA,IAC1F;AACA,eAAW,SAAS,mBAAmB;AACrC,UAAI,YAAY,IAAI,MAAM,EAAE,GAAG;AAC7B,aAAK,KAAK,YAAY,GAAG,KAAK,QAAQ,IAAI,KAAK,MAAM,GAAG,MAAM,EAAE,IAAI,MAAM,KAAK;AAAA,MACjF;AAAA,IACF;AACA,SAAK,KAAK,YAAY,GAAG,KAAK,QAAQ,IAAI,KAAK,MAAM,iBAAiB,UAAU,SAAY,KAAK,KAAK;AACtG,QAAI,UAAU,QAAW;AAEvB,YAAM,QAAQ,oBAAI,IAAY;AAC9B,iBAAW,SAAS,KAAK,gBAAgB;AACvC,YAAI,MAAM,YAAY,SAAS,eAAe,KAAK,MAAM,EAAE,KAAK,CAAC,MAAM,WAAW;AAChF,gBAAM,KAAI,WAAM,aAAN,YAAkB,MAAM,IAAI;AAAA,QACxC;AAAA,MACF;AACA,iBAAW,QAAQ,OAAO;AACxB,aAAK,KAAK,OAAO,IAAI,OAAO,IAAI;AAAA,MAClC;AAAA,IACF;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAWQ,kBAAkB,SAAiB,QAAgB,OAAsB;AAC/E,UAAM,UAAU,sBAAsB,KAAK,WAAW,IAAI,OAAO,CAAC;AAClE,QAAI,YAAY,QAAW;AACzB,WAAK,KAAK,IAAI,MAAM,GAAG,KAAK,QAAQ,KAAK,MAAM,mBAAc,OAAO,gCAAgC;AACpG;AAAA,IACF;AACA,UAAM,QAAQ,KAAK,eAAe,KAAK,OAAK,EAAE,OAAO,UAAU,EAAE,YAAY,OAAO;AACpF,QAAI,UAAU,QAAW;AACvB,WAAK,KAAK,IAAI,MAAM,GAAG,KAAK,QAAQ,KAAK,MAAM,mBAAc,OAAO,oBAAoB;AACxF;AAAA,IACF;AACA,UAAM,aAAS,4BAAY,QAAQ,OAAO,oBAAI,IAAI,CAAC,CAAC,QAAQ,KAAK,CAAC,CAAC,CAAC;AACpE,QAAI,QAAQ;AACV,WAAK,KAAK,OAAO,KAAK,OAAO,SAAS,OAAO,MAAM,OAAO,KAAK;AAAA,IACjE;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAaQ,iBAAiB,SAAiB,OAAyB;AACjE,QAAI,YAAY,mBAAmB;AACjC,YAAM,MAAM,OAAO,KAAK;AACxB,UAAI,CAAC,OAAO,SAAS,GAAG,GAAG;AACzB,eAAO;AAAA,MACT;AACA,UAAI,KAAK,QAAQ;AACf,YAAI,KAAK,cAAc,MAAM;AAC3B,eAAK,KAAK,OAAO,KAAK,OAAO,cAAU,sCAAiB,MAAM,KAAM,CAAC,CAAC;AAAA,QACxE,OAAO;AACL,eAAK,KAAK,IAAI,MAAM,GAAG,KAAK,QAAQ,uDAAkD;AAAA,QACxF;AACA,eAAO;AAAA,MACT;AACA,UAAI,KAAK,cAAc,MAAM;AAC3B,aAAK,KAAK,OAAO,KAAK,OAAO,UAAU,OAAO,KAAK,MAAM,GAAG,CAAC,CAAC;AAAA,MAChE,OAAO;AACL,aAAK,KAAK,OAAO,KAAK,OAAO,cAAU,sCAAiB,MAAM,KAAM,CAAC,CAAC;AAAA,MACxE;AACA,aAAO;AAAA,IACT;AACA,QAAI,YAAY,kBAAkB,KAAK,QAAQ;AAC7C,YAAM,OAAO,KAAK,MAAM,OAAO,KAAK,CAAC;AACrC,UAAI,OAAO,SAAS,IAAI,KAAK,QAAQ,GAAG;AACtC,aAAK,KAAK,OAAO,KAAK,OAAO,KAAK,cAAc,QAAQ,cAAc,YAAY,OAAO,IAAI,CAAC;AAAA,MAChG;AACA,aAAO;AAAA,IACT;AACA,WAAO;AAAA,EACT;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAUA,MAAc,YAAY,cAA+C;AA1wB3E;AA2wBI,UAAM,OAAO,KAAK,KAAK;AACvB,QAAI,CAAC,UAAQ,gBAAK,MAAK,mBAAV,4BAA2B,6BAA4B,OAAO;AACzE;AAAA,IACF;AACA,UAAM,QAAQ,CAAC,OAA8B,KAAK,MAAM,EAAE;AAC1D,UAAM,UAAU,MAAM,KAAK,oBAAoB,YAAY;AAC3D,QAAI,QAAQ,SAAS,GAAG;AAKtB,WAAK,KAAK,IAAI,MAAM,GAAG,KAAK,QAAQ,6EAAwE;AAC5G;AAAA,IACF;AACA,UAAM,SAAS,IAAI,2CAAiB,KAAK,KAAK,QAAQ,SAAS,KAAK;AACpE,SAAK,eAAe,UAAM,oCAAoB,QAAQ,KAAK,UAAU;AAAA,MACnE,cAAc,KAAK,KAAK;AAAA,MACxB,MAAM,CAAC,IAAI,UAAU,KAAK,KAAK,YAAY,GAAG,KAAK,QAAQ,IAAI,EAAE,IAAI,KAAK;AAAA,MAC1E,KAAK,KAAK,KAAK;AAAA,MACf;AAAA,IACF,CAAC;AACD,QAAI,KAAK,cAAc;AACrB,WAAK,eAAe;AAAA,IACtB;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAaA,MAAc,oBAAoB,cAA8D;AAhzBlG;AAizBI,UAAM,aAAa,8CAAoB,OAAO,YAAU,OAAO,WAAW,aAAa,QAAQ;AAC/F,QAAI,WAAW,WAAW,GAAG;AAC3B,aAAO,oBAAI,IAAI;AAAA,IACjB;AAKA,UAAI,kBAAa,SAAS,SAAtB,mBAA4B,SAAQ,MAAM;AAC5C,aAAO,IAAI,IAAI,WAAW,IAAI,YAAU,OAAO,OAAO,CAAC;AAAA,IACzD;AACA,UAAM,SAAS,MAAM,KAAK,KAAK,OAAO;AAAA,MACpC,WAAW,IAAI,aAAW,EAAE,SAAS,OAAO,SAAS,MAAM,WAAW,EAAE;AAAA,IAC1E;AAIA,WAAO,IAAI;AAAA,MACT,WACG,IAAI,YAAU,OAAO,OAAO,EAC5B,OAAO,aAAQ;AAr0BxB,YAAAA;AAq0B2B,sBAAO,MAAKA,MAAA,OAAO,SAAS,OAAO,MAAvB,OAAAA,MAA4B,CAAC,CAAC,EAAE,KAAK,UAAQ,WAAW,KAAK,IAAI,CAAC;AAAA,OAAC;AAAA,IACtG;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQO,OAAO,IAAoC;AAChD,SAAK,KAAK,OAAO,OAAO,EAAE;AAAA,EAC5B;AAAA;AAAA,EAGO,QAAc;AAp1BvB;AAq1BI,eAAK,iBAAL,mBAAmB;AACnB,eAAK,iBAAL,mBAAmB;AACnB,SAAK,KAAK,OAAO,MAAM;AAAA,EACzB;AACF;",
4
+ "sourcesContent": ["import { mergeYncaSubunits, type YncaCapabilities } from \"./ynca/capability\";\nimport { formatWireNumber } from \"./catalog/value-coerce\";\nimport type { ObjectDef } from \"./catalog/types\";\nimport type { ConnectionHandle, ControllerLog } from \"./controller\";\nimport {\n YNCA_CATALOG,\n availGets,\n funcToEntry,\n idToEntry,\n presentYncaEntries,\n sweepGets,\n yncaCommand,\n yncaObjectsFor,\n yncaStateUpdate,\n type YncaEntry,\n} from \"./ynca/catalog\";\nimport type { YncaSubunitCache } from \"./ynca/subunit-cache\";\nimport type { CommandGate } from \"./lifecycle/command-gate\";\nimport type { ProbeMemory } from \"./lifecycle/probe-memory\";\nimport type { BrowseEngine } from \"./browse/browse-engine\";\nimport { createBrowseSurface } from \"./browse/surface\";\nimport { YNCA_BROWSE_SOURCES, YncaBrowseDriver } from \"./browse/ynca-browse-driver\";\n\n// The YNCA catalog and its lookup maps are static \u2014 built once for all devices.\n// SYS:MODELNAME is part of the catalog (info.model), so the sweep already covers it.\n// The AVAIL probe always covers the FULL catalog (not the group-filtered one), so the\n// cached subunit set reflects the device, never the current group configuration.\nconst FUNC_MAP = funcToEntry(YNCA_CATALOG);\nconst ID_MAP = idToEntry(YNCA_CATALOG);\nconst AVAIL_PROBE = availGets(YNCA_CATALOG);\n\n/**\n * Functions whose VALUE cannot change while the device runs: the 23 assignable input names\n * and the 12 scene names. They cost 35 of the ~187 paced reads of a targeted sweep (3.5 s\n * at the specification's mandatory 100 ms spacing) and answer the same thing every time, so\n * a reconnect reuses what the first connect learned. Deliberately per adapter RUN, not\n * persisted: renaming an input at the receiver shows up after the next adapter restart\n * rather than needing anyone to invalidate a stored file.\n */\nconst STATIC_FUNC = /^(INPNAME|SCENE\\d+NAME$)/;\n\n/** The zones whose player/input surface the controller routes (v2.0.0 player unification). */\nconst YNCA_ZONES: Array<{ key: string; subunit: string; prefix: string }> = [\n { key: \"main\", subunit: \"MAIN\", prefix: \"\" },\n { key: \"zone2\", subunit: \"ZONE2\", prefix: \"multiroom.zone2.\" },\n { key: \"zone3\", subunit: \"ZONE3\", prefix: \"multiroom.zone3.\" },\n { key: \"zone4\", subunit: \"ZONE4\", prefix: \"multiroom.zone4.\" },\n];\n\n/**\n * INP value \u2192 the player subunit it selects (normalized: uppercase, alphanumerics only).\n * The wire values come from the RX-V6A INPNAME/INP capture and ynca-python's input map;\n * inputs that are no media player (HDMI, AV, TUNER, \u2026) deliberately map to nothing.\n */\nconst INPUT_SUBUNITS: Record<string, string> = {\n NETRADIO: \"NETRADIO\",\n SERVER: \"SERVER\",\n USB: \"USB\",\n SPOTIFY: \"SPOTIFY\",\n DEEZER: \"DEEZER\",\n TIDAL: \"TIDAL\",\n NAPSTER: \"NAPSTER\",\n PANDORA: \"PANDORA\",\n RHAPSODY: \"RHAP\",\n SIRIUS: \"SIRIUS\",\n SIRIUSXM: \"SIRIUS\",\n SIRIUSIR: \"SIRIUS\",\n AIRPLAY: \"AIRPLAY\",\n BLUETOOTH: \"BT\",\n PC: \"PC\",\n MUSICCASTLINK: \"MCLINK\",\n IPOD: \"IPOD\",\n IPODUSB: \"IPODUSB\",\n};\n\n/**\n * The player subunit an input selection feeds, or undefined when the input is no\n * media player.\n *\n * @param input the zone's INP value (e.g. \"NET RADIO\", \"iPod (USB)\")\n * @returns the subunit (e.g. NETRADIO), or undefined\n */\nfunction playerSubunitForInput(input: string | undefined): string | undefined {\n if (typeof input !== \"string\" || input.length === 0) {\n return undefined;\n }\n return INPUT_SUBUNITS[input.toUpperCase().replace(/[^A-Z0-9]/g, \"\")];\n}\n\n/**\n * A state of the flat per-zone player block: exactly one segment below `player.`.\n * ONLY these are zone-routed \u2014 multi-segment `player.*` ids (bluetooth pairing\n * status, airplay volume interlock, per-source presets) are device-global and go\n * straight to their state (2.0.0 review finding: the broad `player.` prefix check\n * silently dropped BT/AirPlay status while no zone listened to that source).\n */\nconst FLAT_PLAYER_ID = /^player\\.[^.]+$/;\n\n/**\n * What a zone's player block is reset to when the zone leaves its playing source\n * (clear-on-switch): metadata empty, playback Stop. Filtered to the states the\n * device actually has before emitting.\n */\nconst YNCA_PLAYER_CLEAR: Array<{ id: string; value: number | string | boolean }> = [\n { id: \"player.playback\", value: 1 },\n { id: \"player.artist\", value: \"\" },\n { id: \"player.album\", value: \"\" },\n { id: \"player.track\", value: \"\" },\n { id: \"player.station\", value: \"\" },\n { id: \"player.channelName\", value: \"\" },\n { id: \"player.elapsedTime\", value: \"\" },\n { id: \"player.totalTime\", value: \"\" },\n { id: \"player.repeat\", value: 0 },\n { id: \"player.shuffle\", value: false },\n];\n\n/** Memory key for the remembered static values. */\nconst STATIC_KEY = \"yncaStaticValues\";\n\n/** Memory key for the persisted capability shape (the fast-restart layer). */\nconst CAPS_KEY = \"yncaCapabilities\";\n\n/** The persisted capability shape, keyed by the device identity that validated it. */\ninterface CachedCapabilities {\n /** SYS MODELNAME at capture time \u2014 freshness key half 1. */\n model: string;\n /** SYS VERSION at capture time \u2014 freshness key half 2. */\n firmware: string;\n /** The captured subunit\u2192function map (values are last-known, used for SHAPE only). */\n subunits: Record<string, Record<string, string>>;\n}\n\n/**\n * Whether a remembered value carries the cached-capabilities shape (API boundary \u2014\n * the persisted probe memory is untrusted storage).\n *\n * @param value the remembered value\n * @returns true when usable\n */\nfunction isCachedCapabilities(value: unknown): value is CachedCapabilities {\n const candidate = value as Partial<CachedCapabilities> | null;\n return (\n typeof candidate === \"object\" &&\n candidate !== null &&\n typeof candidate.model === \"string\" &&\n typeof candidate.firmware === \"string\" &&\n typeof candidate.subunits === \"object\" &&\n candidate.subunits !== null\n );\n}\n\n/**\n * The functions whose presence proves a subunit really serves menus \u2014 the fields a real\n * `LISTINFO=?` answer is made of (RX-A810 reference log). See {@link YncaDeviceController.probeBrowseSubunits}.\n */\nconst LIST_PROOF = /^(LISTLAYER|LISTLAYERNAME|CURRLINE|MAXLINE|LINE[1-8](TXT|ATRIB))$/;\n\n/** The subset of the YNCA client the controller uses (so tests can inject a fake). */\nexport interface YncaClientLike {\n /** Open the connection. */\n connect(): Promise<void>;\n /** Run the init sweep and return the device's capabilities. */\n readCapabilities(gets: Array<{ subunit: string; func: string }>): Promise<YncaCapabilities>;\n /** Send a PUT command. */\n send(subunit: string, func: string, value: string): void;\n /** Send a GET request (the browse driver reads LISTINFO with it). */\n get(subunit: string, func: string): void;\n /** Register a handler for pushed messages. */\n onMessage(handler: (message: { subunit: string; func: string; value: string }) => void): void;\n /** Register the socket-drop handler the supervisor reconnects on. */\n onDrop(handler: (reason?: Error) => void): void;\n /** Register the refusal handler for user commands the device rejects (optional in older tests). */\n onRefusal?(handler: (command: string, verdict: \"restricted\" | \"undefined\") => void): void;\n /** Start the keepalive poll \u2014 called after the init sweep, not on connect. */\n startKeepalive(): void;\n /** Close the connection synchronously. */\n close(): void;\n}\n\nexport type { ControllerLog };\n\n/** The adapter callbacks the controller drives \u2014 narrow, so no adapter mock is needed in tests. */\nexport interface ControllerDeps {\n /** The YNCA client for this device. */\n client: YncaClientLike;\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 log. */\n log: ControllerLog;\n /**\n * Whether a catalog entry's datapoint group is enabled. A disabled group's entries\n * are dropped BEFORE the sweep, so their GETs are never sent (previously only the\n * object/state writes were gated and the answers thrown away). Absent = all enabled.\n */\n isEntryEnabled?(id: string): boolean;\n /**\n * Per-device cache of the AVAIL probe result, held across reconnects and restarts.\n * With a valid cache the probe phase is skipped and the targeted sweep runs directly;\n * a model/firmware mismatch after the sweep invalidates it and re-probes.\n */\n subunitCache?: YncaSubunitCache;\n /**\n * The device's command gate: every line this controller puts on the wire is already\n * paced through it, and its signal is the connection's shutdown flag (a closed gate\n * ends pending waits and stops state writes). Absent in older tests \u2192 no browsing.\n */\n gate?: CommandGate;\n /** Per-device memory for answers that stay constant while the device runs (see ProbeMemory). */\n probeMemory?: ProbeMemory;\n}\n\n/**\n * Drives one YNCA device: connect, init sweep, build the object tree, and route\n * commands both ways. Create-only \u2014 orphan cleanup and legacy migration are\n * separate, gated steps.\n */\nexport class YncaDeviceController implements ConnectionHandle {\n private browseDriver: YncaBrowseDriver | undefined;\n private browseEngine: BrowseEngine | undefined;\n /**\n * Write map filtered to the entries THIS device reported \u2014 claim-with-proof for\n * writes: a command is only sent with a wire function the device answered in the\n * sweep. Until the sweep ran, the unfiltered static map answers.\n */\n private writeMap: Map<string, YncaEntry> | undefined;\n /** The device's scene titles (SCENExNAME), for the recall dropdown, the list state and title writes. */\n private sceneTitles: Array<{ num: number; title: string }> = [];\n /** The tuner's current band (AM/FM/DAB), for the band-dependent frequency/preset writes. */\n private tunerBand = \"\";\n /** Whether the device carries the DAB subunit (its FM half shares the flat tuner ids). */\n private hasDab = false;\n /** The entries THIS device reported \u2014 the per-subunit lookup behind the player routing. */\n private presentEntries: YncaEntry[] = [];\n /** Each zone's currently selected input (INP), for the player routing (v2.0.0). */\n private readonly zoneInputs = new Map<string, string>();\n /** The zones that got a player block (main plus every present ZONEn, when sources exist). */\n private playerZones: string[] = [];\n\n /**\n * @param deviceId the id-safe device id (object-tree path segment)\n * @param deps the client and adapter callbacks\n */\n public constructor(\n private readonly deviceId: string,\n private readonly deps: ControllerDeps,\n ) {}\n\n /**\n * Connect, sweep the device from the catalog, and create its object tree; wire\n * up push updates. The catalog is the single source: it drives the sweep, the\n * device\u2192state read-back and (in handleStateChange) the state\u2192wire encode.\n *\n * @returns true if the device reported capabilities and its tree was created\n */\n public async start(): Promise<boolean> {\n await this.deps.client.connect();\n // Group-filtered catalog: disabled groups are excluded from the sweep AND the objects.\n const catalog = this.deps.isEntryEnabled\n ? YNCA_CATALOG.filter(entry => this.deps.isEntryEnabled!(entry.id))\n : YNCA_CATALOG;\n const resolved = await this.resolveCapabilities(catalog);\n const { capabilities, fromCache } = resolved;\n const present = presentYncaEntries(capabilities, catalog);\n this.writeMap = idToEntry(present);\n this.presentEntries = present;\n // Seed each zone's input from the sweep \u2014 the player routing needs to know what\n // every zone is listening to before the first INP push arrives.\n for (const zone of YNCA_ZONES) {\n const input = capabilities.subunits[zone.subunit]?.INP;\n if (typeof input === \"string\") {\n this.zoneInputs.set(zone.key, input);\n }\n }\n const objects = yncaObjectsFor(capabilities, catalog);\n if (objects.length === 0) {\n this.deps.log.warn(`${this.deviceId}: no capabilities reported \u2014 creating no objects`);\n return false;\n }\n // A user command the device rejects must leave a trace: @RESTRICTED (not allowed /\n // not possible right now) and @UNDEFINED (unknown on this model) were silently\n // dropped before \u2014 the class of invisible failures behind #615.\n this.deps.client.onRefusal?.((command, verdict) =>\n this.deps.log.warn(`${this.deviceId}: device refused \"${command}\" (@${verdict.toUpperCase()})`),\n );\n // The scene titles ride the sweep as SCENExNAME answers; they become the recall\n // dropdown's labels and the one scene.list state (v2.0.0 \u2014 no per-name datapoints).\n const main = capabilities.subunits.MAIN ?? {};\n this.sceneTitles = [];\n for (let n = 1; n <= 12; n++) {\n const title = main[`SCENE${n}NAME`];\n if (typeof title === \"string\" && title.length > 0) {\n this.sceneTitles.push({ num: n, title });\n }\n }\n // Parents before children (channels before their states) \u2014 created in order.\n for (const object of objects) {\n if (object.id === \"scene.recall\" && this.sceneTitles.length > 0) {\n object.common.states = Object.fromEntries(this.sceneTitles.map(scene => [scene.num, scene.title]));\n }\n await this.deps.upsertObject(`${this.deviceId}.${object.id}`, object);\n }\n if (this.sceneTitles.length > 0) {\n await this.deps.upsertObject(`${this.deviceId}.scene.list`, {\n id: \"scene.list\",\n type: \"state\",\n common: { name: \"Scenes (number + title)\", type: \"string\", role: \"json\", read: true, write: false },\n });\n this.deps.setStateAck(`${this.deviceId}.scene.list`, JSON.stringify(this.sceneTitles));\n }\n await this.setupZonePlayers(capabilities, objects);\n // Seed the states with the values read during the init sweep. On the fast path the\n // cached values are last-run leftovers \u2014 the states already hold exactly those, and\n // the background refresh streams the fresh ones in \u2014 so nothing is seeded there.\n if (!fromCache) {\n for (const [subunit, funcs] of Object.entries(capabilities.subunits)) {\n for (const [func, value] of Object.entries(funcs)) {\n const update = yncaStateUpdate({ subunit, func, value }, FUNC_MAP);\n if (update) {\n if (FLAT_PLAYER_ID.test(update.id)) {\n // Player-block values feed only the zones LISTENING to their source\n // (v2.0.0) \u2014 an idle source's leftover metadata must not seed the block.\n this.routePlayerUpdate(subunit, update.id, update.value);\n } else {\n this.deps.setStateAck(`${this.deviceId}.${update.id}`, update.value);\n }\n }\n }\n }\n }\n // The band decides which wire function a tuner.frequency/preset write goes to\n // (v2.0.0 unification) \u2014 seeded from the sweep, kept fresh from the live pushes.\n this.hasDab = capabilities.subunits.DAB !== undefined;\n this.tunerBand = (capabilities.subunits.DAB?.BAND ?? capabilities.subunits.TUN?.BAND ?? \"\").toUpperCase();\n await this.setupBrowse(capabilities);\n this.deps.client.onMessage(message => {\n // The browse driver sees every line first: list lines (LINE1TXT\u2026, LISTINFO\n // bursts, auto-feedback) are not catalogued and would otherwise be dropped.\n this.browseDriver?.handleMessage(message);\n if (message.func === \"BAND\" && (message.subunit === \"TUN\" || message.subunit === \"DAB\")) {\n this.tunerBand = message.value.toUpperCase();\n }\n if (message.func === \"INP\") {\n const zone = YNCA_ZONES.find(z => z.subunit === message.subunit);\n if (zone) {\n this.handleInputSwitch(zone.key, message.value);\n }\n }\n const update = yncaStateUpdate(message, FUNC_MAP);\n if (update) {\n if (FLAT_PLAYER_ID.test(update.id)) {\n this.routePlayerUpdate(message.subunit, update.id, update.value);\n } else {\n this.deps.setStateAck(`${this.deviceId}.${update.id}`, update.value);\n }\n }\n });\n // Start the keepalive only now the (fast-path) init is done; on the slow path the\n // sweep already ran, on the fast path the background refresh paces itself through\n // the same gate, so the 30 s poll cannot break the spacing either way.\n this.deps.client.startKeepalive();\n if (fromCache) {\n // The whole point of the persisted capability layer: the tree stood in ~1 s from\n // the remembered shape (validated by the LIVE identity answer above), and the\n // 15\u201320 s question round now runs behind the ready line as a pure value refresh \u2014\n // its answers stream into the states through the live handler just registered.\n void this.refreshInBackground(catalog);\n }\n // The adapter logs one combined \"ready\" line across all transports; this per-transport line\n // stays at debug for diagnostics.\n this.deps.log.debug(`${this.deviceId}: ${capabilities.model || \"device\"} ready (YNCA)`);\n return true;\n }\n\n /**\n * The device's capabilities \u2014 from the persisted fast-restart layer when the LIVE\n * identity (model + firmware, two paced reads, ~0.2 s) matches what the layer was\n * captured from, else from the full two-pass sweep. The identity read doubles as the\n * liveness proof the ready line rests on: a cached shape alone must never present a\n * dead device as connected (the v1.5.0 honesty rule).\n *\n * @param catalog the (group-filtered) catalog\n * @returns the capabilities and whether they came from the persisted layer\n */\n private async resolveCapabilities(\n catalog: readonly YncaEntry[],\n ): Promise<{ capabilities: YncaCapabilities; fromCache: boolean }> {\n const identity = await this.deps.client.readCapabilities([\n { subunit: \"SYS\", func: \"MODELNAME\" },\n { subunit: \"SYS\", func: \"VERSION\" },\n ]);\n const model = identity.model;\n const firmware = identity.subunits.SYS?.VERSION ?? \"\";\n const remembered = this.deps.probeMemory?.remembered(CAPS_KEY);\n if (model && isCachedCapabilities(remembered) && remembered.model === model && remembered.firmware === firmware) {\n return { capabilities: { model, subunits: remembered.subunits }, fromCache: true };\n }\n if (remembered !== undefined) {\n // A different (or updated) device behind this address: its remembered YNCA\n // answers are void. The other transports guard their own portions.\n this.deps.probeMemory?.drop(key => key === CAPS_KEY || key === STATIC_KEY);\n }\n const capabilities = await this.sweepDevice(catalog, model, firmware);\n if (capabilities.model) {\n this.deps.probeMemory?.set(CAPS_KEY, {\n model: capabilities.model,\n firmware: capabilities.subunits.SYS?.VERSION ?? firmware,\n subunits: capabilities.subunits,\n } satisfies CachedCapabilities);\n }\n return { capabilities, fromCache: false };\n }\n\n /**\n * The fast path's second half: re-ask every catalogued function of the present\n * subunits \u2014 the answers stream into the states through the live message handler,\n * so current values arrive within the usual sweep time WITHOUT having gated the\n * ready line. Completion refreshes the persisted layers (capabilities, statics)\n * and the write map; a SHAPE change (a function newly answered) is only persisted \u2014\n * its object appears on the next start, because the unified tree is coordinated\n * once per connect and a late upsert would not materialize.\n *\n * @param catalog the (group-filtered) catalog\n */\n private async refreshInBackground(catalog: readonly YncaEntry[]): Promise<void> {\n try {\n const cached = this.deps.subunitCache?.get();\n const gets = sweepGets(catalog).filter(\n get => get.subunit === \"SYS\" || !cached || cached.subunits.includes(get.subunit),\n );\n const fresh = await this.deps.client.readCapabilities(gets);\n if (!fresh.model) {\n // The refresh ran into a drop \u2014 the supervisor handles the reconnect.\n return;\n }\n const statics: Record<string, Record<string, string>> = {};\n for (const [subunit, funcs] of Object.entries(fresh.subunits)) {\n for (const [func, value] of Object.entries(funcs)) {\n if (STATIC_FUNC.test(func)) {\n (statics[subunit] ??= {})[func] = value;\n }\n }\n }\n this.deps.probeMemory?.set(STATIC_KEY, statics);\n // UNION with the remembered shape (same identity \u2014 the fast path proved it):\n // a refresh while the device stands by answers many functions @RESTRICTED and\n // must not strip abilities it proved while awake; a lean standby FIRST capture\n // heals on the next awake refresh instead of staying lean forever (datapoint\n // review finding, 2.0.2).\n const remembered = this.deps.probeMemory?.remembered(CAPS_KEY);\n const subunits = isCachedCapabilities(remembered)\n ? mergeYncaSubunits(remembered.subunits, fresh.subunits)\n : fresh.subunits;\n this.deps.probeMemory?.set(CAPS_KEY, {\n model: fresh.model,\n firmware: fresh.subunits.SYS?.VERSION ?? \"\",\n subunits,\n } satisfies CachedCapabilities);\n // The write map follows the union too \u2014 a standby refresh must not shrink the\n // proven write surface until the next restart either.\n this.presentEntries = presentYncaEntries({ model: fresh.model, subunits }, catalog);\n this.writeMap = idToEntry(this.presentEntries);\n this.deps.log.debug(`${this.deviceId}: background value refresh done (YNCA)`);\n } catch (e) {\n this.deps.log.debug(`${this.deviceId}: background value refresh failed: ${String(e)}`);\n }\n }\n\n /**\n * Read the device's capabilities with the two-pass sweep. Pass 1 probes each\n * catalogued subunit with `AVAIL=?` (~2 s); pass 2 sweeps only the subunits that\n * answered, plus SYS (which never answers AVAIL) \u2014 on a typical receiver that\n * saves a third or more of the ~39 s blind sweep. A cached probe result (per\n * device, surviving reconnects and restarts) skips pass 1 entirely; a device\n * whose model or firmware no longer matches the cache re-probes. A device that\n * answers no AVAIL at all falls back to the full blind sweep, so an unknown\n * firmware loses speed, never features.\n *\n * @param catalog the (group-filtered) catalog whose functions to sweep\n * @param model the live-read SYS model name (from resolveCapabilities)\n * @param firmware the live-read SYS firmware version\n * @returns the assembled capabilities\n */\n private async sweepDevice(catalog: readonly YncaEntry[], model: string, firmware: string): Promise<YncaCapabilities> {\n const cached = this.deps.subunitCache?.get();\n if (cached) {\n // The device's IDENTITY was already read by resolveCapabilities (two reads,\n // ~0.2 s) \u2014 checking it BEFORE sweeping is what keeps a stale cache from costing\n // a full targeted sweep, then the probe, then a second sweep (~40 s).\n if (model === cached.model && firmware === cached.firmware) {\n return await this.targetedSweep(catalog, new Set(cached.subunits));\n }\n // The device behind this IP changed (swap or firmware update) \u2014 re-probe.\n this.deps.log.debug(`${this.deviceId}: cached subunit set is stale (model/firmware changed), re-probing`);\n this.deps.subunitCache?.clear();\n }\n const probe = await this.deps.client.readCapabilities(AVAIL_PROBE);\n const present = new Set(Object.keys(probe.subunits));\n if (present.size === 0) {\n // Device ignores AVAIL \u2014 sweep blind so no function is lost.\n return await this.deps.client.readCapabilities(sweepGets(catalog));\n }\n const capabilities = await this.targetedSweep(catalog, present);\n if (capabilities.model) {\n this.deps.subunitCache?.set({\n subunits: [...present],\n model: capabilities.model,\n firmware: capabilities.subunits.SYS?.VERSION ?? \"\",\n });\n }\n return capabilities;\n }\n\n /**\n * Sweep only the present subunits' functions (SYS always included \u2014 it answers no\n * AVAIL but carries model/firmware/master power).\n *\n * @param catalog the (group-filtered) catalog whose functions to sweep\n * @param present the subunits that answered the AVAIL probe\n * @returns the assembled capabilities\n */\n private async targetedSweep(catalog: readonly YncaEntry[], present: ReadonlySet<string>): Promise<YncaCapabilities> {\n const gets = sweepGets(catalog).filter(get => get.subunit === \"SYS\" || present.has(get.subunit));\n const remembered = this.deps.probeMemory?.remembered<Record<string, Record<string, string>>>(STATIC_KEY);\n // Second connect onwards: skip those reads and put the remembered answers back in, so\n // the objects are built exactly as if the device had answered them again.\n const capabilities = await this.deps.client.readCapabilities(\n remembered ? gets.filter(get => !STATIC_FUNC.test(get.func)) : gets,\n );\n if (remembered) {\n for (const [subunit, funcs] of Object.entries(remembered)) {\n capabilities.subunits[subunit] = { ...funcs, ...capabilities.subunits[subunit] };\n }\n return capabilities;\n }\n const statics: Record<string, Record<string, string>> = {};\n for (const [subunit, funcs] of Object.entries(capabilities.subunits)) {\n for (const [func, value] of Object.entries(funcs)) {\n if (STATIC_FUNC.test(func)) {\n (statics[subunit] ??= {})[func] = value;\n }\n }\n }\n this.deps.probeMemory?.set(STATIC_KEY, statics);\n return capabilities;\n }\n\n /**\n * Handle a state change: a user write (ack false) becomes a YNCA command; an\n * acked change (the device's own echo) is ignored to avoid a resend loop.\n *\n * @param fullStateId the full state id (device id + \".\" + state)\n * @param ack whether the change is acked (device-originated)\n * @param value the new value\n */\n public handleStateChange(fullStateId: string, ack: boolean, value: unknown): void {\n if (ack) {\n return;\n }\n const prefix = `${this.deviceId}.`;\n if (!fullStateId.startsWith(prefix)) {\n return;\n }\n const stateId = fullStateId.slice(prefix.length);\n if (stateId.startsWith(\"player.browse.\")) {\n this.browseEngine?.handleWrite(stateId, value);\n return;\n }\n // A scene TITLE is as valid a recall write as its number (\"Movie Viewing\" \u2192 1).\n if (stateId === \"scene.recall\" && typeof value === \"string\" && !/^\\d+$/.test(value.trim())) {\n const needle = value.trim().toLowerCase();\n const match = this.sceneTitles.find(scene => scene.title.toLowerCase() === needle);\n if (match === undefined) {\n return;\n }\n value = match.num;\n }\n // The unified player writes go to the subunit the ZONE is listening to (v2.0.0) \u2014\n // routed here, BEFORE the generic path.\n const playerWrite = /^(?:multiroom\\.(zone[234])\\.)?player\\.(playback|repeat|shuffle|next|prev)$/.exec(stateId);\n if (playerWrite) {\n this.handlePlayerWrite(playerWrite[1] ?? \"main\", `player.${playerWrite[2]}`, value);\n return;\n }\n // The unified tuner writes are band-dependent (v2.0.0) and routed here, BEFORE the\n // generic path \u2014 one state, the right wire function for the active band.\n if (this.handleTunerWrite(stateId, value)) {\n return;\n }\n const triple = yncaCommand(stateId, value, this.writeMap ?? ID_MAP);\n if (triple) {\n this.deps.client.send(triple.subunit, triple.func, triple.value);\n }\n }\n\n /**\n * Create the per-zone player mirrors (v2.0.0): every present ZONEn gets its own\n * \"now playing\" block under its multiroom folder \u2014 zones can play different\n * sources, one shared block could not show that. The defs are clones of the flat\n * block the catalog built for the main zone, plus the `source` display each zone\n * (main included) gets.\n *\n * @param capabilities the device's swept capabilities\n * @param objects the main tree's object definitions (source of the block's shape)\n */\n private async setupZonePlayers(capabilities: YncaCapabilities, objects: ObjectDef[]): Promise<void> {\n const playerObjects = objects.filter(\n object => object.id === \"player\" || (object.type === \"state\" && FLAT_PLAYER_ID.test(object.id)),\n );\n if (!playerObjects.some(object => object.type === \"state\")) {\n this.playerZones = [];\n return;\n }\n const sourceDef = (id: string): ObjectDef => ({\n id,\n type: \"state\",\n common: { name: \"Playing source\", type: \"string\", role: \"text\", read: true, write: false },\n });\n await this.deps.upsertObject(`${this.deviceId}.player.source`, sourceDef(\"player.source\"));\n this.playerZones = [\"main\"];\n for (const zone of YNCA_ZONES) {\n if (zone.key === \"main\" || capabilities.subunits[zone.subunit] === undefined) {\n continue;\n }\n this.playerZones.push(zone.key);\n for (const object of playerObjects) {\n const id = `${zone.prefix}${object.id}`;\n await this.deps.upsertObject(`${this.deviceId}.${id}`, { ...object, id });\n }\n await this.deps.upsertObject(\n `${this.deviceId}.${zone.prefix}player.source`,\n sourceDef(`${zone.prefix}player.source`),\n );\n }\n // Seed the block's resting shape: a device already playing at adapter start must\n // not show an empty source until its first input switch, and a zone NOT playing a\n // media source must show cleared values, not valueless states (2.0.0 review + live\n // deployment check). Zones ON a source get their values from the routed sweep.\n const presentFlat = new Set(\n this.presentEntries.filter(entry => FLAT_PLAYER_ID.test(entry.id)).map(entry => entry.id),\n );\n for (const zone of YNCA_ZONES) {\n if (!this.playerZones.includes(zone.key)) {\n continue;\n }\n const input = this.zoneInputs.get(zone.key);\n const playing = playerSubunitForInput(input) !== undefined;\n this.deps.setStateAck(\n `${this.deviceId}.${zone.prefix}player.source`,\n playing && input !== undefined ? input : \"\",\n );\n if (!playing) {\n for (const clear of YNCA_PLAYER_CLEAR) {\n if (presentFlat.has(clear.id)) {\n this.deps.setStateAck(`${this.deviceId}.${zone.prefix}${clear.id}`, clear.value);\n }\n }\n }\n }\n }\n\n /**\n * Feed one player-subunit value into the block of every zone listening to that\n * source \u2014 and only those: an idle source's answer (the sweep reads them all)\n * must not overwrite what the active source shows.\n *\n * @param subunit the source subunit the value came from\n * @param id the flat player state id\n * @param value the decoded value\n */\n private routePlayerUpdate(subunit: string, id: string, value: boolean | number | string): void {\n for (const zone of YNCA_ZONES) {\n if (!this.playerZones.includes(zone.key)) {\n continue;\n }\n if (playerSubunitForInput(this.zoneInputs.get(zone.key)) === subunit) {\n this.deps.setStateAck(`${this.deviceId}.${zone.prefix}${id}`, value);\n }\n }\n }\n\n /**\n * Track a zone's input switch: remember the input, and when the zone changed its\n * player source, clear the block (stale metadata must not linger) and ask the new\n * source for its current state \u2014 YNCA pushes changes, but a source that was already\n * playing has nothing new to push.\n *\n * @param zoneKey the zone (`main`, `zone2`, \u2026)\n * @param input the new INP value\n */\n private handleInputSwitch(zoneKey: string, input: string): void {\n const before = playerSubunitForInput(this.zoneInputs.get(zoneKey));\n this.zoneInputs.set(zoneKey, input);\n const after = playerSubunitForInput(input);\n if (before === after || !this.playerZones.includes(zoneKey)) {\n return;\n }\n const zone = YNCA_ZONES.find(z => z.key === zoneKey);\n if (!zone) {\n return;\n }\n const presentFlat = new Set(\n this.presentEntries.filter(entry => FLAT_PLAYER_ID.test(entry.id)).map(entry => entry.id),\n );\n for (const clear of YNCA_PLAYER_CLEAR) {\n if (presentFlat.has(clear.id)) {\n this.deps.setStateAck(`${this.deviceId}.${zone.prefix}${clear.id}`, clear.value);\n }\n }\n this.deps.setStateAck(`${this.deviceId}.${zone.prefix}player.source`, after === undefined ? \"\" : input);\n if (after !== undefined) {\n // Fresh reads for the newly selected source, streamed back through the live handler.\n const funcs = new Set<string>();\n for (const entry of this.presentEntries) {\n if (entry.subunit === after && FLAT_PLAYER_ID.test(entry.id) && !entry.writeOnly) {\n funcs.add(entry.readFunc ?? entry.func);\n }\n }\n for (const func of funcs) {\n this.deps.client.get(after, func);\n }\n }\n }\n\n /**\n * Route a unified player write (playback/repeat/shuffle/next/prev) to the source\n * subunit the ZONE is listening to \u2014 with the entry that subunit itself reported\n * (claim-with-proof, like every other write).\n *\n * @param zoneKey the zone the write belongs to\n * @param flatId the flat player state id\n * @param value the written value\n */\n private handlePlayerWrite(zoneKey: string, flatId: string, value: unknown): void {\n const subunit = playerSubunitForInput(this.zoneInputs.get(zoneKey));\n if (subunit === undefined) {\n this.deps.log.debug(`${this.deviceId}: ${flatId} ignored \u2014 ${zoneKey} is not playing a media source`);\n return;\n }\n const entry = this.presentEntries.find(e => e.id === flatId && e.subunit === subunit);\n if (entry === undefined) {\n this.deps.log.debug(`${this.deviceId}: ${flatId} ignored \u2014 ${subunit} did not report it`);\n return;\n }\n const triple = yncaCommand(flatId, value, new Map([[flatId, entry]]));\n if (triple) {\n this.deps.client.send(triple.subunit, triple.func, triple.value);\n }\n }\n\n /**\n * Route the band-dependent tuner writes (v2.0.0 unification): ONE frequency state\n * in kHz and ONE preset state, sent to the wire function of the ACTIVE band \u2014\n * AM/FM on the classic TUN subunit, FM/DAB on the DAB subunit (whose FM half\n * shares the flat ids). A DAB frequency write is dropped: DAB tunes by service,\n * the device has no frequency command there.\n *\n * @param stateId the state id relative to the device\n * @param value the written value\n * @returns true when the id was a band-routed tuner write (handled here)\n */\n private handleTunerWrite(stateId: string, value: unknown): boolean {\n if (stateId === \"tuner.frequency\") {\n const khz = Number(value);\n if (!Number.isFinite(khz)) {\n return true;\n }\n if (this.hasDab) {\n if (this.tunerBand === \"FM\") {\n this.sendProven(\"DAB\", \"FMFREQ\", formatWireNumber(khz / 1000, 2));\n } else {\n this.deps.log.debug(`${this.deviceId}: DAB tunes by service \u2014 frequency write ignored`);\n }\n return true;\n }\n if (this.tunerBand === \"AM\") {\n this.sendProven(\"TUN\", \"AMFREQ\", String(Math.round(khz)));\n } else {\n this.sendProven(\"TUN\", \"FMFREQ\", formatWireNumber(khz / 1000, 2));\n }\n return true;\n }\n if (stateId === \"tuner.preset\" && this.hasDab) {\n const slot = Math.round(Number(value));\n if (Number.isFinite(slot) && slot >= 1) {\n this.sendProven(\"DAB\", this.tunerBand === \"DAB\" ? \"DABPRESET\" : \"FMPRESET\", String(slot));\n }\n return true;\n }\n return false;\n }\n\n /**\n * Send a band-routed write ONLY with a function THIS device reported in its sweep \u2014\n * the same claim-with-proof rule every generic write obeys (#615 class). Without it\n * the router put a blind TUN:FMFREQ on the wire for any device, tuner or not, and\n * each write surfaced as a \"device refused\" warning (test-audit finding).\n *\n * @param subunit the target subunit\n * @param func the wire function\n * @param wire the encoded wire value\n */\n private sendProven(subunit: string, func: string, wire: string): void {\n if (!this.presentEntries.some(entry => entry.subunit === subunit && entry.func === func)) {\n this.deps.log.debug(`${this.deviceId}: ${subunit}:${func} not reported by this device \u2014 write dropped`);\n return;\n }\n this.deps.client.send(subunit, func, wire);\n }\n\n /**\n * Create the browsing surface (#613) when the device reports a browsable media\n * subunit: the official YNCA list vocabulary (LISTINFO/LISTSEL/LISTPAGE/LISTCURSOR)\n * drives an 8-line window under `player.browse.*`. Skipped without a delay dep\n * (older tests) and when the playback group is switched off.\n *\n * @param capabilities the device's swept capabilities\n */\n private async setupBrowse(capabilities: YncaCapabilities): Promise<void> {\n const gate = this.deps.gate;\n if (!gate || this.deps.isEntryEnabled?.(\"player.browse.source\") === false) {\n return;\n }\n const delay = (ms: number): Promise<void> => gate.delay(ms);\n const present = await this.probeBrowseSubunits(capabilities);\n if (present.size === 0) {\n // Leaving the states uncreated is what hands browsing to another transport: the owner\n // policy ranks by modernity (yxc > ynca > xml), so an unproven YNCA claim would beat a\n // PROVEN xml one and the user would get an empty menu on a device that can browse over\n // XML \u2014 exactly issue #613's RX-V473.\n this.deps.log.debug(`${this.deviceId}: no YNCA source answers LISTINFO \u2014 leaving menus to another transport`);\n return;\n }\n const driver = new YncaBrowseDriver(this.deps.client, present, delay);\n this.browseEngine = await createBrowseSurface(driver, this.deviceId, {\n upsertObject: this.deps.upsertObject,\n emit: (id, value) => this.deps.setStateAck(`${this.deviceId}.${id}`, value),\n log: this.deps.log,\n delay,\n });\n if (this.browseEngine) {\n this.browseDriver = driver;\n }\n }\n\n /**\n * Which browsable subunits actually SERVE menus, proven by asking them.\n *\n * Carrying the subunit is NOT proof: the RX-A810 reference log answers `@SERVER:LISTINFO=?`\n * with `@UNDEFINED` while NETRADIO/PC/USB on the very same device return a full window. The\n * XML driver has always probed (`List_Info` \u2192 `<Menu_Status>`); YNCA claimed the states on\n * presence alone and, ranking higher, silently displaced the transport that could deliver.\n *\n * @param capabilities the device's swept capabilities\n * @returns the subunits that answered with list data\n */\n private async probeBrowseSubunits(capabilities: YncaCapabilities): Promise<ReadonlySet<string>> {\n const candidates = YNCA_BROWSE_SOURCES.filter(source => source.subunit in capabilities.subunits);\n if (candidates.length === 0) {\n return new Set();\n }\n // A receiver in standby answers @RESTRICTED for its media subunits, which is\n // indistinguishable from \"cannot browse\" and would strip the menus off a device that\n // serves them perfectly once it is on. Nobody browses a sleeping receiver, so keep the\n // claim and let the next connect \u2014 with the device awake \u2014 do the real probe.\n if (capabilities.subunits.MAIN?.PWR !== \"On\") {\n return new Set(candidates.map(source => source.subunit));\n }\n const answer = await this.deps.client.readCapabilities(\n candidates.map(source => ({ subunit: source.subunit, func: \"LISTINFO\" })),\n );\n // Only a real list answer counts. Both refusals \u2014 `@UNDEFINED` (function unknown) and\n // `@RESTRICTED` (source not usable right now) \u2014 carry no subunit, so they cannot be\n // attributed to one request; it is the ABSENCE of an answer that excludes a subunit.\n return new Set(\n candidates\n .map(source => source.subunit)\n .filter(subunit => Object.keys(answer.subunits[subunit] ?? {}).some(func => LIST_PROOF.test(func))),\n );\n }\n\n /**\n * Register the supervisor's drop handler \u2014 delegated to the client's socket drop,\n * which is YNCA's genuine connection-lost signal.\n *\n * @param cb invoked once when the connection drops, with the reason if known\n */\n public onDrop(cb: (reason?: Error) => void): void {\n this.deps.client.onDrop(cb);\n }\n\n /** Close the client. Synchronous \u2014 safe to call from onUnload. */\n public close(): void {\n this.browseEngine?.close();\n this.browseDriver?.close();\n this.deps.client.close();\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": ["_a"]
7
7
  }
@@ -24,6 +24,7 @@ __export(pure_helpers_exports, {
24
24
  isUsefulDeviceName: () => isUsefulDeviceName,
25
25
  legacyDeviceRow: () => legacyDeviceRow,
26
26
  mergeDiscovered: () => mergeDiscovered,
27
+ neverWrittenStateIds: () => neverWrittenStateIds,
27
28
  nextDeviceLabel: () => nextDeviceLabel,
28
29
  parseDevices: () => parseDevices,
29
30
  renamedObjectIds: () => renamedObjectIds,
@@ -277,6 +278,28 @@ const RENAMED_CHANNELS = [
277
278
  "player.musicCastLink",
278
279
  "lipSync"
279
280
  ];
281
+ function neverWrittenStateIds(objects, states, deviceIds, namespace) {
282
+ const ids = [];
283
+ for (const [fullId, object] of Object.entries(objects)) {
284
+ const common = object == null ? void 0 : object.common;
285
+ if ((object == null ? void 0 : object.type) !== "state" || (common == null ? void 0 : common.read) === false) {
286
+ continue;
287
+ }
288
+ if ((common == null ? void 0 : common.custom) && Object.keys(common.custom).length > 0) {
289
+ continue;
290
+ }
291
+ const relative = stripNamespace(fullId, namespace);
292
+ const top = relative.split(".")[0];
293
+ if (!deviceIds.has(top) || relative.slice(top.length + 1).startsWith("info.")) {
294
+ continue;
295
+ }
296
+ const state = states[fullId];
297
+ if (!state || (state.val === null || state.val === void 0) && !state.lc) {
298
+ ids.push(fullId);
299
+ }
300
+ }
301
+ return ids;
302
+ }
280
303
  function renamedObjectIds(existing, deviceIds, namespace) {
281
304
  var _a, _b;
282
305
  const stale = [];
@@ -340,6 +363,7 @@ function nextDeviceLabel(current, deviceId, candidate, rank, ownName, ownRank) {
340
363
  isUsefulDeviceName,
341
364
  legacyDeviceRow,
342
365
  mergeDiscovered,
366
+ neverWrittenStateIds,
343
367
  nextDeviceLabel,
344
368
  parseDevices,
345
369
  renamedObjectIds,