iobroker.fakeroku 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -92,6 +92,20 @@ 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.5.0 (2026-09-03)
96
+
97
+ - (krobipd) Fixed: deleting the last emulated Roku left all of its datapoints behind for good. They are now removed whenever the configuration says a device is gone.
98
+ - (krobipd) Fixed: on a host running Docker the adapter could announce itself under a container address no remote can reach. A real network address is preferred now.
99
+ - (krobipd) Fixed: an emulated Roku whose server died while running left the instance showing "connected". It now reports the failure and names the device.
100
+
101
+ ### 1.4.0 (2026-09-03)
102
+ - (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.
103
+ - (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.
104
+ - (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.
105
+ - (krobipd) Changed: every datapoint now carries a translated name and, where useful, a short description — in all eleven languages, in existing installations as well.
106
+ - (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.
107
+ - (krobipd) New: user documentation in English and German, shown in the ioBroker documentation portal.
108
+
95
109
  ### 1.3.0 (2026-09-01)
96
110
  - (krobipd) Fixed: a malformed keyboard keypress from a remote (a bad %-escape in the URL) could crash the adapter.
97
111
  - (krobipd) Fixed: remotes on an IPv6-only local network were refused; link-local and unique-local IPv6 addresses now count as LAN.
@@ -113,16 +127,6 @@ becomes `true` — or watch `.command` for the last button as text.
113
127
  - (krobipd) The device dialog now refuses a name that would clash with another device or a reserved name, preventing a naming conflict.
114
128
  - (krobipd) The adapter description now reads correctly in the non-English and non-German admin languages.
115
129
 
116
- ### 1.0.0 (2026-08-05)
117
- - (krobipd) First stable release — version 1.0.0 marks the complete rewrite as the mature, supported version of the adapter.
118
- - (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
-
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
130
  [Older changelogs can be found there](CHANGELOG_OLD.md)
127
131
 
128
132
  ## 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": "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",
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 its name at runtime, so an edit resolves the identity through the SAME helper\n * the runtime uses (`resolveDeviceUuid`) instead of computing a second answer.\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
  }
@@ -36,6 +36,8 @@ var import_errors = require("../lib/errors");
36
36
  var import_lan_guard = require("../lib/lan-guard");
37
37
  var import_ecp_command = require("./ecp-command");
38
38
  var import_device_info = require("./device-info");
39
+ const MAX_LOGGED_DETAIL = 120;
40
+ const NON_LAN_LOG_INTERVAL_MS = 6e4;
39
41
  class EcpHttpServer {
40
42
  /**
41
43
  * @param config server configuration
@@ -45,6 +47,10 @@ class EcpHttpServer {
45
47
  }
46
48
  config;
47
49
  server;
50
+ /** When the non-LAN rejection was last logged — a scanner must not fill the log. */
51
+ nonLanLoggedAt = 0;
52
+ /** Whether the fatal-error callback has already fired — it reports once, not per event. */
53
+ fatalReported = false;
48
54
  /** Bind the HTTP server to the interface + port. Rejects on bind error. */
49
55
  async start() {
50
56
  var _a;
@@ -55,7 +61,7 @@ class EcpHttpServer {
55
61
  server.once("error", onError);
56
62
  server.listen(this.config.device.port, this.config.bindIp, () => {
57
63
  server.removeListener("error", onError);
58
- server.on("error", (err) => this.config.logger.error(`ECP server error: ${err.message}`));
64
+ server.on("error", (err) => this.onRuntimeError(err));
59
65
  resolve();
60
66
  });
61
67
  });
@@ -63,11 +69,30 @@ class EcpHttpServer {
63
69
  `ECP server "${this.config.friendlyName}" on ${(_a = this.config.bindIp) != null ? _a : "0.0.0.0"}:${this.config.device.port}`
64
70
  );
65
71
  }
72
+ /**
73
+ * A server error after a good start — this emulated Roku is gone. Report it once
74
+ * so the adapter can revise `info.connection` instead of showing a healthy
75
+ * instance whose device answers nothing; the other devices keep running.
76
+ *
77
+ * @param err the server error
78
+ */
79
+ onRuntimeError(err) {
80
+ this.config.logger.error(`ECP server "${this.config.friendlyName}" error: ${err.message}`);
81
+ const notify = this.config.onFatalError;
82
+ if (notify && !this.fatalReported) {
83
+ this.fatalReported = true;
84
+ notify(err);
85
+ }
86
+ }
66
87
  handle(req, res) {
67
88
  var _a, _b, _c, _d, _e, _f;
68
89
  const peer = ((_a = req.socket.remoteAddress) != null ? _a : "").replace(/^::ffff:/, "") || "?";
69
90
  if (!(0, import_lan_guard.isLanClient)(req.socket.remoteAddress)) {
70
- this.config.logger.debug(`ECP request from non-LAN ${peer} rejected (403)`);
91
+ const now = Date.now();
92
+ if (now - this.nonLanLoggedAt >= NON_LAN_LOG_INTERVAL_MS) {
93
+ this.nonLanLoggedAt = now;
94
+ this.config.logger.debug(`ECP request from non-LAN ${peer} rejected (403)`);
95
+ }
71
96
  res.statusCode = 403;
72
97
  res.end();
73
98
  return;
@@ -96,13 +121,16 @@ class EcpHttpServer {
96
121
  res.end();
97
122
  return;
98
123
  }
99
- const detail = ((_f = (_e = (_d = cmd.key) != null ? _d : cmd.appId) != null ? _e : cmd.text) != null ? _f : "").replace(new RegExp("\\p{Cc}", "gu"), "?");
100
- this.config.logger.debug(`ECP ${cmd.type}${detail ? ` ${detail}` : ""} from ${peer}`);
124
+ let accepted = false;
101
125
  try {
102
- this.config.onCommand(cmd);
126
+ accepted = this.config.onCommand(cmd);
103
127
  } catch (e) {
104
128
  this.config.logger.warn(`onCommand failed: ${(0, import_errors.errText)(e)}`);
105
129
  }
130
+ if (accepted) {
131
+ const detail = ((_f = (_e = (_d = cmd.key) != null ? _d : cmd.appId) != null ? _e : cmd.text) != null ? _f : "").replace(new RegExp("\\p{Cc}", "gu"), "?").slice(0, MAX_LOGGED_DETAIL);
132
+ this.config.logger.debug(`ECP ${cmd.type}${detail ? ` ${detail}` : ""} from ${peer}`);
133
+ }
106
134
  res.statusCode = 200;
107
135
  res.end();
108
136
  return;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/ecp/ecp-http-server.ts"],
4
- "sourcesContent": ["import * as http from \"node:http\";\nimport type { RokuAdvert } from \"../discovery/ssdp-messages\";\nimport { errText } from \"../lib/errors\";\nimport { isLanClient } from \"../lib/lan-guard\";\nimport type { AdapterLogger } from \"../lib/logger\";\nimport { type CommandEvent, parseEcpCommand } from \"./ecp-command\";\nimport { type AppEntry, buildAppsXml, buildDescXml, buildDeviceInfoXml } from \"./device-info\";\nimport type { DeviceType } from \"./state-model\";\n\n/** Configuration for one emulated Roku's ECP HTTP server. */\nexport interface EcpServerConfig {\n /** The emulated Roku (uuid + port). */\n device: RokuAdvert;\n /** Display name shown in the description / device-info. */\n friendlyName: string;\n /** Apps advertised at /query/apps. */\n apps: AppEntry[];\n /** The emulated device type (player / tv) \u2014 drives the device-info response. */\n deviceType: DeviceType;\n /** Interface IP to bind the HTTP server to, or `undefined` to bind all interfaces (auto). */\n bindIp: string | undefined;\n /** Logger. */\n logger: AdapterLogger;\n /** Called for every accepted POST command. */\n onCommand: (cmd: CommandEvent) => void;\n}\n\n/**\n * The Roku ECP HTTP server for one emulated device. Serves the UPnP description,\n * /query/device-info (with a current version) and /query/apps; turns POST\n * key/launch/input/search into command events. Unknown GET paths get a clean 404\n * (not the old adapter's empty 200), and commands are accepted only from the LAN.\n */\nexport class EcpHttpServer {\n private server: http.Server | undefined;\n\n /**\n * @param config server configuration\n */\n public constructor(private readonly config: EcpServerConfig) {}\n\n /** Bind the HTTP server to the interface + port. Rejects on bind error. */\n public async start(): Promise<void> {\n const server = http.createServer((req, res) => this.handle(req, res));\n this.server = server;\n await new Promise<void>((resolve, reject) => {\n const onError = (err: Error): void => reject(err);\n server.once(\"error\", onError);\n server.listen(this.config.device.port, this.config.bindIp, () => {\n server.removeListener(\"error\", onError);\n server.on(\"error\", (err: Error) => this.config.logger.error(`ECP server error: ${err.message}`));\n resolve();\n });\n });\n this.config.logger.debug(\n `ECP server \"${this.config.friendlyName}\" on ${this.config.bindIp ?? \"0.0.0.0\"}:${this.config.device.port}`,\n );\n }\n\n private handle(req: http.IncomingMessage, res: http.ServerResponse): void {\n const peer = (req.socket.remoteAddress ?? \"\").replace(/^::ffff:/, \"\") || \"?\";\n if (!isLanClient(req.socket.remoteAddress)) {\n // Debug (not warn): a stray WAN scanner must not spam the log, but when a\n // remote sits on the wrong subnet/VLAN this is the only trace of \"why rejected\".\n this.config.logger.debug(`ECP request from non-LAN ${peer} rejected (403)`);\n res.statusCode = 403;\n res.end();\n return;\n }\n const method = req.method ?? \"GET\";\n const url = req.url ?? \"/\";\n\n if (method === \"GET\") {\n const body = this.routeGet(url);\n if (body === null) {\n res.statusCode = 404;\n res.end();\n return;\n }\n if (url.split(\"?\")[0] === \"/query/device-info\") {\n // The pairing probe \u2014 the first thing a remote asks and the usual failure\n // point (a Sofabaton rejects a too-old version). Visible for diagnosis.\n this.config.logger.debug(`device-info queried from ${peer} (remote pairing/probe)`);\n }\n res.statusCode = 200;\n res.setHeader(\"Content-Type\", \"text/xml; charset=utf-8\");\n res.end(body);\n return;\n }\n\n if (method === \"POST\") {\n // The body is never read: Node discards an unconsumed body when the response\n // finishes, so the keep-alive connection stays usable (guarded by a test).\n const cmd = parseEcpCommand(\"POST\", url);\n if (!cmd) {\n res.statusCode = 404;\n res.end();\n return;\n }\n // The received command \u2014 without this a keypress leaves no trace at all, so a\n // \"I press a button and nothing happens\" report has nothing to go on.\n // Control characters (a decoded `Lit_%0A`) are replaced so one request stays one log line.\n const detail = (cmd.key ?? cmd.appId ?? cmd.text ?? \"\").replace(/\\p{Cc}/gu, \"?\");\n this.config.logger.debug(`ECP ${cmd.type}${detail ? ` ${detail}` : \"\"} from ${peer}`);\n try {\n this.config.onCommand(cmd);\n } catch (e) {\n this.config.logger.warn(`onCommand failed: ${errText(e)}`);\n }\n res.statusCode = 200;\n res.end();\n return;\n }\n\n res.statusCode = 405;\n res.end();\n }\n\n private routeGet(url: string): string | null {\n switch (url.split(\"?\")[0]) {\n case \"/\":\n return buildDescXml(this.config.device, this.config.friendlyName, this.config.deviceType);\n case \"/query/device-info\":\n return buildDeviceInfoXml(this.config.device, this.config.friendlyName, this.config.deviceType);\n case \"/query/apps\":\n return buildAppsXml(this.config.apps);\n default:\n return null;\n }\n }\n\n /** Synchronous close \u2014 safe from onUnload. */\n public stop(): void {\n if (this.server) {\n try {\n this.server.close();\n // close() only stops accepting new sockets; established keep-alive\n // connections (a controller holding one) keep the event loop busy and can\n // drag the synchronous onUnload toward a SIGKILL. Force them shut (Node >= 22).\n this.server.closeAllConnections();\n } catch {\n // already closed\n }\n this.server = undefined;\n }\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import * as http from \"node:http\";\nimport type { RokuAdvert } from \"../discovery/ssdp-messages\";\nimport { errText } from \"../lib/errors\";\nimport { isLanClient } from \"../lib/lan-guard\";\nimport type { AdapterLogger } from \"../lib/logger\";\nimport { type CommandEvent, parseEcpCommand } from \"./ecp-command\";\nimport { type AppEntry, buildAppsXml, buildDescXml, buildDeviceInfoXml } from \"./device-info\";\nimport type { DeviceType } from \"./state-model\";\n\n/** Configuration for one emulated Roku's ECP HTTP server. */\nexport interface EcpServerConfig {\n /** The emulated Roku (uuid + port). */\n device: RokuAdvert;\n /** Display name shown in the description / device-info. */\n friendlyName: string;\n /** Apps advertised at /query/apps. */\n apps: AppEntry[];\n /** The emulated device type (player / tv) \u2014 drives the device-info response. */\n deviceType: DeviceType;\n /** Interface IP to bind the HTTP server to, or `undefined` to bind all interfaces (auto). */\n bindIp: string | undefined;\n /** Logger. */\n logger: AdapterLogger;\n /**\n * Called for every parsed POST command. Returns whether the command was actually\n * applied \u2014 a `false` means the adapter's rate gate dropped it, and the server\n * then keeps quiet about it too (see {@link EcpHttpServer.handle}).\n */\n onCommand: (cmd: CommandEvent) => boolean;\n /**\n * Called at most once if the server dies AFTER a successful start \u2014 a runtime\n * error it cannot recover from. Without it the adapter would keep reporting a\n * connected instance while this emulated Roku no longer answers anything, and\n * `info.connection` means \"EVERY configured Roku is listening\". Mirrors the SSDP\n * responder, which has carried this callback since 1.1.0.\n */\n onFatalError?: (err: Error) => void;\n}\n\n/**\n * Longest command detail written to the log. The text comes from the request URL,\n * which a LAN client controls up to Node's header limit (16 KiB) \u2014 the `command`\n * state is capped at 500 characters for exactly that reason, and the log line\n * beside it must not be the way around the cap.\n */\nconst MAX_LOGGED_DETAIL = 120;\n\n/** How often at most the \"rejected a non-LAN request\" line is written. */\nconst NON_LAN_LOG_INTERVAL_MS = 60_000;\n\n/**\n * The Roku ECP HTTP server for one emulated device. Serves the UPnP description,\n * /query/device-info (with a current version) and /query/apps; turns POST\n * key/launch/input/search into command events. Unknown GET paths get a clean 404\n * (not the old adapter's empty 200), and commands are accepted only from the LAN.\n */\nexport class EcpHttpServer {\n private server: http.Server | undefined;\n /** When the non-LAN rejection was last logged \u2014 a scanner must not fill the log. */\n private nonLanLoggedAt = 0;\n /** Whether the fatal-error callback has already fired \u2014 it reports once, not per event. */\n private fatalReported = false;\n\n /**\n * @param config server configuration\n */\n public constructor(private readonly config: EcpServerConfig) {}\n\n /** Bind the HTTP server to the interface + port. Rejects on bind error. */\n public async start(): Promise<void> {\n const server = http.createServer((req, res) => this.handle(req, res));\n this.server = server;\n await new Promise<void>((resolve, reject) => {\n const onError = (err: Error): void => reject(err);\n server.once(\"error\", onError);\n server.listen(this.config.device.port, this.config.bindIp, () => {\n server.removeListener(\"error\", onError);\n server.on(\"error\", (err: Error) => this.onRuntimeError(err));\n resolve();\n });\n });\n this.config.logger.debug(\n `ECP server \"${this.config.friendlyName}\" on ${this.config.bindIp ?? \"0.0.0.0\"}:${this.config.device.port}`,\n );\n }\n\n /**\n * A server error after a good start \u2014 this emulated Roku is gone. Report it once\n * so the adapter can revise `info.connection` instead of showing a healthy\n * instance whose device answers nothing; the other devices keep running.\n *\n * @param err the server error\n */\n private onRuntimeError(err: Error): void {\n this.config.logger.error(`ECP server \"${this.config.friendlyName}\" error: ${err.message}`);\n const notify = this.config.onFatalError;\n if (notify && !this.fatalReported) {\n this.fatalReported = true;\n notify(err);\n }\n }\n\n private handle(req: http.IncomingMessage, res: http.ServerResponse): void {\n const peer = (req.socket.remoteAddress ?? \"\").replace(/^::ffff:/, \"\") || \"?\";\n if (!isLanClient(req.socket.remoteAddress)) {\n // Debug (not warn): a stray WAN scanner must not spam the log, but when a\n // remote sits on the wrong subnet/VLAN this is the only trace of \"why rejected\".\n // Throttled: a scanner sends thousands of requests, and every one of them\n // would otherwise cost a log line \u2014 the very flood the rate gate exists for.\n const now = Date.now();\n if (now - this.nonLanLoggedAt >= NON_LAN_LOG_INTERVAL_MS) {\n this.nonLanLoggedAt = now;\n this.config.logger.debug(`ECP request from non-LAN ${peer} rejected (403)`);\n }\n res.statusCode = 403;\n res.end();\n return;\n }\n const method = req.method ?? \"GET\";\n const url = req.url ?? \"/\";\n\n if (method === \"GET\") {\n const body = this.routeGet(url);\n if (body === null) {\n res.statusCode = 404;\n res.end();\n return;\n }\n if (url.split(\"?\")[0] === \"/query/device-info\") {\n // The pairing probe \u2014 the first thing a remote asks and the usual failure\n // point (a Sofabaton rejects a too-old version). Visible for diagnosis.\n this.config.logger.debug(`device-info queried from ${peer} (remote pairing/probe)`);\n }\n res.statusCode = 200;\n res.setHeader(\"Content-Type\", \"text/xml; charset=utf-8\");\n res.end(body);\n return;\n }\n\n if (method === \"POST\") {\n // The body is never read: Node discards an unconsumed body when the response\n // finishes, so the keep-alive connection stays usable (guarded by a test).\n const cmd = parseEcpCommand(\"POST\", url);\n if (!cmd) {\n res.statusCode = 404;\n res.end();\n return;\n }\n // Apply FIRST, log second. The adapter's rate gate sits inside onCommand, so\n // logging before it would leave the log wide open for exactly the flood the\n // gate exists to stop: a device sending a thousand presses a second would\n // write a thousand lines while only 25 reach the states database.\n let accepted = false;\n try {\n accepted = this.config.onCommand(cmd);\n } catch (e) {\n this.config.logger.warn(`onCommand failed: ${errText(e)}`);\n }\n if (accepted) {\n // The received command \u2014 without this a keypress leaves no trace at all, so a\n // \"I press a button and nothing happens\" report has nothing to go on.\n // Control characters (a decoded `Lit_%0A`) are replaced so one request stays one\n // log line, and the text is capped like the state value it accompanies.\n const detail = (cmd.key ?? cmd.appId ?? cmd.text ?? \"\").replace(/\\p{Cc}/gu, \"?\").slice(0, MAX_LOGGED_DETAIL);\n this.config.logger.debug(`ECP ${cmd.type}${detail ? ` ${detail}` : \"\"} from ${peer}`);\n }\n res.statusCode = 200;\n res.end();\n return;\n }\n\n res.statusCode = 405;\n res.end();\n }\n\n private routeGet(url: string): string | null {\n switch (url.split(\"?\")[0]) {\n case \"/\":\n return buildDescXml(this.config.device, this.config.friendlyName, this.config.deviceType);\n case \"/query/device-info\":\n return buildDeviceInfoXml(this.config.device, this.config.friendlyName, this.config.deviceType);\n case \"/query/apps\":\n return buildAppsXml(this.config.apps);\n default:\n return null;\n }\n }\n\n /** Synchronous close \u2014 safe from onUnload. */\n public stop(): void {\n if (this.server) {\n try {\n this.server.close();\n // close() only stops accepting new sockets; established keep-alive\n // connections (a controller holding one) keep the event loop busy and can\n // drag the synchronous onUnload toward a SIGKILL. Force them shut (Node >= 22).\n this.server.closeAllConnections();\n } catch {\n // already closed\n }\n this.server = undefined;\n }\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
  }