iobroker.fakeroku 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -92,6 +92,14 @@ becomes `true` — or watch `.command` for the last button as text.
92
92
  Placeholder for the next version (at the beginning of the line):
93
93
  ### **WORK IN PROGRESS**
94
94
  -->
95
+ ### 1.4.0 (2026-09-03)
96
+ - (krobipd) Fixed: renaming an emulated Roku could change its identity on the network, so a paired Harmony or Sofabaton lost the device and had to be set up again.
97
+ - (krobipd) Fixed: a remote key that was pressed when the adapter stopped stayed on for good. All key datapoints are now released at start-up, so the next press works again.
98
+ - (krobipd) Fixed: a device named "info" entered by hand into the configuration replaced the instance's own status channel. The name is refused now and leftovers are removed.
99
+ - (krobipd) Changed: every datapoint now carries a translated name and, where useful, a short description — in all eleven languages, in existing installations as well.
100
+ - (krobipd) Improved: a remote with a globally routable IPv6 address is accepted when it sits in the same network as the ioBroker host, not just on the reserved IPv6 ranges.
101
+ - (krobipd) New: user documentation in English and German, shown in the ioBroker documentation portal.
102
+
95
103
  ### 1.3.0 (2026-09-01)
96
104
  - (krobipd) Fixed: a malformed keyboard keypress from a remote (a bad %-escape in the URL) could crash the adapter.
97
105
  - (krobipd) Fixed: remotes on an IPv6-only local network were refused; link-local and unique-local IPv6 addresses now count as LAN.
@@ -117,12 +125,6 @@ becomes `true` — or watch `.command` for the last button as text.
117
125
  - (krobipd) First stable release — version 1.0.0 marks the complete rewrite as the mature, supported version of the adapter.
118
126
  - (krobipd) Upgrading from an older version now shows a one-time notice that the button data points changed from text to real boolean values, so scripts and visualizations can be checked.
119
127
 
120
- ### 0.6.0 (2026-08-05)
121
- - (krobipd) Complete rewrite. The adapter now answers the full Roku control surface — including device-info with a current Roku version — so Logitech Harmony and Sofabaton remotes pair and work reliably.
122
- - (krobipd) Works out of the box: it detects the network address to advertise on its own, no manual interface picking.
123
- - (krobipd) Manage multiple emulated Rokus from the admin UI, each as a Player or a TV.
124
- - (krobipd) Cleaner object tree — one datapoint per remote button with the correct types, plus a last-command datapoint; leftover objects from older versions are removed on start.
125
-
126
128
  [Older changelogs can be found there](CHANGELOG_OLD.md)
127
129
 
128
130
  ## History
@@ -23,5 +23,14 @@
23
23
  "supportHeader": "Unterstützen",
24
24
  "aboutInfo": "Wenn dir dieser Adapter nützt, hilft eine kleine Spende, ihn zu pflegen — danke!",
25
25
  "donateKofi": "Ko-fi",
26
- "donatePaypal": "PayPal"
26
+ "donatePaypal": "PayPal",
27
+ "channelInfo": "Adapter-Informationen",
28
+ "connectionStatus": "Verbindungsstatus",
29
+ "connectionStatusDesc": "Nur wahr, solange jeder konfigurierte emulierte Roku tatsächlich lauscht.",
30
+ "stateLastCommand": "Letzter Befehl",
31
+ "stateLastCommandDesc": "Der zuletzt empfangene Befehl dieses emulierten Roku als Klartext.",
32
+ "stateLastCommandType": "Art des letzten Befehls",
33
+ "stateLastCommandTypeDesc": "Um welche Art Befehl es sich handelte: Tastendruck, Taste gehalten oder losgelassen, App-Start oder -Installation, Texteingabe oder Suche.",
34
+ "channelKeys": "Fernbedienungstasten",
35
+ "channelKeysDesc": "Ein Datenpunkt je Taste, die dieses Gerät anbietet; ein Tastendruck setzt ihn kurz auf wahr."
27
36
  }
@@ -23,5 +23,14 @@
23
23
  "supportHeader": "Support",
24
24
  "aboutInfo": "If this adapter is useful to you, a small donation helps keep it maintained — thank you!",
25
25
  "donateKofi": "Ko-fi",
26
- "donatePaypal": "PayPal"
26
+ "donatePaypal": "PayPal",
27
+ "channelInfo": "Adapter information",
28
+ "connectionStatus": "Connection status",
29
+ "connectionStatusDesc": "True only while every configured emulated Roku is actually listening.",
30
+ "stateLastCommand": "Last command",
31
+ "stateLastCommandDesc": "The most recent command this emulated Roku received, as plain text.",
32
+ "stateLastCommandType": "Last command type",
33
+ "stateLastCommandTypeDesc": "What kind of command it was: a key press, a key held or released, an app launch or install, a text input or a search.",
34
+ "channelKeys": "Remote keys",
35
+ "channelKeysDesc": "One state per remote key this device offers; a key press pulses it briefly."
27
36
  }
@@ -23,5 +23,14 @@
23
23
  "supportHeader": "Apoyar",
24
24
  "aboutInfo": "Si este adaptador te resulta útil, una pequeña donación ayuda a mantenerlo — ¡gracias!",
25
25
  "donateKofi": "Ko-fi",
26
- "donatePaypal": "PayPal"
26
+ "donatePaypal": "PayPal",
27
+ "channelInfo": "Información del adaptador",
28
+ "connectionStatus": "Estado de conexión",
29
+ "connectionStatusDesc": "Verdadero solo mientras cada Roku emulado configurado esté realmente a la escucha.",
30
+ "stateLastCommand": "Último comando",
31
+ "stateLastCommandDesc": "El comando más reciente que recibió este Roku emulado, como texto simple.",
32
+ "stateLastCommandType": "Tipo del último comando",
33
+ "stateLastCommandTypeDesc": "Qué tipo de comando fue: pulsar una tecla, mantenerla o soltarla, iniciar o instalar una aplicación, introducir texto o buscar.",
34
+ "channelKeys": "Teclas del mando",
35
+ "channelKeysDesc": "Un estado por cada tecla que ofrece este dispositivo; una pulsación lo pone brevemente en verdadero."
27
36
  }
@@ -23,5 +23,14 @@
23
23
  "supportHeader": "Soutenir",
24
24
  "aboutInfo": "Si cet adaptateur vous est utile, un petit don aide à le maintenir — merci !",
25
25
  "donateKofi": "Ko-fi",
26
- "donatePaypal": "PayPal"
26
+ "donatePaypal": "PayPal",
27
+ "channelInfo": "Informations sur l'adaptateur",
28
+ "connectionStatus": "État de la connexion",
29
+ "connectionStatusDesc": "Vrai uniquement tant que chaque Roku émulé configuré est réellement à l'écoute.",
30
+ "stateLastCommand": "Dernière commande",
31
+ "stateLastCommandDesc": "La commande la plus récente reçue par ce Roku émulé, en texte brut.",
32
+ "stateLastCommandType": "Type de la dernière commande",
33
+ "stateLastCommandTypeDesc": "De quel type de commande il s'agissait : appui sur une touche, touche maintenue ou relâchée, lancement ou installation d'une application, saisie de texte ou recherche.",
34
+ "channelKeys": "Touches de la télécommande",
35
+ "channelKeysDesc": "Un état par touche proposée par cet appareil ; un appui le met brièvement à vrai."
27
36
  }
@@ -23,5 +23,14 @@
23
23
  "supportHeader": "Sostieni",
24
24
  "aboutInfo": "Se questo adattatore ti è utile, una piccola donazione aiuta a mantenerlo — grazie!",
25
25
  "donateKofi": "Ko-fi",
26
- "donatePaypal": "PayPal"
26
+ "donatePaypal": "PayPal",
27
+ "channelInfo": "Informazioni sull'adattatore",
28
+ "connectionStatus": "Stato connessione",
29
+ "connectionStatusDesc": "Vero solo finché ogni Roku emulato configurato è effettivamente in ascolto.",
30
+ "stateLastCommand": "Ultimo comando",
31
+ "stateLastCommandDesc": "Il comando più recente ricevuto da questo Roku emulato, come testo semplice.",
32
+ "stateLastCommandType": "Tipo dell'ultimo comando",
33
+ "stateLastCommandTypeDesc": "Di che tipo di comando si trattava: pressione di un tasto, tasto tenuto premuto o rilasciato, avvio o installazione di un'app, immissione di testo o ricerca.",
34
+ "channelKeys": "Tasti del telecomando",
35
+ "channelKeysDesc": "Uno stato per ogni tasto offerto da questo dispositivo; una pressione lo porta brevemente a vero."
27
36
  }
@@ -23,5 +23,14 @@
23
23
  "supportHeader": "Ondersteunen",
24
24
  "aboutInfo": "Als deze adapter nuttig voor je is, helpt een kleine donatie om hem te onderhouden — bedankt!",
25
25
  "donateKofi": "Ko-fi",
26
- "donatePaypal": "PayPal"
26
+ "donatePaypal": "PayPal",
27
+ "channelInfo": "Adapterinformatie",
28
+ "connectionStatus": "Verbindingsstatus",
29
+ "connectionStatusDesc": "Alleen waar zolang elke geconfigureerde geëmuleerde Roku daadwerkelijk luistert.",
30
+ "stateLastCommand": "Laatste opdracht",
31
+ "stateLastCommandDesc": "De meest recente opdracht die deze geëmuleerde Roku ontving, als platte tekst.",
32
+ "stateLastCommandType": "Type laatste opdracht",
33
+ "stateLastCommandTypeDesc": "Wat voor soort opdracht het was: een toetsdruk, een toets ingedrukt of losgelaten, een app starten of installeren, tekstinvoer of zoeken.",
34
+ "channelKeys": "Afstandsbedieningstoetsen",
35
+ "channelKeysDesc": "Eén status per toets die dit apparaat biedt; een toetsdruk zet hem kort op waar."
27
36
  }
@@ -23,5 +23,14 @@
23
23
  "supportHeader": "Wsparcie",
24
24
  "aboutInfo": "Jeśli ten adapter jest dla Ciebie przydatny, mała darowizna pomaga go utrzymać — dziękuję!",
25
25
  "donateKofi": "Ko-fi",
26
- "donatePaypal": "PayPal"
26
+ "donatePaypal": "PayPal",
27
+ "channelInfo": "Informacje o adapterze",
28
+ "connectionStatus": "Stan połączenia",
29
+ "connectionStatusDesc": "Prawda tylko wtedy, gdy każdy skonfigurowany emulowany Roku faktycznie nasłuchuje.",
30
+ "stateLastCommand": "Ostatnie polecenie",
31
+ "stateLastCommandDesc": "Najnowsze polecenie odebrane przez ten emulowany Roku, jako zwykły tekst.",
32
+ "stateLastCommandType": "Typ ostatniego polecenia",
33
+ "stateLastCommandTypeDesc": "Jakiego rodzaju było to polecenie: naciśnięcie klawisza, przytrzymanie lub zwolnienie, uruchomienie lub instalacja aplikacji, wpisanie tekstu albo wyszukiwanie.",
34
+ "channelKeys": "Klawisze pilota",
35
+ "channelKeysDesc": "Jeden stan na każdy klawisz oferowany przez to urządzenie; naciśnięcie ustawia go na krótko na prawdę."
27
36
  }
@@ -23,5 +23,14 @@
23
23
  "supportHeader": "Apoiar",
24
24
  "aboutInfo": "Se este adaptador lhe é útil, um pequeno donativo ajuda a mantê-lo — obrigado!",
25
25
  "donateKofi": "Ko-fi",
26
- "donatePaypal": "PayPal"
26
+ "donatePaypal": "PayPal",
27
+ "channelInfo": "Informação do adaptador",
28
+ "connectionStatus": "Estado da ligação",
29
+ "connectionStatusDesc": "Verdadeiro apenas enquanto todos os Roku emulados configurados estiverem realmente à escuta.",
30
+ "stateLastCommand": "Último comando",
31
+ "stateLastCommandDesc": "O comando mais recente recebido por este Roku emulado, em texto simples.",
32
+ "stateLastCommandType": "Tipo do último comando",
33
+ "stateLastCommandTypeDesc": "Que tipo de comando foi: pressionar uma tecla, manter ou soltar, iniciar ou instalar uma aplicação, introduzir texto ou pesquisar.",
34
+ "channelKeys": "Teclas do comando",
35
+ "channelKeysDesc": "Um estado por cada tecla que este dispositivo oferece; uma pressão coloca-o brevemente a verdadeiro."
27
36
  }
@@ -23,5 +23,14 @@
23
23
  "supportHeader": "Поддержать",
24
24
  "aboutInfo": "Если адаптер вам полезен, небольшое пожертвование помогает его поддерживать — спасибо!",
25
25
  "donateKofi": "Ko-fi",
26
- "donatePaypal": "PayPal"
26
+ "donatePaypal": "PayPal",
27
+ "channelInfo": "Информация об адаптере",
28
+ "connectionStatus": "Статус подключения",
29
+ "connectionStatusDesc": "Истина только пока каждый настроенный эмулированный Roku действительно слушает.",
30
+ "stateLastCommand": "Последняя команда",
31
+ "stateLastCommandDesc": "Последняя команда, полученная этим эмулированным Roku, в виде текста.",
32
+ "stateLastCommandType": "Тип последней команды",
33
+ "stateLastCommandTypeDesc": "Какого рода была команда: нажатие кнопки, удержание или отпускание, запуск или установка приложения, ввод текста или поиск.",
34
+ "channelKeys": "Кнопки пульта",
35
+ "channelKeysDesc": "По одному состоянию на каждую кнопку пульта этого устройства; нажатие кратко переводит его в истину."
27
36
  }
@@ -23,5 +23,14 @@
23
23
  "supportHeader": "Підтримати",
24
24
  "aboutInfo": "Якщо цей адаптер вам корисний, невелика пожертва допомагає його підтримувати — дякую!",
25
25
  "donateKofi": "Ko-fi",
26
- "donatePaypal": "PayPal"
26
+ "donatePaypal": "PayPal",
27
+ "channelInfo": "Інформація про адаптер",
28
+ "connectionStatus": "Стан з'єднання",
29
+ "connectionStatusDesc": "Істина лише поки кожен налаштований емульований Roku справді слухає.",
30
+ "stateLastCommand": "Остання команда",
31
+ "stateLastCommandDesc": "Найновіша команда, яку отримав цей емульований Roku, у вигляді тексту.",
32
+ "stateLastCommandType": "Тип останньої команди",
33
+ "stateLastCommandTypeDesc": "Якого роду була команда: натискання кнопки, утримання чи відпускання, запуск або встановлення застосунку, введення тексту чи пошук.",
34
+ "channelKeys": "Кнопки пульта",
35
+ "channelKeysDesc": "По одному стану на кожну кнопку, яку пропонує цей пристрій; натискання ненадовго переводить його в істину."
27
36
  }
@@ -23,5 +23,14 @@
23
23
  "supportHeader": "支持",
24
24
  "aboutInfo": "如果此适配器对您有用,一点捐赠有助于维护它——谢谢!",
25
25
  "donateKofi": "Ko-fi",
26
- "donatePaypal": "PayPal"
26
+ "donatePaypal": "PayPal",
27
+ "channelInfo": "适配器信息",
28
+ "connectionStatus": "连接状态",
29
+ "connectionStatusDesc": "仅当每个已配置的模拟 Roku 都在监听时才为真。",
30
+ "stateLastCommand": "最后一条命令",
31
+ "stateLastCommandDesc": "该模拟 Roku 最近收到的命令,以纯文本表示。",
32
+ "stateLastCommandType": "最后一条命令的类型",
33
+ "stateLastCommandTypeDesc": "命令的种类:按键、按住或松开按键、启动或安装应用、输入文本或搜索。",
34
+ "channelKeys": "遥控器按键",
35
+ "channelKeysDesc": "该设备提供的每个遥控器按键各有一个状态;按下时会短暂置为真。"
27
36
  }
@@ -30,7 +30,6 @@ var import_constants = require("./lib/constants");
30
30
  var import_device_identity = require("./lib/device-identity");
31
31
  var import_i18n = require("./lib/i18n");
32
32
  var import_pure_helpers = require("./lib/pure-helpers");
33
- const RESERVED_IDS = /* @__PURE__ */ new Set(["info"]);
34
33
  function nextFreePort(usedPorts) {
35
34
  const taken = new Set(usedPorts);
36
35
  let port = import_constants.DEFAULT_ECP_PORT;
@@ -90,7 +89,7 @@ function cleanDevice(raw) {
90
89
  function findClash(devices, candidate, exceptIndex) {
91
90
  const name = candidate.name.trim().toLowerCase();
92
91
  const id = (0, import_pure_helpers.sanitizeId)(candidate.name.trim());
93
- if (id === "" || RESERVED_IDS.has(id)) {
92
+ if (id === "" || import_constants.RESERVED_IDS.has(id)) {
94
93
  return (0, import_i18n.t)("deviceNameInvalid");
95
94
  }
96
95
  for (let i = 0; i < devices.length; i++) {
@@ -225,6 +224,12 @@ class FakerokuDeviceManagement extends import_dm_utils.DeviceManagement {
225
224
  * Edit a device via the pre-filled form. Its own name/port are excluded from
226
225
  * the clash check, and its uuid is preserved so the pairing survives a rename.
227
226
  *
227
+ * A row that carries no uuid yet — the manifest's default device, or anything
228
+ * written before 0.8.0 — gets one derived from its PREVIOUS name, because that
229
+ * is the identity main.ts is advertising right now (it derives from the stored
230
+ * name and never writes the value back). Deriving from the NEW name here would
231
+ * change the SSDP identity on a plain rename and silently unpair the remote.
232
+ *
228
233
  * @param index the device's list position
229
234
  * @param context the action context
230
235
  * @returns a directive to reload the list
@@ -248,7 +253,7 @@ class FakerokuDeviceManagement extends import_dm_utils.DeviceManagement {
248
253
  await context.showMessage(clash);
249
254
  return { refresh: "devices" };
250
255
  }
251
- devices[index] = { ...clean, uuid: current.uuid || (0, import_device_identity.deriveUuid)(clean.name) };
256
+ devices[index] = { ...clean, uuid: (0, import_device_identity.resolveDeviceUuid)(current) };
252
257
  await this.writeDevices(devices);
253
258
  }
254
259
  return { refresh: "devices" };
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/device-management.ts"],
4
- "sourcesContent": ["import {\n DeviceManagement,\n type ActionContext,\n type DeviceInfo,\n type DeviceLoadContext,\n type JsonFormSchema,\n} from \"@iobroker/dm-utils\";\nimport { DEFAULT_ECP_PORT } from \"./lib/constants\";\nimport { deriveUuid } from \"./lib/device-identity\";\nimport { t } from \"./lib/i18n\";\nimport { sanitizeId } from \"./lib/pure-helpers\";\nimport type { DeviceType } from \"./ecp/state-model\";\n\n/** Object-id segments the adapter reserves for its own tree \u2014 a device may not take them. */\nconst RESERVED_IDS = new Set([\"info\"]);\n\n/**\n * One emulated Roku as stored in the adapter's native.devices \u2014 the manifest's own\n * element type, so the manager and the runtime (main.ts) read the same shape.\n * `uuid` is the stable SSDP identity, carried through edits so the pairing survives.\n */\ntype RokuDeviceConfig = ioBroker.AdapterConfig[\"devices\"][number];\n\n/** Manager directive: reload the whole view. */\ntype InstanceResult = { refresh: boolean };\n/** Manager directive: reload the device list. */\ntype DeviceResult = { refresh: \"devices\" };\n\n/**\n * The lowest free ECP port at or above the real-Roku default, so a newly added\n * device never pre-selects a port another emulated Roku already uses.\n *\n * @param usedPorts the ports already taken by other devices\n * @returns the first free port >= 8060\n */\nexport function nextFreePort(usedPorts: readonly number[]): number {\n const taken = new Set(usedPorts);\n let port = DEFAULT_ECP_PORT;\n while (taken.has(port)) {\n port++;\n }\n return port;\n}\n\n/**\n * The add/edit form for one emulated Roku: name, ECP port and device type. The\n * name and port carry a live validator against the values already in use (the\n * OK button greys out on a clash); a hint explains that each Roku needs its own\n * port. Labels are resolved translation objects so the embedded form is\n * language-correct.\n *\n * @param usedNames names taken by OTHER devices (the edited device is excluded)\n * @param usedPorts ports taken by OTHER devices\n * @returns the jsonConfig panel schema for one device\n */\nexport function buildDeviceForm(usedNames: readonly string[], usedPorts: readonly number[]): JsonFormSchema {\n const nameList = JSON.stringify(usedNames.map(n => n.trim().toLowerCase()));\n const portList = JSON.stringify([...usedPorts]);\n return {\n type: \"panel\",\n items: {\n name: {\n type: \"text\",\n label: t(\"deviceName\"),\n validator: `!${nameList}.includes((data.name||'').trim().toLowerCase())`,\n validatorErrorText: t(\"deviceNameInUse\"),\n validatorNoSaveOnError: true,\n sm: 12,\n md: 6,\n },\n port: {\n type: \"number\",\n label: t(\"devicePort\"),\n min: 1,\n max: 65535,\n validator: `!${portList}.includes(Number(data.port))`,\n validatorErrorText: t(\"devicePortInUse\"),\n validatorNoSaveOnError: true,\n sm: 12,\n md: 3,\n },\n type: {\n type: \"select\",\n label: t(\"deviceTypeLabel\"),\n default: \"player\",\n options: [\n { label: t(\"deviceTypePlayer\"), value: \"player\" },\n { label: t(\"deviceTypeTv\"), value: \"tv\" },\n ],\n sm: 12,\n md: 3,\n },\n _portHint: { type: \"staticText\", text: t(\"devicePortHint\"), sm: 12 },\n _typeHint: { type: \"staticText\", text: t(\"deviceTypeHint\"), sm: 12 },\n },\n } as unknown as JsonFormSchema;\n}\n\n/**\n * Normalise raw form values into a clean device (trimmed name, numeric port,\n * valid type) so native.devices stays tidy. The uuid is set by the caller.\n *\n * @param raw the submitted form values\n * @returns the normalised device config (without uuid)\n */\nexport function cleanDevice(raw: Record<string, unknown>): Omit<RokuDeviceConfig, \"uuid\"> {\n const name = typeof raw.name === \"string\" ? raw.name.trim() : \"\";\n const port = Number(raw.port) || DEFAULT_ECP_PORT;\n const type: DeviceType = raw.type === \"tv\" ? \"tv\" : \"player\";\n return { name, port, type };\n}\n\n/**\n * A name/port clash against the other devices, as a ready-to-show message \u2014 the\n * backend safety net behind the form validator (the dialog validator may not\n * fire in every admin version; this never lets a duplicate through).\n *\n * @param devices the full current device list\n * @param candidate the name+port being added/edited\n * @param candidate.name the candidate device name\n * @param candidate.port the candidate ECP port\n * @param exceptIndex the list position to ignore (the device being edited), or -1\n * @returns a translated clash message, or null if free\n */\nexport function findClash(\n devices: readonly RokuDeviceConfig[],\n candidate: { name: string; port: number },\n exceptIndex: number,\n): ioBroker.StringOrTranslated | null {\n const name = candidate.name.trim().toLowerCase();\n // The object-tree path is sanitizeId(name); guard the two ways it can go wrong\n // regardless of the plain-name check: a name that sanitizes to a reserved id\n // (\"info\" would collide with the adapter's own channel), and two different\n // names that sanitize to the SAME id (\"My Roku\" and \"My*Roku\" \u2192 \"My_Roku\").\n const id = sanitizeId(candidate.name.trim());\n if (id === \"\" || RESERVED_IDS.has(id)) {\n return t(\"deviceNameInvalid\");\n }\n for (let i = 0; i < devices.length; i++) {\n if (i === exceptIndex) {\n continue;\n }\n if (devices[i].name.trim().toLowerCase() === name) {\n return t(\"deviceNameInUse\");\n }\n if (sanitizeId(devices[i].name.trim()) === id) {\n return t(\"deviceNameInvalid\");\n }\n if (Number(devices[i].port) === candidate.port) {\n return t(\"devicePortInUse\");\n }\n }\n return null;\n}\n\n/**\n * ioBroker device-manager backend: the emulated Rokus as cards with manual\n * add/edit/delete \u2014 no scan, the devices are purely user-defined. Owns no state;\n * it reads and writes the adapter's own `native.devices` config array.\n */\nexport class FakerokuDeviceManagement extends DeviceManagement {\n private get objId(): string {\n return `system.adapter.${this.adapter.namespace}`;\n }\n\n /**\n * Read the device list from the live config object.\n *\n * @returns the configured devices (normalised), or an empty list\n */\n private async readDevices(): Promise<RokuDeviceConfig[]> {\n const obj = await this.adapter.getForeignObjectAsync(this.objId);\n const devices = (obj?.native as { devices?: unknown } | undefined)?.devices;\n if (!Array.isArray(devices)) {\n return [];\n }\n // native.devices is user-editable (expert mode, CLI). Normalise every row the\n // same way the dialog does, so a hand-edited entry (numeric name, missing port)\n // shows up as a card the user can fix instead of throwing inside add/edit.\n return devices\n .filter((d): d is Record<string, unknown> => typeof d === \"object\" && d !== null)\n .map(d => {\n const clean = cleanDevice(d);\n return typeof d.uuid === \"string\" && d.uuid ? { ...clean, uuid: d.uuid } : clean;\n });\n }\n\n /**\n * Persist the device list. Writing `native.*` restarts the adapter, which\n * re-creates the object trees and servers with the new devices.\n *\n * @param devices the full device list to store\n */\n private async writeDevices(devices: RokuDeviceConfig[]): Promise<void> {\n await this.adapter.extendForeignObjectAsync(this.objId, { native: { devices } });\n }\n\n /**\n * Populate the manager with one card per configured device.\n *\n * @param context the load context\n */\n protected async loadDevices(context: DeviceLoadContext<string>): Promise<void> {\n const devices = await this.readDevices();\n devices.forEach((device, index) => context.addDevice(this.toDeviceInfo(device, index)));\n }\n\n /**\n * Build one device card. The model (Player/TV) and the ECP port each get their\n * own line \u2014 the port via `identifier` (labelled in getInstanceInfo). No\n * manufacturer line: it is always \"Roku\" and tells the user nothing for an emulator.\n * The row comes from readDevices(), so the port is already a number; only the name\n * can still be empty (a row saved without one) and gets a numbered stand-in.\n *\n * @param device the stored device\n * @param index its list position \u2014 the (per-session stable) card id\n * @returns the card descriptor\n */\n private toDeviceInfo(device: RokuDeviceConfig, index: number): DeviceInfo<string> {\n const kind = device.type === \"tv\" ? \"TV\" : \"Player\";\n return {\n id: String(index),\n name: device.name || `Roku ${index + 1}`,\n identifier: String(device.port),\n model: kind,\n actions: [\n {\n id: \"edit\",\n icon: \"edit\",\n description: t(\"dmEdit\"),\n handler: async (id: string, context: ActionContext) => this.editDevice(Number(id), context),\n },\n {\n id: \"delete\",\n icon: \"delete\",\n description: t(\"dmDelete\"),\n handler: async (id: string, context: ActionContext) => this.deleteDevice(Number(id), context),\n },\n ],\n };\n }\n\n /**\n * The \"+ add\" action above the list, plus the label for the port shown on each card.\n *\n * @returns the instance action descriptor\n */\n protected getInstanceInfo(): ReturnType<DeviceManagement[\"getInstanceInfo\"]> {\n return {\n apiVersion: \"v3\",\n identifierLabel: t(\"portLabel\"),\n actions: [{ id: \"add\", icon: \"add\", description: t(\"dmAdd\"), handler: async context => this.addDevice(context) }],\n };\n }\n\n /**\n * Manual add: pre-select a free port, show the form, and append the device with\n * a stable derived uuid.\n *\n * @param context the action context\n * @returns a directive to reload the manager\n */\n private async addDevice(context: ActionContext): Promise<InstanceResult> {\n const devices = await this.readDevices();\n const usedNames = devices.map(d => d.name);\n const usedPorts = devices.map(d => Number(d.port));\n const data = await context.showForm(buildDeviceForm(usedNames, usedPorts), {\n title: t(\"dmAdd\"),\n data: { type: \"player\", port: nextFreePort(usedPorts) },\n });\n if (data && typeof data.name === \"string\" && data.name.trim()) {\n const clean = cleanDevice(data);\n const clash = findClash(devices, clean, -1);\n if (clash) {\n await context.showMessage(clash);\n return { refresh: true };\n }\n devices.push({ ...clean, uuid: deriveUuid(clean.name) });\n await this.writeDevices(devices);\n }\n return { refresh: true };\n }\n\n /**\n * Edit a device via the pre-filled form. Its own name/port are excluded from\n * the clash check, and its uuid is preserved so the pairing survives a rename.\n *\n * @param index the device's list position\n * @param context the action context\n * @returns a directive to reload the list\n */\n private async editDevice(index: number, context: ActionContext): Promise<DeviceResult> {\n const devices = await this.readDevices();\n const current = devices[index];\n if (!current) {\n return { refresh: \"devices\" };\n }\n const usedNames = devices.filter((_, i) => i !== index).map(d => d.name);\n const usedPorts = devices.filter((_, i) => i !== index).map(d => Number(d.port));\n const data = await context.showForm(buildDeviceForm(usedNames, usedPorts), {\n title: t(\"dmEditTitle\"),\n data: { ...current },\n });\n if (data && typeof data.name === \"string\" && data.name.trim()) {\n const clean = cleanDevice(data);\n const clash = findClash(devices, clean, index);\n if (clash) {\n await context.showMessage(clash);\n return { refresh: \"devices\" };\n }\n devices[index] = { ...clean, uuid: current.uuid || deriveUuid(clean.name) };\n await this.writeDevices(devices);\n }\n return { refresh: \"devices\" };\n }\n\n /**\n * Delete a device after confirmation.\n *\n * @param index the device's list position\n * @param context the action context\n * @returns a directive to reload the list\n */\n private async deleteDevice(index: number, context: ActionContext): Promise<DeviceResult> {\n const devices = await this.readDevices();\n const target = devices[index];\n if (!target) {\n return { refresh: \"devices\" };\n }\n const confirmed = await context.showConfirmation(t(\"dmDeleteConfirm\", target.name || \"\"));\n if (confirmed) {\n devices.splice(index, 1);\n await this.writeDevices(devices);\n }\n return { refresh: \"devices\" };\n }\n}\n"],
5
- "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,sBAMO;AACP,uBAAiC;AACjC,6BAA2B;AAC3B,kBAAkB;AAClB,0BAA2B;AAI3B,MAAM,eAAe,oBAAI,IAAI,CAAC,MAAM,CAAC;AAqB9B,SAAS,aAAa,WAAsC;AACjE,QAAM,QAAQ,IAAI,IAAI,SAAS;AAC/B,MAAI,OAAO;AACX,SAAO,MAAM,IAAI,IAAI,GAAG;AACtB;AAAA,EACF;AACA,SAAO;AACT;AAaO,SAAS,gBAAgB,WAA8B,WAA8C;AAC1G,QAAM,WAAW,KAAK,UAAU,UAAU,IAAI,OAAK,EAAE,KAAK,EAAE,YAAY,CAAC,CAAC;AAC1E,QAAM,WAAW,KAAK,UAAU,CAAC,GAAG,SAAS,CAAC;AAC9C,SAAO;AAAA,IACL,MAAM;AAAA,IACN,OAAO;AAAA,MACL,MAAM;AAAA,QACJ,MAAM;AAAA,QACN,WAAO,eAAE,YAAY;AAAA,QACrB,WAAW,IAAI,QAAQ;AAAA,QACvB,wBAAoB,eAAE,iBAAiB;AAAA,QACvC,wBAAwB;AAAA,QACxB,IAAI;AAAA,QACJ,IAAI;AAAA,MACN;AAAA,MACA,MAAM;AAAA,QACJ,MAAM;AAAA,QACN,WAAO,eAAE,YAAY;AAAA,QACrB,KAAK;AAAA,QACL,KAAK;AAAA,QACL,WAAW,IAAI,QAAQ;AAAA,QACvB,wBAAoB,eAAE,iBAAiB;AAAA,QACvC,wBAAwB;AAAA,QACxB,IAAI;AAAA,QACJ,IAAI;AAAA,MACN;AAAA,MACA,MAAM;AAAA,QACJ,MAAM;AAAA,QACN,WAAO,eAAE,iBAAiB;AAAA,QAC1B,SAAS;AAAA,QACT,SAAS;AAAA,UACP,EAAE,WAAO,eAAE,kBAAkB,GAAG,OAAO,SAAS;AAAA,UAChD,EAAE,WAAO,eAAE,cAAc,GAAG,OAAO,KAAK;AAAA,QAC1C;AAAA,QACA,IAAI;AAAA,QACJ,IAAI;AAAA,MACN;AAAA,MACA,WAAW,EAAE,MAAM,cAAc,UAAM,eAAE,gBAAgB,GAAG,IAAI,GAAG;AAAA,MACnE,WAAW,EAAE,MAAM,cAAc,UAAM,eAAE,gBAAgB,GAAG,IAAI,GAAG;AAAA,IACrE;AAAA,EACF;AACF;AASO,SAAS,YAAY,KAA8D;AACxF,QAAM,OAAO,OAAO,IAAI,SAAS,WAAW,IAAI,KAAK,KAAK,IAAI;AAC9D,QAAM,OAAO,OAAO,IAAI,IAAI,KAAK;AACjC,QAAM,OAAmB,IAAI,SAAS,OAAO,OAAO;AACpD,SAAO,EAAE,MAAM,MAAM,KAAK;AAC5B;AAcO,SAAS,UACd,SACA,WACA,aACoC;AACpC,QAAM,OAAO,UAAU,KAAK,KAAK,EAAE,YAAY;AAK/C,QAAM,SAAK,gCAAW,UAAU,KAAK,KAAK,CAAC;AAC3C,MAAI,OAAO,MAAM,aAAa,IAAI,EAAE,GAAG;AACrC,eAAO,eAAE,mBAAmB;AAAA,EAC9B;AACA,WAAS,IAAI,GAAG,IAAI,QAAQ,QAAQ,KAAK;AACvC,QAAI,MAAM,aAAa;AACrB;AAAA,IACF;AACA,QAAI,QAAQ,CAAC,EAAE,KAAK,KAAK,EAAE,YAAY,MAAM,MAAM;AACjD,iBAAO,eAAE,iBAAiB;AAAA,IAC5B;AACA,YAAI,gCAAW,QAAQ,CAAC,EAAE,KAAK,KAAK,CAAC,MAAM,IAAI;AAC7C,iBAAO,eAAE,mBAAmB;AAAA,IAC9B;AACA,QAAI,OAAO,QAAQ,CAAC,EAAE,IAAI,MAAM,UAAU,MAAM;AAC9C,iBAAO,eAAE,iBAAiB;AAAA,IAC5B;AAAA,EACF;AACA,SAAO;AACT;AAOO,MAAM,iCAAiC,iCAAiB;AAAA,EAC7D,IAAY,QAAgB;AAC1B,WAAO,kBAAkB,KAAK,QAAQ,SAAS;AAAA,EACjD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,MAAc,cAA2C;AA1K3D;AA2KI,UAAM,MAAM,MAAM,KAAK,QAAQ,sBAAsB,KAAK,KAAK;AAC/D,UAAM,WAAW,gCAAK,WAAL,mBAAmD;AACpE,QAAI,CAAC,MAAM,QAAQ,OAAO,GAAG;AAC3B,aAAO,CAAC;AAAA,IACV;AAIA,WAAO,QACJ,OAAO,CAAC,MAAoC,OAAO,MAAM,YAAY,MAAM,IAAI,EAC/E,IAAI,OAAK;AACR,YAAM,QAAQ,YAAY,CAAC;AAC3B,aAAO,OAAO,EAAE,SAAS,YAAY,EAAE,OAAO,EAAE,GAAG,OAAO,MAAM,EAAE,KAAK,IAAI;AAAA,IAC7E,CAAC;AAAA,EACL;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,MAAc,aAAa,SAA4C;AACrE,UAAM,KAAK,QAAQ,yBAAyB,KAAK,OAAO,EAAE,QAAQ,EAAE,QAAQ,EAAE,CAAC;AAAA,EACjF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,MAAgB,YAAY,SAAmD;AAC7E,UAAM,UAAU,MAAM,KAAK,YAAY;AACvC,YAAQ,QAAQ,CAAC,QAAQ,UAAU,QAAQ,UAAU,KAAK,aAAa,QAAQ,KAAK,CAAC,CAAC;AAAA,EACxF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAaQ,aAAa,QAA0B,OAAmC;AAChF,UAAM,OAAO,OAAO,SAAS,OAAO,OAAO;AAC3C,WAAO;AAAA,MACL,IAAI,OAAO,KAAK;AAAA,MAChB,MAAM,OAAO,QAAQ,QAAQ,QAAQ,CAAC;AAAA,MACtC,YAAY,OAAO,OAAO,IAAI;AAAA,MAC9B,OAAO;AAAA,MACP,SAAS;AAAA,QACP;AAAA,UACE,IAAI;AAAA,UACJ,MAAM;AAAA,UACN,iBAAa,eAAE,QAAQ;AAAA,UACvB,SAAS,OAAO,IAAY,YAA2B,KAAK,WAAW,OAAO,EAAE,GAAG,OAAO;AAAA,QAC5F;AAAA,QACA;AAAA,UACE,IAAI;AAAA,UACJ,MAAM;AAAA,UACN,iBAAa,eAAE,UAAU;AAAA,UACzB,SAAS,OAAO,IAAY,YAA2B,KAAK,aAAa,OAAO,EAAE,GAAG,OAAO;AAAA,QAC9F;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOU,kBAAmE;AAC3E,WAAO;AAAA,MACL,YAAY;AAAA,MACZ,qBAAiB,eAAE,WAAW;AAAA,MAC9B,SAAS,CAAC,EAAE,IAAI,OAAO,MAAM,OAAO,iBAAa,eAAE,OAAO,GAAG,SAAS,OAAM,YAAW,KAAK,UAAU,OAAO,EAAE,CAAC;AAAA,IAClH;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EASA,MAAc,UAAU,SAAiD;AACvE,UAAM,UAAU,MAAM,KAAK,YAAY;AACvC,UAAM,YAAY,QAAQ,IAAI,OAAK,EAAE,IAAI;AACzC,UAAM,YAAY,QAAQ,IAAI,OAAK,OAAO,EAAE,IAAI,CAAC;AACjD,UAAM,OAAO,MAAM,QAAQ,SAAS,gBAAgB,WAAW,SAAS,GAAG;AAAA,MACzE,WAAO,eAAE,OAAO;AAAA,MAChB,MAAM,EAAE,MAAM,UAAU,MAAM,aAAa,SAAS,EAAE;AAAA,IACxD,CAAC;AACD,QAAI,QAAQ,OAAO,KAAK,SAAS,YAAY,KAAK,KAAK,KAAK,GAAG;AAC7D,YAAM,QAAQ,YAAY,IAAI;AAC9B,YAAM,QAAQ,UAAU,SAAS,OAAO,EAAE;AAC1C,UAAI,OAAO;AACT,cAAM,QAAQ,YAAY,KAAK;AAC/B,eAAO,EAAE,SAAS,KAAK;AAAA,MACzB;AACA,cAAQ,KAAK,EAAE,GAAG,OAAO,UAAM,mCAAW,MAAM,IAAI,EAAE,CAAC;AACvD,YAAM,KAAK,aAAa,OAAO;AAAA,IACjC;AACA,WAAO,EAAE,SAAS,KAAK;AAAA,EACzB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAUA,MAAc,WAAW,OAAe,SAA+C;AACrF,UAAM,UAAU,MAAM,KAAK,YAAY;AACvC,UAAM,UAAU,QAAQ,KAAK;AAC7B,QAAI,CAAC,SAAS;AACZ,aAAO,EAAE,SAAS,UAAU;AAAA,IAC9B;AACA,UAAM,YAAY,QAAQ,OAAO,CAAC,GAAG,MAAM,MAAM,KAAK,EAAE,IAAI,OAAK,EAAE,IAAI;AACvE,UAAM,YAAY,QAAQ,OAAO,CAAC,GAAG,MAAM,MAAM,KAAK,EAAE,IAAI,OAAK,OAAO,EAAE,IAAI,CAAC;AAC/E,UAAM,OAAO,MAAM,QAAQ,SAAS,gBAAgB,WAAW,SAAS,GAAG;AAAA,MACzE,WAAO,eAAE,aAAa;AAAA,MACtB,MAAM,EAAE,GAAG,QAAQ;AAAA,IACrB,CAAC;AACD,QAAI,QAAQ,OAAO,KAAK,SAAS,YAAY,KAAK,KAAK,KAAK,GAAG;AAC7D,YAAM,QAAQ,YAAY,IAAI;AAC9B,YAAM,QAAQ,UAAU,SAAS,OAAO,KAAK;AAC7C,UAAI,OAAO;AACT,cAAM,QAAQ,YAAY,KAAK;AAC/B,eAAO,EAAE,SAAS,UAAU;AAAA,MAC9B;AACA,cAAQ,KAAK,IAAI,EAAE,GAAG,OAAO,MAAM,QAAQ,YAAQ,mCAAW,MAAM,IAAI,EAAE;AAC1E,YAAM,KAAK,aAAa,OAAO;AAAA,IACjC;AACA,WAAO,EAAE,SAAS,UAAU;AAAA,EAC9B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EASA,MAAc,aAAa,OAAe,SAA+C;AACvF,UAAM,UAAU,MAAM,KAAK,YAAY;AACvC,UAAM,SAAS,QAAQ,KAAK;AAC5B,QAAI,CAAC,QAAQ;AACX,aAAO,EAAE,SAAS,UAAU;AAAA,IAC9B;AACA,UAAM,YAAY,MAAM,QAAQ,qBAAiB,eAAE,mBAAmB,OAAO,QAAQ,EAAE,CAAC;AACxF,QAAI,WAAW;AACb,cAAQ,OAAO,OAAO,CAAC;AACvB,YAAM,KAAK,aAAa,OAAO;AAAA,IACjC;AACA,WAAO,EAAE,SAAS,UAAU;AAAA,EAC9B;AACF;",
4
+ "sourcesContent": ["import {\n DeviceManagement,\n type ActionContext,\n type DeviceInfo,\n type DeviceLoadContext,\n type JsonFormSchema,\n} from \"@iobroker/dm-utils\";\nimport { DEFAULT_ECP_PORT, RESERVED_IDS } from \"./lib/constants\";\nimport { deriveUuid, resolveDeviceUuid } from \"./lib/device-identity\";\nimport { t } from \"./lib/i18n\";\nimport { sanitizeId } from \"./lib/pure-helpers\";\nimport type { DeviceType } from \"./ecp/state-model\";\n\n/**\n * One emulated Roku as stored in the adapter's native.devices \u2014 the manifest's own\n * element type, so the manager and the runtime (main.ts) read the same shape.\n * `uuid` is the stable SSDP identity, carried through edits so the pairing survives.\n * A row without one (the manifest default, or a hand-written config) is identified\n * by `deriveUuid(name)` at runtime, so an edit must derive from the OLD name.\n */\ntype RokuDeviceConfig = ioBroker.AdapterConfig[\"devices\"][number];\n\n/** Manager directive: reload the whole view. */\ntype InstanceResult = { refresh: boolean };\n/** Manager directive: reload the device list. */\ntype DeviceResult = { refresh: \"devices\" };\n\n/**\n * The lowest free ECP port at or above the real-Roku default, so a newly added\n * device never pre-selects a port another emulated Roku already uses.\n *\n * @param usedPorts the ports already taken by other devices\n * @returns the first free port >= 8060\n */\nexport function nextFreePort(usedPorts: readonly number[]): number {\n const taken = new Set(usedPorts);\n let port = DEFAULT_ECP_PORT;\n while (taken.has(port)) {\n port++;\n }\n return port;\n}\n\n/**\n * The add/edit form for one emulated Roku: name, ECP port and device type. The\n * name and port carry a live validator against the values already in use (the\n * OK button greys out on a clash); a hint explains that each Roku needs its own\n * port. Labels are resolved translation objects so the embedded form is\n * language-correct.\n *\n * @param usedNames names taken by OTHER devices (the edited device is excluded)\n * @param usedPorts ports taken by OTHER devices\n * @returns the jsonConfig panel schema for one device\n */\nexport function buildDeviceForm(usedNames: readonly string[], usedPorts: readonly number[]): JsonFormSchema {\n const nameList = JSON.stringify(usedNames.map(n => n.trim().toLowerCase()));\n const portList = JSON.stringify([...usedPorts]);\n return {\n type: \"panel\",\n items: {\n name: {\n type: \"text\",\n label: t(\"deviceName\"),\n validator: `!${nameList}.includes((data.name||'').trim().toLowerCase())`,\n validatorErrorText: t(\"deviceNameInUse\"),\n validatorNoSaveOnError: true,\n sm: 12,\n md: 6,\n },\n port: {\n type: \"number\",\n label: t(\"devicePort\"),\n min: 1,\n max: 65535,\n validator: `!${portList}.includes(Number(data.port))`,\n validatorErrorText: t(\"devicePortInUse\"),\n validatorNoSaveOnError: true,\n sm: 12,\n md: 3,\n },\n type: {\n type: \"select\",\n label: t(\"deviceTypeLabel\"),\n default: \"player\",\n options: [\n { label: t(\"deviceTypePlayer\"), value: \"player\" },\n { label: t(\"deviceTypeTv\"), value: \"tv\" },\n ],\n sm: 12,\n md: 3,\n },\n _portHint: { type: \"staticText\", text: t(\"devicePortHint\"), sm: 12 },\n _typeHint: { type: \"staticText\", text: t(\"deviceTypeHint\"), sm: 12 },\n },\n } as unknown as JsonFormSchema;\n}\n\n/**\n * Normalise raw form values into a clean device (trimmed name, numeric port,\n * valid type) so native.devices stays tidy. The uuid is set by the caller.\n *\n * @param raw the submitted form values\n * @returns the normalised device config (without uuid)\n */\nexport function cleanDevice(raw: Record<string, unknown>): Omit<RokuDeviceConfig, \"uuid\"> {\n const name = typeof raw.name === \"string\" ? raw.name.trim() : \"\";\n const port = Number(raw.port) || DEFAULT_ECP_PORT;\n const type: DeviceType = raw.type === \"tv\" ? \"tv\" : \"player\";\n return { name, port, type };\n}\n\n/**\n * A name/port clash against the other devices, as a ready-to-show message \u2014 the\n * backend safety net behind the form validator (the dialog validator may not\n * fire in every admin version; this never lets a duplicate through).\n *\n * @param devices the full current device list\n * @param candidate the name+port being added/edited\n * @param candidate.name the candidate device name\n * @param candidate.port the candidate ECP port\n * @param exceptIndex the list position to ignore (the device being edited), or -1\n * @returns a translated clash message, or null if free\n */\nexport function findClash(\n devices: readonly RokuDeviceConfig[],\n candidate: { name: string; port: number },\n exceptIndex: number,\n): ioBroker.StringOrTranslated | null {\n const name = candidate.name.trim().toLowerCase();\n // The object-tree path is sanitizeId(name); guard the two ways it can go wrong\n // regardless of the plain-name check: a name that sanitizes to a reserved id\n // (\"info\" would collide with the adapter's own channel), and two different\n // names that sanitize to the SAME id (\"My Roku\" and \"My*Roku\" \u2192 \"My_Roku\").\n const id = sanitizeId(candidate.name.trim());\n if (id === \"\" || RESERVED_IDS.has(id)) {\n return t(\"deviceNameInvalid\");\n }\n for (let i = 0; i < devices.length; i++) {\n if (i === exceptIndex) {\n continue;\n }\n if (devices[i].name.trim().toLowerCase() === name) {\n return t(\"deviceNameInUse\");\n }\n if (sanitizeId(devices[i].name.trim()) === id) {\n return t(\"deviceNameInvalid\");\n }\n if (Number(devices[i].port) === candidate.port) {\n return t(\"devicePortInUse\");\n }\n }\n return null;\n}\n\n/**\n * ioBroker device-manager backend: the emulated Rokus as cards with manual\n * add/edit/delete \u2014 no scan, the devices are purely user-defined. Owns no state;\n * it reads and writes the adapter's own `native.devices` config array.\n */\nexport class FakerokuDeviceManagement extends DeviceManagement {\n private get objId(): string {\n return `system.adapter.${this.adapter.namespace}`;\n }\n\n /**\n * Read the device list from the live config object.\n *\n * @returns the configured devices (normalised), or an empty list\n */\n private async readDevices(): Promise<RokuDeviceConfig[]> {\n const obj = await this.adapter.getForeignObjectAsync(this.objId);\n const devices = (obj?.native as { devices?: unknown } | undefined)?.devices;\n if (!Array.isArray(devices)) {\n return [];\n }\n // native.devices is user-editable (expert mode, CLI). Normalise every row the\n // same way the dialog does, so a hand-edited entry (numeric name, missing port)\n // shows up as a card the user can fix instead of throwing inside add/edit.\n return devices\n .filter((d): d is Record<string, unknown> => typeof d === \"object\" && d !== null)\n .map(d => {\n const clean = cleanDevice(d);\n return typeof d.uuid === \"string\" && d.uuid ? { ...clean, uuid: d.uuid } : clean;\n });\n }\n\n /**\n * Persist the device list. Writing `native.*` restarts the adapter, which\n * re-creates the object trees and servers with the new devices.\n *\n * @param devices the full device list to store\n */\n private async writeDevices(devices: RokuDeviceConfig[]): Promise<void> {\n await this.adapter.extendForeignObjectAsync(this.objId, { native: { devices } });\n }\n\n /**\n * Populate the manager with one card per configured device.\n *\n * @param context the load context\n */\n protected async loadDevices(context: DeviceLoadContext<string>): Promise<void> {\n const devices = await this.readDevices();\n devices.forEach((device, index) => context.addDevice(this.toDeviceInfo(device, index)));\n }\n\n /**\n * Build one device card. The model (Player/TV) and the ECP port each get their\n * own line \u2014 the port via `identifier` (labelled in getInstanceInfo). No\n * manufacturer line: it is always \"Roku\" and tells the user nothing for an emulator.\n * The row comes from readDevices(), so the port is already a number; only the name\n * can still be empty (a row saved without one) and gets a numbered stand-in.\n *\n * @param device the stored device\n * @param index its list position \u2014 the (per-session stable) card id\n * @returns the card descriptor\n */\n private toDeviceInfo(device: RokuDeviceConfig, index: number): DeviceInfo<string> {\n const kind = device.type === \"tv\" ? \"TV\" : \"Player\";\n return {\n id: String(index),\n name: device.name || `Roku ${index + 1}`,\n identifier: String(device.port),\n model: kind,\n actions: [\n {\n id: \"edit\",\n icon: \"edit\",\n description: t(\"dmEdit\"),\n handler: async (id: string, context: ActionContext) => this.editDevice(Number(id), context),\n },\n {\n id: \"delete\",\n icon: \"delete\",\n description: t(\"dmDelete\"),\n handler: async (id: string, context: ActionContext) => this.deleteDevice(Number(id), context),\n },\n ],\n };\n }\n\n /**\n * The \"+ add\" action above the list, plus the label for the port shown on each card.\n *\n * @returns the instance action descriptor\n */\n protected getInstanceInfo(): ReturnType<DeviceManagement[\"getInstanceInfo\"]> {\n return {\n apiVersion: \"v3\",\n identifierLabel: t(\"portLabel\"),\n actions: [{ id: \"add\", icon: \"add\", description: t(\"dmAdd\"), handler: async context => this.addDevice(context) }],\n };\n }\n\n /**\n * Manual add: pre-select a free port, show the form, and append the device with\n * a stable derived uuid.\n *\n * @param context the action context\n * @returns a directive to reload the manager\n */\n private async addDevice(context: ActionContext): Promise<InstanceResult> {\n const devices = await this.readDevices();\n const usedNames = devices.map(d => d.name);\n const usedPorts = devices.map(d => Number(d.port));\n const data = await context.showForm(buildDeviceForm(usedNames, usedPorts), {\n title: t(\"dmAdd\"),\n data: { type: \"player\", port: nextFreePort(usedPorts) },\n });\n if (data && typeof data.name === \"string\" && data.name.trim()) {\n const clean = cleanDevice(data);\n const clash = findClash(devices, clean, -1);\n if (clash) {\n await context.showMessage(clash);\n return { refresh: true };\n }\n devices.push({ ...clean, uuid: deriveUuid(clean.name) });\n await this.writeDevices(devices);\n }\n return { refresh: true };\n }\n\n /**\n * Edit a device via the pre-filled form. Its own name/port are excluded from\n * the clash check, and its uuid is preserved so the pairing survives a rename.\n *\n * A row that carries no uuid yet \u2014 the manifest's default device, or anything\n * written before 0.8.0 \u2014 gets one derived from its PREVIOUS name, because that\n * is the identity main.ts is advertising right now (it derives from the stored\n * name and never writes the value back). Deriving from the NEW name here would\n * change the SSDP identity on a plain rename and silently unpair the remote.\n *\n * @param index the device's list position\n * @param context the action context\n * @returns a directive to reload the list\n */\n private async editDevice(index: number, context: ActionContext): Promise<DeviceResult> {\n const devices = await this.readDevices();\n const current = devices[index];\n if (!current) {\n return { refresh: \"devices\" };\n }\n const usedNames = devices.filter((_, i) => i !== index).map(d => d.name);\n const usedPorts = devices.filter((_, i) => i !== index).map(d => Number(d.port));\n const data = await context.showForm(buildDeviceForm(usedNames, usedPorts), {\n title: t(\"dmEditTitle\"),\n data: { ...current },\n });\n if (data && typeof data.name === \"string\" && data.name.trim()) {\n const clean = cleanDevice(data);\n const clash = findClash(devices, clean, index);\n if (clash) {\n await context.showMessage(clash);\n return { refresh: \"devices\" };\n }\n devices[index] = { ...clean, uuid: resolveDeviceUuid(current) };\n await this.writeDevices(devices);\n }\n return { refresh: \"devices\" };\n }\n\n /**\n * Delete a device after confirmation.\n *\n * @param index the device's list position\n * @param context the action context\n * @returns a directive to reload the list\n */\n private async deleteDevice(index: number, context: ActionContext): Promise<DeviceResult> {\n const devices = await this.readDevices();\n const target = devices[index];\n if (!target) {\n return { refresh: \"devices\" };\n }\n const confirmed = await context.showConfirmation(t(\"dmDeleteConfirm\", target.name || \"\"));\n if (confirmed) {\n devices.splice(index, 1);\n await this.writeDevices(devices);\n }\n return { refresh: \"devices\" };\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -18,12 +18,18 @@ var __copyProps = (to, from, except, desc) => {
18
18
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
19
  var constants_exports = {};
20
20
  __export(constants_exports, {
21
- DEFAULT_ECP_PORT: () => DEFAULT_ECP_PORT
21
+ DEFAULT_ECP_PORT: () => DEFAULT_ECP_PORT,
22
+ OWN_INFO_IDS: () => OWN_INFO_IDS,
23
+ RESERVED_IDS: () => RESERVED_IDS
22
24
  });
23
25
  module.exports = __toCommonJS(constants_exports);
24
26
  const DEFAULT_ECP_PORT = 8060;
27
+ const RESERVED_IDS = /* @__PURE__ */ new Set(["info"]);
28
+ const OWN_INFO_IDS = /* @__PURE__ */ new Set(["info", "info.connection"]);
25
29
  // Annotate the CommonJS export names for ESM import in node:
26
30
  0 && (module.exports = {
27
- DEFAULT_ECP_PORT
31
+ DEFAULT_ECP_PORT,
32
+ OWN_INFO_IDS,
33
+ RESERVED_IDS
28
34
  });
29
35
  //# sourceMappingURL=constants.js.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/lib/constants.ts"],
4
- "sourcesContent": ["/**\n * Shared constants for the Roku emulator \u2014 kept in one place so the runtime\n * (main.ts) and the admin device-manager (device-management.ts) cannot drift apart.\n */\n\n/**\n * The real-Roku ECP port and this adapter's default. A controller reads the\n * actual port from the SSDP advertisement, so any free port works; 8060 is the\n * risk-free default (it also covers controllers that assume the standard port).\n */\nexport const DEFAULT_ECP_PORT = 8060;\n"],
5
- "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAUO,MAAM,mBAAmB;",
4
+ "sourcesContent": ["/**\n * Shared constants for the Roku emulator \u2014 kept in one place so the runtime\n * (main.ts) and the admin device-manager (device-management.ts) cannot drift apart.\n */\n\n/**\n * The real-Roku ECP port and this adapter's default. A controller reads the\n * actual port from the SSDP advertisement, so any free port works; 8060 is the\n * risk-free default (it also covers controllers that assume the standard port).\n */\nexport const DEFAULT_ECP_PORT = 8060;\n\n/**\n * Object-id segments the adapter reserves for its own tree \u2014 an emulated Roku may\n * not take one. `info` carries `info.connection`, the instance's own status; a\n * device of that name would turn the adapter's channel into a device object and\n * hang its command/keys states underneath it.\n *\n * Enforced in BOTH directions: the device manager refuses the name in the dialog,\n * and the runtime skips such a row, because native.devices is user-editable\n * (expert mode, CLI) and can carry what the dialog never allowed.\n */\nexport const RESERVED_IDS: ReadonlySet<string> = new Set([\"info\"]);\n\n/** The adapter's own objects below `info` \u2014 never device leftovers, never swept. */\nexport const OWN_INFO_IDS: ReadonlySet<string> = new Set([\"info\", \"info.connection\"]);\n"],
5
+ "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAUO,MAAM,mBAAmB;AAYzB,MAAM,eAAoC,oBAAI,IAAI,CAAC,MAAM,CAAC;AAG1D,MAAM,eAAoC,oBAAI,IAAI,CAAC,QAAQ,iBAAiB,CAAC;",
6
6
  "names": []
7
7
  }
@@ -19,7 +19,10 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
19
19
  var detect_ip_exports = {};
20
20
  __export(detect_ip_exports, {
21
21
  detectLocalIPv4s: () => detectLocalIPv4s,
22
+ detectLocalIPv6Prefixes: () => detectLocalIPv6Prefixes,
22
23
  detectPrimaryIPv4: () => detectPrimaryIPv4,
24
+ ipv6Prefix64: () => ipv6Prefix64,
25
+ listLocalIPv6Prefixes: () => listLocalIPv6Prefixes,
23
26
  listNonInternalIPv4s: () => listNonInternalIPv4s,
24
27
  pickPrimaryIPv4: () => pickPrimaryIPv4
25
28
  });
@@ -49,10 +52,52 @@ function detectPrimaryIPv4() {
49
52
  function detectLocalIPv4s() {
50
53
  return listNonInternalIPv4s((0, import_node_os.networkInterfaces)());
51
54
  }
55
+ function expandIPv6(address) {
56
+ const bare = address.toLowerCase().split("%")[0];
57
+ if (!bare.includes(":") || bare.includes(".")) {
58
+ return null;
59
+ }
60
+ const halves = bare.split("::");
61
+ if (halves.length > 2) {
62
+ return null;
63
+ }
64
+ const head = halves[0] ? halves[0].split(":") : [];
65
+ const tail = halves.length === 2 ? halves[1] ? halves[1].split(":") : [] : [];
66
+ const groups = halves.length === 2 ? [...head, ...Array(8 - head.length - tail.length).fill("0"), ...tail] : head;
67
+ if (groups.length !== 8 || groups.some((g) => !/^[0-9a-f]{1,4}$/.test(g))) {
68
+ return null;
69
+ }
70
+ return groups.map((g) => g.padStart(4, "0"));
71
+ }
72
+ function ipv6Prefix64(address) {
73
+ const groups = expandIPv6(address);
74
+ return groups ? groups.slice(0, 4).join(":") : null;
75
+ }
76
+ function listLocalIPv6Prefixes(interfaces) {
77
+ const out = /* @__PURE__ */ new Set();
78
+ for (const addrs of Object.values(interfaces)) {
79
+ for (const addr of addrs != null ? addrs : []) {
80
+ if (addr.family !== "IPv6" || addr.internal) {
81
+ continue;
82
+ }
83
+ const prefix = ipv6Prefix64(addr.address);
84
+ if (prefix) {
85
+ out.add(prefix);
86
+ }
87
+ }
88
+ }
89
+ return [...out];
90
+ }
91
+ function detectLocalIPv6Prefixes() {
92
+ return listLocalIPv6Prefixes((0, import_node_os.networkInterfaces)());
93
+ }
52
94
  // Annotate the CommonJS export names for ESM import in node:
53
95
  0 && (module.exports = {
54
96
  detectLocalIPv4s,
97
+ detectLocalIPv6Prefixes,
55
98
  detectPrimaryIPv4,
99
+ ipv6Prefix64,
100
+ listLocalIPv6Prefixes,
56
101
  listNonInternalIPv4s,
57
102
  pickPrimaryIPv4
58
103
  });
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/lib/detect-ip.ts"],
4
- "sourcesContent": ["import { networkInterfaces, type NetworkInterfaceInfo } from \"node:os\";\n\n/**\n * Is this a routable (non-internal) IPv4 address?\n *\n * @param addr one entry from an os.networkInterfaces() list\n * @returns true for a non-internal IPv4 address\n */\nfunction isRoutableIPv4(addr: NetworkInterfaceInfo): boolean {\n return addr.family === \"IPv4\" && !addr.internal;\n}\n\n/**\n * Every non-internal IPv4 address of the given interface map, in enumeration\n * order. Pure \u2014 takes the interface map so it can be unit-tested without real\n * network cards.\n *\n * @param interfaces the OS network-interface map (os.networkInterfaces() shape)\n * @returns every routable IPv4 address (may be empty)\n */\nexport function listNonInternalIPv4s(interfaces: NodeJS.Dict<NetworkInterfaceInfo[]>): string[] {\n const out: string[] = [];\n for (const addrs of Object.values(interfaces)) {\n for (const addr of addrs ?? []) {\n if (isRoutableIPv4(addr)) {\n out.push(addr.address);\n }\n }\n }\n return out;\n}\n\n/**\n * Pick the first non-internal IPv4 from a set of OS network interfaces. Pure \u2014\n * takes the interface map so it can be unit-tested without real network cards.\n *\n * @param interfaces the OS network-interface map (os.networkInterfaces() shape)\n * @returns the first routable IPv4 address, or \"\" if none is found\n */\nexport function pickPrimaryIPv4(interfaces: NodeJS.Dict<NetworkInterfaceInfo[]>): string {\n return listNonInternalIPv4s(interfaces)[0] ?? \"\";\n}\n\n/**\n * Best-effort primary IPv4 of the host \u2014 used as the advertised SSDP LOCATION\n * when no interface is configured, so a controller gets a routable IP instead of\n * the bind wildcard (`0.0.0.0` is not reachable). Returns \"\" when the host has no\n * routable IPv4 (e.g. no network), which the caller treats as \"cannot advertise\".\n *\n * @returns the primary IPv4 address, or \"\" if none is found\n */\nexport function detectPrimaryIPv4(): string {\n return pickPrimaryIPv4(networkInterfaces());\n}\n\n/**\n * All routable IPv4 addresses of the host \u2014 the interfaces to join the SSDP\n * multicast group on when no specific interface is configured, so discovery\n * works on every LAN the host is on. Without this a multi-homed host only hears\n * M-SEARCH on the OS default interface. Empty when the host has no routable IPv4.\n *\n * @returns every routable IPv4 address of the host (may be empty)\n */\nexport function detectLocalIPv4s(): string[] {\n return listNonInternalIPv4s(networkInterfaces());\n}\n"],
5
- "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,qBAA6D;AAQ7D,SAAS,eAAe,MAAqC;AAC3D,SAAO,KAAK,WAAW,UAAU,CAAC,KAAK;AACzC;AAUO,SAAS,qBAAqB,YAA2D;AAC9F,QAAM,MAAgB,CAAC;AACvB,aAAW,SAAS,OAAO,OAAO,UAAU,GAAG;AAC7C,eAAW,QAAQ,wBAAS,CAAC,GAAG;AAC9B,UAAI,eAAe,IAAI,GAAG;AACxB,YAAI,KAAK,KAAK,OAAO;AAAA,MACvB;AAAA,IACF;AAAA,EACF;AACA,SAAO;AACT;AASO,SAAS,gBAAgB,YAAyD;AAvCzF;AAwCE,UAAO,0BAAqB,UAAU,EAAE,CAAC,MAAlC,YAAuC;AAChD;AAUO,SAAS,oBAA4B;AAC1C,SAAO,oBAAgB,kCAAkB,CAAC;AAC5C;AAUO,SAAS,mBAA6B;AAC3C,SAAO,yBAAqB,kCAAkB,CAAC;AACjD;",
4
+ "sourcesContent": ["import { networkInterfaces, type NetworkInterfaceInfo } from \"node:os\";\n\n/**\n * Is this a routable (non-internal) IPv4 address?\n *\n * @param addr one entry from an os.networkInterfaces() list\n * @returns true for a non-internal IPv4 address\n */\nfunction isRoutableIPv4(addr: NetworkInterfaceInfo): boolean {\n return addr.family === \"IPv4\" && !addr.internal;\n}\n\n/**\n * Every non-internal IPv4 address of the given interface map, in enumeration\n * order. Pure \u2014 takes the interface map so it can be unit-tested without real\n * network cards.\n *\n * @param interfaces the OS network-interface map (os.networkInterfaces() shape)\n * @returns every routable IPv4 address (may be empty)\n */\nexport function listNonInternalIPv4s(interfaces: NodeJS.Dict<NetworkInterfaceInfo[]>): string[] {\n const out: string[] = [];\n for (const addrs of Object.values(interfaces)) {\n for (const addr of addrs ?? []) {\n if (isRoutableIPv4(addr)) {\n out.push(addr.address);\n }\n }\n }\n return out;\n}\n\n/**\n * Pick the first non-internal IPv4 from a set of OS network interfaces. Pure \u2014\n * takes the interface map so it can be unit-tested without real network cards.\n *\n * @param interfaces the OS network-interface map (os.networkInterfaces() shape)\n * @returns the first routable IPv4 address, or \"\" if none is found\n */\nexport function pickPrimaryIPv4(interfaces: NodeJS.Dict<NetworkInterfaceInfo[]>): string {\n return listNonInternalIPv4s(interfaces)[0] ?? \"\";\n}\n\n/**\n * Best-effort primary IPv4 of the host \u2014 used as the advertised SSDP LOCATION\n * when no interface is configured, so a controller gets a routable IP instead of\n * the bind wildcard (`0.0.0.0` is not reachable). Returns \"\" when the host has no\n * routable IPv4 (e.g. no network), which the caller treats as \"cannot advertise\".\n *\n * @returns the primary IPv4 address, or \"\" if none is found\n */\nexport function detectPrimaryIPv4(): string {\n return pickPrimaryIPv4(networkInterfaces());\n}\n\n/**\n * All routable IPv4 addresses of the host \u2014 the interfaces to join the SSDP\n * multicast group on when no specific interface is configured, so discovery\n * works on every LAN the host is on. Without this a multi-homed host only hears\n * M-SEARCH on the OS default interface. Empty when the host has no routable IPv4.\n *\n * @returns every routable IPv4 address of the host (may be empty)\n */\nexport function detectLocalIPv4s(): string[] {\n return listNonInternalIPv4s(networkInterfaces());\n}\n\n/**\n * Expand an IPv6 address to its eight four-digit groups, resolving the `::`\n * shorthand and dropping a `%zone` suffix. Returns null for anything that is not\n * a plain IPv6 address (an IPv4-mapped form, a malformed value).\n *\n * @param address the IPv6 address text\n * @returns the eight normalised groups, or null\n */\nfunction expandIPv6(address: string): string[] | null {\n const bare = address.toLowerCase().split(\"%\")[0];\n if (!bare.includes(\":\") || bare.includes(\".\")) {\n return null; // not IPv6, or an IPv4-mapped/embedded form\n }\n const halves = bare.split(\"::\");\n if (halves.length > 2) {\n return null;\n }\n const head = halves[0] ? halves[0].split(\":\") : [];\n const tail = halves.length === 2 ? (halves[1] ? halves[1].split(\":\") : []) : [];\n const groups =\n halves.length === 2 ? [...head, ...Array<string>(8 - head.length - tail.length).fill(\"0\"), ...tail] : head;\n if (groups.length !== 8 || groups.some(g => !/^[0-9a-f]{1,4}$/.test(g))) {\n return null;\n }\n return groups.map(g => g.padStart(4, \"0\"));\n}\n\n/**\n * The /64 network prefix of an IPv6 address \u2014 its first four groups. A /64 is\n * exactly one network segment (the size SLAAC assigns), so two addresses sharing\n * it are on the same link.\n *\n * @param address the IPv6 address text\n * @returns the normalised prefix (e.g. \"2003:00e1:1f28:9a00\"), or null\n */\nexport function ipv6Prefix64(address: string): string | null {\n const groups = expandIPv6(address);\n return groups ? groups.slice(0, 4).join(\":\") : null;\n}\n\n/**\n * The /64 prefixes of every non-internal IPv6 address of the given interface map.\n * Pure \u2014 takes the interface map so it can be unit-tested without real network\n * cards.\n *\n * These are what makes a globally routable IPv6 client recognisable as local: on\n * a modern connection the router hands every device in the house an address out\n * of the provider's block, which looks exactly like an internet address. Only the\n * shared prefix tells \"the TV in the living room\" from \"a host on the internet\".\n *\n * @param interfaces the OS network-interface map (os.networkInterfaces() shape)\n * @returns every /64 prefix the host itself is in (may be empty), de-duplicated\n */\nexport function listLocalIPv6Prefixes(interfaces: NodeJS.Dict<NetworkInterfaceInfo[]>): string[] {\n const out = new Set<string>();\n for (const addrs of Object.values(interfaces)) {\n for (const addr of addrs ?? []) {\n if (addr.family !== \"IPv6\" || addr.internal) {\n continue;\n }\n const prefix = ipv6Prefix64(addr.address);\n if (prefix) {\n out.add(prefix);\n }\n }\n }\n return [...out];\n}\n\n/**\n * The /64 prefixes of the host's own IPv6 addresses \u2014 the LAN guard's notion of\n * \"my network\" for a globally routable client.\n *\n * @returns every /64 prefix the host is in (may be empty)\n */\nexport function detectLocalIPv6Prefixes(): string[] {\n return listLocalIPv6Prefixes(networkInterfaces());\n}\n"],
5
+ "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,qBAA6D;AAQ7D,SAAS,eAAe,MAAqC;AAC3D,SAAO,KAAK,WAAW,UAAU,CAAC,KAAK;AACzC;AAUO,SAAS,qBAAqB,YAA2D;AAC9F,QAAM,MAAgB,CAAC;AACvB,aAAW,SAAS,OAAO,OAAO,UAAU,GAAG;AAC7C,eAAW,QAAQ,wBAAS,CAAC,GAAG;AAC9B,UAAI,eAAe,IAAI,GAAG;AACxB,YAAI,KAAK,KAAK,OAAO;AAAA,MACvB;AAAA,IACF;AAAA,EACF;AACA,SAAO;AACT;AASO,SAAS,gBAAgB,YAAyD;AAvCzF;AAwCE,UAAO,0BAAqB,UAAU,EAAE,CAAC,MAAlC,YAAuC;AAChD;AAUO,SAAS,oBAA4B;AAC1C,SAAO,oBAAgB,kCAAkB,CAAC;AAC5C;AAUO,SAAS,mBAA6B;AAC3C,SAAO,yBAAqB,kCAAkB,CAAC;AACjD;AAUA,SAAS,WAAW,SAAkC;AACpD,QAAM,OAAO,QAAQ,YAAY,EAAE,MAAM,GAAG,EAAE,CAAC;AAC/C,MAAI,CAAC,KAAK,SAAS,GAAG,KAAK,KAAK,SAAS,GAAG,GAAG;AAC7C,WAAO;AAAA,EACT;AACA,QAAM,SAAS,KAAK,MAAM,IAAI;AAC9B,MAAI,OAAO,SAAS,GAAG;AACrB,WAAO;AAAA,EACT;AACA,QAAM,OAAO,OAAO,CAAC,IAAI,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;AACjD,QAAM,OAAO,OAAO,WAAW,IAAK,OAAO,CAAC,IAAI,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC,IAAK,CAAC;AAC9E,QAAM,SACJ,OAAO,WAAW,IAAI,CAAC,GAAG,MAAM,GAAG,MAAc,IAAI,KAAK,SAAS,KAAK,MAAM,EAAE,KAAK,GAAG,GAAG,GAAG,IAAI,IAAI;AACxG,MAAI,OAAO,WAAW,KAAK,OAAO,KAAK,OAAK,CAAC,kBAAkB,KAAK,CAAC,CAAC,GAAG;AACvE,WAAO;AAAA,EACT;AACA,SAAO,OAAO,IAAI,OAAK,EAAE,SAAS,GAAG,GAAG,CAAC;AAC3C;AAUO,SAAS,aAAa,SAAgC;AAC3D,QAAM,SAAS,WAAW,OAAO;AACjC,SAAO,SAAS,OAAO,MAAM,GAAG,CAAC,EAAE,KAAK,GAAG,IAAI;AACjD;AAeO,SAAS,sBAAsB,YAA2D;AAC/F,QAAM,MAAM,oBAAI,IAAY;AAC5B,aAAW,SAAS,OAAO,OAAO,UAAU,GAAG;AAC7C,eAAW,QAAQ,wBAAS,CAAC,GAAG;AAC9B,UAAI,KAAK,WAAW,UAAU,KAAK,UAAU;AAC3C;AAAA,MACF;AACA,YAAM,SAAS,aAAa,KAAK,OAAO;AACxC,UAAI,QAAQ;AACV,YAAI,IAAI,MAAM;AAAA,MAChB;AAAA,IACF;AAAA,EACF;AACA,SAAO,CAAC,GAAG,GAAG;AAChB;AAQO,SAAS,0BAAoC;AAClD,SAAO,0BAAsB,kCAAkB,CAAC;AAClD;",
6
6
  "names": []
7
7
  }
@@ -18,15 +18,21 @@ var __copyProps = (to, from, except, desc) => {
18
18
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
19
  var device_identity_exports = {};
20
20
  __export(device_identity_exports, {
21
- deriveUuid: () => deriveUuid
21
+ deriveUuid: () => deriveUuid,
22
+ resolveDeviceUuid: () => resolveDeviceUuid
22
23
  });
23
24
  module.exports = __toCommonJS(device_identity_exports);
24
25
  var import_node_crypto = require("node:crypto");
25
26
  function deriveUuid(name) {
26
27
  return (0, import_node_crypto.createHash)("md5").update(`fakeroku:${name}`).digest("hex");
27
28
  }
29
+ const UUID_SHAPE = /^[A-Za-z0-9-]{1,64}$/;
30
+ function resolveDeviceUuid(device) {
31
+ return typeof device.uuid === "string" && UUID_SHAPE.test(device.uuid) ? device.uuid : deriveUuid(device.name);
32
+ }
28
33
  // Annotate the CommonJS export names for ESM import in node:
29
34
  0 && (module.exports = {
30
- deriveUuid
35
+ deriveUuid,
36
+ resolveDeviceUuid
31
37
  });
32
38
  //# sourceMappingURL=device-identity.js.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/lib/device-identity.ts"],
4
- "sourcesContent": ["import { createHash } from \"node:crypto\";\n\n/**\n * Derive a stable Roku device identity (serial / UDN) from the device name.\n *\n * Deterministic md5 of the name, so the emulated Roku keeps the same USN/serial\n * across restarts \u2014 unlike the old adapter, whose UUID changed between the first\n * two runs (bug A1) and re-paired the Harmony. Milestone 2 (device-info) may\n * replace this with a persisted, richer identity; for discovery a stable\n * per-name value is enough.\n *\n * @param name the configured device name\n * @returns a 32-char lowercase hex identity\n */\nexport function deriveUuid(name: string): string {\n return createHash(\"md5\").update(`fakeroku:${name}`).digest(\"hex\");\n}\n"],
5
- "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,yBAA2B;AAcpB,SAAS,WAAW,MAAsB;AAC/C,aAAO,+BAAW,KAAK,EAAE,OAAO,YAAY,IAAI,EAAE,EAAE,OAAO,KAAK;AAClE;",
4
+ "sourcesContent": ["import { createHash } from \"node:crypto\";\n\n/**\n * Derive a stable Roku device identity (serial / UDN) from the device name.\n *\n * Deterministic md5 of the name, so the emulated Roku keeps the same USN/serial\n * across restarts \u2014 unlike the old adapter, whose UUID changed between the first\n * two runs (bug A1) and re-paired the Harmony.\n *\n * Used only for a device that has no persisted `uuid`: the device manager stores\n * one when it CREATES a device (since 0.8.0), and main.ts adopts whatever is\n * stored. Rows the manager never created \u2014 the manifest's default device, or a\n * hand-written config \u2014 carry none, so their identity is this derivation from the\n * stored name. That is why an edit derives from the device's PREVIOUS name\n * (device-management.ts): deriving from the new one would move the identity on a\n * plain rename and unpair the remote.\n *\n * The exact output is pinned by a test \u2014 changing it would unpair every installed remote.\n *\n * @param name the configured device name\n * @returns a 32-char lowercase hex identity\n */\nexport function deriveUuid(name: string): string {\n return createHash(\"md5\").update(`fakeroku:${name}`).digest(\"hex\");\n}\n\n/**\n * The only shapes a device id ever had: the md5 hex the adapters derive, or a\n * dashed uuid from the old adapter. The value goes verbatim into SSDP headers and\n * XML, so anything else is replaced rather than emitted.\n */\nconst UUID_SHAPE = /^[A-Za-z0-9-]{1,64}$/;\n\n/**\n * The SSDP identity of one configured device: its persisted `uuid` when that is a\n * shape the adapters ever wrote, otherwise one derived from its name.\n *\n * THE single source for that decision. The runtime (main.ts) and the device\n * manager both call it \u2014 the manager so that an edit persists exactly the identity\n * the runtime is advertising at that moment, instead of computing a second,\n * different answer and unpairing the remote.\n *\n * @param device the stored device row\n * @param device.name the configured device name\n * @param device.uuid the persisted identity, if the row carries one\n * @returns the identity to advertise for this device\n */\nexport function resolveDeviceUuid(device: { name: string; uuid?: unknown }): string {\n return typeof device.uuid === \"string\" && UUID_SHAPE.test(device.uuid) ? device.uuid : deriveUuid(device.name);\n}\n"],
5
+ "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,yBAA2B;AAsBpB,SAAS,WAAW,MAAsB;AAC/C,aAAO,+BAAW,KAAK,EAAE,OAAO,YAAY,IAAI,EAAE,EAAE,OAAO,KAAK;AAClE;AAOA,MAAM,aAAa;AAgBZ,SAAS,kBAAkB,QAAkD;AAClF,SAAO,OAAO,OAAO,SAAS,YAAY,WAAW,KAAK,OAAO,IAAI,IAAI,OAAO,OAAO,WAAW,OAAO,IAAI;AAC/G;",
6
6
  "names": []
7
7
  }
package/build/lib/i18n.js CHANGED
@@ -18,15 +18,31 @@ var __copyProps = (to, from, except, desc) => {
18
18
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
19
  var i18n_exports = {};
20
20
  __export(i18n_exports, {
21
- t: () => t
21
+ t: () => t,
22
+ tDesc: () => tDesc,
23
+ tName: () => tName,
24
+ tRaw: () => tRaw
22
25
  });
23
26
  module.exports = __toCommonJS(i18n_exports);
24
27
  var import_adapter_core = require("@iobroker/adapter-core");
28
+ const LANGUAGES = ["en", "de", "ru", "pt", "nl", "fr", "it", "es", "pl", "uk", "zh-cn"];
25
29
  function t(key, ...args) {
26
30
  return import_adapter_core.I18n.getTranslatedObject(key, ...args);
27
31
  }
32
+ function tName(key) {
33
+ return import_adapter_core.I18n.getTranslatedObject(key);
34
+ }
35
+ function tDesc(key) {
36
+ return import_adapter_core.I18n.getTranslatedObject(key);
37
+ }
38
+ function tRaw(text) {
39
+ return Object.fromEntries(LANGUAGES.map((lang) => [lang, text]));
40
+ }
28
41
  // Annotate the CommonJS export names for ESM import in node:
29
42
  0 && (module.exports = {
30
- t
43
+ t,
44
+ tDesc,
45
+ tName,
46
+ tRaw
31
47
  });
32
48
  //# sourceMappingURL=i18n.js.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/lib/i18n.ts"],
4
- "sourcesContent": ["import { I18n } from \"@iobroker/adapter-core\";\nimport type translations from \"../../admin/i18n/en.json\";\n\ntype I18nKey = keyof typeof translations;\n\n/**\n * Translation object for a user-facing string, with optional `%s` interpolation.\n * Returns all admin languages (via adapter-core `I18n.getTranslatedObject`) so\n * device-manager titles, dialogs and confirmations render correctly regardless\n * of the admin language.\n *\n * @param key translation key from admin/i18n/en.json\n * @param args optional values substituted into the key's `%s` placeholders\n * @returns the translated string object\n */\nexport function t(key: I18nKey, ...args: (string | number | boolean | null)[]): ioBroker.StringOrTranslated {\n return I18n.getTranslatedObject(key, ...args);\n}\n"],
5
- "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,0BAAqB;AAed,SAAS,EAAE,QAAiB,MAAyE;AAC1G,SAAO,yBAAK,oBAAoB,KAAK,GAAG,IAAI;AAC9C;",
4
+ "sourcesContent": ["import { I18n } from \"@iobroker/adapter-core\";\nimport type translations from \"../../admin/i18n/en.json\";\n\ntype I18nKey = keyof typeof translations;\n\n/** The eleven languages every ioBroker manifest and admin translation carries. */\nconst LANGUAGES = [\"en\", \"de\", \"ru\", \"pt\", \"nl\", \"fr\", \"it\", \"es\", \"pl\", \"uk\", \"zh-cn\"] as const;\n\n/**\n * Translation object for a user-facing string, with optional `%s` interpolation.\n * Returns all admin languages (via adapter-core `I18n.getTranslatedObject`) so\n * device-manager titles, dialogs and confirmations render correctly regardless\n * of the admin language.\n *\n * @param key translation key from admin/i18n/en.json\n * @param args optional values substituted into the key's `%s` placeholders\n * @returns the translated string object\n */\nexport function t(key: I18nKey, ...args: (string | number | boolean | null)[]): ioBroker.StringOrTranslated {\n return I18n.getTranslatedObject(key, ...args);\n}\n\n/**\n * Translation object for an object's `common.name`. Same mechanism as {@link t};\n * a separate function so the intent is visible at the call site and the fleet's\n * state-role gate can tell a name from an explanation.\n *\n * @param key translation key from admin/i18n/en.json\n * @returns the translated name object\n */\nexport function tName(key: I18nKey): ioBroker.StringOrTranslated {\n return I18n.getTranslatedObject(key);\n}\n\n/**\n * Translation object for an object's `common.desc` \u2014 the short explanation a user\n * reads next to the name. Never the protocol's identifier, never the name again.\n *\n * @param key translation key from admin/i18n/en.json\n * @returns the translated description object\n */\nexport function tDesc(key: I18nKey): ioBroker.StringOrTranslated {\n return I18n.getTranslatedObject(key);\n}\n\n/**\n * Wrap a text that comes from the Roku protocol \u2014 a remote key name such as\n * `Home` or `VolumeUp` \u2014 as a translation object.\n *\n * There is nothing to translate (the key names are ECP identifiers and identical\n * in every language), but `common.name` must be a translation object for every\n * object type, never a bare string (core team, nut2 #15). Offering the same text\n * under every language key makes the object browser show it in any system\n * language instead of falling back on an untranslated name.\n *\n * @param text the protocol-provided text\n * @returns the same text under every language key\n */\nexport function tRaw(text: string): ioBroker.StringOrTranslated {\n return Object.fromEntries(LANGUAGES.map(lang => [lang, text])) as ioBroker.StringOrTranslated;\n}\n"],
5
+ "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,0BAAqB;AAMrB,MAAM,YAAY,CAAC,MAAM,MAAM,MAAM,MAAM,MAAM,MAAM,MAAM,MAAM,MAAM,MAAM,OAAO;AAY/E,SAAS,EAAE,QAAiB,MAAyE;AAC1G,SAAO,yBAAK,oBAAoB,KAAK,GAAG,IAAI;AAC9C;AAUO,SAAS,MAAM,KAA2C;AAC/D,SAAO,yBAAK,oBAAoB,GAAG;AACrC;AASO,SAAS,MAAM,KAA2C;AAC/D,SAAO,yBAAK,oBAAoB,GAAG;AACrC;AAeO,SAAS,KAAK,MAA2C;AAC9D,SAAO,OAAO,YAAY,UAAU,IAAI,UAAQ,CAAC,MAAM,IAAI,CAAC,CAAC;AAC/D;",
6
6
  "names": []
7
7
  }
@@ -21,7 +21,8 @@ __export(lan_guard_exports, {
21
21
  isLanClient: () => isLanClient
22
22
  });
23
23
  module.exports = __toCommonJS(lan_guard_exports);
24
- function isLanClient(remoteAddress) {
24
+ var import_detect_ip = require("./detect-ip");
25
+ function isLanClient(remoteAddress, localPrefixes = import_detect_ip.detectLocalIPv6Prefixes) {
25
26
  if (!remoteAddress) {
26
27
  return false;
27
28
  }
@@ -45,7 +46,8 @@ function isLanClient(remoteAddress) {
45
46
  if (/^fe[89ab][0-9a-f]:/.test(v6) || /^f[cd][0-9a-f]{2}:/.test(v6)) {
46
47
  return true;
47
48
  }
48
- return false;
49
+ const prefix = (0, import_detect_ip.ipv6Prefix64)(v6);
50
+ return prefix !== null && localPrefixes().includes(prefix);
49
51
  }
50
52
  // Annotate the CommonJS export names for ESM import in node:
51
53
  0 && (module.exports = {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/lib/lan-guard.ts"],
4
- "sourcesContent": ["/**\n * LAN restriction for both network services (ECP HTTP + SSDP): only accept\n * requests from private / local networks \u2014 the IPv4 private and link-local ranges, loopback, and the IPv6\n * link-local / unique-local ranges. The old adapter accepted key presses from any\n * reachable IP.\n *\n * @param remoteAddress the client IP from the request socket\n * @returns true if the client is on a private/local network\n */\nexport function isLanClient(remoteAddress: string | undefined): boolean {\n if (!remoteAddress) {\n return false;\n }\n const ip = remoteAddress.replace(/^::ffff:/, \"\");\n if (ip === \"127.0.0.1\" || ip === \"::1\") {\n return true;\n }\n if (/^10\\./.test(ip)) {\n return true;\n }\n if (/^192\\.168\\./.test(ip)) {\n return true;\n }\n if (/^172\\.(1[6-9]|2\\d|3[01])\\./.test(ip)) {\n return true;\n }\n if (/^169\\.254\\./.test(ip)) {\n return true;\n }\n // IPv6 in the LAN: link-local (fe80::/10, possibly with a `%zone` suffix) and\n // unique-local (fc00::/7). A remote on an IPv6-only segment is still local.\n const v6 = ip.toLowerCase();\n if (/^fe[89ab][0-9a-f]:/.test(v6) || /^f[cd][0-9a-f]{2}:/.test(v6)) {\n return true;\n }\n return false;\n}\n"],
5
- "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AASO,SAAS,YAAY,eAA4C;AACtE,MAAI,CAAC,eAAe;AAClB,WAAO;AAAA,EACT;AACA,QAAM,KAAK,cAAc,QAAQ,YAAY,EAAE;AAC/C,MAAI,OAAO,eAAe,OAAO,OAAO;AACtC,WAAO;AAAA,EACT;AACA,MAAI,QAAQ,KAAK,EAAE,GAAG;AACpB,WAAO;AAAA,EACT;AACA,MAAI,cAAc,KAAK,EAAE,GAAG;AAC1B,WAAO;AAAA,EACT;AACA,MAAI,6BAA6B,KAAK,EAAE,GAAG;AACzC,WAAO;AAAA,EACT;AACA,MAAI,cAAc,KAAK,EAAE,GAAG;AAC1B,WAAO;AAAA,EACT;AAGA,QAAM,KAAK,GAAG,YAAY;AAC1B,MAAI,qBAAqB,KAAK,EAAE,KAAK,qBAAqB,KAAK,EAAE,GAAG;AAClE,WAAO;AAAA,EACT;AACA,SAAO;AACT;",
4
+ "sourcesContent": ["import { detectLocalIPv6Prefixes, ipv6Prefix64 } from \"./detect-ip\";\n\n/**\n * LAN restriction for both network services (ECP HTTP + SSDP): only accept\n * requests from private / local networks \u2014 the IPv4 private and link-local ranges, loopback, and the IPv6\n * link-local / unique-local ranges. The old adapter accepted key presses from any\n * reachable IP.\n *\n * A globally routable IPv6 address is accepted when it sits in one of the host's\n * OWN /64 prefixes. On a connection with native IPv6 the router hands every\n * device in the house an address out of the provider's block, so a remote on the\n * same link looks exactly like an internet host \u2014 the shared prefix is what tells\n * them apart. Anything from a different prefix stays out, as before. The prefixes\n * are read lazily and only for such an address, so the common IPv4 case does not\n * touch the network interfaces at all, and a provider's prefix change is picked\n * up on the next request instead of being frozen at start-up.\n *\n * @param remoteAddress the client IP from the request socket\n * @param localPrefixes supplies the host's own IPv6 /64 prefixes (injected for tests)\n * @returns true if the client is on a private/local network\n */\nexport function isLanClient(\n remoteAddress: string | undefined,\n localPrefixes: () => readonly string[] = detectLocalIPv6Prefixes,\n): boolean {\n if (!remoteAddress) {\n return false;\n }\n const ip = remoteAddress.replace(/^::ffff:/, \"\");\n if (ip === \"127.0.0.1\" || ip === \"::1\") {\n return true;\n }\n if (/^10\\./.test(ip)) {\n return true;\n }\n if (/^192\\.168\\./.test(ip)) {\n return true;\n }\n if (/^172\\.(1[6-9]|2\\d|3[01])\\./.test(ip)) {\n return true;\n }\n if (/^169\\.254\\./.test(ip)) {\n return true;\n }\n // IPv6 in the LAN: link-local (fe80::/10, possibly with a `%zone` suffix) and\n // unique-local (fc00::/7). A remote on an IPv6-only segment is still local.\n const v6 = ip.toLowerCase();\n if (/^fe[89ab][0-9a-f]:/.test(v6) || /^f[cd][0-9a-f]{2}:/.test(v6)) {\n return true;\n }\n // A globally routable IPv6 address: local exactly when it shares one of the\n // host's own /64 network prefixes.\n const prefix = ipv6Prefix64(v6);\n return prefix !== null && localPrefixes().includes(prefix);\n}\n"],
5
+ "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,uBAAsD;AAqB/C,SAAS,YACd,eACA,gBAAyC,0CAChC;AACT,MAAI,CAAC,eAAe;AAClB,WAAO;AAAA,EACT;AACA,QAAM,KAAK,cAAc,QAAQ,YAAY,EAAE;AAC/C,MAAI,OAAO,eAAe,OAAO,OAAO;AACtC,WAAO;AAAA,EACT;AACA,MAAI,QAAQ,KAAK,EAAE,GAAG;AACpB,WAAO;AAAA,EACT;AACA,MAAI,cAAc,KAAK,EAAE,GAAG;AAC1B,WAAO;AAAA,EACT;AACA,MAAI,6BAA6B,KAAK,EAAE,GAAG;AACzC,WAAO;AAAA,EACT;AACA,MAAI,cAAc,KAAK,EAAE,GAAG;AAC1B,WAAO;AAAA,EACT;AAGA,QAAM,KAAK,GAAG,YAAY;AAC1B,MAAI,qBAAqB,KAAK,EAAE,KAAK,qBAAqB,KAAK,EAAE,GAAG;AAClE,WAAO;AAAA,EACT;AAGA,QAAM,aAAS,+BAAa,EAAE;AAC9B,SAAO,WAAW,QAAQ,cAAc,EAAE,SAAS,MAAM;AAC3D;",
6
6
  "names": []
7
7
  }
@@ -21,12 +21,20 @@ __export(object_cleanup_exports, {
21
21
  planObjectCleanup: () => planObjectCleanup
22
22
  });
23
23
  module.exports = __toCommonJS(object_cleanup_exports);
24
+ var import_constants = require("./constants");
24
25
  function planObjectCleanup(existingIds, configuredDeviceIds, validKeysByDevice) {
25
26
  const del = /* @__PURE__ */ new Set();
26
27
  for (const id of existingIds) {
27
28
  const parts = id.split(".");
28
29
  const device = parts[0];
30
+ if (import_constants.OWN_INFO_IDS.has(id)) {
31
+ continue;
32
+ }
29
33
  if (device === "info") {
34
+ const child = `info.${parts[1]}`;
35
+ if (!import_constants.OWN_INFO_IDS.has(child)) {
36
+ del.add(child);
37
+ }
30
38
  continue;
31
39
  }
32
40
  if (!configuredDeviceIds.has(device)) {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/lib/object-cleanup.ts"],
4
- "sourcesContent": ["/**\n * Decide which existing adapter objects are orphaned after the current device\n * tree has been (re)created. Pure \u2014 the caller performs the actual deletes \u2014 so\n * the tricky \"what is stale\" logic is unit-testable without a live adapter.\n *\n * Three kinds of orphan are collected:\n * - a whole device sub-tree whose device is no longer configured (rename/removal),\n * - the legacy `<device>.apps` node the old adapter created and 0.5.x does not,\n * - a `<device>.keys.<Key>` state whose key is no longer part of the device's\n * type (e.g. the TV keys after switching a device from \"tv\" back to \"player\").\n *\n * The adapter's own `info` channel is never touched.\n *\n * @param existingIds adapter object ids relative to the namespace (e.g. \"ioBroker.keys.Home\")\n * @param configuredDeviceIds the id-safe names of the currently configured devices\n * @param validKeysByDevice per configured device, the key names its type currently exposes\n * @returns the relative ids to delete (recursively); de-duplicated\n */\nexport function planObjectCleanup(\n existingIds: readonly string[],\n configuredDeviceIds: ReadonlySet<string>,\n validKeysByDevice: ReadonlyMap<string, ReadonlySet<string>>,\n): string[] {\n const del = new Set<string>();\n for (const id of existingIds) {\n const parts = id.split(\".\");\n const device = parts[0];\n if (device === \"info\") {\n continue; // the adapter's own info channel, not a device\n }\n if (!configuredDeviceIds.has(device)) {\n del.add(device); // whole orphaned device tree \u2014 recursive delete drops the children\n continue;\n }\n if (parts[1] === \"apps\") {\n del.add(`${device}.apps`); // legacy node the old adapter created\n } else if (parts[1] === \"keys\" && parts.length === 3) {\n const valid = validKeysByDevice.get(device);\n if (valid && !valid.has(parts[2])) {\n del.add(`${device}.keys.${parts[2]}`); // key no longer part of this device's type\n }\n }\n }\n return [...del];\n}\n"],
5
- "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAkBO,SAAS,kBACd,aACA,qBACA,mBACU;AACV,QAAM,MAAM,oBAAI,IAAY;AAC5B,aAAW,MAAM,aAAa;AAC5B,UAAM,QAAQ,GAAG,MAAM,GAAG;AAC1B,UAAM,SAAS,MAAM,CAAC;AACtB,QAAI,WAAW,QAAQ;AACrB;AAAA,IACF;AACA,QAAI,CAAC,oBAAoB,IAAI,MAAM,GAAG;AACpC,UAAI,IAAI,MAAM;AACd;AAAA,IACF;AACA,QAAI,MAAM,CAAC,MAAM,QAAQ;AACvB,UAAI,IAAI,GAAG,MAAM,OAAO;AAAA,IAC1B,WAAW,MAAM,CAAC,MAAM,UAAU,MAAM,WAAW,GAAG;AACpD,YAAM,QAAQ,kBAAkB,IAAI,MAAM;AAC1C,UAAI,SAAS,CAAC,MAAM,IAAI,MAAM,CAAC,CAAC,GAAG;AACjC,YAAI,IAAI,GAAG,MAAM,SAAS,MAAM,CAAC,CAAC,EAAE;AAAA,MACtC;AAAA,IACF;AAAA,EACF;AACA,SAAO,CAAC,GAAG,GAAG;AAChB;",
4
+ "sourcesContent": ["import { OWN_INFO_IDS } from \"./constants\";\n\n/**\n * Decide which existing adapter objects are orphaned after the current device\n * tree has been (re)created. Pure \u2014 the caller performs the actual deletes \u2014 so\n * the tricky \"what is stale\" logic is unit-testable without a live adapter.\n *\n * Three kinds of orphan are collected:\n * - a whole device sub-tree whose device is no longer configured (rename/removal),\n * - the legacy `<device>.apps` node the old adapter created and 0.5.x does not,\n * - a `<device>.keys.<Key>` state whose key is no longer part of the device's\n * type (e.g. the TV keys after switching a device from \"tv\" back to \"player\").\n *\n * The adapter's own objects \u2014 `info` and `info.connection` \u2014 are never touched.\n * Anything ELSE below `info` is: a hand-edited device row called \"info\" used to\n * create `info.command` / `info.keys.*` there, and blanket-skipping the whole\n * subtree meant those leftovers stayed for good, even after the row was removed.\n *\n * @param existingIds adapter object ids relative to the namespace (e.g. \"ioBroker.keys.Home\")\n * @param configuredDeviceIds the id-safe names of the currently configured devices\n * @param validKeysByDevice per configured device, the key names its type currently exposes\n * @returns the relative ids to delete (recursively); de-duplicated\n */\nexport function planObjectCleanup(\n existingIds: readonly string[],\n configuredDeviceIds: ReadonlySet<string>,\n validKeysByDevice: ReadonlyMap<string, ReadonlySet<string>>,\n): string[] {\n const del = new Set<string>();\n for (const id of existingIds) {\n const parts = id.split(\".\");\n const device = parts[0];\n if (OWN_INFO_IDS.has(id)) {\n continue; // the adapter's own status objects, not a device\n }\n if (device === \"info\") {\n // Leftover from a hand-edited device row named \"info\". Collect the topmost\n // foreign node (`info.command`, `info.keys`) \u2014 the recursive delete takes its\n // children, so listing those separately would only produce failing deletes.\n const child = `info.${parts[1]}`;\n if (!OWN_INFO_IDS.has(child)) {\n del.add(child);\n }\n continue;\n }\n if (!configuredDeviceIds.has(device)) {\n del.add(device); // whole orphaned device tree \u2014 recursive delete drops the children\n continue;\n }\n if (parts[1] === \"apps\") {\n del.add(`${device}.apps`); // legacy node the old adapter created\n } else if (parts[1] === \"keys\" && parts.length === 3) {\n const valid = validKeysByDevice.get(device);\n if (valid && !valid.has(parts[2])) {\n del.add(`${device}.keys.${parts[2]}`); // key no longer part of this device's type\n }\n }\n }\n return [...del];\n}\n"],
5
+ "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,uBAA6B;AAuBtB,SAAS,kBACd,aACA,qBACA,mBACU;AACV,QAAM,MAAM,oBAAI,IAAY;AAC5B,aAAW,MAAM,aAAa;AAC5B,UAAM,QAAQ,GAAG,MAAM,GAAG;AAC1B,UAAM,SAAS,MAAM,CAAC;AACtB,QAAI,8BAAa,IAAI,EAAE,GAAG;AACxB;AAAA,IACF;AACA,QAAI,WAAW,QAAQ;AAIrB,YAAM,QAAQ,QAAQ,MAAM,CAAC,CAAC;AAC9B,UAAI,CAAC,8BAAa,IAAI,KAAK,GAAG;AAC5B,YAAI,IAAI,KAAK;AAAA,MACf;AACA;AAAA,IACF;AACA,QAAI,CAAC,oBAAoB,IAAI,MAAM,GAAG;AACpC,UAAI,IAAI,MAAM;AACd;AAAA,IACF;AACA,QAAI,MAAM,CAAC,MAAM,QAAQ;AACvB,UAAI,IAAI,GAAG,MAAM,OAAO;AAAA,IAC1B,WAAW,MAAM,CAAC,MAAM,UAAU,MAAM,WAAW,GAAG;AACpD,YAAM,QAAQ,kBAAkB,IAAI,MAAM;AAC1C,UAAI,SAAS,CAAC,MAAM,IAAI,MAAM,CAAC,CAAC,GAAG;AACjC,YAAI,IAAI,GAAG,MAAM,SAAS,MAAM,CAAC,CAAC,EAAE;AAAA,MACtC;AAAA,IACF;AAAA,EACF;AACA,SAAO,CAAC,GAAG,GAAG;AAChB;",
6
6
  "names": []
7
7
  }
package/build/main.js CHANGED
@@ -44,6 +44,7 @@ var import_constants = require("./lib/constants");
44
44
  var import_device_identity = require("./lib/device-identity");
45
45
  var import_detect_ip = require("./lib/detect-ip");
46
46
  var import_errors = require("./lib/errors");
47
+ var import_i18n = require("./lib/i18n");
47
48
  var import_object_cleanup = require("./lib/object-cleanup");
48
49
  var import_pure_helpers = require("./lib/pure-helpers");
49
50
  var import_rate_gate = require("./lib/rate-gate");
@@ -53,7 +54,6 @@ const KEY_PULSE_MS = 50;
53
54
  const HOLD_MAX_MS = 3e4;
54
55
  const MAX_COMMANDS_PER_SECOND = 25;
55
56
  const RATE_WARN_INTERVAL_MS = 6e4;
56
- const UUID_SHAPE = /^[A-Za-z0-9-]{1,64}$/;
57
57
  class Fakeroku extends utils.Adapter {
58
58
  ssdp;
59
59
  notifyTimer;
@@ -93,6 +93,7 @@ class Fakeroku extends utils.Adapter {
93
93
  try {
94
94
  await this.setState("info.connection", { val: false, ack: true });
95
95
  await import_adapter_core.I18n.init((0, import_node_path.join)(this.adapterDir, "admin"), this);
96
+ await this.refreshOwnObjects();
96
97
  const configuredIp = this.config.networkInterface || this.config.BIND;
97
98
  const bindIp = configuredIp && configuredIp !== "0.0.0.0" ? configuredIp : void 0;
98
99
  const advertiseIp = bindIp != null ? bindIp : (0, import_detect_ip.detectPrimaryIPv4)();
@@ -113,9 +114,15 @@ class Fakeroku extends utils.Adapter {
113
114
  this.log.warn(`Emulated Roku "${d.name}" maps to an object id already in use (${deviceId}) \u2014 skipping it.`);
114
115
  continue;
115
116
  }
117
+ if (import_constants.RESERVED_IDS.has(deviceId)) {
118
+ this.log.warn(
119
+ `Emulated Roku "${d.name}" maps to the object id "${deviceId}", which the adapter reserves for its own status \u2014 skipping it.`
120
+ );
121
+ continue;
122
+ }
116
123
  const deviceType = d.type === "tv" ? "tv" : "player";
117
124
  const keys = (0, import_state_model.keysForType)(deviceType);
118
- const uuid = typeof d.uuid === "string" && UUID_SHAPE.test(d.uuid) ? d.uuid : (0, import_device_identity.deriveUuid)(d.name);
125
+ const uuid = (0, import_device_identity.resolveDeviceUuid)(d);
119
126
  if (d.uuid && uuid !== d.uuid) {
120
127
  this.log.warn(`Emulated Roku "${d.name}" has an unusable device id in its config \u2014 using a derived one.`);
121
128
  }
@@ -187,26 +194,79 @@ class Fakeroku extends utils.Adapter {
187
194
  this.log.error(`onReady failed: ${(0, import_errors.errText)(e)}`);
188
195
  }
189
196
  }
197
+ /**
198
+ * Re-apply the adapter's OWN objects — the `info` channel and `info.connection`
199
+ * — on every start.
200
+ *
201
+ * js-controller creates the manifest's instanceObjects only where they are
202
+ * missing, so a changed name or description never reaches an installation that
203
+ * already has them: the manifest would be correct and the real tree unchanged.
204
+ * extendObject is what carries the change into an existing tree, so an update
205
+ * always lands on every datapoint, not just on fresh installs.
206
+ *
207
+ * It also repairs the `info` channel after a hand-edited device row named
208
+ * "info" turned it into a device object (see the reserved-id guard in onReady).
209
+ */
210
+ async refreshOwnObjects() {
211
+ await this.extendObject("info", {
212
+ type: "channel",
213
+ common: { name: (0, import_i18n.tName)("channelInfo") },
214
+ native: {}
215
+ });
216
+ await this.extendObject("info.connection", {
217
+ type: "state",
218
+ common: {
219
+ name: (0, import_i18n.tName)("connectionStatus"),
220
+ desc: (0, import_i18n.tDesc)("connectionStatusDesc"),
221
+ type: "boolean",
222
+ role: "indicator.connected",
223
+ read: true,
224
+ write: false,
225
+ def: false
226
+ },
227
+ native: {}
228
+ });
229
+ }
190
230
  /**
191
231
  * Create the fixed object tree for one emulated Roku: the device, `command` +
192
232
  * `commandType`, and one `sensor` boolean state per key the device type exposes —
193
233
  * all up front, so the tree is usable before any key is ever pressed.
194
234
  *
235
+ * Every key state is also RESET to false here. A key is a momentary signal, but
236
+ * nothing writes its release when the adapter goes down: a keypress pulses true
237
+ * and schedules the false 50 ms later, a keydown holds true until its keyup, and
238
+ * onUnload drops both timers without writing. So a stop inside that window — or
239
+ * a crash, or a controller that never sent its keyup — leaves the key true in
240
+ * the database for good, and a rule watching for the next press never sees an
241
+ * edge again. The reset belongs on STARTUP, not into onUnload: only startup also
242
+ * covers the crash, and 27 writes per device would eat the shutdown budget that
243
+ * today comfortably carries a single one. setStateChanged writes only where the
244
+ * value actually differs, so a healthy tree costs nothing.
245
+ *
195
246
  * @param deviceId the id-safe device path segment
196
247
  * @param friendlyName the configured device name
197
248
  * @param keys the key names to create for this device (from its type)
198
249
  */
199
250
  async createDeviceStates(deviceId, friendlyName, keys) {
200
- await this.extendObject(deviceId, { type: "device", common: { name: friendlyName }, native: {} });
251
+ await this.extendObject(deviceId, { type: "device", common: { name: (0, import_i18n.tRaw)(friendlyName) }, native: {} });
201
252
  await this.extendObject(`${deviceId}.command`, {
202
253
  type: "state",
203
- common: { name: "Last command", type: "string", role: "text", read: true, write: false, def: "" },
254
+ common: {
255
+ name: (0, import_i18n.tName)("stateLastCommand"),
256
+ desc: (0, import_i18n.tDesc)("stateLastCommandDesc"),
257
+ type: "string",
258
+ role: "text",
259
+ read: true,
260
+ write: false,
261
+ def: ""
262
+ },
204
263
  native: {}
205
264
  });
206
265
  await this.extendObject(`${deviceId}.commandType`, {
207
266
  type: "state",
208
267
  common: {
209
- name: "Last command type",
268
+ name: (0, import_i18n.tName)("stateLastCommandType"),
269
+ desc: (0, import_i18n.tDesc)("stateLastCommandTypeDesc"),
210
270
  type: "string",
211
271
  role: "text",
212
272
  read: true,
@@ -218,17 +278,25 @@ class Fakeroku extends utils.Adapter {
218
278
  },
219
279
  native: {}
220
280
  });
221
- await this.extendObject(`${deviceId}.keys`, { type: "channel", common: { name: "Remote keys" }, native: {} });
281
+ await this.extendObject(`${deviceId}.keys`, {
282
+ type: "channel",
283
+ common: { name: (0, import_i18n.tName)("channelKeys"), desc: (0, import_i18n.tDesc)("channelKeysDesc") },
284
+ native: {}
285
+ });
222
286
  for (const key of keys) {
223
287
  await this.extendObject(`${deviceId}.keys.${key}`, {
224
288
  type: "state",
225
289
  // "sensor" = generic boolean read-only (active/inactive). The docs suggest
226
290
  // button.press for a keypress-as-state, but the repochecker rejects it
227
291
  // (E1010 — not in its role list); sensor is the gate-conformant fit.
228
- common: { name: key, type: "boolean", role: "sensor", read: true, write: false, def: false },
292
+ // The name is the ECP key identifier and identical in every language, but
293
+ // it still has to BE a translation object (tRaw), never a bare string.
294
+ // No desc: the key name already says everything there is to say.
295
+ common: { name: (0, import_i18n.tRaw)(key), type: "boolean", role: "sensor", read: true, write: false, def: false },
229
296
  native: {}
230
297
  });
231
298
  }
299
+ await Promise.all(keys.map((key) => this.setStateChangedAsync(`${deviceId}.keys.${key}`, { val: false, ack: true })));
232
300
  }
233
301
  /**
234
302
  * Remove objects left over from an earlier version or config — the legacy
package/build/main.js.map CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/main.ts"],
4
- "sourcesContent": ["import * as utils from \"@iobroker/adapter-core\";\nimport { I18n } from \"@iobroker/adapter-core\";\nimport { join } from \"node:path\";\nimport { FakerokuDeviceManagement } from \"./device-management\";\nimport type { RokuAdvert } from \"./discovery/ssdp-messages\";\nimport { RokuSsdpResponder } from \"./discovery/ssdp-responder\";\nimport { DEFAULT_APPS } from \"./ecp/device-info\";\nimport { COMMAND_TYPES, type CommandEvent } from \"./ecp/ecp-command\";\nimport { EcpHttpServer } from \"./ecp/ecp-http-server\";\nimport { commandToStateWrite, type DeviceType, keysForType } from \"./ecp/state-model\";\nimport { DEFAULT_ECP_PORT } from \"./lib/constants\";\nimport { deriveUuid } from \"./lib/device-identity\";\nimport { detectLocalIPv4s, detectPrimaryIPv4 } from \"./lib/detect-ip\";\nimport { errText } from \"./lib/errors\";\nimport { planObjectCleanup } from \"./lib/object-cleanup\";\nimport { sanitizeId } from \"./lib/pure-helpers\";\nimport { RateGate } from \"./lib/rate-gate\";\n\n/** Managed timeout for a stuck SSDP start (a busy port 1900 must not hang onReady). */\nconst SSDP_START_TIMEOUT_MS = 5000;\n/** Proactive ssdp:alive interval so controllers find the device without searching. */\nconst SSDP_NOTIFY_INTERVAL_MS = 300_000;\n/** How long a keypress pulses its keys.<Key> state true before falling back to false. */\nconst KEY_PULSE_MS = 50;\n/** Safety cap for a held key: a keydown with no matching keyup resets after this. */\nconst HOLD_MAX_MS = 30_000;\n/** Commands accepted per second and emulated Roku; the excess is dropped (see lib/rate-gate.ts). */\nconst MAX_COMMANDS_PER_SECOND = 25;\n/** How often at most the dropped-commands warning repeats per device. */\nconst RATE_WARN_INTERVAL_MS = 60_000;\n/** The only shapes a device id ever had: the md5 hex the adapters derive, or a dashed uuid. */\nconst UUID_SHAPE = /^[A-Za-z0-9-]{1,64}$/;\n\n/**\n * ioBroker.fakeroku \u2014 Roku emulator (input side).\n *\n * Emulates one or more Roku devices on the LAN so that ECP/SSDP remotes\n * (Logitech Harmony or a Sofabaton X1/X2) trigger events in ioBroker:\n * a keypress lands in `<device>.command` and pulses `<device>.keys.<Key>`.\n */\nexport class Fakeroku extends utils.Adapter {\n private ssdp: RokuSsdpResponder | undefined;\n private notifyTimer: ioBroker.Interval | undefined;\n private readonly ecpServers: EcpHttpServer[] = [];\n private readonly pulseTimers = new Set<ioBroker.Timeout>();\n /** Per held key id, its watchdog timer \u2014 so a keydown without a keyup cannot pin it true forever. */\n private readonly holdTimers = new Map<string, ioBroker.Timeout>();\n /** Per device, the key names it exposes \u2014 so a keypress only writes keys this device carries. */\n private readonly deviceKeys = new Map<string, ReadonlySet<string>>();\n /** Per device, its command rate gate \u2014 the write-flood protection for the states database. */\n private readonly commandGates = new Map<string, RateGate>();\n /** Per device, when the dropped-commands warning was last written. */\n private readonly rateWarnedAt = new Map<string, number>();\n /** Device-manager backend: the emulated Rokus as cards with add/edit/delete. */\n private readonly deviceManagement: FakerokuDeviceManagement;\n\n // Construction seams for the two network-facing collaborators. Production uses\n // the real classes; the orchestration tests swap them for fakes so onReady's\n // wiring (per-device isolation, SSDP degradation, timers) is testable without\n // binding a port. Behaviour is unchanged \u2014 same constructors, same arguments.\n private makeEcpServer: (options: ConstructorParameters<typeof EcpHttpServer>[0]) => EcpHttpServer = options =>\n new EcpHttpServer(options);\n private makeSsdpResponder: (options: ConstructorParameters<typeof RokuSsdpResponder>[0]) => RokuSsdpResponder =\n options => new RokuSsdpResponder(options);\n\n /**\n * @param options adapter options passed through by js-controller\n */\n public constructor(options: Partial<utils.AdapterOptions> = {}) {\n super({\n ...options,\n name: \"fakeroku\",\n });\n\n this.on(\"ready\", this.onReady.bind(this));\n this.on(\"unload\", this.onUnload.bind(this));\n this.deviceManagement = new FakerokuDeviceManagement(this);\n }\n\n /** Create each device's object tree, start its ECP server, then the shared SSDP responder. */\n private async onReady(): Promise<void> {\n try {\n await this.setState(\"info.connection\", { val: false, ack: true });\n await I18n.init(join(this.adapterDir, \"admin\"), this);\n\n // Empty AND \"0.0.0.0\" both mean \"auto\": bind all interfaces, advertise the\n // detected primary IP so the adapter runs without configuration. js-controller\n // never rewrites an existing native default, so instances from before 0.5.1\n // still carry \"\" \u2014 both must take the auto path. A concrete IP is honoured as-is.\n // Migration: a pre-0.5.0 instance has no `networkInterface`, only the old `BIND` \u2014\n // adopt it so a multi-homed host keeps the interface the user had configured.\n const configuredIp = this.config.networkInterface || this.config.BIND;\n const bindIp = configuredIp && configuredIp !== \"0.0.0.0\" ? configuredIp : undefined;\n const advertiseIp = bindIp ?? detectPrimaryIPv4();\n if (!advertiseIp) {\n this.log.warn(\"No routable IPv4 address found to advertise \u2014 set the network interface in the settings.\");\n return;\n }\n\n const configured = (this.config.devices ?? []).filter(d => d && typeof d.name === \"string\" && d.name.length > 0);\n if (configured.length === 0) {\n this.log.warn(\"No emulated Roku devices configured.\");\n return;\n }\n\n const adverts: RokuAdvert[] = [];\n const seenIds = new Set<string>();\n for (const d of configured) {\n const deviceId = sanitizeId(d.name);\n // Two configured names can sanitize to the same object id \u2014 the admin guards\n // against it, but a hand-edited config could still carry it. Skip the duplicate\n // instead of letting two devices fight over one object tree.\n if (seenIds.has(deviceId)) {\n this.log.warn(`Emulated Roku \"${d.name}\" maps to an object id already in use (${deviceId}) \u2014 skipping it.`);\n continue;\n }\n const deviceType: DeviceType = d.type === \"tv\" ? \"tv\" : \"player\";\n const keys = keysForType(deviceType);\n // Adopt the device's persisted uuid (old adapter or an earlier run) so the SSDP\n // identity \u2014 and thus the controller pairing \u2014 survives an update; derive a stable\n // one from the name only for a device that never had one. The value goes verbatim\n // into SSDP headers and XML, so only the shape the adapters ever wrote (hex, or a\n // dashed uuid) is accepted \u2014 a hand-edited stray value is replaced, not emitted.\n const uuid = typeof d.uuid === \"string\" && UUID_SHAPE.test(d.uuid) ? d.uuid : deriveUuid(d.name);\n if (d.uuid && uuid !== d.uuid) {\n this.log.warn(`Emulated Roku \"${d.name}\" has an unusable device id in its config \u2014 using a derived one.`);\n }\n const advert: RokuAdvert = { uuid, port: Number(d.port) || DEFAULT_ECP_PORT };\n let server: EcpHttpServer | undefined;\n try {\n await this.createDeviceStates(deviceId, d.name, keys);\n server = this.makeEcpServer({\n device: advert,\n friendlyName: d.name,\n apps: DEFAULT_APPS,\n deviceType,\n bindIp,\n logger: this.log,\n onCommand: cmd => this.applyCommand(deviceId, cmd),\n });\n await server.start();\n this.ecpServers.push(server);\n this.deviceKeys.set(deviceId, new Set(keys));\n seenIds.add(deviceId);\n adverts.push(advert);\n } catch (e) {\n // One device's failure (a busy ECP port) must not take the others down.\n // Close whatever the failed start left behind, so nothing outlives this turn.\n server?.stop();\n this.log.warn(\n `Emulated Roku \"${d.name}\" could not start on port ${advert.port}: ${errText(e)} \u2014 skipping it.`,\n );\n }\n }\n\n await this.cleanupOrphans(new Set(configured.map(d => sanitizeId(d.name))));\n\n if (adverts.length === 0) {\n // Nothing is controllable \u2014 leave info.connection false and stop here.\n this.log.error(\"No emulated Roku device could be started \u2014 check the configured ports for conflicts.\");\n return;\n }\n\n // Discovery is only an aid; the ECP servers above already make the adapter\n // controllable. Isolate the SSDP start so a busy port 1900 (or a stuck bind)\n // degrades to \"discovery off, already-paired remotes still work\" instead of\n // failing the whole start-up. In the auto case join every routable interface so\n // a multi-homed host is discoverable on all its LANs; a chosen interface pins\n // both membership and NOTIFY egress.\n const membershipInterfaces = bindIp ? [bindIp] : detectLocalIPv4s();\n const ssdp = this.makeSsdpResponder({\n devices: adverts,\n bindIp,\n advertiseIp,\n membershipInterfaces,\n logger: this.log,\n onFatalError: () => this.onSsdpFatal(),\n });\n this.ssdp = ssdp;\n try {\n await this.startWithTimeout(ssdp.start(), SSDP_START_TIMEOUT_MS);\n ssdp.announce();\n this.notifyTimer = this.setInterval(() => this.ssdp?.announce(), SSDP_NOTIFY_INTERVAL_MS);\n } catch (e) {\n this.log.warn(\n `SSDP discovery unavailable: ${errText(e)} \u2014 already-paired remotes still work; set the network interface if devices are not found.`,\n );\n // A start that only timed out can still bind later. Close it, or the socket\n // outlives the reference dropped here and keeps answering with nobody to stop it.\n ssdp.stop();\n this.ssdp = undefined;\n }\n\n // \"Connected\" means EVERY configured Roku is listening, not just some of\n // them. A device whose port is taken (or whose name collides) is skipped\n // above with a warning naming it; reporting \"connected\" anyway would hide\n // a broken configuration behind the devices that did come up, and the user\n // would only find out when the remote stops working. Config rows without a\n // usable name are filtered out before this point and deliberately do not\n // count \u2014 every device in `configured` that fails has said so in the log.\n const allStarted = adverts.length === configured.length;\n await this.setState(\"info.connection\", { val: allStarted, ack: true });\n const where = `advertising on ${advertiseIp}${this.ssdp ? \"\" : \" (discovery off)\"}`;\n if (allStarted) {\n this.log.info(`Emulating ${adverts.length} Roku device(s), ${where}`);\n } else {\n this.log.error(\n `Only ${adverts.length} of ${configured.length} configured Roku device(s) could be started, ${where} \u2014 fix the cause reported above; the instance stays disconnected until every device runs.`,\n );\n }\n } catch (e) {\n this.log.error(`onReady failed: ${errText(e)}`);\n }\n }\n\n /**\n * Create the fixed object tree for one emulated Roku: the device, `command` +\n * `commandType`, and one `sensor` boolean state per key the device type exposes \u2014\n * all up front, so the tree is usable before any key is ever pressed.\n *\n * @param deviceId the id-safe device path segment\n * @param friendlyName the configured device name\n * @param keys the key names to create for this device (from its type)\n */\n private async createDeviceStates(deviceId: string, friendlyName: string, keys: readonly string[]): Promise<void> {\n await this.extendObject(deviceId, { type: \"device\", common: { name: friendlyName }, native: {} });\n await this.extendObject(`${deviceId}.command`, {\n type: \"state\",\n common: { name: \"Last command\", type: \"string\", role: \"text\", read: true, write: false, def: \"\" },\n native: {},\n });\n await this.extendObject(`${deviceId}.commandType`, {\n type: \"state\",\n common: {\n name: \"Last command type\",\n type: \"string\",\n role: \"text\",\n read: true,\n write: false,\n def: \"\",\n // The fixed verb list, so the admin shows the value as a label. Plain strings\n // only \u2014 a translation object here crashes the admin's object view.\n states: Object.fromEntries(COMMAND_TYPES.map(verb => [verb, verb])),\n },\n native: {},\n });\n await this.extendObject(`${deviceId}.keys`, { type: \"channel\", common: { name: \"Remote keys\" }, native: {} });\n for (const key of keys) {\n await this.extendObject(`${deviceId}.keys.${key}`, {\n type: \"state\",\n // \"sensor\" = generic boolean read-only (active/inactive). The docs suggest\n // button.press for a keypress-as-state, but the repochecker rejects it\n // (E1010 \u2014 not in its role list); sensor is the gate-conformant fit.\n common: { name: key, type: \"boolean\", role: \"sensor\", read: true, write: false, def: false },\n native: {},\n });\n }\n }\n\n /**\n * Remove objects left over from an earlier version or config \u2014 the legacy\n * `apps` node, keys no longer standard, and whole device sub-trees no longer\n * configured (a renamed/removed device). The adapter otherwise only ever\n * creates objects, so without this the tree would accrete stale entries.\n *\n * @param configuredDeviceIds the id-safe names of the currently configured devices\n */\n private async cleanupOrphans(configuredDeviceIds: ReadonlySet<string>): Promise<void> {\n const objects = await this.getAdapterObjectsAsync();\n const prefix = `${this.namespace}.`;\n const existingIds = Object.keys(objects)\n .filter(id => id.startsWith(prefix))\n .map(id => id.slice(prefix.length));\n const toDelete = planObjectCleanup(existingIds, configuredDeviceIds, this.deviceKeys);\n for (const id of toDelete) {\n await this.delObjectAsync(id, { recursive: true }).catch((e: unknown) => {\n this.log.debug(`cleanup: could not delete ${id}: ${errText(e)}`);\n });\n }\n if (toDelete.length > 0) {\n this.log.debug(`Removed ${toDelete.length} orphaned object(s) from an earlier version or config.`);\n }\n }\n\n /**\n * Apply a received ECP command to this device's states: record it in `command`\n * / `commandType`, and pulse or hold the standard key if it is one.\n *\n * @param deviceId the id-safe device path segment\n * @param cmd the parsed ECP command\n */\n private applyCommand(deviceId: string, cmd: CommandEvent): void {\n if (!this.admitCommand(deviceId)) {\n return;\n }\n const write = commandToStateWrite(cmd);\n this.writeState(`${deviceId}.command`, write.command);\n this.writeState(`${deviceId}.commandType`, write.commandType);\n // Only write keys.<Key> if THIS device's type carries the key \u2014 a player has no\n // TV key objects, so a stray TV keypress lands in `command` only, never a missing state.\n const keys = this.deviceKeys.get(deviceId);\n if (write.pulseKey && keys?.has(write.pulseKey)) {\n const id = `${deviceId}.keys.${write.pulseKey}`;\n this.writeState(id, true);\n const timer = this.setTimeout(() => {\n if (timer) {\n this.pulseTimers.delete(timer);\n }\n this.writeState(id, false);\n }, KEY_PULSE_MS);\n if (timer) {\n this.pulseTimers.add(timer);\n }\n } else if (write.holdKey && keys?.has(write.holdKey.key)) {\n const id = `${deviceId}.keys.${write.holdKey.key}`;\n this.writeState(id, write.holdKey.value);\n // A keydown holds the key true until its keyup. Arm a watchdog so a lost keyup\n // (controller disconnects mid-press) cannot pin the key true forever; a keyup\n // clears it, and a repeated keydown re-arms it.\n const pending = this.holdTimers.get(id);\n if (pending) {\n this.clearTimeout(pending);\n this.holdTimers.delete(id);\n }\n if (write.holdKey.value) {\n const timer = this.setTimeout(() => {\n this.holdTimers.delete(id);\n this.writeState(id, false);\n }, HOLD_MAX_MS);\n if (timer) {\n this.holdTimers.set(id, timer);\n }\n }\n }\n }\n\n /**\n * The rate gate in front of every state write: MAX_COMMANDS_PER_SECOND per device,\n * the excess is dropped and reported once per RATE_WARN_INTERVAL_MS. Every accepted\n * command costs the states database three writes plus one more when the pulse\n * ends \u2014 a flooding device in the LAN would otherwise slow the whole host.\n *\n * @param deviceId the id-safe device path segment\n * @returns true if the command may be applied\n */\n private admitCommand(deviceId: string): boolean {\n const now = Date.now();\n let gate = this.commandGates.get(deviceId);\n if (!gate) {\n gate = new RateGate(MAX_COMMANDS_PER_SECOND, now);\n this.commandGates.set(deviceId, gate);\n }\n if (gate.allow(now)) {\n return true;\n }\n if (now - (this.rateWarnedAt.get(deviceId) ?? 0) >= RATE_WARN_INTERVAL_MS) {\n this.rateWarnedAt.set(deviceId, now);\n this.log.warn(\n `Emulated Roku \"${deviceId}\" receives more than ${MAX_COMMANDS_PER_SECOND} commands per second \u2014 dropping the excess (a misbehaving controller?)`,\n );\n }\n return false;\n }\n\n /**\n * Fire-and-forget state write for the command hot path. The states exist (created\n * up front in createDeviceStates), so there is no read-before-write; a rejection \u2014\n * the states database already closed while a remote still sends \u2014 is traced at\n * debug, because an unhandled one would crash the adapter over one lost keypress.\n *\n * @param id the state id relative to the namespace\n * @param val the value to write\n */\n private writeState(id: string, val: string | boolean): void {\n this.setState(id, { val, ack: true }).catch((e: unknown) => {\n this.log.debug(`State write ${id} failed: ${errText(e)}`);\n });\n }\n\n /**\n * The SSDP responder died at runtime (a socket error after a good start). Stop\n * announcing into the dead socket and drop the discovery aid. The ECP servers\n * keep working, so info.connection \u2014 which reflects ECP readiness \u2014 stays true.\n */\n private onSsdpFatal(): void {\n if (this.notifyTimer) {\n this.clearInterval(this.notifyTimer);\n this.notifyTimer = undefined;\n }\n this.ssdp = undefined;\n this.log.warn(\n \"SSDP discovery stopped after a socket error \u2014 already-paired remotes still work; restart the instance to re-enable discovery.\",\n );\n }\n\n /**\n * Bound await: reject if the SSDP start doesn't settle in time, so a stuck\n * port-1900 bind degrades to \"discovery off\" instead of hanging the adapter.\n *\n * @param promise the SSDP start promise\n * @param ms the timeout in milliseconds\n * @returns a promise that settles with the start result or a timeout error\n */\n private startWithTimeout(promise: Promise<void>, ms: number): Promise<void> {\n return new Promise<void>((resolve, reject) => {\n const timer = this.setTimeout(() => reject(new Error(`SSDP start timed out after ${ms} ms`)), ms);\n const clear = (): void => {\n if (timer) {\n this.clearTimeout(timer);\n }\n };\n promise.then(\n () => {\n clear();\n resolve();\n },\n (e: unknown) => {\n clear();\n // Lint (prefer-promise-reject-errors) wants an Error here; errText downstream\n // would cope with anything, so this is the rule's shape, not a second safeguard.\n reject(e instanceof Error ? e : new Error(String(e)));\n },\n );\n });\n }\n\n /**\n * Teardown: drop the timers and sockets synchronously, then report done only\n * once the last write has landed.\n *\n * `info.connection` is the only status this adapter carries, and nothing else\n * resets it: the host means to, but writes its reset to the namespace root\n * instead of the datapoint (js-controller#3472). So if the final write is\n * lost, the instance shows \"connected\" while the adapter is off.\n *\n * A fire-and-forget write plus an immediate callback is a race, not a\n * guaranteed loss \u2014 measured on 1.1.0, it still arrived, because without\n * `common.supportedMessages.stopInstance` the process ends in an orderly way\n * and flushes what is pending. Waiting closes the race for the slow or busy\n * case, and it is safe for the same reason: no `stopInstance` means the host\n * grants the full `common.stopTimeout` instead of killing the process.\n *\n * @param callback function to invoke once teardown is complete\n */\n private onUnload(callback: () => void): void {\n try {\n if (this.notifyTimer) {\n this.clearInterval(this.notifyTimer);\n this.notifyTimer = undefined;\n }\n for (const t of this.pulseTimers) {\n this.clearTimeout(t);\n }\n this.pulseTimers.clear();\n for (const t of this.holdTimers.values()) {\n this.clearTimeout(t);\n }\n this.holdTimers.clear();\n this.ssdp?.stop();\n for (const s of this.ecpServers) {\n s.stop();\n }\n void this.setState(\"info.connection\", { val: false, ack: true })\n // A rejected write must not become an unhandled rejection \u2014 that is a\n // crash (exit code 6) instead of an orderly stop. The trace stays at\n // debug: it explains a stale \"connected\" afterwards, and nobody can act\n // on it while the adapter is already going down.\n .catch((e: unknown) => {\n this.log.debug(`Final connection write failed: ${errText(e)}`);\n })\n .finally(() => callback());\n } catch {\n callback();\n }\n }\n}\n\nif (require.main !== module) {\n // Export the constructor in compact mode\n module.exports = (options: Partial<utils.AdapterOptions> | undefined) => new Fakeroku(options);\n} else {\n // Start the instance directly\n (() => new Fakeroku())();\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import * as utils from \"@iobroker/adapter-core\";\nimport { I18n } from \"@iobroker/adapter-core\";\nimport { join } from \"node:path\";\nimport { FakerokuDeviceManagement } from \"./device-management\";\nimport type { RokuAdvert } from \"./discovery/ssdp-messages\";\nimport { RokuSsdpResponder } from \"./discovery/ssdp-responder\";\nimport { DEFAULT_APPS } from \"./ecp/device-info\";\nimport { COMMAND_TYPES, type CommandEvent } from \"./ecp/ecp-command\";\nimport { EcpHttpServer } from \"./ecp/ecp-http-server\";\nimport { commandToStateWrite, type DeviceType, keysForType } from \"./ecp/state-model\";\nimport { DEFAULT_ECP_PORT, RESERVED_IDS } from \"./lib/constants\";\nimport { resolveDeviceUuid } from \"./lib/device-identity\";\nimport { detectLocalIPv4s, detectPrimaryIPv4 } from \"./lib/detect-ip\";\nimport { errText } from \"./lib/errors\";\nimport { tDesc, tName, tRaw } from \"./lib/i18n\";\nimport { planObjectCleanup } from \"./lib/object-cleanup\";\nimport { sanitizeId } from \"./lib/pure-helpers\";\nimport { RateGate } from \"./lib/rate-gate\";\n\n/** Managed timeout for a stuck SSDP start (a busy port 1900 must not hang onReady). */\nconst SSDP_START_TIMEOUT_MS = 5000;\n/** Proactive ssdp:alive interval so controllers find the device without searching. */\nconst SSDP_NOTIFY_INTERVAL_MS = 300_000;\n/** How long a keypress pulses its keys.<Key> state true before falling back to false. */\nconst KEY_PULSE_MS = 50;\n/** Safety cap for a held key: a keydown with no matching keyup resets after this. */\nconst HOLD_MAX_MS = 30_000;\n/** Commands accepted per second and emulated Roku; the excess is dropped (see lib/rate-gate.ts). */\nconst MAX_COMMANDS_PER_SECOND = 25;\n/** How often at most the dropped-commands warning repeats per device. */\nconst RATE_WARN_INTERVAL_MS = 60_000;\n\n/**\n * ioBroker.fakeroku \u2014 Roku emulator (input side).\n *\n * Emulates one or more Roku devices on the LAN so that ECP/SSDP remotes\n * (Logitech Harmony or a Sofabaton X1/X2) trigger events in ioBroker:\n * a keypress lands in `<device>.command` and pulses `<device>.keys.<Key>`.\n */\nexport class Fakeroku extends utils.Adapter {\n private ssdp: RokuSsdpResponder | undefined;\n private notifyTimer: ioBroker.Interval | undefined;\n private readonly ecpServers: EcpHttpServer[] = [];\n private readonly pulseTimers = new Set<ioBroker.Timeout>();\n /** Per held key id, its watchdog timer \u2014 so a keydown without a keyup cannot pin it true forever. */\n private readonly holdTimers = new Map<string, ioBroker.Timeout>();\n /** Per device, the key names it exposes \u2014 so a keypress only writes keys this device carries. */\n private readonly deviceKeys = new Map<string, ReadonlySet<string>>();\n /** Per device, its command rate gate \u2014 the write-flood protection for the states database. */\n private readonly commandGates = new Map<string, RateGate>();\n /** Per device, when the dropped-commands warning was last written. */\n private readonly rateWarnedAt = new Map<string, number>();\n /** Device-manager backend: the emulated Rokus as cards with add/edit/delete. */\n private readonly deviceManagement: FakerokuDeviceManagement;\n\n // Construction seams for the two network-facing collaborators. Production uses\n // the real classes; the orchestration tests swap them for fakes so onReady's\n // wiring (per-device isolation, SSDP degradation, timers) is testable without\n // binding a port. Behaviour is unchanged \u2014 same constructors, same arguments.\n private makeEcpServer: (options: ConstructorParameters<typeof EcpHttpServer>[0]) => EcpHttpServer = options =>\n new EcpHttpServer(options);\n private makeSsdpResponder: (options: ConstructorParameters<typeof RokuSsdpResponder>[0]) => RokuSsdpResponder =\n options => new RokuSsdpResponder(options);\n\n /**\n * @param options adapter options passed through by js-controller\n */\n public constructor(options: Partial<utils.AdapterOptions> = {}) {\n super({\n ...options,\n name: \"fakeroku\",\n });\n\n this.on(\"ready\", this.onReady.bind(this));\n this.on(\"unload\", this.onUnload.bind(this));\n this.deviceManagement = new FakerokuDeviceManagement(this);\n }\n\n /** Create each device's object tree, start its ECP server, then the shared SSDP responder. */\n private async onReady(): Promise<void> {\n try {\n await this.setState(\"info.connection\", { val: false, ack: true });\n await I18n.init(join(this.adapterDir, \"admin\"), this);\n await this.refreshOwnObjects();\n\n // Empty AND \"0.0.0.0\" both mean \"auto\": bind all interfaces, advertise the\n // detected primary IP so the adapter runs without configuration. js-controller\n // never rewrites an existing native default, so instances from before 0.5.1\n // still carry \"\" \u2014 both must take the auto path. A concrete IP is honoured as-is.\n // Migration: a pre-0.5.0 instance has no `networkInterface`, only the old `BIND` \u2014\n // adopt it so a multi-homed host keeps the interface the user had configured.\n const configuredIp = this.config.networkInterface || this.config.BIND;\n const bindIp = configuredIp && configuredIp !== \"0.0.0.0\" ? configuredIp : undefined;\n const advertiseIp = bindIp ?? detectPrimaryIPv4();\n if (!advertiseIp) {\n this.log.warn(\"No routable IPv4 address found to advertise \u2014 set the network interface in the settings.\");\n return;\n }\n\n const configured = (this.config.devices ?? []).filter(d => d && typeof d.name === \"string\" && d.name.length > 0);\n if (configured.length === 0) {\n this.log.warn(\"No emulated Roku devices configured.\");\n return;\n }\n\n const adverts: RokuAdvert[] = [];\n const seenIds = new Set<string>();\n for (const d of configured) {\n const deviceId = sanitizeId(d.name);\n // Two configured names can sanitize to the same object id \u2014 the admin guards\n // against it, but a hand-edited config could still carry it. Skip the duplicate\n // instead of letting two devices fight over one object tree.\n if (seenIds.has(deviceId)) {\n this.log.warn(`Emulated Roku \"${d.name}\" maps to an object id already in use (${deviceId}) \u2014 skipping it.`);\n continue;\n }\n // The dialog refuses these names, but native.devices is hand-editable. A\n // device called \"info\" would rewrite the adapter's own info channel into a\n // device object and hang its command/keys states under info.connection.\n if (RESERVED_IDS.has(deviceId)) {\n this.log.warn(\n `Emulated Roku \"${d.name}\" maps to the object id \"${deviceId}\", which the adapter reserves for its own status \u2014 skipping it.`,\n );\n continue;\n }\n const deviceType: DeviceType = d.type === \"tv\" ? \"tv\" : \"player\";\n const keys = keysForType(deviceType);\n // Adopt the device's persisted uuid (old adapter or an earlier run) so the SSDP\n // identity \u2014 and thus the controller pairing \u2014 survives an update; derive a stable\n // one from the name only for a device that never had one. resolveDeviceUuid is the\n // single source for that decision, shared with the device manager so an edit\n // persists exactly what is advertised here (lib/device-identity.ts).\n const uuid = resolveDeviceUuid(d);\n if (d.uuid && uuid !== d.uuid) {\n this.log.warn(`Emulated Roku \"${d.name}\" has an unusable device id in its config \u2014 using a derived one.`);\n }\n const advert: RokuAdvert = { uuid, port: Number(d.port) || DEFAULT_ECP_PORT };\n let server: EcpHttpServer | undefined;\n try {\n await this.createDeviceStates(deviceId, d.name, keys);\n server = this.makeEcpServer({\n device: advert,\n friendlyName: d.name,\n apps: DEFAULT_APPS,\n deviceType,\n bindIp,\n logger: this.log,\n onCommand: cmd => this.applyCommand(deviceId, cmd),\n });\n await server.start();\n this.ecpServers.push(server);\n this.deviceKeys.set(deviceId, new Set(keys));\n seenIds.add(deviceId);\n adverts.push(advert);\n } catch (e) {\n // One device's failure (a busy ECP port) must not take the others down.\n // Close whatever the failed start left behind, so nothing outlives this turn.\n server?.stop();\n this.log.warn(\n `Emulated Roku \"${d.name}\" could not start on port ${advert.port}: ${errText(e)} \u2014 skipping it.`,\n );\n }\n }\n\n await this.cleanupOrphans(new Set(configured.map(d => sanitizeId(d.name))));\n\n if (adverts.length === 0) {\n // Nothing is controllable \u2014 leave info.connection false and stop here.\n this.log.error(\"No emulated Roku device could be started \u2014 check the configured ports for conflicts.\");\n return;\n }\n\n // Discovery is only an aid; the ECP servers above already make the adapter\n // controllable. Isolate the SSDP start so a busy port 1900 (or a stuck bind)\n // degrades to \"discovery off, already-paired remotes still work\" instead of\n // failing the whole start-up. In the auto case join every routable interface so\n // a multi-homed host is discoverable on all its LANs; a chosen interface pins\n // both membership and NOTIFY egress.\n const membershipInterfaces = bindIp ? [bindIp] : detectLocalIPv4s();\n const ssdp = this.makeSsdpResponder({\n devices: adverts,\n bindIp,\n advertiseIp,\n membershipInterfaces,\n logger: this.log,\n onFatalError: () => this.onSsdpFatal(),\n });\n this.ssdp = ssdp;\n try {\n await this.startWithTimeout(ssdp.start(), SSDP_START_TIMEOUT_MS);\n ssdp.announce();\n this.notifyTimer = this.setInterval(() => this.ssdp?.announce(), SSDP_NOTIFY_INTERVAL_MS);\n } catch (e) {\n this.log.warn(\n `SSDP discovery unavailable: ${errText(e)} \u2014 already-paired remotes still work; set the network interface if devices are not found.`,\n );\n // A start that only timed out can still bind later. Close it, or the socket\n // outlives the reference dropped here and keeps answering with nobody to stop it.\n ssdp.stop();\n this.ssdp = undefined;\n }\n\n // \"Connected\" means EVERY configured Roku is listening, not just some of\n // them. A device whose port is taken (or whose name collides) is skipped\n // above with a warning naming it; reporting \"connected\" anyway would hide\n // a broken configuration behind the devices that did come up, and the user\n // would only find out when the remote stops working. Config rows without a\n // usable name are filtered out before this point and deliberately do not\n // count \u2014 every device in `configured` that fails has said so in the log.\n const allStarted = adverts.length === configured.length;\n await this.setState(\"info.connection\", { val: allStarted, ack: true });\n const where = `advertising on ${advertiseIp}${this.ssdp ? \"\" : \" (discovery off)\"}`;\n if (allStarted) {\n this.log.info(`Emulating ${adverts.length} Roku device(s), ${where}`);\n } else {\n this.log.error(\n `Only ${adverts.length} of ${configured.length} configured Roku device(s) could be started, ${where} \u2014 fix the cause reported above; the instance stays disconnected until every device runs.`,\n );\n }\n } catch (e) {\n this.log.error(`onReady failed: ${errText(e)}`);\n }\n }\n\n /**\n * Re-apply the adapter's OWN objects \u2014 the `info` channel and `info.connection`\n * \u2014 on every start.\n *\n * js-controller creates the manifest's instanceObjects only where they are\n * missing, so a changed name or description never reaches an installation that\n * already has them: the manifest would be correct and the real tree unchanged.\n * extendObject is what carries the change into an existing tree, so an update\n * always lands on every datapoint, not just on fresh installs.\n *\n * It also repairs the `info` channel after a hand-edited device row named\n * \"info\" turned it into a device object (see the reserved-id guard in onReady).\n */\n private async refreshOwnObjects(): Promise<void> {\n await this.extendObject(\"info\", {\n type: \"channel\",\n common: { name: tName(\"channelInfo\") },\n native: {},\n });\n await this.extendObject(\"info.connection\", {\n type: \"state\",\n common: {\n name: tName(\"connectionStatus\"),\n desc: tDesc(\"connectionStatusDesc\"),\n type: \"boolean\",\n role: \"indicator.connected\",\n read: true,\n write: false,\n def: false,\n },\n native: {},\n });\n }\n\n /**\n * Create the fixed object tree for one emulated Roku: the device, `command` +\n * `commandType`, and one `sensor` boolean state per key the device type exposes \u2014\n * all up front, so the tree is usable before any key is ever pressed.\n *\n * Every key state is also RESET to false here. A key is a momentary signal, but\n * nothing writes its release when the adapter goes down: a keypress pulses true\n * and schedules the false 50 ms later, a keydown holds true until its keyup, and\n * onUnload drops both timers without writing. So a stop inside that window \u2014 or\n * a crash, or a controller that never sent its keyup \u2014 leaves the key true in\n * the database for good, and a rule watching for the next press never sees an\n * edge again. The reset belongs on STARTUP, not into onUnload: only startup also\n * covers the crash, and 27 writes per device would eat the shutdown budget that\n * today comfortably carries a single one. setStateChanged writes only where the\n * value actually differs, so a healthy tree costs nothing.\n *\n * @param deviceId the id-safe device path segment\n * @param friendlyName the configured device name\n * @param keys the key names to create for this device (from its type)\n */\n private async createDeviceStates(deviceId: string, friendlyName: string, keys: readonly string[]): Promise<void> {\n // The device name is the user's own text \u2014 nothing to translate, but it must\n // still BE a translation object like every other common.name (tRaw).\n await this.extendObject(deviceId, { type: \"device\", common: { name: tRaw(friendlyName) }, native: {} });\n await this.extendObject(`${deviceId}.command`, {\n type: \"state\",\n common: {\n name: tName(\"stateLastCommand\"),\n desc: tDesc(\"stateLastCommandDesc\"),\n type: \"string\",\n role: \"text\",\n read: true,\n write: false,\n def: \"\",\n },\n native: {},\n });\n await this.extendObject(`${deviceId}.commandType`, {\n type: \"state\",\n common: {\n name: tName(\"stateLastCommandType\"),\n desc: tDesc(\"stateLastCommandTypeDesc\"),\n type: \"string\",\n role: \"text\",\n read: true,\n write: false,\n def: \"\",\n // The fixed verb list, so the admin shows the value as a label. Plain strings\n // only \u2014 a translation object here crashes the admin's object view.\n states: Object.fromEntries(COMMAND_TYPES.map(verb => [verb, verb])),\n },\n native: {},\n });\n await this.extendObject(`${deviceId}.keys`, {\n type: \"channel\",\n common: { name: tName(\"channelKeys\"), desc: tDesc(\"channelKeysDesc\") },\n native: {},\n });\n for (const key of keys) {\n await this.extendObject(`${deviceId}.keys.${key}`, {\n type: \"state\",\n // \"sensor\" = generic boolean read-only (active/inactive). The docs suggest\n // button.press for a keypress-as-state, but the repochecker rejects it\n // (E1010 \u2014 not in its role list); sensor is the gate-conformant fit.\n // The name is the ECP key identifier and identical in every language, but\n // it still has to BE a translation object (tRaw), never a bare string.\n // No desc: the key name already says everything there is to say.\n common: { name: tRaw(key), type: \"boolean\", role: \"sensor\", read: true, write: false, def: false },\n native: {},\n });\n }\n await Promise.all(keys.map(key => this.setStateChangedAsync(`${deviceId}.keys.${key}`, { val: false, ack: true })));\n }\n\n /**\n * Remove objects left over from an earlier version or config \u2014 the legacy\n * `apps` node, keys no longer standard, and whole device sub-trees no longer\n * configured (a renamed/removed device). The adapter otherwise only ever\n * creates objects, so without this the tree would accrete stale entries.\n *\n * @param configuredDeviceIds the id-safe names of the currently configured devices\n */\n private async cleanupOrphans(configuredDeviceIds: ReadonlySet<string>): Promise<void> {\n const objects = await this.getAdapterObjectsAsync();\n const prefix = `${this.namespace}.`;\n const existingIds = Object.keys(objects)\n .filter(id => id.startsWith(prefix))\n .map(id => id.slice(prefix.length));\n const toDelete = planObjectCleanup(existingIds, configuredDeviceIds, this.deviceKeys);\n for (const id of toDelete) {\n await this.delObjectAsync(id, { recursive: true }).catch((e: unknown) => {\n this.log.debug(`cleanup: could not delete ${id}: ${errText(e)}`);\n });\n }\n if (toDelete.length > 0) {\n this.log.debug(`Removed ${toDelete.length} orphaned object(s) from an earlier version or config.`);\n }\n }\n\n /**\n * Apply a received ECP command to this device's states: record it in `command`\n * / `commandType`, and pulse or hold the standard key if it is one.\n *\n * @param deviceId the id-safe device path segment\n * @param cmd the parsed ECP command\n */\n private applyCommand(deviceId: string, cmd: CommandEvent): void {\n if (!this.admitCommand(deviceId)) {\n return;\n }\n const write = commandToStateWrite(cmd);\n this.writeState(`${deviceId}.command`, write.command);\n this.writeState(`${deviceId}.commandType`, write.commandType);\n // Only write keys.<Key> if THIS device's type carries the key \u2014 a player has no\n // TV key objects, so a stray TV keypress lands in `command` only, never a missing state.\n const keys = this.deviceKeys.get(deviceId);\n if (write.pulseKey && keys?.has(write.pulseKey)) {\n const id = `${deviceId}.keys.${write.pulseKey}`;\n this.writeState(id, true);\n const timer = this.setTimeout(() => {\n if (timer) {\n this.pulseTimers.delete(timer);\n }\n this.writeState(id, false);\n }, KEY_PULSE_MS);\n if (timer) {\n this.pulseTimers.add(timer);\n }\n } else if (write.holdKey && keys?.has(write.holdKey.key)) {\n const id = `${deviceId}.keys.${write.holdKey.key}`;\n this.writeState(id, write.holdKey.value);\n // A keydown holds the key true until its keyup. Arm a watchdog so a lost keyup\n // (controller disconnects mid-press) cannot pin the key true forever; a keyup\n // clears it, and a repeated keydown re-arms it.\n const pending = this.holdTimers.get(id);\n if (pending) {\n this.clearTimeout(pending);\n this.holdTimers.delete(id);\n }\n if (write.holdKey.value) {\n const timer = this.setTimeout(() => {\n this.holdTimers.delete(id);\n this.writeState(id, false);\n }, HOLD_MAX_MS);\n if (timer) {\n this.holdTimers.set(id, timer);\n }\n }\n }\n }\n\n /**\n * The rate gate in front of every state write: MAX_COMMANDS_PER_SECOND per device,\n * the excess is dropped and reported once per RATE_WARN_INTERVAL_MS. Every accepted\n * command costs the states database three writes plus one more when the pulse\n * ends \u2014 a flooding device in the LAN would otherwise slow the whole host.\n *\n * @param deviceId the id-safe device path segment\n * @returns true if the command may be applied\n */\n private admitCommand(deviceId: string): boolean {\n const now = Date.now();\n let gate = this.commandGates.get(deviceId);\n if (!gate) {\n gate = new RateGate(MAX_COMMANDS_PER_SECOND, now);\n this.commandGates.set(deviceId, gate);\n }\n if (gate.allow(now)) {\n return true;\n }\n if (now - (this.rateWarnedAt.get(deviceId) ?? 0) >= RATE_WARN_INTERVAL_MS) {\n this.rateWarnedAt.set(deviceId, now);\n this.log.warn(\n `Emulated Roku \"${deviceId}\" receives more than ${MAX_COMMANDS_PER_SECOND} commands per second \u2014 dropping the excess (a misbehaving controller?)`,\n );\n }\n return false;\n }\n\n /**\n * Fire-and-forget state write for the command hot path. The states exist (created\n * up front in createDeviceStates), so there is no read-before-write; a rejection \u2014\n * the states database already closed while a remote still sends \u2014 is traced at\n * debug, because an unhandled one would crash the adapter over one lost keypress.\n *\n * @param id the state id relative to the namespace\n * @param val the value to write\n */\n private writeState(id: string, val: string | boolean): void {\n this.setState(id, { val, ack: true }).catch((e: unknown) => {\n this.log.debug(`State write ${id} failed: ${errText(e)}`);\n });\n }\n\n /**\n * The SSDP responder died at runtime (a socket error after a good start). Stop\n * announcing into the dead socket and drop the discovery aid. The ECP servers\n * keep working, so info.connection \u2014 which reflects ECP readiness \u2014 stays true.\n */\n private onSsdpFatal(): void {\n if (this.notifyTimer) {\n this.clearInterval(this.notifyTimer);\n this.notifyTimer = undefined;\n }\n this.ssdp = undefined;\n this.log.warn(\n \"SSDP discovery stopped after a socket error \u2014 already-paired remotes still work; restart the instance to re-enable discovery.\",\n );\n }\n\n /**\n * Bound await: reject if the SSDP start doesn't settle in time, so a stuck\n * port-1900 bind degrades to \"discovery off\" instead of hanging the adapter.\n *\n * @param promise the SSDP start promise\n * @param ms the timeout in milliseconds\n * @returns a promise that settles with the start result or a timeout error\n */\n private startWithTimeout(promise: Promise<void>, ms: number): Promise<void> {\n return new Promise<void>((resolve, reject) => {\n const timer = this.setTimeout(() => reject(new Error(`SSDP start timed out after ${ms} ms`)), ms);\n const clear = (): void => {\n if (timer) {\n this.clearTimeout(timer);\n }\n };\n promise.then(\n () => {\n clear();\n resolve();\n },\n (e: unknown) => {\n clear();\n // Lint (prefer-promise-reject-errors) wants an Error here; errText downstream\n // would cope with anything, so this is the rule's shape, not a second safeguard.\n reject(e instanceof Error ? e : new Error(String(e)));\n },\n );\n });\n }\n\n /**\n * Teardown: drop the timers and sockets synchronously, then report done only\n * once the last write has landed.\n *\n * `info.connection` is the only status this adapter carries, and nothing else\n * resets it: the host means to, but writes its reset to the namespace root\n * instead of the datapoint (js-controller#3472). So if the final write is\n * lost, the instance shows \"connected\" while the adapter is off.\n *\n * A fire-and-forget write plus an immediate callback is a race, not a\n * guaranteed loss \u2014 measured on 1.1.0, it still arrived, because without\n * `common.supportedMessages.stopInstance` the process ends in an orderly way\n * and flushes what is pending. Waiting closes the race for the slow or busy\n * case, and it is safe for the same reason: no `stopInstance` means the host\n * grants the full `common.stopTimeout` instead of killing the process.\n *\n * @param callback function to invoke once teardown is complete\n */\n private onUnload(callback: () => void): void {\n try {\n if (this.notifyTimer) {\n this.clearInterval(this.notifyTimer);\n this.notifyTimer = undefined;\n }\n for (const t of this.pulseTimers) {\n this.clearTimeout(t);\n }\n this.pulseTimers.clear();\n for (const t of this.holdTimers.values()) {\n this.clearTimeout(t);\n }\n this.holdTimers.clear();\n this.ssdp?.stop();\n for (const s of this.ecpServers) {\n s.stop();\n }\n void this.setState(\"info.connection\", { val: false, ack: true })\n // A rejected write must not become an unhandled rejection \u2014 that is a\n // crash (exit code 6) instead of an orderly stop. The trace stays at\n // debug: it explains a stale \"connected\" afterwards, and nobody can act\n // on it while the adapter is already going down.\n .catch((e: unknown) => {\n this.log.debug(`Final connection write failed: ${errText(e)}`);\n })\n .finally(() => callback());\n } catch {\n callback();\n }\n }\n}\n\nif (require.main !== module) {\n // Export the constructor in compact mode\n module.exports = (options: Partial<utils.AdapterOptions> | undefined) => new Fakeroku(options);\n} else {\n // Start the instance directly\n (() => new Fakeroku())();\n}\n"],
5
+ "mappings": "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",
6
6
  "names": ["_a"]
7
7
  }
package/io-package.json CHANGED
@@ -1,8 +1,21 @@
1
1
  {
2
2
  "common": {
3
3
  "name": "fakeroku",
4
- "version": "1.3.0",
4
+ "version": "1.4.0",
5
5
  "news": {
6
+ "1.4.0": {
7
+ "en": "Renaming a Roku keeps the remote paired, a stuck key is released at start-up, all datapoints get translated names, and IPv6 remotes from your network are accepted.",
8
+ "de": "Umbenennen behält die Kopplung der Fernbedienung, eine hängende Taste wird beim Start freigegeben, alle Datenpunkte bekommen übersetzte Namen, IPv6-Fernbedienungen werden akzeptiert.",
9
+ "ru": "Переименование сохраняет сопряжение пульта, зависшая кнопка сбрасывается при старте, все точки данных получают переведённые названия, IPv6-пульты принимаются.",
10
+ "pt": "Mudar o nome mantém o emparelhamento, uma tecla presa é libertada no arranque, todos os pontos de dados recebem nomes traduzidos, comandos IPv6 são aceites.",
11
+ "nl": "Hernoemen behoudt de koppeling, een blijvende toets wordt bij de start vrijgegeven, alle datapunten krijgen vertaalde namen, IPv6-afstandsbedieningen worden geaccepteerd.",
12
+ "fr": "Renommer conserve l'appairage, une touche bloquée est libérée au démarrage, tous les points de données ont des noms traduits, les télécommandes IPv6 sont acceptées.",
13
+ "it": "Rinominare mantiene l'associazione, un tasto bloccato viene rilasciato all'avvio, tutti i punti dati hanno nomi tradotti, i telecomandi IPv6 sono accettati.",
14
+ "es": "Renombrar mantiene el emparejamiento, una tecla atascada se libera al iniciar, todos los puntos de datos tienen nombres traducidos, se aceptan mandos IPv6.",
15
+ "pl": "Zmiana nazwy zachowuje parowanie, zablokowany klawisz jest zwalniany przy starcie, wszystkie punkty danych mają przetłumaczone nazwy, piloty IPv6 są akceptowane.",
16
+ "uk": "Перейменування зберігає сполучення, залипла кнопка звільняється під час запуску, усі точки даних мають перекладені назви, IPv6-пульти приймаються.",
17
+ "zh-cn": "重命名后遥控器仍保持配对,卡住的按键在启动时释放,所有数据点获得翻译后的名称,接受本网络的 IPv6 遥控器。"
18
+ },
6
19
  "1.3.0": {
7
20
  "en": "Fixes a crash on a malformed keypress, accepts IPv6 remotes, caps each Roku at 25 commands/s, shows the correct icon, reports Roku OS 15.0, requires admin >= 8.0.11.",
8
21
  "de": "Behebt einen Absturz bei fehlerhaftem Tastendruck, akzeptiert IPv6-Fernbedienungen, begrenzt jeden Roku auf 25 Befehle/s, zeigt das richtige Symbol, meldet Roku OS 15.0, benötigt admin >= 8.0.11.",
@@ -80,19 +93,6 @@
80
93
  "pl": "Adapter wymaga teraz node.js >= 22\nZaktualizowano zależności",
81
94
  "uk": "Адаптер тепер вимагає node.js >= 22\nЗалежності оновлено",
82
95
  "zh-cn": "适配器现在需要 node.js 版本 >= 22。\n依赖项已更新"
83
- },
84
- "0.4.0": {
85
- "en": "Adapter requires node.js >= 20 now.\nAdapter requires admin >= 7.7.22 now\nAdapter requires js-controller >= 6.0.11 now\nDependencies have been updated",
86
- "de": "Adapter benötigt jetzt node.js >= 20.\nAdapter benötigt admin >= 7.7.22 jetzt\nAdapter benötigt jetzt js-controller >= 6.0.11\nAbhängigkeiten wurden aktualisiert",
87
- "ru": "Адаптер требует node.js >= 20 сейчас.\nАдаптер требует администратора >= 7.7.22\nАдаптер требует js-контроллер >= 6.0.11\nЗависимости были обновлены",
88
- "pt": "O adaptador requer gnod.js >= 20 agora.\nAdaptador requer admin >= 7.7.22 agora\nAdaptador requer js-controlador >= 6.0.11 agora\nAs dependências foram atualizadas",
89
- "nl": "Voor de adapter zijn node.js < 20 nu nodig.\nAdapter vereist admin < 7.7.22 nu\nAdapter vereist js-controller > 6,0.11 nu\nAfhankelijkheden zijn bijgewerkt",
90
- "fr": "Adaptateur nécessite node.js >= 20 maintenant.\nAdaptateur nécessite admin >= 7.7.22 maintenant\nAdaptateur nécessite js-controller >= 6.0.11 maintenant\nLes dépendances ont été actualisées",
91
- "it": "Adattatore richiede node.js >= 20 ora.\nAdattatore richiede admin >= 7.7.22 ora\nAdattatore richiede js-controller >= 6.0.11 ora\nLe dipendenze sono state aggiornate",
92
- "es": "Adaptador requiere node.js >= 20 ahora.\nEl adaptador requiere admin= 7.7.22 ahora\nAdaptador requiere js-controller >= 6.0.11\nSe han actualizado las dependencias",
93
- "pl": "Adapter wymaga node.js > = 20 teraz.\nAdapter wymaga admin > = 7.7.22\nAdapter wymaga sterownika js- > = 6.0.11\nZaktualizowano zależności",
94
- "uk": "Адаптер вимагає node.js >= 20 тепер.\nАдаптер вимагає адмін >= 7.7.22 тепер\nАдаптер вимагає js-controller >=6.0.11 тепер\nЗалежність було оновлено",
95
- "zh-cn": "适配器需要节点.js >= 20 现在.\n适任者需要管理员 \\ 7.7.22 现在\n适配器需要js控制器 现在6.0.11\n依赖关系已更新"
96
96
  }
97
97
  },
98
98
  "titleLang": {
@@ -138,6 +138,14 @@
138
138
  "icon": "fakeroku.svg",
139
139
  "extIcon": "https://raw.githubusercontent.com/iobroker-community-adapters/ioBroker.fakeroku/master/admin/fakeroku.svg",
140
140
  "readme": "https://github.com/iobroker-community-adapters/ioBroker.fakeroku/blob/master/README.md",
141
+ "docs": {
142
+ "en": [
143
+ "docs/en/README.md"
144
+ ],
145
+ "de": [
146
+ "docs/de/README.md"
147
+ ]
148
+ },
141
149
  "loglevel": "info",
142
150
  "tier": 2,
143
151
  "compact": false,
@@ -232,7 +240,19 @@
232
240
  "_id": "info",
233
241
  "type": "channel",
234
242
  "common": {
235
- "name": "Information"
243
+ "name": {
244
+ "en": "Adapter information",
245
+ "de": "Adapter-Informationen",
246
+ "ru": "Информация об адаптере",
247
+ "pt": "Informação do adaptador",
248
+ "nl": "Adapterinformatie",
249
+ "fr": "Informations sur l'adaptateur",
250
+ "it": "Informazioni sull'adattatore",
251
+ "es": "Información del adaptador",
252
+ "pl": "Informacje o adapterze",
253
+ "uk": "Інформація про адаптер",
254
+ "zh-cn": "适配器信息"
255
+ }
236
256
  },
237
257
  "native": {}
238
258
  },
@@ -241,11 +261,36 @@
241
261
  "type": "state",
242
262
  "common": {
243
263
  "role": "indicator.connected",
244
- "name": "Device or service connected",
264
+ "name": {
265
+ "en": "Connection status",
266
+ "de": "Verbindungsstatus",
267
+ "ru": "Статус подключения",
268
+ "pt": "Estado da ligação",
269
+ "nl": "Verbindingsstatus",
270
+ "fr": "État de la connexion",
271
+ "it": "Stato connessione",
272
+ "es": "Estado de conexión",
273
+ "pl": "Stan połączenia",
274
+ "uk": "Стан з'єднання",
275
+ "zh-cn": "连接状态"
276
+ },
245
277
  "type": "boolean",
246
278
  "read": true,
247
279
  "write": false,
248
- "def": false
280
+ "def": false,
281
+ "desc": {
282
+ "en": "True only while every configured emulated Roku is actually listening.",
283
+ "de": "Nur wahr, solange jeder konfigurierte emulierte Roku tatsächlich lauscht.",
284
+ "ru": "Истина только пока каждый настроенный эмулированный Roku действительно слушает.",
285
+ "pt": "Verdadeiro apenas enquanto todos os Roku emulados configurados estiverem realmente à escuta.",
286
+ "nl": "Alleen waar zolang elke geconfigureerde geëmuleerde Roku daadwerkelijk luistert.",
287
+ "fr": "Vrai uniquement tant que chaque Roku émulé configuré est réellement à l'écoute.",
288
+ "it": "Vero solo finché ogni Roku emulato configurato è effettivamente in ascolto.",
289
+ "es": "Verdadero solo mientras cada Roku emulado configurado esté realmente a la escucha.",
290
+ "pl": "Prawda tylko wtedy, gdy każdy skonfigurowany emulowany Roku faktycznie nasłuchuje.",
291
+ "uk": "Істина лише поки кожен налаштований емульований Roku справді слухає.",
292
+ "zh-cn": "仅当每个已配置的模拟 Roku 都在监听时才为真。"
293
+ }
249
294
  },
250
295
  "native": {}
251
296
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iobroker.fakeroku",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "ioBroker adapter that emulates Roku devices, letting ECP/SSDP remotes (Logitech Harmony, Sofabaton) trigger events in ioBroker",
5
5
  "author": {
6
6
  "name": "krobi",