iobroker.yamaha 2.0.1 → 2.0.3
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 +8 -12
- package/build/lib/catalog/groups.js.map +1 -1
- package/build/lib/device-controller.js +8 -5
- package/build/lib/device-controller.js.map +2 -2
- package/build/lib/pure-helpers.js +0 -3
- package/build/lib/pure-helpers.js.map +2 -2
- package/build/lib/ynca/capability.js +14 -2
- package/build/lib/ynca/capability.js.map +2 -2
- package/io-package.json +27 -27
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -103,6 +103,14 @@ On the very first contact the adapter asks the receiver which functions it suppo
|
|
|
103
103
|
Placeholder for the next version (at the beginning of the line):
|
|
104
104
|
### **WORK IN PROGRESS**
|
|
105
105
|
-->
|
|
106
|
+
### 2.0.3 (2026-09-01)
|
|
107
|
+
|
|
108
|
+
- (krobipd) Fixed: the update cleanup now removes every never-filled leftover datapoint — a history recording setting no longer shields it, because nothing was ever recorded there and nothing is lost
|
|
109
|
+
|
|
110
|
+
### 2.0.2 (2026-09-01)
|
|
111
|
+
|
|
112
|
+
- (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
|
|
113
|
+
|
|
106
114
|
### 2.0.1 (2026-09-01)
|
|
107
115
|
|
|
108
116
|
- (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
|
|
@@ -133,18 +141,6 @@ On the very first contact the adapter asks the receiver which functions it suppo
|
|
|
133
141
|
- (krobipd) Improved: restarts are fast — the adapter remembers what each device can do, brings it online in seconds and refreshes values in the background; only the first contact asks everything
|
|
134
142
|
- (krobipd) Improved: known devices no longer wait for the network search at startup — it runs in the background and only adds newcomers
|
|
135
143
|
|
|
136
|
-
### 1.6.0 (2026-08-27)
|
|
137
|
-
|
|
138
|
-
- (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
|
|
139
|
-
- (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
|
|
140
|
-
|
|
141
|
-
### 1.5.0 (2026-08-27)
|
|
142
|
-
|
|
143
|
-
- (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)
|
|
144
|
-
- (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
|
|
145
|
-
- (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
|
|
146
|
-
- (krobipd) New: optional error reporting via Sentry — only active if you enabled diagnostics in ioBroker, and it transmits no personal data
|
|
147
|
-
|
|
148
144
|
[Older changelogs can be found there](CHANGELOG_OLD.md)
|
|
149
145
|
|
|
150
146
|
## 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.
|
|
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
|
-
(
|
|
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: (
|
|
281
|
-
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) {
|
|
@@ -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 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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",
|
|
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
|
}
|
|
@@ -285,9 +285,6 @@ function neverWrittenStateIds(objects, states, deviceIds, namespace) {
|
|
|
285
285
|
if ((object == null ? void 0 : object.type) !== "state" || (common == null ? void 0 : common.read) === false) {
|
|
286
286
|
continue;
|
|
287
287
|
}
|
|
288
|
-
if ((common == null ? void 0 : common.custom) && Object.keys(common.custom).length > 0) {
|
|
289
|
-
continue;
|
|
290
|
-
}
|
|
291
288
|
const relative = stripNamespace(fullId, namespace);
|
|
292
289
|
const top = relative.split(".")[0];
|
|
293
290
|
if (!deviceIds.has(top) || relative.slice(top.length + 1).startsWith("info.")) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/pure-helpers.ts"],
|
|
4
|
-
"sourcesContent": ["import type { DeviceRecord } from \"./types\";\nimport type { DiscoveredDevice } from \"./discovery\";\n\ninterface ConfiguredDevice {\n name?: string;\n ip: string;\n}\n\n/**\n * True when a raw config row carries a non-empty ip (the name is optional \u2014 a row\n * with an ip but no name is valid and falls back to the ip as its id, so a device\n * is never silently dropped just because its name was left blank).\n *\n * @param entry a raw config row from the admin device table\n * @returns whether the row is a valid configured device\n */\nfunction isConfiguredDevice(entry: unknown): entry is ConfiguredDevice {\n if (typeof entry !== \"object\" || entry === null) {\n return false;\n }\n const candidate = entry as { name?: unknown; ip?: unknown };\n return (\n typeof candidate.ip === \"string\" &&\n candidate.ip.length > 0 &&\n (candidate.name === undefined || typeof candidate.name === \"string\")\n );\n}\n\n/**\n * Make a string safe for use as an ioBroker object id segment.\n *\n * @param raw the raw string (e.g. a device name)\n * @returns the string with id-unsafe characters replaced by underscores\n */\nexport function sanitizeId(raw: string): string {\n return raw.replace(/[^A-Za-z0-9\\-_]/g, \"_\");\n}\n\n/**\n * Strip the adapter namespace (e.g. `yamaha.0`) from a full state id, leaving the\n * device-relative path (e.g. `living.power`).\n *\n * @param fullId the full state id\n * @param namespace the adapter namespace\n * @returns the id relative to the adapter instance\n */\nexport function stripNamespace(fullId: string, namespace: string): string {\n return fullId.slice(namespace.length + 1);\n}\n\n/**\n * Turn the admin device table (untrusted native config) into device records.\n * Invalid rows are dropped, as are rows whose id collides with the adapter's own\n * reserved `info` branch or with an id already taken \u2014 two names that sanitise to\n * the same id (e.g. \"Living Room\" and \"Living.Room\") would otherwise share one\n * object tree. A dropped collision is reported via `onCollision` so the device does\n * not just silently \"not exist\" for the user.\n *\n * @param raw the raw `native.devices` value\n * @param onCollision called with the dropped row's name/ip and the clashing id\n * @returns validated, de-duplicated device records\n */\nexport function parseDevices(raw: unknown, onCollision?: (dropped: string, takenId: string) => void): DeviceRecord[] {\n if (!Array.isArray(raw)) {\n return [];\n }\n const records: DeviceRecord[] = [];\n const taken = new Set<string>([\"info\"]); // reserved: the adapter's own info channel\n for (const entry of raw) {\n if (!isConfiguredDevice(entry)) {\n continue;\n }\n // Fall back to the ip as the id when the name is blank, so the device still appears\n // instead of vanishing silently.\n const id = sanitizeId(entry.name && entry.name.length > 0 ? entry.name : entry.ip);\n if (taken.has(id)) {\n onCollision?.(entry.name || entry.ip, id);\n continue;\n }\n taken.add(id);\n records.push({ id, ip: entry.ip });\n }\n return records;\n}\n\n/**\n * Merge freshly discovered devices into the set already known from earlier runs\n * (the auto-discovery standby protection). A known device is kept even when this\n * run's scan did not find it \u2014 a receiver in deep standby answers no SSDP, and its\n * object tree must survive. New addresses are added; a discovered device is turned\n * into a record via its friendly name (or its ip when it advertises none), and one\n * whose id would collide with an already-kept device is skipped (reported via\n * `onCollision`, so the missing device is explainable). De-duplicated by ip.\n *\n * @param known the device records remembered from earlier runs\n * @param found the devices discovered this run\n * @param onCollision called with the dropped device's name/ip and the clashing id\n * @returns the merged records, de-duplicated by ip\n */\nexport function mergeDiscovered(\n known: DeviceRecord[],\n found: DiscoveredDevice[],\n onCollision?: (dropped: string, takenId: string) => void,\n): DeviceRecord[] {\n const byIp = new Map<string, DeviceRecord>();\n const takenIds = new Set<string>([\"info\"]); // reserved: the adapter's own info channel\n for (const device of known) {\n if (byIp.has(device.ip) || takenIds.has(device.id)) {\n continue;\n }\n byIp.set(device.ip, device);\n takenIds.add(device.id);\n }\n for (const device of found) {\n if (byIp.has(device.ip)) {\n continue;\n }\n const id = sanitizeId(device.name || device.ip);\n if (takenIds.has(id)) {\n onCollision?.(device.name || device.ip, id);\n continue;\n }\n takenIds.add(id);\n byIp.set(device.ip, { id, ip: device.ip });\n }\n return [...byIp.values()];\n}\n\n/**\n * From all object ids under the instance, pick the stale ones to delete on start:\n * everything that does not belong to a configured device and is outside the\n * adapter's own `info` branch. Removes the previous adapter's whole object tree\n * and any device dropped from the config. Keyed on the configured device ids\n * (not on what was created this run), so it works with the async connect where a\n * device's tree may only appear later \u2014 a configured device's subtree is kept\n * regardless of whether it has connected yet. Deepest first, so children go\n * before their parents.\n *\n * @param existing all object ids currently under the instance\n * @param deviceIds the ids of the currently configured devices\n * @param namespace the adapter namespace (e.g. `yamaha.0`)\n * @returns the stale ids to delete, deepest first\n */\nexport function staleObjects(existing: string[], deviceIds: Set<string>, namespace: string): string[] {\n // No configured devices \u2192 never wipe the whole tree (a user who cleared the\n // device table by accident would otherwise lose every object in one pass).\n if (deviceIds.size === 0) {\n return [];\n }\n const isKept = (fullId: string): boolean => {\n const top = stripNamespace(fullId, namespace).split(\".\")[0];\n return top === \"info\" || deviceIds.has(top);\n };\n return existing.filter(id => !isKept(id)).sort((a, b) => b.length - a.length);\n}\n\n/**\n * Relative state ids an earlier version created under a different path and that this\n * version has renamed or moved. On start-up the old object is deleted so it does not\n * linger orphaned beside the new one \u2014 {@link staleObjects} only removes whole\n * non-configured device trees, not renamed states inside a device that is kept.\n */\n/**\n * The per-source playback-block states of the pre-2.0.0 tree \u2014 the same 19 states\n * used to sit under every player source folder, almost all permanently empty. In\n * 2.0.0 ONE flat block per zone replaces them; these copies are removed on the\n * first start (the sources that keep genuinely own states keep those untouched).\n */\nconst V2_PLAYER_BLOCK_STATES = [\n \"playback\",\n \"artist\",\n \"album\",\n \"track\",\n \"station\",\n \"channelName\",\n \"totalTime\",\n \"elapsedTime\",\n \"repeat\",\n \"shuffle\",\n \"albumArt\",\n \"source\",\n \"play\",\n \"pause\",\n \"stop\",\n \"next\",\n \"prev\",\n \"repeatToggle\",\n \"shuffleToggle\",\n];\n\n/** The source folders that stay in 2.0.0 (own states remain) but lose their block copy. */\nconst V2_SLIMMED_SOURCES = [\n \"netPlayer\",\n \"cd\",\n \"netRadio\",\n \"server\",\n \"usb\",\n \"napster\",\n \"pandora\",\n \"rhapsody\",\n \"sirius\",\n \"pc\",\n \"airplay\",\n \"bluetooth\",\n];\n\nexport const RENAMED_STATE_IDS = [\n \"hdmiOut\",\n \"directMode\",\n \"masterPower\",\n \"party\",\n \"partyMute\",\n \"distributionEnable\",\n \"partyEnable\",\n \"remoteCode\",\n // v1.0.0 multiroom regroup: the MusicCast-Link states moved into their own\n // multiroom.group folder, so the tree itself tells device-group from all-zones scope.\n \"multiroom.distributionEnable\",\n // Short-lived v1.0.0 pre-cut id \u2014 mislabeled \"active\", the field means \"enabled for use\".\n \"multiroom.group.streamingActive\",\n \"multiroom.role\",\n \"multiroom.groupId\",\n \"multiroom.groupName\",\n \"multiroom.serverZone\",\n \"multiroom.clientList\",\n \"multiroom.linkClient\",\n \"multiroom.leaveGroup\",\n // ---- v2.0.0 tree rework ------------------------------------------------------\n // Player unification: the per-source copies of the playback block are gone (the\n // slimmed folders keep only their own preset/pairing/drive states).\n ...V2_SLIMMED_SOURCES.flatMap(source => V2_PLAYER_BLOCK_STATES.map(state => `player.${source}.${state}`)),\n // Scenes: the twelve per-name datapoints became the recall dropdown + scene.list.\n ...Array.from({ length: 12 }, (_unused, i) => `scene.name${i + 1}`),\n // Tuner unification: ONE band/frequency/preset; the DAB subunit's FM half moved\n // onto the flat tuner ids, the two per-band frequencies became tuner.frequency.\n \"tuner.amFrequency\",\n \"tuner.fmFrequency\",\n \"tuner.dab.band\",\n \"tuner.dab.preset\",\n \"tuner.dab.fmPreset\",\n \"tuner.dab.fmFrequency\",\n \"tuner.dab.fmSearchMode\",\n \"tuner.dab.fmRdsService\",\n \"tuner.dab.fmRdsProgramType\",\n \"tuner.dab.fmRdsText\",\n \"tuner.dab.fmRdsClock\",\n \"tuner.dab.fmStereo\",\n \"tuner.dab.fmTuned\",\n \"tuner.dab.audioMode\",\n // Sound polish: equalizer and signal info each moved into their own subfolder.\n \"sound.equalizerMode\",\n \"sound.equalizerLow\",\n \"sound.equalizerMid\",\n \"sound.equalizerHigh\",\n \"sound.signalFormat\",\n \"sound.signalSampling\",\n \"sound.signalBits\",\n \"sound.signalBitrate\",\n // HDMI polish: the lip-sync offsets moved into the hdmi folder (the lipSync\n // channel itself is in RENAMED_CHANNELS); the A/B toggles joined the speakers.\n \"advanced.speakerA\",\n \"advanced.speakerB\",\n];\n\n/**\n * Old channel prefixes whose whole subtree this version moved out \u2014 the `system`\n * grab-bag is gone (model/firmware \u2192 info, HDMI outputs \u2192 hdmi, speaker patterns \u2192\n * speakers, input names \u2192 inputNames, master power \u2192 multiroom.masterPower). The channel and\n * every state under it are removed.\n */\nexport const RENAMED_CHANNELS = [\n // pre-0.11 system folder\n \"system\",\n // v0.18.1 multiroom regroup: zone2/3/4, zoneB, flat multiroom states moved under multiroom/.\n \"zone2\",\n \"zone3\",\n \"zone4\",\n \"zoneB\",\n // v1.0.0: stray per-zone copies of the device-global YXC states (the zone loop used to\n // prefix them too, yielding multiroom.zoneN.multiroom.* junk) are swept away.\n \"multiroom.zone2.multiroom\",\n \"multiroom.zone3.multiroom\",\n \"multiroom.zone4.multiroom\",\n // Regrouping: media sources moved under player/, DAB under tuner/, dist \u2192 multiroom. The old\n // flat channels (and their whole subtree) are deleted so the new grouped ones do not sit\n // beside orphaned copies on an upgraded instance.\n \"netRadio\",\n \"server\",\n \"usb\",\n \"spotify\",\n \"deezer\",\n \"tidal\",\n \"napster\",\n \"pandora\",\n \"rhapsody\",\n \"sirius\",\n \"airplay\",\n \"bluetooth\",\n \"pc\",\n \"musicCastLink\",\n \"ipod\",\n \"ipodUsb\",\n \"netPlayer\",\n \"cd\",\n \"dab\",\n \"dist\",\n // Sound/Advanced regroup: DSP/tone-tuning states moved under sound.*, setup-only states\n // (+ the speakers/initialVolume/inputNames subtrees, already dotted before) under advanced.*.\n // {@link renamedObjectIds} also checks these against a stripped zone2/3/4 prefix, so one\n // entry here catches a MAIN state and its zoned copies (e.g. \"straight\" and \"zone2.straight\").\n \"straight\",\n \"enhancer\",\n \"pureDirect\",\n \"direct\",\n \"adaptiveDrc\",\n \"surroundAI\",\n \"surroundDecoder\",\n \"cinemaDsp3d\",\n \"extraBass\",\n \"bass\",\n \"treble\",\n \"subwooferTrim\",\n \"balance\",\n \"dialogueLevel\",\n \"dialogueLift\",\n \"dtsDialogueControl\",\n \"monaural\",\n \"surround3d\",\n \"adaptiveDspLevel\",\n \"audioSelect\",\n \"linkControl\",\n \"linkAudioDelay\",\n \"linkAudioQuality\",\n \"contentsDisplay\",\n \"equalizerLow\",\n \"equalizerMid\",\n \"equalizerHigh\",\n \"clearVoice\",\n \"bassExtension\",\n \"ypaoVolume\",\n \"maxVolume\",\n \"speakerA\",\n \"speakerB\",\n \"speakers\",\n \"initialVolume\",\n \"inputNames\",\n // ---- v2.0.0 tree rework: the always-empty source channels are gone entirely\n // (their playback lives in the flat per-zone block), lip sync moved into hdmi.\n \"player.spotify\",\n \"player.deezer\",\n \"player.tidal\",\n \"player.ipod\",\n \"player.ipodUsb\",\n \"player.musicCastLink\",\n \"lipSync\",\n];\n\n/**\n * Read-capable state objects under a configured device whose state NEVER carried a\n * value (no value, no last-change). They are over-declarations of an earlier adapter\n * version \u2014 today's claim-with-proof creation would not make them \u2014 and deleting them\n * is lossless: there is no value and no history to lose, and anything a transport\n * legitimately claims is recreated right after, when the device connects. Runs once\n * per adapter version (the caller keeps a marker), so a freshly created state that is\n * merely waiting for its first value does not flap on every start. Excluded: buttons\n * and other write-only states (naturally valueless), states carrying per-state user\n * settings (`common.custom`, e.g. a history binding \u2014 a deliberate user link is never\n * deleted behind their back), and the `info.` header the adapter itself maintains for\n * every device, connected or not.\n *\n * @param objects all objects under the instance, keyed by full id\n * @param states all states under the instance, keyed by full id\n * @param deviceIds the ids of the devices to sweep\n * @param namespace the adapter namespace (e.g. `yamaha.0`)\n * @returns the full ids of never-filled read-capable states\n */\nexport function neverWrittenStateIds(\n objects: Record<string, { type?: string; common?: unknown } | undefined>,\n states: Record<string, { val?: unknown; lc?: number } | null | undefined>,\n deviceIds: Set<string>,\n namespace: string,\n): string[] {\n const ids: string[] = [];\n for (const [fullId, object] of Object.entries(objects)) {\n const common = object?.common as { read?: boolean; custom?: Record<string, unknown> } | undefined;\n if (object?.type !== \"state\" || common?.read === false) {\n continue;\n }\n if (common?.custom && Object.keys(common.custom).length > 0) {\n continue;\n }\n const relative = stripNamespace(fullId, namespace);\n const top = relative.split(\".\")[0];\n if (!deviceIds.has(top) || relative.slice(top.length + 1).startsWith(\"info.\")) {\n continue;\n }\n const state = states[fullId];\n if (!state || ((state.val === null || state.val === undefined) && !state.lc)) {\n ids.push(fullId);\n }\n }\n return ids;\n}\n\n/**\n * The full ids of renamed old states (and old channel subtrees) that still exist\n * under a configured device, to be deleted on start-up so no orphan lingers beside\n * the new object. Deepest first, so children go before their parents.\n *\n * @param existing all object ids currently under the instance\n * @param deviceIds the ids of the currently configured devices\n * @param namespace the adapter namespace (e.g. `yamaha.0`)\n * @returns the full old ids to delete, deepest first\n */\nexport function renamedObjectIds(existing: string[], deviceIds: Set<string>, namespace: string): string[] {\n const stale: string[] = [];\n for (const deviceId of deviceIds) {\n const base = `${namespace}.${deviceId}.`;\n for (const full of existing) {\n if (!full.startsWith(base)) {\n continue;\n }\n const rel = full.slice(base.length);\n // Strip an optional zone prefix before matching too, so one RENAMED_CHANNELS entry\n // (e.g. \"straight\") catches both the MAIN state and its zoned copies \u2014 the old flat\n // \"zone2.\" form and today's \"multiroom.zone2.\" form alike (v2.0.0 renames live in\n // zoned folders, e.g. multiroom.zone2.scene.name1).\n const zone = /^(?:multiroom\\.)?zone[234]\\./.exec(rel)?.[0] ?? \"\";\n const template = rel.slice(zone.length);\n const renamedState = RENAMED_STATE_IDS.includes(rel) || RENAMED_STATE_IDS.includes(template);\n const underRenamedChannel = RENAMED_CHANNELS.some(\n ch => rel === ch || rel.startsWith(`${ch}.`) || template === ch || template.startsWith(`${ch}.`),\n );\n if (renamedState || underRenamedChannel) {\n stale.push(full);\n }\n }\n }\n return stale.sort((a, b) => b.length - a.length);\n}\n\n/**\n * The device row to carry over from the previous adapter's single-device config,\n * or undefined if nothing needs migrating. The old yamaha stored one receiver as\n * `config.ip` (older installs: `config.IP`); the new adapter uses a `devices`\n * table. Only migrates when the table is still empty, so it runs once.\n *\n * The old value could be a hostname (its HTTP client resolved names) and could\n * carry a `:port` suffix (its HTTP library split host:port). A port suffix would\n * break every transport here \u2014 YNCA is TCP :50000, YXC/XML build `http://<ip>\u2026`\n * \u2014 so it is stripped; the host/IP itself is carried over as-is.\n *\n * @param config the instance's native config\n * @returns the row to add to the devices table, or undefined\n */\nexport function legacyDeviceRow(config: Record<string, unknown>): { name: string; ip: string } | undefined {\n if (Array.isArray(config.devices) && config.devices.length > 0) {\n return undefined;\n }\n const raw =\n typeof config.ip === \"string\" && config.ip\n ? config.ip\n : typeof config.IP === \"string\" && config.IP\n ? config.IP\n : undefined;\n if (!raw) {\n return undefined;\n }\n const ip = raw.trim().replace(/:\\d+$/, \"\");\n return ip ? { name: ip, ip } : undefined;\n}\n\n/**\n * How trustworthy a display-name candidate is. A name the device carries for itself\n * (the MusicCast zone name a user typed in the app) beats its model designation.\n */\nexport const LABEL_RANK = { model: 1, deviceName: 2 } as const;\n\n/** Rank of a display-name candidate \u2014 see {@link LABEL_RANK}. */\nexport type LabelRank = (typeof LABEL_RANK)[keyof typeof LABEL_RANK];\n\n/**\n * Zone names that say nothing about the device \u2014 a receiver ships with these and a\n * user who never renamed the zone would end up with \"Main Zone\" as the device name.\n */\nconst GENERIC_ZONE_NAMES = new Set([\"main\", \"main zone\", \"mainzone\", \"zone\", \"zone 1\", \"zone1\"]);\n\n/**\n * Is this candidate worth showing as a device name?\n *\n * @param candidate the reported name\n * @returns true when it carries information about this particular device\n */\nexport function isUsefulDeviceName(candidate: string | undefined): boolean {\n const trimmed = (candidate ?? \"\").trim();\n return trimmed.length > 0 && !GENERIC_ZONE_NAMES.has(trimmed.toLowerCase());\n}\n\n/**\n * The display name to write onto a device object, or undefined to leave it alone.\n *\n * An upgraded instance carries the IP as its device name: the previous adapter knew\n * only an IP, so the migration had nothing else to call the device, and the object id\n * \u2014 which must not change, every history and visualisation binding hangs off it \u2014\n * became that IP. This decides when the adapter may replace that placeholder with\n * something a user recognises.\n *\n * Two things must never be overwritten: a name the user typed, and a better name by a\n * weaker source. The adapter therefore only writes over its own placeholder (the id\n * itself) or over what it wrote last, and only when the new candidate ranks at least\n * as high as the one behind the current name.\n *\n * @param current the device object's present `common.name`\n * @param deviceId the object id, which is also the placeholder name\n * @param candidate the newly reported name\n * @param rank how trustworthy the candidate is\n * @param ownName the name this adapter wrote last for the device, if any\n * @param ownRank the rank behind {@link ownName}\n * @returns the name to write, or undefined when the current name stays\n */\nexport function nextDeviceLabel(\n current: string | undefined,\n deviceId: string,\n candidate: string | undefined,\n rank: LabelRank,\n ownName?: string,\n ownRank?: LabelRank,\n): string | undefined {\n const wanted = (candidate ?? \"\").trim();\n if (!isUsefulDeviceName(wanted) || wanted === current) {\n return undefined;\n }\n const isPlaceholder = current === undefined || current === deviceId;\n const isOurs = ownName !== undefined && current === ownName;\n if (!isPlaceholder && !isOurs) {\n return undefined; // the user named this device \u2014 theirs wins\n }\n if (isOurs && ownRank !== undefined && rank < ownRank) {\n return undefined; // do not fall back from a device name to its model\n }\n return wanted;\n}\n"],
|
|
5
|
-
"mappings": "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
|
|
4
|
+
"sourcesContent": ["import type { DeviceRecord } from \"./types\";\nimport type { DiscoveredDevice } from \"./discovery\";\n\ninterface ConfiguredDevice {\n name?: string;\n ip: string;\n}\n\n/**\n * True when a raw config row carries a non-empty ip (the name is optional \u2014 a row\n * with an ip but no name is valid and falls back to the ip as its id, so a device\n * is never silently dropped just because its name was left blank).\n *\n * @param entry a raw config row from the admin device table\n * @returns whether the row is a valid configured device\n */\nfunction isConfiguredDevice(entry: unknown): entry is ConfiguredDevice {\n if (typeof entry !== \"object\" || entry === null) {\n return false;\n }\n const candidate = entry as { name?: unknown; ip?: unknown };\n return (\n typeof candidate.ip === \"string\" &&\n candidate.ip.length > 0 &&\n (candidate.name === undefined || typeof candidate.name === \"string\")\n );\n}\n\n/**\n * Make a string safe for use as an ioBroker object id segment.\n *\n * @param raw the raw string (e.g. a device name)\n * @returns the string with id-unsafe characters replaced by underscores\n */\nexport function sanitizeId(raw: string): string {\n return raw.replace(/[^A-Za-z0-9\\-_]/g, \"_\");\n}\n\n/**\n * Strip the adapter namespace (e.g. `yamaha.0`) from a full state id, leaving the\n * device-relative path (e.g. `living.power`).\n *\n * @param fullId the full state id\n * @param namespace the adapter namespace\n * @returns the id relative to the adapter instance\n */\nexport function stripNamespace(fullId: string, namespace: string): string {\n return fullId.slice(namespace.length + 1);\n}\n\n/**\n * Turn the admin device table (untrusted native config) into device records.\n * Invalid rows are dropped, as are rows whose id collides with the adapter's own\n * reserved `info` branch or with an id already taken \u2014 two names that sanitise to\n * the same id (e.g. \"Living Room\" and \"Living.Room\") would otherwise share one\n * object tree. A dropped collision is reported via `onCollision` so the device does\n * not just silently \"not exist\" for the user.\n *\n * @param raw the raw `native.devices` value\n * @param onCollision called with the dropped row's name/ip and the clashing id\n * @returns validated, de-duplicated device records\n */\nexport function parseDevices(raw: unknown, onCollision?: (dropped: string, takenId: string) => void): DeviceRecord[] {\n if (!Array.isArray(raw)) {\n return [];\n }\n const records: DeviceRecord[] = [];\n const taken = new Set<string>([\"info\"]); // reserved: the adapter's own info channel\n for (const entry of raw) {\n if (!isConfiguredDevice(entry)) {\n continue;\n }\n // Fall back to the ip as the id when the name is blank, so the device still appears\n // instead of vanishing silently.\n const id = sanitizeId(entry.name && entry.name.length > 0 ? entry.name : entry.ip);\n if (taken.has(id)) {\n onCollision?.(entry.name || entry.ip, id);\n continue;\n }\n taken.add(id);\n records.push({ id, ip: entry.ip });\n }\n return records;\n}\n\n/**\n * Merge freshly discovered devices into the set already known from earlier runs\n * (the auto-discovery standby protection). A known device is kept even when this\n * run's scan did not find it \u2014 a receiver in deep standby answers no SSDP, and its\n * object tree must survive. New addresses are added; a discovered device is turned\n * into a record via its friendly name (or its ip when it advertises none), and one\n * whose id would collide with an already-kept device is skipped (reported via\n * `onCollision`, so the missing device is explainable). De-duplicated by ip.\n *\n * @param known the device records remembered from earlier runs\n * @param found the devices discovered this run\n * @param onCollision called with the dropped device's name/ip and the clashing id\n * @returns the merged records, de-duplicated by ip\n */\nexport function mergeDiscovered(\n known: DeviceRecord[],\n found: DiscoveredDevice[],\n onCollision?: (dropped: string, takenId: string) => void,\n): DeviceRecord[] {\n const byIp = new Map<string, DeviceRecord>();\n const takenIds = new Set<string>([\"info\"]); // reserved: the adapter's own info channel\n for (const device of known) {\n if (byIp.has(device.ip) || takenIds.has(device.id)) {\n continue;\n }\n byIp.set(device.ip, device);\n takenIds.add(device.id);\n }\n for (const device of found) {\n if (byIp.has(device.ip)) {\n continue;\n }\n const id = sanitizeId(device.name || device.ip);\n if (takenIds.has(id)) {\n onCollision?.(device.name || device.ip, id);\n continue;\n }\n takenIds.add(id);\n byIp.set(device.ip, { id, ip: device.ip });\n }\n return [...byIp.values()];\n}\n\n/**\n * From all object ids under the instance, pick the stale ones to delete on start:\n * everything that does not belong to a configured device and is outside the\n * adapter's own `info` branch. Removes the previous adapter's whole object tree\n * and any device dropped from the config. Keyed on the configured device ids\n * (not on what was created this run), so it works with the async connect where a\n * device's tree may only appear later \u2014 a configured device's subtree is kept\n * regardless of whether it has connected yet. Deepest first, so children go\n * before their parents.\n *\n * @param existing all object ids currently under the instance\n * @param deviceIds the ids of the currently configured devices\n * @param namespace the adapter namespace (e.g. `yamaha.0`)\n * @returns the stale ids to delete, deepest first\n */\nexport function staleObjects(existing: string[], deviceIds: Set<string>, namespace: string): string[] {\n // No configured devices \u2192 never wipe the whole tree (a user who cleared the\n // device table by accident would otherwise lose every object in one pass).\n if (deviceIds.size === 0) {\n return [];\n }\n const isKept = (fullId: string): boolean => {\n const top = stripNamespace(fullId, namespace).split(\".\")[0];\n return top === \"info\" || deviceIds.has(top);\n };\n return existing.filter(id => !isKept(id)).sort((a, b) => b.length - a.length);\n}\n\n/**\n * Relative state ids an earlier version created under a different path and that this\n * version has renamed or moved. On start-up the old object is deleted so it does not\n * linger orphaned beside the new one \u2014 {@link staleObjects} only removes whole\n * non-configured device trees, not renamed states inside a device that is kept.\n */\n/**\n * The per-source playback-block states of the pre-2.0.0 tree \u2014 the same 19 states\n * used to sit under every player source folder, almost all permanently empty. In\n * 2.0.0 ONE flat block per zone replaces them; these copies are removed on the\n * first start (the sources that keep genuinely own states keep those untouched).\n */\nconst V2_PLAYER_BLOCK_STATES = [\n \"playback\",\n \"artist\",\n \"album\",\n \"track\",\n \"station\",\n \"channelName\",\n \"totalTime\",\n \"elapsedTime\",\n \"repeat\",\n \"shuffle\",\n \"albumArt\",\n \"source\",\n \"play\",\n \"pause\",\n \"stop\",\n \"next\",\n \"prev\",\n \"repeatToggle\",\n \"shuffleToggle\",\n];\n\n/** The source folders that stay in 2.0.0 (own states remain) but lose their block copy. */\nconst V2_SLIMMED_SOURCES = [\n \"netPlayer\",\n \"cd\",\n \"netRadio\",\n \"server\",\n \"usb\",\n \"napster\",\n \"pandora\",\n \"rhapsody\",\n \"sirius\",\n \"pc\",\n \"airplay\",\n \"bluetooth\",\n];\n\nexport const RENAMED_STATE_IDS = [\n \"hdmiOut\",\n \"directMode\",\n \"masterPower\",\n \"party\",\n \"partyMute\",\n \"distributionEnable\",\n \"partyEnable\",\n \"remoteCode\",\n // v1.0.0 multiroom regroup: the MusicCast-Link states moved into their own\n // multiroom.group folder, so the tree itself tells device-group from all-zones scope.\n \"multiroom.distributionEnable\",\n // Short-lived v1.0.0 pre-cut id \u2014 mislabeled \"active\", the field means \"enabled for use\".\n \"multiroom.group.streamingActive\",\n \"multiroom.role\",\n \"multiroom.groupId\",\n \"multiroom.groupName\",\n \"multiroom.serverZone\",\n \"multiroom.clientList\",\n \"multiroom.linkClient\",\n \"multiroom.leaveGroup\",\n // ---- v2.0.0 tree rework ------------------------------------------------------\n // Player unification: the per-source copies of the playback block are gone (the\n // slimmed folders keep only their own preset/pairing/drive states).\n ...V2_SLIMMED_SOURCES.flatMap(source => V2_PLAYER_BLOCK_STATES.map(state => `player.${source}.${state}`)),\n // Scenes: the twelve per-name datapoints became the recall dropdown + scene.list.\n ...Array.from({ length: 12 }, (_unused, i) => `scene.name${i + 1}`),\n // Tuner unification: ONE band/frequency/preset; the DAB subunit's FM half moved\n // onto the flat tuner ids, the two per-band frequencies became tuner.frequency.\n \"tuner.amFrequency\",\n \"tuner.fmFrequency\",\n \"tuner.dab.band\",\n \"tuner.dab.preset\",\n \"tuner.dab.fmPreset\",\n \"tuner.dab.fmFrequency\",\n \"tuner.dab.fmSearchMode\",\n \"tuner.dab.fmRdsService\",\n \"tuner.dab.fmRdsProgramType\",\n \"tuner.dab.fmRdsText\",\n \"tuner.dab.fmRdsClock\",\n \"tuner.dab.fmStereo\",\n \"tuner.dab.fmTuned\",\n \"tuner.dab.audioMode\",\n // Sound polish: equalizer and signal info each moved into their own subfolder.\n \"sound.equalizerMode\",\n \"sound.equalizerLow\",\n \"sound.equalizerMid\",\n \"sound.equalizerHigh\",\n \"sound.signalFormat\",\n \"sound.signalSampling\",\n \"sound.signalBits\",\n \"sound.signalBitrate\",\n // HDMI polish: the lip-sync offsets moved into the hdmi folder (the lipSync\n // channel itself is in RENAMED_CHANNELS); the A/B toggles joined the speakers.\n \"advanced.speakerA\",\n \"advanced.speakerB\",\n];\n\n/**\n * Old channel prefixes whose whole subtree this version moved out \u2014 the `system`\n * grab-bag is gone (model/firmware \u2192 info, HDMI outputs \u2192 hdmi, speaker patterns \u2192\n * speakers, input names \u2192 inputNames, master power \u2192 multiroom.masterPower). The channel and\n * every state under it are removed.\n */\nexport const RENAMED_CHANNELS = [\n // pre-0.11 system folder\n \"system\",\n // v0.18.1 multiroom regroup: zone2/3/4, zoneB, flat multiroom states moved under multiroom/.\n \"zone2\",\n \"zone3\",\n \"zone4\",\n \"zoneB\",\n // v1.0.0: stray per-zone copies of the device-global YXC states (the zone loop used to\n // prefix them too, yielding multiroom.zoneN.multiroom.* junk) are swept away.\n \"multiroom.zone2.multiroom\",\n \"multiroom.zone3.multiroom\",\n \"multiroom.zone4.multiroom\",\n // Regrouping: media sources moved under player/, DAB under tuner/, dist \u2192 multiroom. The old\n // flat channels (and their whole subtree) are deleted so the new grouped ones do not sit\n // beside orphaned copies on an upgraded instance.\n \"netRadio\",\n \"server\",\n \"usb\",\n \"spotify\",\n \"deezer\",\n \"tidal\",\n \"napster\",\n \"pandora\",\n \"rhapsody\",\n \"sirius\",\n \"airplay\",\n \"bluetooth\",\n \"pc\",\n \"musicCastLink\",\n \"ipod\",\n \"ipodUsb\",\n \"netPlayer\",\n \"cd\",\n \"dab\",\n \"dist\",\n // Sound/Advanced regroup: DSP/tone-tuning states moved under sound.*, setup-only states\n // (+ the speakers/initialVolume/inputNames subtrees, already dotted before) under advanced.*.\n // {@link renamedObjectIds} also checks these against a stripped zone2/3/4 prefix, so one\n // entry here catches a MAIN state and its zoned copies (e.g. \"straight\" and \"zone2.straight\").\n \"straight\",\n \"enhancer\",\n \"pureDirect\",\n \"direct\",\n \"adaptiveDrc\",\n \"surroundAI\",\n \"surroundDecoder\",\n \"cinemaDsp3d\",\n \"extraBass\",\n \"bass\",\n \"treble\",\n \"subwooferTrim\",\n \"balance\",\n \"dialogueLevel\",\n \"dialogueLift\",\n \"dtsDialogueControl\",\n \"monaural\",\n \"surround3d\",\n \"adaptiveDspLevel\",\n \"audioSelect\",\n \"linkControl\",\n \"linkAudioDelay\",\n \"linkAudioQuality\",\n \"contentsDisplay\",\n \"equalizerLow\",\n \"equalizerMid\",\n \"equalizerHigh\",\n \"clearVoice\",\n \"bassExtension\",\n \"ypaoVolume\",\n \"maxVolume\",\n \"speakerA\",\n \"speakerB\",\n \"speakers\",\n \"initialVolume\",\n \"inputNames\",\n // ---- v2.0.0 tree rework: the always-empty source channels are gone entirely\n // (their playback lives in the flat per-zone block), lip sync moved into hdmi.\n \"player.spotify\",\n \"player.deezer\",\n \"player.tidal\",\n \"player.ipod\",\n \"player.ipodUsb\",\n \"player.musicCastLink\",\n \"lipSync\",\n];\n\n/**\n * Read-capable state objects under a configured device whose state NEVER carried a\n * value (no value, no last-change). They are over-declarations of an earlier adapter\n * version \u2014 today's claim-with-proof creation would not make them \u2014 and deleting them\n * is lossless: there is no value and no history to lose, and anything a transport\n * legitimately claims is recreated right after, when the device connects. Runs once\n * per adapter version (the caller keeps a marker), so a freshly created state that is\n * merely waiting for its first value does not flap on every start. Excluded: buttons\n * and other write-only states (naturally valueless) and the `info.` header the adapter\n * itself maintains for every device, connected or not. A recording setting\n * (`common.custom`) is deliberately NO factor: whether anyone records a datapoint is\n * the user's business and says nothing about whether the datapoint belongs in the\n * tree \u2014 that is solely the adapter's responsibility (krobi, 2.0.3). On a never-filled\n * state there is nothing recorded to lose anyway.\n *\n * @param objects all objects under the instance, keyed by full id\n * @param states all states under the instance, keyed by full id\n * @param deviceIds the ids of the devices to sweep\n * @param namespace the adapter namespace (e.g. `yamaha.0`)\n * @returns the full ids of never-filled read-capable states\n */\nexport function neverWrittenStateIds(\n objects: Record<string, { type?: string; common?: unknown } | undefined>,\n states: Record<string, { val?: unknown; lc?: number } | null | undefined>,\n deviceIds: Set<string>,\n namespace: string,\n): string[] {\n const ids: string[] = [];\n for (const [fullId, object] of Object.entries(objects)) {\n const common = object?.common as { read?: boolean } | undefined;\n if (object?.type !== \"state\" || common?.read === false) {\n continue;\n }\n const relative = stripNamespace(fullId, namespace);\n const top = relative.split(\".\")[0];\n if (!deviceIds.has(top) || relative.slice(top.length + 1).startsWith(\"info.\")) {\n continue;\n }\n const state = states[fullId];\n if (!state || ((state.val === null || state.val === undefined) && !state.lc)) {\n ids.push(fullId);\n }\n }\n return ids;\n}\n\n/**\n * The full ids of renamed old states (and old channel subtrees) that still exist\n * under a configured device, to be deleted on start-up so no orphan lingers beside\n * the new object. Deepest first, so children go before their parents.\n *\n * @param existing all object ids currently under the instance\n * @param deviceIds the ids of the currently configured devices\n * @param namespace the adapter namespace (e.g. `yamaha.0`)\n * @returns the full old ids to delete, deepest first\n */\nexport function renamedObjectIds(existing: string[], deviceIds: Set<string>, namespace: string): string[] {\n const stale: string[] = [];\n for (const deviceId of deviceIds) {\n const base = `${namespace}.${deviceId}.`;\n for (const full of existing) {\n if (!full.startsWith(base)) {\n continue;\n }\n const rel = full.slice(base.length);\n // Strip an optional zone prefix before matching too, so one RENAMED_CHANNELS entry\n // (e.g. \"straight\") catches both the MAIN state and its zoned copies \u2014 the old flat\n // \"zone2.\" form and today's \"multiroom.zone2.\" form alike (v2.0.0 renames live in\n // zoned folders, e.g. multiroom.zone2.scene.name1).\n const zone = /^(?:multiroom\\.)?zone[234]\\./.exec(rel)?.[0] ?? \"\";\n const template = rel.slice(zone.length);\n const renamedState = RENAMED_STATE_IDS.includes(rel) || RENAMED_STATE_IDS.includes(template);\n const underRenamedChannel = RENAMED_CHANNELS.some(\n ch => rel === ch || rel.startsWith(`${ch}.`) || template === ch || template.startsWith(`${ch}.`),\n );\n if (renamedState || underRenamedChannel) {\n stale.push(full);\n }\n }\n }\n return stale.sort((a, b) => b.length - a.length);\n}\n\n/**\n * The device row to carry over from the previous adapter's single-device config,\n * or undefined if nothing needs migrating. The old yamaha stored one receiver as\n * `config.ip` (older installs: `config.IP`); the new adapter uses a `devices`\n * table. Only migrates when the table is still empty, so it runs once.\n *\n * The old value could be a hostname (its HTTP client resolved names) and could\n * carry a `:port` suffix (its HTTP library split host:port). A port suffix would\n * break every transport here \u2014 YNCA is TCP :50000, YXC/XML build `http://<ip>\u2026`\n * \u2014 so it is stripped; the host/IP itself is carried over as-is.\n *\n * @param config the instance's native config\n * @returns the row to add to the devices table, or undefined\n */\nexport function legacyDeviceRow(config: Record<string, unknown>): { name: string; ip: string } | undefined {\n if (Array.isArray(config.devices) && config.devices.length > 0) {\n return undefined;\n }\n const raw =\n typeof config.ip === \"string\" && config.ip\n ? config.ip\n : typeof config.IP === \"string\" && config.IP\n ? config.IP\n : undefined;\n if (!raw) {\n return undefined;\n }\n const ip = raw.trim().replace(/:\\d+$/, \"\");\n return ip ? { name: ip, ip } : undefined;\n}\n\n/**\n * How trustworthy a display-name candidate is. A name the device carries for itself\n * (the MusicCast zone name a user typed in the app) beats its model designation.\n */\nexport const LABEL_RANK = { model: 1, deviceName: 2 } as const;\n\n/** Rank of a display-name candidate \u2014 see {@link LABEL_RANK}. */\nexport type LabelRank = (typeof LABEL_RANK)[keyof typeof LABEL_RANK];\n\n/**\n * Zone names that say nothing about the device \u2014 a receiver ships with these and a\n * user who never renamed the zone would end up with \"Main Zone\" as the device name.\n */\nconst GENERIC_ZONE_NAMES = new Set([\"main\", \"main zone\", \"mainzone\", \"zone\", \"zone 1\", \"zone1\"]);\n\n/**\n * Is this candidate worth showing as a device name?\n *\n * @param candidate the reported name\n * @returns true when it carries information about this particular device\n */\nexport function isUsefulDeviceName(candidate: string | undefined): boolean {\n const trimmed = (candidate ?? \"\").trim();\n return trimmed.length > 0 && !GENERIC_ZONE_NAMES.has(trimmed.toLowerCase());\n}\n\n/**\n * The display name to write onto a device object, or undefined to leave it alone.\n *\n * An upgraded instance carries the IP as its device name: the previous adapter knew\n * only an IP, so the migration had nothing else to call the device, and the object id\n * \u2014 which must not change, every history and visualisation binding hangs off it \u2014\n * became that IP. This decides when the adapter may replace that placeholder with\n * something a user recognises.\n *\n * Two things must never be overwritten: a name the user typed, and a better name by a\n * weaker source. The adapter therefore only writes over its own placeholder (the id\n * itself) or over what it wrote last, and only when the new candidate ranks at least\n * as high as the one behind the current name.\n *\n * @param current the device object's present `common.name`\n * @param deviceId the object id, which is also the placeholder name\n * @param candidate the newly reported name\n * @param rank how trustworthy the candidate is\n * @param ownName the name this adapter wrote last for the device, if any\n * @param ownRank the rank behind {@link ownName}\n * @returns the name to write, or undefined when the current name stays\n */\nexport function nextDeviceLabel(\n current: string | undefined,\n deviceId: string,\n candidate: string | undefined,\n rank: LabelRank,\n ownName?: string,\n ownRank?: LabelRank,\n): string | undefined {\n const wanted = (candidate ?? \"\").trim();\n if (!isUsefulDeviceName(wanted) || wanted === current) {\n return undefined;\n }\n const isPlaceholder = current === undefined || current === deviceId;\n const isOurs = ownName !== undefined && current === ownName;\n if (!isPlaceholder && !isOurs) {\n return undefined; // the user named this device \u2014 theirs wins\n }\n if (isOurs && ownRank !== undefined && rank < ownRank) {\n return undefined; // do not fall back from a device name to its model\n }\n return wanted;\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -18,7 +18,8 @@ var __copyProps = (to, from, except, desc) => {
|
|
|
18
18
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
19
|
var capability_exports = {};
|
|
20
20
|
__export(capability_exports, {
|
|
21
|
-
buildCapabilities: () => buildCapabilities
|
|
21
|
+
buildCapabilities: () => buildCapabilities,
|
|
22
|
+
mergeYncaSubunits: () => mergeYncaSubunits
|
|
22
23
|
});
|
|
23
24
|
module.exports = __toCommonJS(capability_exports);
|
|
24
25
|
function buildCapabilities(messages) {
|
|
@@ -29,8 +30,19 @@ function buildCapabilities(messages) {
|
|
|
29
30
|
}
|
|
30
31
|
return { model: (_d = (_c = subunits.SYS) == null ? void 0 : _c.MODELNAME) != null ? _d : "", subunits };
|
|
31
32
|
}
|
|
33
|
+
function mergeYncaSubunits(remembered, fresh) {
|
|
34
|
+
const merged = {};
|
|
35
|
+
for (const [subunit, funcs] of Object.entries(remembered)) {
|
|
36
|
+
merged[subunit] = { ...funcs };
|
|
37
|
+
}
|
|
38
|
+
for (const [subunit, funcs] of Object.entries(fresh)) {
|
|
39
|
+
merged[subunit] = { ...merged[subunit], ...funcs };
|
|
40
|
+
}
|
|
41
|
+
return merged;
|
|
42
|
+
}
|
|
32
43
|
// Annotate the CommonJS export names for ESM import in node:
|
|
33
44
|
0 && (module.exports = {
|
|
34
|
-
buildCapabilities
|
|
45
|
+
buildCapabilities,
|
|
46
|
+
mergeYncaSubunits
|
|
35
47
|
});
|
|
36
48
|
//# sourceMappingURL=capability.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/ynca/capability.ts"],
|
|
4
|
-
"sourcesContent": ["import type { YncaMessage } from \"./protocol\";\n\n/** A device's YNCA capabilities: model plus each subunit's functions and their init-sweep values. */\nexport interface YncaCapabilities {\n /** Model name from SYS:MODELNAME, or \"\" if the sweep did not report it. */\n model: string;\n /** subunit (MAIN, ZONE2, TUN, \u2026) \u2192 function name \u2192 value seen in the init sweep. */\n subunits: Record<string, Record<string, string>>;\n}\n\n/**\n * Assemble a capability report from decoded messages: group by subunit and\n * function, take the model from SYS:MODELNAME.\n *\n * @param messages the decoded ok messages from the receiver\n * @returns the assembled capabilities\n */\nexport function buildCapabilities(messages: YncaMessage[]): YncaCapabilities {\n const subunits: Record<string, Record<string, string>> = {};\n for (const message of messages) {\n (subunits[message.subunit] ??= {})[message.func] = message.value;\n }\n return { model: subunits.SYS?.MODELNAME ?? \"\", subunits };\n}\n"],
|
|
5
|
-
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAiBO,SAAS,kBAAkB,UAA2C;AAjB7E;AAkBE,QAAM,WAAmD,CAAC;AAC1D,aAAW,WAAW,UAAU;AAC9B,MAAC,mBAAS,QAAQ,aAAjB,2BAA8B,CAAC,GAAG,QAAQ,IAAI,IAAI,QAAQ;AAAA,EAC7D;AACA,SAAO,EAAE,QAAO,oBAAS,QAAT,mBAAc,cAAd,YAA2B,IAAI,SAAS;AAC1D;",
|
|
4
|
+
"sourcesContent": ["import type { YncaMessage } from \"./protocol\";\n\n/** A device's YNCA capabilities: model plus each subunit's functions and their init-sweep values. */\nexport interface YncaCapabilities {\n /** Model name from SYS:MODELNAME, or \"\" if the sweep did not report it. */\n model: string;\n /** subunit (MAIN, ZONE2, TUN, \u2026) \u2192 function name \u2192 value seen in the init sweep. */\n subunits: Record<string, Record<string, string>>;\n}\n\n/**\n * Assemble a capability report from decoded messages: group by subunit and\n * function, take the model from SYS:MODELNAME.\n *\n * @param messages the decoded ok messages from the receiver\n * @returns the assembled capabilities\n */\nexport function buildCapabilities(messages: YncaMessage[]): YncaCapabilities {\n const subunits: Record<string, Record<string, string>> = {};\n for (const message of messages) {\n (subunits[message.subunit] ??= {})[message.func] = message.value;\n }\n return { model: subunits.SYS?.MODELNAME ?? \"\", subunits };\n}\n\n/**\n * Union-merge two capability maps of the SAME device identity: every function either\n * side ever reported, fresh values winning. A standby sweep answers many functions\n * with the unattributable `@RESTRICTED`, so REPLACING the stored shape with it would\n * strip abilities the device proved while awake \u2014 and a first capture in standby\n * would stay lean forever, because the identity-matched cache never re-sweeps.\n * Only call this for a matching identity; an identity change drops the memory instead.\n *\n * @param remembered the persisted subunit\u2192function map\n * @param fresh the just-swept subunit\u2192function map\n * @returns the union, fresh values overriding remembered ones\n */\nexport function mergeYncaSubunits(\n remembered: Record<string, Record<string, string>>,\n fresh: Record<string, Record<string, string>>,\n): Record<string, Record<string, string>> {\n const merged: Record<string, Record<string, string>> = {};\n for (const [subunit, funcs] of Object.entries(remembered)) {\n merged[subunit] = { ...funcs };\n }\n for (const [subunit, funcs] of Object.entries(fresh)) {\n merged[subunit] = { ...merged[subunit], ...funcs };\n }\n return merged;\n}\n"],
|
|
5
|
+
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAiBO,SAAS,kBAAkB,UAA2C;AAjB7E;AAkBE,QAAM,WAAmD,CAAC;AAC1D,aAAW,WAAW,UAAU;AAC9B,MAAC,mBAAS,QAAQ,aAAjB,2BAA8B,CAAC,GAAG,QAAQ,IAAI,IAAI,QAAQ;AAAA,EAC7D;AACA,SAAO,EAAE,QAAO,oBAAS,QAAT,mBAAc,cAAd,YAA2B,IAAI,SAAS;AAC1D;AAcO,SAAS,kBACd,YACA,OACwC;AACxC,QAAM,SAAiD,CAAC;AACxD,aAAW,CAAC,SAAS,KAAK,KAAK,OAAO,QAAQ,UAAU,GAAG;AACzD,WAAO,OAAO,IAAI,EAAE,GAAG,MAAM;AAAA,EAC/B;AACA,aAAW,CAAC,SAAS,KAAK,KAAK,OAAO,QAAQ,KAAK,GAAG;AACpD,WAAO,OAAO,IAAI,EAAE,GAAG,OAAO,OAAO,GAAG,GAAG,MAAM;AAAA,EACnD;AACA,SAAO;AACT;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/io-package.json
CHANGED
|
@@ -1,8 +1,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"common": {
|
|
3
3
|
"name": "yamaha",
|
|
4
|
-
"version": "2.0.
|
|
4
|
+
"version": "2.0.3",
|
|
5
5
|
"news": {
|
|
6
|
+
"2.0.3": {
|
|
7
|
+
"en": "The update cleanup now removes every never-filled leftover datapoint — a history recording setting no longer shields it, because nothing was ever recorded there and nothing is lost",
|
|
8
|
+
"de": "Das Update-Aufräumen entfernt jetzt jeden nie befüllten Alt-Datenpunkt — eine Verlaufs-Aufzeichnungseinstellung schützt ihn nicht mehr, denn dort wurde nie etwas aufgezeichnet",
|
|
9
|
+
"ru": "Очистка при обновлении теперь удаляет каждую незаполненную устаревшую точку данных — настройка записи истории больше не защищает её, ведь туда никогда ничего не записывалось",
|
|
10
|
+
"pt": "A limpeza de atualização agora remove todos os pontos de dados antigos nunca preenchidos — uma configuração de gravação de histórico não os protege mais, pois nada foi gravado lá",
|
|
11
|
+
"nl": "De update-opschoning verwijdert nu elk nooit gevuld achtergebleven datapunt — een instelling voor historie-opname beschermt het niet meer, want er is daar nooit iets opgenomen",
|
|
12
|
+
"fr": "Le nettoyage de mise à jour supprime désormais chaque point de données jamais rempli — un réglage d'enregistrement d'historique ne le protège plus, car rien n'y a jamais été enregistré",
|
|
13
|
+
"it": "La pulizia dell'aggiornamento ora rimuove ogni datapoint residuo mai riempito — un'impostazione di registrazione della cronologia non lo protegge più, lì non è mai stato registrato nulla",
|
|
14
|
+
"es": "La limpieza de actualización ahora elimina cada punto de datos antiguo nunca llenado — un ajuste de grabación de historial ya no lo protege, pues allí nunca se grabó nada",
|
|
15
|
+
"pl": "Czyszczenie przy aktualizacji usuwa teraz każdy nigdy niewypełniony stary punkt danych — ustawienie zapisu historii już go nie chroni, bo nic tam nigdy nie zapisano",
|
|
16
|
+
"uk": "Очищення під час оновлення тепер видаляє кожну ніколи не заповнену застарілу точку даних — налаштування запису історії більше не захищає її, адже туди ніколи нічого не записувалось",
|
|
17
|
+
"zh-cn": "更新清理现在会删除每个从未填充过的遗留数据点——历史记录设置不再保护它,因为那里从未记录过任何数据,不会有任何损失"
|
|
18
|
+
},
|
|
19
|
+
"2.0.2": {
|
|
20
|
+
"en": "Bug fix: 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",
|
|
21
|
+
"de": "Fehlerbereinigung: Ein Neustart bei Receiver im Bereitschaftszustand vergisst keine wach nachgewiesenen Fähigkeiten mehr — der gemerkte Fähigkeits-Stand wächst je Gerät und Firmware nur noch",
|
|
22
|
+
"ru": "Исправление: перезапуск при ресивере в режиме ожидания больше не забывает подтверждённые возможности — сохранённая карта возможностей для того же устройства и прошивки только растёт",
|
|
23
|
+
"pt": "Correção: um reinício com o recetor em espera já não esquece capacidades comprovadas — o mapa de capacidades memorizado só cresce para o mesmo aparelho e firmware",
|
|
24
|
+
"nl": "Foutcorrectie: een herstart terwijl de receiver stand-by staat vergeet geen bewezen mogelijkheden meer — de onthouden mogelijkhedenkaart groeit per apparaat en firmware alleen nog",
|
|
25
|
+
"fr": "Correction : un redémarrage avec l'ampli en veille n'oublie plus les capacités prouvées — la carte des capacités mémorisée ne fait que croître pour le même appareil et firmware",
|
|
26
|
+
"it": "Correzione: un riavvio con il ricevitore in standby non dimentica più le capacità dimostrate — la mappa delle capacità memorizzata cresce soltanto per lo stesso apparecchio e firmware",
|
|
27
|
+
"es": "Corrección: un reinicio con el receptor en espera ya no olvida capacidades demostradas — el mapa de capacidades memorizado solo crece para el mismo equipo y firmware",
|
|
28
|
+
"pl": "Poprawka: restart przy amplitunerze w trybie czuwania nie zapomina już potwierdzonych możliwości — zapamiętana mapa możliwości dla tego samego urządzenia i firmware tylko rośnie",
|
|
29
|
+
"uk": "Виправлення: перезапуск, коли ресивер у режимі очікування, більше не забуває підтверджені можливості — збережена карта можливостей для того ж пристрою і прошивки лише росте",
|
|
30
|
+
"zh-cn": "问题修复:接收机待机时重启不再遗忘已被证实的能力——同一设备和固件的能力记录只会增长"
|
|
31
|
+
},
|
|
6
32
|
"2.0.1": {
|
|
7
33
|
"en": "Bug fixes: datapoints the device can never fill are no longer created, a one-time sweep clears never-filled leftovers, and idle player blocks start with cleared values",
|
|
8
34
|
"de": "Fehlerbereinigung: Datenpunkte, die das Gerät nie füllen kann, entstehen nicht mehr, ein Einmal-Durchlauf entfernt nie befüllte Altlasten, ruhende Player-Blöcke starten mit Leerwerten",
|
|
@@ -67,32 +93,6 @@
|
|
|
67
93
|
"pl": "Poprawiono: przeglądanie menu w amplitunerach udostępniających je starym protokołem XML (#613)\nPoprawiono: pozbawione zasilania urządzenie MusicCast jest zgłaszane jako offline, a nie nadal połączone\nPo włączeniu lub wyłączeniu grupy punktów danych widzisz teraz, ile punktów przybyło lub zniknęło\nNowość: opcjonalne raportowanie błędów przez Sentry, bez danych osobowych",
|
|
68
94
|
"uk": "Виправлено: навігація меню на ресиверах, які надають меню старим протоколом XML (#613)\nВиправлено: знеструмлений пристрій MusicCast повідомляється як офлайн, а не як підключений\nПісля вмикання чи вимикання групи точок даних тепер видно, скільки точок з'явилося або зникло\nНове: необов'язкові звіти про помилки через Sentry, без персональних даних",
|
|
69
95
|
"zh-cn": "修复:可通过旧版 XML 协议提供菜单的功放现在可以浏览菜单 (#613)\n修复:断电的 MusicCast 设备会报告为离线,而不再显示为已连接\n开启或关闭数据点分组后,现在可以看到新增或消失了多少数据点\n新增:可选的 Sentry 错误报告,不传输个人数据"
|
|
70
|
-
},
|
|
71
|
-
"1.4.0": {
|
|
72
|
-
"en": "Commands sent in one go all arrive, favourites land in the zone that is listening, special characters work again, and startup and reconnects are noticeably faster.",
|
|
73
|
-
"de": "Mehrere Befehle auf einmal kommen alle an, Favoriten landen in der hörenden Zone, Sonderzeichen funktionieren wieder, und Start und Neuverbindung sind spürbar schneller.",
|
|
74
|
-
"ru": "Команды, отправленные подряд, доходят все; избранное попадает в слушающую зону; спецсимволы снова работают; запуск и переподключение заметно быстрее.",
|
|
75
|
-
"pt": "Comandos enviados de uma vez chegam todos, os favoritos vão para a zona que está a ouvir, os caracteres especiais voltam a funcionar e o arranque e a reconexão são bem mais rápidos.",
|
|
76
|
-
"nl": "Meerdere commando's tegelijk komen allemaal aan, favorieten belanden in de luisterende zone, speciale tekens werken weer, en opstarten en opnieuw verbinden gaan merkbaar sneller.",
|
|
77
|
-
"fr": "Les commandes envoyées d'un coup arrivent toutes, les favoris vont dans la zone qui écoute, les caractères spéciaux fonctionnent de nouveau, et le démarrage est nettement plus rapide.",
|
|
78
|
-
"it": "I comandi inviati insieme arrivano tutti, i preferiti finiscono nella zona in ascolto, i caratteri speciali funzionano di nuovo e avvio e riconnessione sono molto più rapidi.",
|
|
79
|
-
"es": "Los comandos enviados a la vez llegan todos, los favoritos van a la zona que está escuchando, los caracteres especiales vuelven a funcionar y el arranque y la reconexión son mucho más rápidos.",
|
|
80
|
-
"pl": "Komendy wysłane naraz docierają wszystkie, ulubione trafiają do słuchającej strefy, znaki specjalne znów działają, a start i ponowne łączenie są wyraźnie szybsze.",
|
|
81
|
-
"uk": "Команди, надіслані разом, доходять усі, обране потрапляє в зону, що слухає, спецсимволи знову працюють, а запуск і перепідключення помітно швидші.",
|
|
82
|
-
"zh-cn": "一次发送的多条指令全部送达,收藏会进入正在收听的分区,特殊字符恢复正常,启动与重连明显更快。"
|
|
83
|
-
},
|
|
84
|
-
"1.3.0": {
|
|
85
|
-
"en": "Menu browsing for Net Radio, server and USB like with the remote, preset saving and station bookmarks from ioBroker, and Bluetooth pairing controls.",
|
|
86
|
-
"de": "Menü-Browsing für Net Radio, Server und USB wie mit der Fernbedienung, Senderspeicher und Favoriten direkt aus ioBroker sowie Bluetooth-Kopplung.",
|
|
87
|
-
"ru": "Просмотр меню Net Radio, сервера и USB как с пульта, сохранение пресетов и закладок станций из ioBroker, а также управление сопряжением Bluetooth.",
|
|
88
|
-
"pt": "Navegação nos menus de Net Radio, servidor e USB como com o controle remoto, gravação de presets e favoritos a partir do ioBroker e emparelhamento Bluetooth.",
|
|
89
|
-
"nl": "Menubladeren voor Net Radio, server en USB zoals met de afstandsbediening, presets en favorieten opslaan vanuit ioBroker en Bluetooth-koppeling.",
|
|
90
|
-
"fr": "Navigation dans les menus Net Radio, serveur et USB comme avec la télécommande, enregistrement de présélections et favoris depuis ioBroker et appairage Bluetooth.",
|
|
91
|
-
"it": "Navigazione nei menu di Net Radio, server e USB come con il telecomando, salvataggio di preset e preferiti da ioBroker e abbinamento Bluetooth.",
|
|
92
|
-
"es": "Navegación por los menús de Net Radio, servidor y USB como con el mando, guardado de presintonías y favoritos desde ioBroker y emparejamiento Bluetooth.",
|
|
93
|
-
"pl": "Przeglądanie menu Net Radio, serwera i USB jak pilotem, zapisywanie presetów i ulubionych z ioBroker oraz parowanie Bluetooth.",
|
|
94
|
-
"uk": "Перегляд меню Net Radio, сервера та USB як із пульта, збереження пресетів і закладок станцій з ioBroker, а також керування спарюванням Bluetooth.",
|
|
95
|
-
"zh-cn": "像使用遥控器一样浏览 Net Radio、服务器和 USB 菜单,可从 ioBroker 保存预设和收藏电台,并支持蓝牙配对控制。"
|
|
96
96
|
}
|
|
97
97
|
},
|
|
98
98
|
"titleLang": {
|