iobroker.fakeroku 1.2.0 → 1.3.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.
Files changed (41) hide show
  1. package/README.md +29 -14
  2. package/admin/i18n/de.json +1 -1
  3. package/admin/i18n/en.json +1 -1
  4. package/admin/i18n/es.json +1 -1
  5. package/admin/i18n/fr.json +1 -1
  6. package/admin/i18n/it.json +1 -1
  7. package/admin/i18n/nl.json +1 -1
  8. package/admin/i18n/pl.json +1 -1
  9. package/admin/i18n/pt.json +1 -1
  10. package/admin/i18n/ru.json +1 -1
  11. package/admin/i18n/uk.json +1 -1
  12. package/admin/i18n/zh-cn.json +1 -1
  13. package/admin/jsonConfig.json +2 -2
  14. package/build/device-management.js +11 -3
  15. package/build/device-management.js.map +2 -2
  16. package/build/discovery/ssdp-responder.js +8 -5
  17. package/build/discovery/ssdp-responder.js.map +2 -2
  18. package/build/ecp/device-info.js +2 -2
  19. package/build/ecp/device-info.js.map +2 -2
  20. package/build/ecp/ecp-command.js +3 -0
  21. package/build/ecp/ecp-command.js.map +2 -2
  22. package/build/ecp/ecp-http-server.js +4 -4
  23. package/build/ecp/ecp-http-server.js.map +2 -2
  24. package/build/ecp/state-model.js +4 -1
  25. package/build/ecp/state-model.js.map +2 -2
  26. package/build/lib/detect-ip.js +1 -2
  27. package/build/lib/detect-ip.js.map +2 -2
  28. package/build/lib/errors.js +31 -0
  29. package/build/lib/errors.js.map +7 -0
  30. package/build/{ecp → lib}/lan-guard.js +4 -0
  31. package/build/lib/lan-guard.js.map +7 -0
  32. package/build/lib/pure-helpers.js +8 -1
  33. package/build/lib/pure-helpers.js.map +2 -2
  34. package/build/lib/rate-gate.js +58 -0
  35. package/build/lib/rate-gate.js.map +7 -0
  36. package/build/main.js +103 -26
  37. package/build/main.js.map +2 -2
  38. package/io-package.json +33 -29
  39. package/package.json +1 -1
  40. package/admin/fakeroku.png +0 -0
  41. package/build/ecp/lan-guard.js.map +0 -7
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  **Support:** [![Ko-fi](https://img.shields.io/badge/Ko--fi-Support-ff5e5b?logo=ko-fi)](https://ko-fi.com/krobipd) [![PayPal](https://img.shields.io/badge/Donate-PayPal-blue.svg)](https://paypal.me/krobipd)
8
8
 
9
- Emulates one or more **Roku devices** on your LAN so that ECP/SSDP remotes — a
9
+ Emulates one or more **[Roku](https://www.roku.com/) devices** on your LAN so that ECP/SSDP remotes — a
10
10
  Logitech Harmony Hub or a Sofabaton X1/X2 — can trigger events in
11
11
  ioBroker. It is the **input** counterpart to the Logitech Harmony adapter: a button
12
12
  on the remote becomes a datapoint in ioBroker.
@@ -25,7 +25,7 @@ classic Harmony hub.
25
25
  - Emulates one or more Roku devices on the LAN — the Roku control protocol (ECP) over HTTP plus SSDP discovery on port 1900.
26
26
  - Full Roku control surface including `/query/device-info` with a current Roku version, beyond what a classic Harmony hub needs.
27
27
  - Clean data model per device: a `command` datapoint plus fixed `keys.<Key>` states, all created up front.
28
- - Several emulated Rokus from a single instance; discovery bound to the chosen network interface; command handling restricted to the LAN.
28
+ - Several emulated Rokus from a single instance; discovery bound to the chosen network interface; command handling restricted to the LAN and capped at 25 commands per second per emulated Roku, so a misbehaving device cannot flood ioBroker.
29
29
 
30
30
  ## Sentry / Error reporting
31
31
 
@@ -37,11 +37,12 @@ For details and how to disable it, see the [Sentry plugin documentation](https:/
37
37
 
38
38
  - Node.js >= 22
39
39
  - js-controller >= 7.2.2
40
- - admin >= 7.8.23
40
+ - admin >= 8.0.11
41
41
 
42
- ## Installation
42
+ ## Ports
43
43
 
44
- Install the adapter from the ioBroker admin.
44
+ - **TCP 8060 (listening, one per emulated Roku, configurable)** — the Roku control protocol (ECP): the remote sends its key presses here and reads the actual port from the discovery announcement.
45
+ - **UDP 1900 (multicast, listening and outgoing)** — SSDP discovery, so a Harmony hub or Sofabaton finds the emulated Rokus; fixed by the UPnP standard.
45
46
 
46
47
  ## Configuration
47
48
 
@@ -60,7 +61,7 @@ Install the adapter from the ioBroker admin.
60
61
  To add the emulated Roku to a Harmony hub, add a "Roku" device in the Harmony app
61
62
  and point it at the ioBroker host.
62
63
 
63
- ## Objects
64
+ ## State Tree
64
65
 
65
66
  At instance level:
66
67
 
@@ -83,19 +84,26 @@ not get their own objects.
83
84
  > play and pause cannot be told apart here — that is a protocol limitation, not an
84
85
  > adapter one.
85
86
 
86
- ## Usage
87
-
88
87
  In a script or Blockly rule, react to a key — e.g. when `fakeroku.0.<name>.keys.Play`
89
- becomes `true`, or watch `.command` for the last button as text.
88
+ becomes `true` or watch `.command` for the last button as text.
90
89
 
91
90
  ## Changelog
92
91
  <!--
93
92
  Placeholder for the next version (at the beginning of the line):
94
93
  ### **WORK IN PROGRESS**
95
94
  -->
95
+ ### 1.3.0 (2026-09-01)
96
+ - (krobipd) Fixed: a malformed keyboard keypress from a remote (a bad %-escape in the URL) could crash the adapter.
97
+ - (krobipd) Fixed: remotes on an IPv6-only local network were refused; link-local and unique-local IPv6 addresses now count as LAN.
98
+ - (krobipd) Fixed: the adapter icon in the admin is now the same one shown on GitHub.
99
+ - (krobipd) Changed: requires admin >= 8.0.11.
100
+ - (krobipd) Improved: discovery answers only searches from your own network, and the device dialog in the admin keeps working after the device list was edited by hand.
101
+ - (krobipd) Improved: the emulated Roku reports Roku OS 15.0 (was 14.1), and the command-type datapoint lists its possible values so the admin shows them as labels.
102
+ - (krobipd) New: a misbehaving device on your network can no longer flood ioBroker — more than 25 commands per second per emulated Roku are dropped and reported in the log.
103
+
96
104
  ### 1.2.0 (2026-08-27)
97
105
  - (krobipd) Changed: the instance now reports "not connected" while a configured Roku is missing — a device whose port is taken no longer hides behind the ones that did start.
98
- - (krobipd) Fixed: stopping the adapter now clears its connection state instead of leaving the instance showing "connected" while nothing is running.
106
+ - (krobipd) Improved: shutdown now waits for its last write to land before reporting done, so the connection state cannot be lost on a slow or busy system.
99
107
  - (krobipd) New: optional error reporting via Sentry — only active if you enabled diagnostics in ioBroker, and it transmits no personal data.
100
108
 
101
109
  ### 1.1.0 (2026-08-10) — stable
@@ -115,10 +123,6 @@ becomes `true`, or watch `.command` for the last button as text.
115
123
  - (krobipd) Manage multiple emulated Rokus from the admin UI, each as a Player or a TV.
116
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.
117
125
 
118
- ### 0.5.1 (2026-08-05)
119
- - (mcm1957) Adapter requires Node.js >= 22 now
120
- - (mcm1957) Dependencies have been updated
121
-
122
126
  [Older changelogs can be found there](CHANGELOG_OLD.md)
123
127
 
124
128
  ## History
@@ -137,6 +141,17 @@ users it is simply a new version of the same adapter:
137
141
  - From **0.6.0** on, [krobi](https://github.com/krobipd) rewrote the adapter from the
138
142
  ground up in TypeScript and added the full ECP surface including `device-info`.
139
143
 
144
+ ## Support
145
+
146
+ - [ioBroker Forum](https://forum.iobroker.net/)
147
+ - [GitHub Issues](https://github.com/iobroker-community-adapters/ioBroker.fakeroku/issues)
148
+
149
+ ### Support Development
150
+
151
+ This adapter is free and open source. If you find it useful, consider buying me a coffee:
152
+
153
+ [![Ko-fi](https://img.shields.io/badge/Ko--fi-Support%20me-ff5e5b?logo=ko-fi)](https://ko-fi.com/krobipd) [![PayPal](https://img.shields.io/badge/Donate-PayPal-blue.svg)](https://paypal.me/krobipd)
154
+
140
155
  ## License
141
156
 
142
157
  The MIT License (MIT)
@@ -3,7 +3,7 @@
3
3
  "networkInterface": "Netzwerkschnittstelle (Binden + Ankündigen)",
4
4
  "networkInterfaceTooltip": "Die Netzwerkkarte, an die sich die emulierten Rokus binden und über die sie sich ankündigen. Auf 'alle Schnittstellen' lassen — die passende IP wird automatisch erkannt, es läuft also ohne Einstellung. Nur bei mehreren Netzwerkkarten eine feste Adresse wählen.",
5
5
  "headerDevices": "Emulierte Roku-Geräte",
6
- "devicesHelp": "Jede Karte ist ein emulierter Roku. Lege einen an, dann füge in deiner Harmony- oder Sofabaton-App ein **Roku**-Gerät hinzu, das auf diesen ioBroker-Host zeigt — jeder Tastendruck landet in den Objekten des Geräts.",
6
+ "devicesHelp": "Jede Karte ist ein emulierter Roku. Lege einen an, dann füge in deiner Harmony- oder Sofabaton-App ein <b>Roku</b>-Gerät hinzu, das auf diesen ioBroker-Host zeigt — jeder Tastendruck landet in den Objekten des Geräts.",
7
7
  "deviceName": "Name",
8
8
  "devicePort": "ECP-Port",
9
9
  "portLabel": "Port",
@@ -3,7 +3,7 @@
3
3
  "networkInterface": "Network interface (bind + advertise)",
4
4
  "networkInterfaceTooltip": "The network card the emulated Rokus bind to and advertise on. Leave it on 'all interfaces' — the routable IP is detected automatically, so it works out of the box. Only pick a specific address on a host with several network cards.",
5
5
  "headerDevices": "Emulated Roku devices",
6
- "devicesHelp": "Each card is one emulated Roku. Add one, then add a **Roku** device in your Harmony or Sofabaton app pointing at this ioBroker host — every keypress lands in the device's objects.",
6
+ "devicesHelp": "Each card is one emulated Roku. Add one, then add a <b>Roku</b> device in your Harmony or Sofabaton app pointing at this ioBroker host — every keypress lands in the device's objects.",
7
7
  "deviceName": "Name",
8
8
  "devicePort": "ECP port",
9
9
  "portLabel": "Port",
@@ -3,7 +3,7 @@
3
3
  "networkInterface": "Interfaz de red (enlace + anuncio)",
4
4
  "networkInterfaceTooltip": "La tarjeta de red a la que se enlazan y anuncian los Roku emulados. Déjala en 'todas las interfaces': la IP enrutable se detecta automáticamente y funciona sin configurar. Elige una dirección concreta solo en un host con varias tarjetas de red.",
5
5
  "headerDevices": "Dispositivos Roku emulados",
6
- "devicesHelp": "Cada tarjeta es un Roku emulado. Añade uno y luego añade un dispositivo **Roku** en tu app Harmony o Sofabaton apuntando a este host de ioBroker — cada pulsación llega a los objetos del dispositivo.",
6
+ "devicesHelp": "Cada tarjeta es un Roku emulado. Añade uno y luego añade un dispositivo <b>Roku</b> en tu app Harmony o Sofabaton apuntando a este host de ioBroker — cada pulsación llega a los objetos del dispositivo.",
7
7
  "deviceName": "Nombre",
8
8
  "devicePort": "Puerto ECP",
9
9
  "portLabel": "Puerto",
@@ -3,7 +3,7 @@
3
3
  "networkInterface": "Interface réseau (liaison + annonce)",
4
4
  "networkInterfaceTooltip": "La carte réseau à laquelle les Roku émulés se lient et s'annoncent. Laissez-la sur 'toutes les interfaces' : l'IP routable est détectée automatiquement, cela fonctionne sans réglage. Ne choisissez une adresse précise que sur un hôte avec plusieurs cartes réseau.",
5
5
  "headerDevices": "Appareils Roku émulés",
6
- "devicesHelp": "Chaque carte est un Roku émulé. Ajoutez-en un, puis ajoutez un appareil **Roku** dans votre app Harmony ou Sofabaton pointant vers cet hôte ioBroker — chaque appui atterrit dans les objets de l'appareil.",
6
+ "devicesHelp": "Chaque carte est un Roku émulé. Ajoutez-en un, puis ajoutez un appareil <b>Roku</b> dans votre app Harmony ou Sofabaton pointant vers cet hôte ioBroker — chaque appui atterrit dans les objets de l'appareil.",
7
7
  "deviceName": "Nom",
8
8
  "devicePort": "Port ECP",
9
9
  "portLabel": "Port",
@@ -3,7 +3,7 @@
3
3
  "networkInterface": "Interfaccia di rete (bind + annuncio)",
4
4
  "networkInterfaceTooltip": "La scheda di rete a cui i Roku emulati si collegano e si annunciano. Lasciala su 'tutte le interfacce': l'IP instradabile viene rilevato automaticamente e funziona senza configurazione. Scegli un indirizzo specifico solo su un host con più schede di rete.",
5
5
  "headerDevices": "Dispositivi Roku emulati",
6
- "devicesHelp": "Ogni scheda è un Roku emulato. Aggiungine uno, poi aggiungi un dispositivo **Roku** nell'app Harmony o Sofabaton che punta a questo host ioBroker — ogni pressione finisce negli oggetti del dispositivo.",
6
+ "devicesHelp": "Ogni scheda è un Roku emulato. Aggiungine uno, poi aggiungi un dispositivo <b>Roku</b> nell'app Harmony o Sofabaton che punta a questo host ioBroker — ogni pressione finisce negli oggetti del dispositivo.",
7
7
  "deviceName": "Nome",
8
8
  "devicePort": "Porta ECP",
9
9
  "portLabel": "Porta",
@@ -3,7 +3,7 @@
3
3
  "networkInterface": "Netwerkinterface (binden + aankondigen)",
4
4
  "networkInterfaceTooltip": "De netwerkkaart waaraan de geëmuleerde Roku's zich binden en aankondigen. Laat het op 'alle interfaces' staan — het routeerbare IP wordt automatisch gedetecteerd, dus het werkt meteen. Kies alleen een specifiek adres op een host met meerdere netwerkkaarten.",
5
5
  "headerDevices": "Geëmuleerde Roku-apparaten",
6
- "devicesHelp": "Elke kaart is één geëmuleerde Roku. Voeg er een toe en voeg daarna in je Harmony- of Sofabaton-app een **Roku**-apparaat toe dat naar deze ioBroker-host wijst — elke toetsdruk komt in de objecten van het apparaat.",
6
+ "devicesHelp": "Elke kaart is één geëmuleerde Roku. Voeg er een toe en voeg daarna in je Harmony- of Sofabaton-app een <b>Roku</b>-apparaat toe dat naar deze ioBroker-host wijst — elke toetsdruk komt in de objecten van het apparaat.",
7
7
  "deviceName": "Naam",
8
8
  "devicePort": "ECP-poort",
9
9
  "portLabel": "Poort",
@@ -3,7 +3,7 @@
3
3
  "networkInterface": "Interfejs sieciowy (bind + rozgłaszanie)",
4
4
  "networkInterfaceTooltip": "Karta sieciowa, do której podłączają się i rozgłaszają emulowane Roku. Zostaw na 'wszystkich interfejsach' — routowalny adres IP jest wykrywany automatycznie, więc działa bez konfiguracji. Wybierz konkretny adres tylko na hoście z kilkoma kartami sieciowymi.",
5
5
  "headerDevices": "Emulowane urządzenia Roku",
6
- "devicesHelp": "Każda karta to jeden emulowany Roku. Dodaj urządzenie, a następnie w aplikacji Harmony lub Sofabaton dodaj urządzenie **Roku** wskazujące na tego hosta ioBroker — każde naciśnięcie trafia do obiektów urządzenia.",
6
+ "devicesHelp": "Każda karta to jeden emulowany Roku. Dodaj urządzenie, a następnie w aplikacji Harmony lub Sofabaton dodaj urządzenie <b>Roku</b> wskazujące na tego hosta ioBroker — każde naciśnięcie trafia do obiektów urządzenia.",
7
7
  "deviceName": "Nazwa",
8
8
  "devicePort": "Port ECP",
9
9
  "portLabel": "Port",
@@ -3,7 +3,7 @@
3
3
  "networkInterface": "Interface de rede (vincular + anunciar)",
4
4
  "networkInterfaceTooltip": "A placa de rede à qual os Roku emulados se vinculam e anunciam. Deixe em 'todas as interfaces' — o IP roteável é detetado automaticamente, por isso funciona sem configurar. Escolha um endereço específico apenas num host com várias placas de rede.",
5
5
  "headerDevices": "Dispositivos Roku emulados",
6
- "devicesHelp": "Cada cartão é um Roku emulado. Adicione um e depois adicione um dispositivo **Roku** na app Harmony ou Sofabaton a apontar para este host ioBroker — cada tecla cai nos objetos do dispositivo.",
6
+ "devicesHelp": "Cada cartão é um Roku emulado. Adicione um e depois adicione um dispositivo <b>Roku</b> na app Harmony ou Sofabaton a apontar para este host ioBroker — cada tecla cai nos objetos do dispositivo.",
7
7
  "deviceName": "Nome",
8
8
  "devicePort": "Porta ECP",
9
9
  "portLabel": "Porta",
@@ -3,7 +3,7 @@
3
3
  "networkInterface": "Сетевой интерфейс (привязка + объявление)",
4
4
  "networkInterfaceTooltip": "Сетевая карта, к которой привязываются и на которой объявляются эмулируемые Roku. Оставьте 'все интерфейсы' — маршрутизируемый IP определяется автоматически, поэтому работает без настройки. Выбирайте конкретный адрес только на хосте с несколькими сетевыми картами.",
5
5
  "headerDevices": "Эмулируемые устройства Roku",
6
- "devicesHelp": "Каждая карточка — это один эмулируемый Roku. Добавьте устройство, затем в приложении Harmony или Sofabaton добавьте устройство **Roku**, указывающее на этот хост ioBroker — каждое нажатие попадает в объекты устройства.",
6
+ "devicesHelp": "Каждая карточка — это один эмулируемый Roku. Добавьте устройство, затем в приложении Harmony или Sofabaton добавьте устройство <b>Roku</b>, указывающее на этот хост ioBroker — каждое нажатие попадает в объекты устройства.",
7
7
  "deviceName": "Имя",
8
8
  "devicePort": "Порт ECP",
9
9
  "portLabel": "Порт",
@@ -3,7 +3,7 @@
3
3
  "networkInterface": "Мережевий інтерфейс (прив'язка + оголошення)",
4
4
  "networkInterfaceTooltip": "Мережева карта, до якої прив'язуються та на якій оголошуються емульовані Roku. Залиште 'всі інтерфейси' — маршрутизовану IP-адресу визначено автоматично, тож працює без налаштування. Вибирайте конкретну адресу лише на хості з кількома мережевими картами.",
5
5
  "headerDevices": "Емульовані пристрої Roku",
6
- "devicesHelp": "Кожна картка — це один емульований Roku. Додайте пристрій, потім у застосунку Harmony або Sofabaton додайте пристрій **Roku**, що вказує на цей хост ioBroker — кожне натискання потрапляє в об'єкти пристрою.",
6
+ "devicesHelp": "Кожна картка — це один емульований Roku. Додайте пристрій, потім у застосунку Harmony або Sofabaton додайте пристрій <b>Roku</b>, що вказує на цей хост ioBroker — кожне натискання потрапляє в об'єкти пристрою.",
7
7
  "deviceName": "Назва",
8
8
  "devicePort": "Порт ECP",
9
9
  "portLabel": "Порт",
@@ -3,7 +3,7 @@
3
3
  "networkInterface": "网络接口(绑定 + 广播)",
4
4
  "networkInterfaceTooltip": "模拟 Roku 绑定并广播的网卡。保留为“所有接口”即可——可路由的 IP 会被自动检测,因此无需配置即可运行。仅在有多块网卡的主机上才选择具体地址。",
5
5
  "headerDevices": "模拟的 Roku 设备",
6
- "devicesHelp": "每张卡片是一个模拟的 Roku。添加一个后,在 Harmony 或 Sofabaton 应用中添加一个指向此 ioBroker 主机的 **Roku** 设备——每次按键都会进入该设备的对象。",
6
+ "devicesHelp": "每张卡片是一个模拟的 Roku。添加一个后,在 Harmony 或 Sofabaton 应用中添加一个指向此 ioBroker 主机的 <b>Roku</b> 设备——每次按键都会进入该设备的对象。",
7
7
  "deviceName": "名称",
8
8
  "devicePort": "ECP 端口",
9
9
  "portLabel": "端口",
@@ -5,7 +5,7 @@
5
5
  "_headerNetwork": {
6
6
  "type": "header",
7
7
  "text": "headerNetwork",
8
- "size": 3
8
+ "size": 4
9
9
  },
10
10
  "networkInterface": {
11
11
  "type": "ip",
@@ -24,7 +24,7 @@
24
24
  "newLine": true,
25
25
  "type": "header",
26
26
  "text": "headerDevices",
27
- "size": 3
27
+ "size": 4
28
28
  },
29
29
  "_devicesHelp": {
30
30
  "type": "staticText",
@@ -116,13 +116,19 @@ class FakerokuDeviceManagement extends import_dm_utils.DeviceManagement {
116
116
  /**
117
117
  * Read the device list from the live config object.
118
118
  *
119
- * @returns the configured devices, or an empty list
119
+ * @returns the configured devices (normalised), or an empty list
120
120
  */
121
121
  async readDevices() {
122
122
  var _a;
123
123
  const obj = await this.adapter.getForeignObjectAsync(this.objId);
124
124
  const devices = (_a = obj == null ? void 0 : obj.native) == null ? void 0 : _a.devices;
125
- return Array.isArray(devices) ? devices : [];
125
+ if (!Array.isArray(devices)) {
126
+ return [];
127
+ }
128
+ return devices.filter((d) => typeof d === "object" && d !== null).map((d) => {
129
+ const clean = cleanDevice(d);
130
+ return typeof d.uuid === "string" && d.uuid ? { ...clean, uuid: d.uuid } : clean;
131
+ });
126
132
  }
127
133
  /**
128
134
  * Persist the device list. Writing `native.*` restarts the adapter, which
@@ -146,6 +152,8 @@ class FakerokuDeviceManagement extends import_dm_utils.DeviceManagement {
146
152
  * Build one device card. The model (Player/TV) and the ECP port each get their
147
153
  * own line — the port via `identifier` (labelled in getInstanceInfo). No
148
154
  * manufacturer line: it is always "Roku" and tells the user nothing for an emulator.
155
+ * The row comes from readDevices(), so the port is already a number; only the name
156
+ * can still be empty (a row saved without one) and gets a numbered stand-in.
149
157
  *
150
158
  * @param device the stored device
151
159
  * @param index its list position — the (per-session stable) card id
@@ -156,7 +164,7 @@ class FakerokuDeviceManagement extends import_dm_utils.DeviceManagement {
156
164
  return {
157
165
  id: String(index),
158
166
  name: device.name || `Roku ${index + 1}`,
159
- identifier: String(device.port || import_constants.DEFAULT_ECP_PORT),
167
+ identifier: String(device.port),
160
168
  model: kind,
161
169
  actions: [
162
170
  {
@@ -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/** One emulated Roku as stored in the adapter's native.devices. */\ninterface RokuDeviceConfig {\n name: string;\n port: number;\n type: DeviceType;\n /** Stable SSDP identity \u2014 carried through edits so the controller pairing survives. */\n uuid?: string;\n}\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, 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 return Array.isArray(devices) ? (devices as RokuDeviceConfig[]) : [];\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 *\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 || DEFAULT_ECP_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 } 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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",
6
6
  "names": []
7
7
  }
@@ -32,6 +32,8 @@ __export(ssdp_responder_exports, {
32
32
  });
33
33
  module.exports = __toCommonJS(ssdp_responder_exports);
34
34
  var dgram = __toESM(require("node:dgram"));
35
+ var import_errors = require("../lib/errors");
36
+ var import_lan_guard = require("../lib/lan-guard");
35
37
  var import_ssdp_messages = require("./ssdp-messages");
36
38
  const SSDP_PORT = 1900;
37
39
  const MULTICAST_ADDR = "239.255.255.250";
@@ -60,7 +62,7 @@ class RokuSsdpResponder {
60
62
  socket.setMulticastInterface(this.config.bindIp);
61
63
  } catch (e) {
62
64
  this.config.logger.warn(
63
- `SSDP: could not pin multicast egress to ${this.config.bindIp}: ${errMsg(e)} \u2014 NOTIFY may use the default interface`
65
+ `SSDP: could not pin multicast egress to ${this.config.bindIp}: ${(0, import_errors.errText)(e)} \u2014 NOTIFY may use the default interface`
64
66
  );
65
67
  }
66
68
  }
@@ -102,7 +104,7 @@ class RokuSsdpResponder {
102
104
  socket.addMembership(MULTICAST_ADDR, iface);
103
105
  } catch (e) {
104
106
  this.config.logger.warn(
105
- `SSDP multicast join failed on ${iface != null ? iface : "default interface"}: ${errMsg(e)} \u2014 discovery may be incomplete`
107
+ `SSDP multicast join failed on ${iface != null ? iface : "default interface"}: ${(0, import_errors.errText)(e)} \u2014 discovery may be incomplete`
106
108
  );
107
109
  }
108
110
  }
@@ -126,6 +128,10 @@ class RokuSsdpResponder {
126
128
  if (!(0, import_ssdp_messages.matchesRokuSearch)(text)) {
127
129
  return;
128
130
  }
131
+ if (!(0, import_lan_guard.isLanClient)(address)) {
132
+ this.config.logger.debug(`SSDP search from non-LAN ${address} ignored`);
133
+ return;
134
+ }
129
135
  for (const device of this.config.devices) {
130
136
  const response = Buffer.from((0, import_ssdp_messages.buildSearchResponse)(device, this.config.advertiseIp));
131
137
  (_a = this.socket) == null ? void 0 : _a.send(response, port, address, (err) => {
@@ -160,9 +166,6 @@ class RokuSsdpResponder {
160
166
  }
161
167
  }
162
168
  }
163
- function errMsg(e) {
164
- return e instanceof Error ? e.message : String(e);
165
- }
166
169
  // Annotate the CommonJS export names for ESM import in node:
167
170
  0 && (module.exports = {
168
171
  RokuSsdpResponder
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/discovery/ssdp-responder.ts"],
4
- "sourcesContent": ["import * as dgram from \"node:dgram\";\nimport type { AdapterLogger } from \"../lib/logger\";\nimport { buildAliveNotify, buildSearchResponse, matchesRokuSearch, type RokuAdvert } from \"./ssdp-messages\";\n\nconst SSDP_PORT = 1900;\nconst MULTICAST_ADDR = \"239.255.255.250\";\n\n/** Configuration for the Roku SSDP responder. */\nexport interface RokuSsdpResponderConfig {\n /** Emulated Rokus to answer for. */\n devices: RokuAdvert[];\n /**\n * Interface IP the outgoing multicast (NOTIFY) is pinned to, or `undefined` to\n * let the OS default route decide (the auto case). A concrete value is only\n * needed on multi-homed hosts \u2014 see the class doc.\n */\n bindIp: string | undefined;\n /** Routable IP announced in the SSDP LOCATION so the controller reaches the ECP server \u2014 never `0.0.0.0`. */\n advertiseIp: string;\n /**\n * Interface IPs to join the multicast group on. With a concrete interface\n * selected this is just that one; in the auto case it is every routable IPv4\n * the host has, so a multi-homed host hears M-SEARCH on all its LANs (like the\n * emulator siblings, which listen on every interface). Empty \u2192 OS default only.\n */\n membershipInterfaces: string[];\n /** Logger. */\n logger: AdapterLogger;\n /**\n * Called at most once if the socket dies AFTER a successful start \u2014 a runtime\n * error the responder cannot recover from. Lets the adapter stop announcing\n * into a dead socket instead of showing a healthy instance with dead discovery.\n */\n onFatalError?: (err: Error) => void;\n}\n\n/**\n * Roku SSDP responder. Answers M-SEARCH(roku:ecp) with Roku's exact response\n * format (built by hand \u2014 node-ssdp appends a `::device` suffix to the USN that\n * Roku never uses) and can proactively announce ssdp:alive.\n *\n * The multicast group is joined on {@link RokuSsdpResponderConfig.membershipInterfaces}\n * \u2014 every routable interface in the auto case, or the single selected one. The\n * outgoing NOTIFY is pinned to {@link RokuSsdpResponderConfig.bindIp} when a\n * concrete interface is chosen (binding sets only the source address, not the\n * multicast egress interface); in the auto case the OS default route is used, which\n * is the honest choice when the user has not selected an interface. The LOCATION\n * always carries the routable {@link RokuSsdpResponderConfig.advertiseIp}.\n *\n * Owns no timers: the adapter drives {@link announce} on a managed interval and\n * bounds {@link start} with a managed timeout.\n */\nexport class RokuSsdpResponder {\n private socket: dgram.Socket | undefined;\n private fatalReported = false;\n\n /**\n * @param config responder configuration\n */\n public constructor(private readonly config: RokuSsdpResponderConfig) {}\n\n /** Bind on 1900, join multicast on the selected interface(s), start answering. Rejects on bind error. */\n public async start(): Promise<void> {\n const socket = dgram.createSocket({ type: \"udp4\", reuseAddr: true });\n this.socket = socket;\n\n await new Promise<void>((resolve, reject) => {\n const onBindError = (err: Error): void => reject(err);\n socket.once(\"error\", onBindError);\n socket.bind(SSDP_PORT, () => {\n socket.removeListener(\"error\", onBindError);\n this.joinMulticast(socket);\n // Pin OUTGOING multicast (NOTIFY) to the chosen interface. Binding the socket\n // only sets the source address; the multicast egress interface is IP_MULTICAST_IF\n // (Node dgram docs), so without this a proactive NOTIFY can leave the wrong NIC on\n // a multi-homed host despite an explicit selection. Only when a concrete interface\n // is chosen \u2014 in the auto case the OS default route is the honest choice.\n if (this.config.bindIp) {\n try {\n socket.setMulticastInterface(this.config.bindIp);\n } catch (e) {\n this.config.logger.warn(\n `SSDP: could not pin multicast egress to ${this.config.bindIp}: ${errMsg(e)} \u2014 NOTIFY may use the default interface`,\n );\n }\n }\n socket.on(\"error\", (err: Error) => this.onSocketError(err));\n socket.on(\"message\", (msg, rinfo) => this.onMessage(msg.toString(\"utf8\"), rinfo.address, rinfo.port));\n resolve();\n });\n });\n\n const join = this.config.membershipInterfaces.length ? this.config.membershipInterfaces.join(\", \") : \"default\";\n this.config.logger.debug(\n `Roku SSDP responder on :${SSDP_PORT}, advertising ${this.config.advertiseIp} (join: ${join})`,\n );\n }\n\n /**\n * Join the multicast group on each selected interface, or on the OS default when\n * none is known.\n *\n * @param socket the bound SSDP socket\n */\n private joinMulticast(socket: dgram.Socket): void {\n const ifaces = this.config.membershipInterfaces;\n if (ifaces.length === 0) {\n this.tryJoin(socket, undefined);\n return;\n }\n for (const ip of ifaces) {\n this.tryJoin(socket, ip);\n }\n }\n\n /**\n * Join the group on one interface; a failure warns but does not throw, so one bad\n * interface cannot stop the responder.\n *\n * @param socket the bound SSDP socket\n * @param iface the interface IP to join on, or undefined for the OS default\n */\n private tryJoin(socket: dgram.Socket, iface: string | undefined): void {\n try {\n socket.addMembership(MULTICAST_ADDR, iface);\n } catch (e) {\n // A join failure is usually an OS routing issue (the chosen interface is not\n // in the multicast table). Don't die: a paired controller still reaches the\n // ECP port by the advertised IP, and NOTIFY may still leave via the default\n // route. Warn so the \"no discovery\" symptom is findable.\n this.config.logger.warn(\n `SSDP multicast join failed on ${iface ?? \"default interface\"}: ${errMsg(e)} \u2014 discovery may be incomplete`,\n );\n }\n }\n\n /**\n * A socket error after a good start \u2014 discovery is dead. Close the socket and tell\n * the adapter once so it can stop announcing and revise its connection state.\n *\n * @param err the socket error\n */\n private onSocketError(err: Error): void {\n this.config.logger.error(`SSDP socket error: ${err.message}`);\n const notify = this.config.onFatalError;\n this.stop();\n if (notify && !this.fatalReported) {\n this.fatalReported = true;\n notify(err);\n }\n }\n\n private onMessage(text: string, address: string, port: number): void {\n if (!matchesRokuSearch(text)) {\n return;\n }\n for (const device of this.config.devices) {\n const response = Buffer.from(buildSearchResponse(device, this.config.advertiseIp));\n this.socket?.send(response, port, address, err => {\n if (err) {\n this.config.logger.warn(`SSDP response send failed: ${err.message}`);\n }\n });\n }\n }\n\n /** Send one proactive ssdp:alive burst for every device. The adapter calls this on a managed interval. */\n public announce(): void {\n if (!this.socket) {\n return;\n }\n for (const device of this.config.devices) {\n const notify = Buffer.from(buildAliveNotify(device, this.config.advertiseIp));\n this.socket.send(notify, SSDP_PORT, MULTICAST_ADDR, err => {\n if (err) {\n this.config.logger.debug(`SSDP NOTIFY send failed: ${err.message}`);\n }\n });\n }\n }\n\n /** Synchronous close \u2014 safe to call from onUnload. Closing the socket drops its memberships. */\n public stop(): void {\n if (this.socket) {\n try {\n this.socket.close();\n } catch {\n // socket already closed\n }\n this.socket = undefined;\n }\n }\n}\n\n/**\n * Message of an unknown thrown value.\n *\n * @param e the caught value\n * @returns its message or a string form\n */\nfunction errMsg(e: unknown): string {\n return e instanceof Error ? e.message : String(e);\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import * as dgram from \"node:dgram\";\nimport { errText } from \"../lib/errors\";\nimport { isLanClient } from \"../lib/lan-guard\";\nimport type { AdapterLogger } from \"../lib/logger\";\nimport { buildAliveNotify, buildSearchResponse, matchesRokuSearch, type RokuAdvert } from \"./ssdp-messages\";\n\nconst SSDP_PORT = 1900;\nconst MULTICAST_ADDR = \"239.255.255.250\";\n\n/** Configuration for the Roku SSDP responder. */\nexport interface RokuSsdpResponderConfig {\n /** Emulated Rokus to answer for. */\n devices: RokuAdvert[];\n /**\n * Interface IP the outgoing multicast (NOTIFY) is pinned to, or `undefined` to\n * let the OS default route decide (the auto case). A concrete value is only\n * needed on multi-homed hosts \u2014 see the class doc.\n */\n bindIp: string | undefined;\n /** Routable IP announced in the SSDP LOCATION so the controller reaches the ECP server \u2014 never `0.0.0.0`. */\n advertiseIp: string;\n /**\n * Interface IPs to join the multicast group on. With a concrete interface\n * selected this is just that one; in the auto case it is every routable IPv4\n * the host has, so a multi-homed host hears M-SEARCH on all its LANs (like the\n * emulator siblings, which listen on every interface). Empty \u2192 OS default only.\n */\n membershipInterfaces: string[];\n /** Logger. */\n logger: AdapterLogger;\n /**\n * Called at most once if the socket dies AFTER a successful start \u2014 a runtime\n * error the responder cannot recover from. Lets the adapter stop announcing\n * into a dead socket instead of showing a healthy instance with dead discovery.\n */\n onFatalError?: (err: Error) => void;\n}\n\n/**\n * Roku SSDP responder. Answers M-SEARCH(roku:ecp) with Roku's exact response\n * format (built by hand \u2014 node-ssdp appends a `::device` suffix to the USN that\n * Roku never uses) and can proactively announce ssdp:alive.\n *\n * The multicast group is joined on {@link RokuSsdpResponderConfig.membershipInterfaces}\n * \u2014 every routable interface in the auto case, or the single selected one. The\n * outgoing NOTIFY is pinned to {@link RokuSsdpResponderConfig.bindIp} when a\n * concrete interface is chosen (binding sets only the source address, not the\n * multicast egress interface); in the auto case the OS default route is used, which\n * is the honest choice when the user has not selected an interface. The LOCATION\n * always carries the routable {@link RokuSsdpResponderConfig.advertiseIp}.\n *\n * Owns no timers: the adapter drives {@link announce} on a managed interval and\n * bounds {@link start} with a managed timeout.\n */\nexport class RokuSsdpResponder {\n private socket: dgram.Socket | undefined;\n private fatalReported = false;\n\n /**\n * @param config responder configuration\n */\n public constructor(private readonly config: RokuSsdpResponderConfig) {}\n\n /** Bind on 1900, join multicast on the selected interface(s), start answering. Rejects on bind error. */\n public async start(): Promise<void> {\n const socket = dgram.createSocket({ type: \"udp4\", reuseAddr: true });\n this.socket = socket;\n\n await new Promise<void>((resolve, reject) => {\n const onBindError = (err: Error): void => reject(err);\n socket.once(\"error\", onBindError);\n socket.bind(SSDP_PORT, () => {\n socket.removeListener(\"error\", onBindError);\n this.joinMulticast(socket);\n // Pin OUTGOING multicast (NOTIFY) to the chosen interface. Binding the socket\n // only sets the source address; the multicast egress interface is IP_MULTICAST_IF\n // (Node dgram docs), so without this a proactive NOTIFY can leave the wrong NIC on\n // a multi-homed host despite an explicit selection. Only when a concrete interface\n // is chosen \u2014 in the auto case the OS default route is the honest choice.\n if (this.config.bindIp) {\n try {\n socket.setMulticastInterface(this.config.bindIp);\n } catch (e) {\n this.config.logger.warn(\n `SSDP: could not pin multicast egress to ${this.config.bindIp}: ${errText(e)} \u2014 NOTIFY may use the default interface`,\n );\n }\n }\n socket.on(\"error\", (err: Error) => this.onSocketError(err));\n socket.on(\"message\", (msg, rinfo) => this.onMessage(msg.toString(\"utf8\"), rinfo.address, rinfo.port));\n resolve();\n });\n });\n\n const join = this.config.membershipInterfaces.length ? this.config.membershipInterfaces.join(\", \") : \"default\";\n this.config.logger.debug(\n `Roku SSDP responder on :${SSDP_PORT}, advertising ${this.config.advertiseIp} (join: ${join})`,\n );\n }\n\n /**\n * Join the multicast group on each selected interface, or on the OS default when\n * none is known.\n *\n * @param socket the bound SSDP socket\n */\n private joinMulticast(socket: dgram.Socket): void {\n const ifaces = this.config.membershipInterfaces;\n if (ifaces.length === 0) {\n this.tryJoin(socket, undefined);\n return;\n }\n for (const ip of ifaces) {\n this.tryJoin(socket, ip);\n }\n }\n\n /**\n * Join the group on one interface; a failure warns but does not throw, so one bad\n * interface cannot stop the responder.\n *\n * @param socket the bound SSDP socket\n * @param iface the interface IP to join on, or undefined for the OS default\n */\n private tryJoin(socket: dgram.Socket, iface: string | undefined): void {\n try {\n socket.addMembership(MULTICAST_ADDR, iface);\n } catch (e) {\n // A join failure is usually an OS routing issue (the chosen interface is not\n // in the multicast table). Don't die: a paired controller still reaches the\n // ECP port by the advertised IP, and NOTIFY may still leave via the default\n // route. Warn so the \"no discovery\" symptom is findable.\n this.config.logger.warn(\n `SSDP multicast join failed on ${iface ?? \"default interface\"}: ${errText(e)} \u2014 discovery may be incomplete`,\n );\n }\n }\n\n /**\n * A socket error after a good start \u2014 discovery is dead. Close the socket and tell\n * the adapter once so it can stop announcing and revise its connection state.\n *\n * @param err the socket error\n */\n private onSocketError(err: Error): void {\n this.config.logger.error(`SSDP socket error: ${err.message}`);\n const notify = this.config.onFatalError;\n this.stop();\n if (notify && !this.fatalReported) {\n this.fatalReported = true;\n notify(err);\n }\n }\n\n private onMessage(text: string, address: string, port: number): void {\n if (!matchesRokuSearch(text)) {\n return;\n }\n // Port 1900 is bound on every interface. A search from outside the LAN gets no\n // answer: the ECP server would refuse that client anyway, and replying to a\n // spoofed source would make the emulator a small reflection amplifier.\n if (!isLanClient(address)) {\n this.config.logger.debug(`SSDP search from non-LAN ${address} ignored`);\n return;\n }\n for (const device of this.config.devices) {\n const response = Buffer.from(buildSearchResponse(device, this.config.advertiseIp));\n this.socket?.send(response, port, address, err => {\n if (err) {\n this.config.logger.warn(`SSDP response send failed: ${err.message}`);\n }\n });\n }\n }\n\n /** Send one proactive ssdp:alive burst for every device. The adapter calls this on a managed interval. */\n public announce(): void {\n if (!this.socket) {\n return;\n }\n for (const device of this.config.devices) {\n const notify = Buffer.from(buildAliveNotify(device, this.config.advertiseIp));\n this.socket.send(notify, SSDP_PORT, MULTICAST_ADDR, err => {\n if (err) {\n this.config.logger.debug(`SSDP NOTIFY send failed: ${err.message}`);\n }\n });\n }\n }\n\n /** Synchronous close \u2014 safe to call from onUnload. Closing the socket drops its memberships. */\n public stop(): void {\n if (this.socket) {\n try {\n this.socket.close();\n } catch {\n // socket already closed\n }\n this.socket = undefined;\n }\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -24,7 +24,7 @@ __export(device_info_exports, {
24
24
  buildDeviceInfoXml: () => buildDeviceInfoXml
25
25
  });
26
26
  module.exports = __toCommonJS(device_info_exports);
27
- const SOFTWARE_VERSION = "14.1.4";
27
+ const SOFTWARE_VERSION = "15.0.0";
28
28
  const SOFTWARE_BUILD = "4200";
29
29
  const PROFILES = {
30
30
  player: {
@@ -51,7 +51,7 @@ const DEFAULT_APPS = [
51
51
  { id: "291097", name: "Disney Plus" }
52
52
  ];
53
53
  function xmlEscape(s) {
54
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
54
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
55
55
  }
56
56
  function buildDescXml(device, friendlyName, type) {
57
57
  const p = PROFILES[type];
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/ecp/device-info.ts"],
4
- "sourcesContent": ["import type { RokuAdvert } from \"../discovery/ssdp-messages\";\nimport type { DeviceType } from \"./state-model\";\n\n/**\n * A fake but CURRENT Roku OS version. This is the pairing lever: a modern remote\n * like a Sofabaton reads /query/device-info at pairing and rejects a too-old\n * version (HA forum #501046) \u2014 emulated_roku hardcodes 7.5.0 / \"Roku 4\" (2016) and\n * fails there, the old fakeroku had no device-info at all. The exact \"new enough\"\n * value is not documented; verify against real hardware (a Sofabaton) and bump if\n * needed. (The official Roku app is a different case: it uses ECP-2 and never reads\n * device-info \u2014 see the README note.)\n */\nconst SOFTWARE_VERSION = \"14.1.4\";\nconst SOFTWARE_BUILD = \"4200\";\n\n/** Per-device-type identity + capability flags advertised in device-info. */\ninterface DeviceProfile {\n modelName: string;\n modelNumber: string;\n deviceType: string;\n isTv: boolean;\n supportsTvPowerControl: boolean;\n supportsAudioVolumeControl: boolean;\n}\n\n/**\n * The two emulated device profiles. A player is a streaming box (no volume/power/\n * channel/input keys); a TV additionally controls volume, power, channel and input,\n * which is exactly what the extra ECP keys and the capability flags reflect.\n */\nconst PROFILES: Record<DeviceType, DeviceProfile> = {\n player: {\n modelName: \"Roku Ultra\",\n modelNumber: \"4800X\",\n deviceType: \"urn:roku-com:device:player:1-0\",\n isTv: false,\n supportsTvPowerControl: false,\n supportsAudioVolumeControl: false,\n },\n tv: {\n modelName: \"Roku TV\",\n modelNumber: \"C4A4X\",\n deviceType: \"urn:roku-com:device:tv:1-0\",\n isTv: true,\n supportsTvPowerControl: true,\n supportsAudioVolumeControl: true,\n },\n};\n\n/** One configured app entry for /query/apps. */\nexport interface AppEntry {\n /** Roku app id. */\n id: string;\n /** Display name. */\n name: string;\n}\n\n/** A minimal, current default app list served at /query/apps (no dead 2015 services). */\nexport const DEFAULT_APPS: AppEntry[] = [\n { id: \"12\", name: \"Netflix\" },\n { id: \"837\", name: \"YouTube\" },\n { id: \"13\", name: \"Prime Video\" },\n { id: \"291097\", name: \"Disney Plus\" },\n];\n\n/**\n * Escape XML text content.\n *\n * @param s the raw text\n * @returns the escaped text\n */\nfunction xmlEscape(s: string): string {\n return s.replace(/&/g, \"&amp;\").replace(/</g, \"&lt;\").replace(/>/g, \"&gt;\");\n}\n\n/**\n * UPnP root description served at `GET /` \u2014 the device \"business card\".\n *\n * @param device the emulated Roku\n * @param friendlyName the configured device name\n * @param type the emulated device type (player / tv)\n * @returns the UPnP root XML\n */\nexport function buildDescXml(device: RokuAdvert, friendlyName: string, type: DeviceType): string {\n const p = PROFILES[type];\n return `<?xml version=\"1.0\" encoding=\"UTF-8\" ?>\n<root xmlns=\"urn:schemas-upnp-org:device-1-0\">\n <specVersion><major>1</major><minor>0</minor></specVersion>\n <device>\n <deviceType>${p.deviceType}</deviceType>\n <friendlyName>${xmlEscape(friendlyName)}</friendlyName>\n <manufacturer>Roku</manufacturer>\n <modelName>${p.modelName}</modelName>\n <modelNumber>${p.modelNumber}</modelNumber>\n <serialNumber>${device.uuid}</serialNumber>\n <UDN>uuid:roku:ecp:${device.uuid}</UDN>\n <serviceList>\n <service>\n <serviceType>urn:roku-com:service:ecp:1</serviceType>\n <serviceId>urn:roku-com:serviceId:ecp1-0</serviceId>\n <controlURL/>\n <eventSubURL/>\n <SCPDURL>ecp_SCPD.xml</SCPDURL>\n </service>\n </serviceList>\n </device>\n</root>`;\n}\n\n/**\n * The `/query/device-info` payload \u2014 read by controllers at pairing time and\n * checked for a current version. The `is-tv` / `supports-*` flags follow the\n * device type so a controller (e.g. Harmony) offers the matching key set.\n *\n * @param device the emulated Roku\n * @param friendlyName the configured device name\n * @param type the emulated device type (player / tv)\n * @returns the device-info XML\n */\nexport function buildDeviceInfoXml(device: RokuAdvert, friendlyName: string, type: DeviceType): string {\n const p = PROFILES[type];\n return `<device-info>\n <udn>${device.uuid}</udn>\n <serial-number>${device.uuid}</serial-number>\n <device-id>${device.uuid}</device-id>\n <vendor-name>Roku</vendor-name>\n <model-name>${p.modelName}</model-name>\n <model-number>${p.modelNumber}</model-number>\n <model-region>US</model-region>\n <friendly-device-name>${xmlEscape(friendlyName)}</friendly-device-name>\n <is-tv>${p.isTv}</is-tv>\n <is-stick>false</is-stick>\n <software-version>${SOFTWARE_VERSION}</software-version>\n <software-build>${SOFTWARE_BUILD}</software-build>\n <power-mode>PowerOn</power-mode>\n <supports-suspend>false</supports-suspend>\n <supports-find-remote>true</supports-find-remote>\n <supports-tv-power-control>${p.supportsTvPowerControl}</supports-tv-power-control>\n <supports-audio-volume-control>${p.supportsAudioVolumeControl}</supports-audio-volume-control>\n <developer-enabled>false</developer-enabled>\n <search-enabled>true</search-enabled>\n <voice-search-enabled>true</voice-search-enabled>\n <notifications-enabled>true</notifications-enabled>\n <headphones-connected>false</headphones-connected>\n</device-info>`;\n}\n\n/**\n * The `/query/apps` list.\n *\n * @param apps the configured apps\n * @returns the apps XML\n */\nexport function buildAppsXml(apps: AppEntry[]): string {\n const entries = apps.map(a => ` <app id=\"${xmlEscape(a.id)}\">${xmlEscape(a.name)}</app>`).join(\"\\n\");\n return `<apps>\\n${entries}\\n</apps>`;\n}\n"],
5
- "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAYA,MAAM,mBAAmB;AACzB,MAAM,iBAAiB;AAiBvB,MAAM,WAA8C;AAAA,EAClD,QAAQ;AAAA,IACN,WAAW;AAAA,IACX,aAAa;AAAA,IACb,YAAY;AAAA,IACZ,MAAM;AAAA,IACN,wBAAwB;AAAA,IACxB,4BAA4B;AAAA,EAC9B;AAAA,EACA,IAAI;AAAA,IACF,WAAW;AAAA,IACX,aAAa;AAAA,IACb,YAAY;AAAA,IACZ,MAAM;AAAA,IACN,wBAAwB;AAAA,IACxB,4BAA4B;AAAA,EAC9B;AACF;AAWO,MAAM,eAA2B;AAAA,EACtC,EAAE,IAAI,MAAM,MAAM,UAAU;AAAA,EAC5B,EAAE,IAAI,OAAO,MAAM,UAAU;AAAA,EAC7B,EAAE,IAAI,MAAM,MAAM,cAAc;AAAA,EAChC,EAAE,IAAI,UAAU,MAAM,cAAc;AACtC;AAQA,SAAS,UAAU,GAAmB;AACpC,SAAO,EAAE,QAAQ,MAAM,OAAO,EAAE,QAAQ,MAAM,MAAM,EAAE,QAAQ,MAAM,MAAM;AAC5E;AAUO,SAAS,aAAa,QAAoB,cAAsB,MAA0B;AAC/F,QAAM,IAAI,SAAS,IAAI;AACvB,SAAO;AAAA;AAAA;AAAA;AAAA,kBAIS,EAAE,UAAU;AAAA,oBACV,UAAU,YAAY,CAAC;AAAA;AAAA,iBAE1B,EAAE,SAAS;AAAA,mBACT,EAAE,WAAW;AAAA,oBACZ,OAAO,IAAI;AAAA,yBACN,OAAO,IAAI;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAYpC;AAYO,SAAS,mBAAmB,QAAoB,cAAsB,MAA0B;AACrG,QAAM,IAAI,SAAS,IAAI;AACvB,SAAO;AAAA,SACA,OAAO,IAAI;AAAA,mBACD,OAAO,IAAI;AAAA,eACf,OAAO,IAAI;AAAA;AAAA,gBAEV,EAAE,SAAS;AAAA,kBACT,EAAE,WAAW;AAAA;AAAA,0BAEL,UAAU,YAAY,CAAC;AAAA,WACtC,EAAE,IAAI;AAAA;AAAA,sBAEK,gBAAgB;AAAA,oBAClB,cAAc;AAAA;AAAA;AAAA;AAAA,+BAIH,EAAE,sBAAsB;AAAA,mCACpB,EAAE,0BAA0B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAO/D;AAQO,SAAS,aAAa,MAA0B;AACrD,QAAM,UAAU,KAAK,IAAI,OAAK,cAAc,UAAU,EAAE,EAAE,CAAC,KAAK,UAAU,EAAE,IAAI,CAAC,QAAQ,EAAE,KAAK,IAAI;AACpG,SAAO;AAAA,EAAW,OAAO;AAAA;AAC3B;",
4
+ "sourcesContent": ["import type { RokuAdvert } from \"../discovery/ssdp-messages\";\nimport type { DeviceType } from \"./state-model\";\n\n/**\n * A fake but CURRENT Roku OS version. This is the pairing lever: a modern remote\n * like a Sofabaton reads /query/device-info at pairing and rejects a too-old\n * version (HA forum #501046) \u2014 emulated_roku hardcodes 7.5.0 / \"Roku 4\" (2016) and\n * fails there, the old fakeroku had no device-info at all.\n *\n * Kept at the newest Roku OS major (checked 2026-09-01: Roku OS 15.0, released\n * 2025-10-15 \u2014 support.roku.com release notes + Wikipedia \"Roku OS\"). The exact\n * threshold Sofabaton applies is documented nowhere (thread, Sofabaton docs and\n * their 2024-12 Roku notice name none); staying current is the only lever we have.\n * The build number is cosmetic \u2014 no controller is known to read it. (The official\n * Roku app is a different case: it uses ECP-2 and never reads device-info.)\n */\nconst SOFTWARE_VERSION = \"15.0.0\";\nconst SOFTWARE_BUILD = \"4200\";\n\n/** Per-device-type identity + capability flags advertised in device-info. */\ninterface DeviceProfile {\n modelName: string;\n modelNumber: string;\n deviceType: string;\n isTv: boolean;\n supportsTvPowerControl: boolean;\n supportsAudioVolumeControl: boolean;\n}\n\n/**\n * The two emulated device profiles. A player is a streaming box (no volume/power/\n * channel/input keys); a TV additionally controls volume, power, channel and input,\n * which is exactly what the extra ECP keys and the capability flags reflect.\n */\nconst PROFILES: Record<DeviceType, DeviceProfile> = {\n player: {\n modelName: \"Roku Ultra\",\n modelNumber: \"4800X\",\n deviceType: \"urn:roku-com:device:player:1-0\",\n isTv: false,\n supportsTvPowerControl: false,\n supportsAudioVolumeControl: false,\n },\n tv: {\n modelName: \"Roku TV\",\n modelNumber: \"C4A4X\",\n deviceType: \"urn:roku-com:device:tv:1-0\",\n isTv: true,\n supportsTvPowerControl: true,\n supportsAudioVolumeControl: true,\n },\n};\n\n/** One configured app entry for /query/apps. */\nexport interface AppEntry {\n /** Roku app id. */\n id: string;\n /** Display name. */\n name: string;\n}\n\n/** A minimal, current default app list served at /query/apps (no dead 2015 services). */\nexport const DEFAULT_APPS: AppEntry[] = [\n { id: \"12\", name: \"Netflix\" },\n { id: \"837\", name: \"YouTube\" },\n { id: \"13\", name: \"Prime Video\" },\n { id: \"291097\", name: \"Disney Plus\" },\n];\n\n/**\n * Escape XML text content and attribute values.\n *\n * @param s the raw text\n * @returns the escaped text\n */\nfunction xmlEscape(s: string): string {\n return s.replace(/&/g, \"&amp;\").replace(/</g, \"&lt;\").replace(/>/g, \"&gt;\").replace(/\"/g, \"&quot;\");\n}\n\n/**\n * UPnP root description served at `GET /` \u2014 the device \"business card\".\n *\n * @param device the emulated Roku\n * @param friendlyName the configured device name\n * @param type the emulated device type (player / tv)\n * @returns the UPnP root XML\n */\nexport function buildDescXml(device: RokuAdvert, friendlyName: string, type: DeviceType): string {\n const p = PROFILES[type];\n return `<?xml version=\"1.0\" encoding=\"UTF-8\" ?>\n<root xmlns=\"urn:schemas-upnp-org:device-1-0\">\n <specVersion><major>1</major><minor>0</minor></specVersion>\n <device>\n <deviceType>${p.deviceType}</deviceType>\n <friendlyName>${xmlEscape(friendlyName)}</friendlyName>\n <manufacturer>Roku</manufacturer>\n <modelName>${p.modelName}</modelName>\n <modelNumber>${p.modelNumber}</modelNumber>\n <serialNumber>${device.uuid}</serialNumber>\n <UDN>uuid:roku:ecp:${device.uuid}</UDN>\n <serviceList>\n <service>\n <serviceType>urn:roku-com:service:ecp:1</serviceType>\n <serviceId>urn:roku-com:serviceId:ecp1-0</serviceId>\n <controlURL/>\n <eventSubURL/>\n <SCPDURL>ecp_SCPD.xml</SCPDURL>\n </service>\n </serviceList>\n </device>\n</root>`;\n}\n\n/**\n * The `/query/device-info` payload \u2014 read by controllers at pairing time and\n * checked for a current version. The `is-tv` / `supports-*` flags follow the\n * device type so a controller (e.g. Harmony) offers the matching key set.\n *\n * @param device the emulated Roku\n * @param friendlyName the configured device name\n * @param type the emulated device type (player / tv)\n * @returns the device-info XML\n */\nexport function buildDeviceInfoXml(device: RokuAdvert, friendlyName: string, type: DeviceType): string {\n const p = PROFILES[type];\n return `<device-info>\n <udn>${device.uuid}</udn>\n <serial-number>${device.uuid}</serial-number>\n <device-id>${device.uuid}</device-id>\n <vendor-name>Roku</vendor-name>\n <model-name>${p.modelName}</model-name>\n <model-number>${p.modelNumber}</model-number>\n <model-region>US</model-region>\n <friendly-device-name>${xmlEscape(friendlyName)}</friendly-device-name>\n <is-tv>${p.isTv}</is-tv>\n <is-stick>false</is-stick>\n <software-version>${SOFTWARE_VERSION}</software-version>\n <software-build>${SOFTWARE_BUILD}</software-build>\n <power-mode>PowerOn</power-mode>\n <supports-suspend>false</supports-suspend>\n <supports-find-remote>true</supports-find-remote>\n <supports-tv-power-control>${p.supportsTvPowerControl}</supports-tv-power-control>\n <supports-audio-volume-control>${p.supportsAudioVolumeControl}</supports-audio-volume-control>\n <developer-enabled>false</developer-enabled>\n <search-enabled>true</search-enabled>\n <voice-search-enabled>true</voice-search-enabled>\n <notifications-enabled>true</notifications-enabled>\n <headphones-connected>false</headphones-connected>\n</device-info>`;\n}\n\n/**\n * The `/query/apps` list.\n *\n * @param apps the configured apps\n * @returns the apps XML\n */\nexport function buildAppsXml(apps: AppEntry[]): string {\n const entries = apps.map(a => ` <app id=\"${xmlEscape(a.id)}\">${xmlEscape(a.name)}</app>`).join(\"\\n\");\n return `<apps>\\n${entries}\\n</apps>`;\n}\n"],
5
+ "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAgBA,MAAM,mBAAmB;AACzB,MAAM,iBAAiB;AAiBvB,MAAM,WAA8C;AAAA,EAClD,QAAQ;AAAA,IACN,WAAW;AAAA,IACX,aAAa;AAAA,IACb,YAAY;AAAA,IACZ,MAAM;AAAA,IACN,wBAAwB;AAAA,IACxB,4BAA4B;AAAA,EAC9B;AAAA,EACA,IAAI;AAAA,IACF,WAAW;AAAA,IACX,aAAa;AAAA,IACb,YAAY;AAAA,IACZ,MAAM;AAAA,IACN,wBAAwB;AAAA,IACxB,4BAA4B;AAAA,EAC9B;AACF;AAWO,MAAM,eAA2B;AAAA,EACtC,EAAE,IAAI,MAAM,MAAM,UAAU;AAAA,EAC5B,EAAE,IAAI,OAAO,MAAM,UAAU;AAAA,EAC7B,EAAE,IAAI,MAAM,MAAM,cAAc;AAAA,EAChC,EAAE,IAAI,UAAU,MAAM,cAAc;AACtC;AAQA,SAAS,UAAU,GAAmB;AACpC,SAAO,EAAE,QAAQ,MAAM,OAAO,EAAE,QAAQ,MAAM,MAAM,EAAE,QAAQ,MAAM,MAAM,EAAE,QAAQ,MAAM,QAAQ;AACpG;AAUO,SAAS,aAAa,QAAoB,cAAsB,MAA0B;AAC/F,QAAM,IAAI,SAAS,IAAI;AACvB,SAAO;AAAA;AAAA;AAAA;AAAA,kBAIS,EAAE,UAAU;AAAA,oBACV,UAAU,YAAY,CAAC;AAAA;AAAA,iBAE1B,EAAE,SAAS;AAAA,mBACT,EAAE,WAAW;AAAA,oBACZ,OAAO,IAAI;AAAA,yBACN,OAAO,IAAI;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAYpC;AAYO,SAAS,mBAAmB,QAAoB,cAAsB,MAA0B;AACrG,QAAM,IAAI,SAAS,IAAI;AACvB,SAAO;AAAA,SACA,OAAO,IAAI;AAAA,mBACD,OAAO,IAAI;AAAA,eACf,OAAO,IAAI;AAAA;AAAA,gBAEV,EAAE,SAAS;AAAA,kBACT,EAAE,WAAW;AAAA;AAAA,0BAEL,UAAU,YAAY,CAAC;AAAA,WACtC,EAAE,IAAI;AAAA;AAAA,sBAEK,gBAAgB;AAAA,oBAClB,cAAc;AAAA;AAAA;AAAA;AAAA,+BAIH,EAAE,sBAAsB;AAAA,mCACpB,EAAE,0BAA0B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAO/D;AAQO,SAAS,aAAa,MAA0B;AACrD,QAAM,UAAU,KAAK,IAAI,OAAK,cAAc,UAAU,EAAE,EAAE,CAAC,KAAK,UAAU,EAAE,IAAI,CAAC,QAAQ,EAAE,KAAK,IAAI;AACpG,SAAO;AAAA,EAAW,OAAO;AAAA;AAC3B;",
6
6
  "names": []
7
7
  }
@@ -18,10 +18,12 @@ var __copyProps = (to, from, except, desc) => {
18
18
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
19
  var ecp_command_exports = {};
20
20
  __export(ecp_command_exports, {
21
+ COMMAND_TYPES: () => COMMAND_TYPES,
21
22
  parseEcpCommand: () => parseEcpCommand
22
23
  });
23
24
  module.exports = __toCommonJS(ecp_command_exports);
24
25
  var import_pure_helpers = require("../lib/pure-helpers");
26
+ const COMMAND_TYPES = ["keypress", "keydown", "keyup", "launch", "install", "input", "search"];
25
27
  function parseEcpCommand(method, url) {
26
28
  if (method !== "POST") {
27
29
  return null;
@@ -50,6 +52,7 @@ function parseEcpCommand(method, url) {
50
52
  }
51
53
  // Annotate the CommonJS export names for ESM import in node:
52
54
  0 && (module.exports = {
55
+ COMMAND_TYPES,
53
56
  parseEcpCommand
54
57
  });
55
58
  //# sourceMappingURL=ecp-command.js.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/ecp/ecp-command.ts"],
4
- "sourcesContent": ["import { normalizeKey } from \"../lib/pure-helpers\";\n\n/** The ECP verbs a Roku remote sends via POST. */\nexport type CommandType = \"keypress\" | \"keydown\" | \"keyup\" | \"launch\" | \"install\" | \"input\" | \"search\";\n\n/** A parsed ECP command from the Roku HTTP interface. */\nexport interface CommandEvent {\n /** The ECP verb. */\n type: CommandType;\n /** Normalized key name for key* verbs (e.g. \"Home\", \"Lit_a\"). */\n key?: string;\n /** App id for launch/install. */\n appId?: string;\n /** Raw query text for input/search. */\n text?: string;\n}\n\n/**\n * Parse an ECP request (method + url) into a command event, or null if it is not\n * a POST command (a GET query, an unknown verb, or a missing argument).\n *\n * @param method the HTTP method\n * @param url the request URL (path plus optional query string)\n * @returns the parsed command, or null\n */\nexport function parseEcpCommand(method: string, url: string): CommandEvent | null {\n if (method !== \"POST\") {\n return null;\n }\n const [path, query] = url.split(\"?\");\n const match = path.match(/^\\/([^/]+)(?:\\/(.+))?$/);\n if (!match) {\n return null;\n }\n const verb = match[1];\n const arg = match[2];\n switch (verb) {\n case \"keypress\":\n case \"keydown\":\n case \"keyup\":\n return arg ? { type: verb, key: normalizeKey(arg) } : null;\n case \"launch\":\n case \"install\":\n return arg ? { type: verb, appId: arg } : null;\n case \"input\":\n case \"search\":\n return { type: verb, text: query ?? \"\" };\n default:\n return null;\n }\n}\n"],
5
- "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,0BAA6B;AAyBtB,SAAS,gBAAgB,QAAgB,KAAkC;AAChF,MAAI,WAAW,QAAQ;AACrB,WAAO;AAAA,EACT;AACA,QAAM,CAAC,MAAM,KAAK,IAAI,IAAI,MAAM,GAAG;AACnC,QAAM,QAAQ,KAAK,MAAM,wBAAwB;AACjD,MAAI,CAAC,OAAO;AACV,WAAO;AAAA,EACT;AACA,QAAM,OAAO,MAAM,CAAC;AACpB,QAAM,MAAM,MAAM,CAAC;AACnB,UAAQ,MAAM;AAAA,IACZ,KAAK;AAAA,IACL,KAAK;AAAA,IACL,KAAK;AACH,aAAO,MAAM,EAAE,MAAM,MAAM,SAAK,kCAAa,GAAG,EAAE,IAAI;AAAA,IACxD,KAAK;AAAA,IACL,KAAK;AACH,aAAO,MAAM,EAAE,MAAM,MAAM,OAAO,IAAI,IAAI;AAAA,IAC5C,KAAK;AAAA,IACL,KAAK;AACH,aAAO,EAAE,MAAM,MAAM,MAAM,wBAAS,GAAG;AAAA,IACzC;AACE,aAAO;AAAA,EACX;AACF;",
4
+ "sourcesContent": ["import { normalizeKey } from \"../lib/pure-helpers\";\n\n/** The ECP verbs a Roku remote sends via POST \u2014 also the value list of the `commandType` state. */\nexport const COMMAND_TYPES = [\"keypress\", \"keydown\", \"keyup\", \"launch\", \"install\", \"input\", \"search\"] as const;\n\n/** One ECP verb. */\nexport type CommandType = (typeof COMMAND_TYPES)[number];\n\n/** A parsed ECP command from the Roku HTTP interface. */\nexport interface CommandEvent {\n /** The ECP verb. */\n type: CommandType;\n /** Normalized key name for key* verbs (e.g. \"Home\", \"Lit_a\"). */\n key?: string;\n /** App id for launch/install. */\n appId?: string;\n /** Raw query text for input/search. */\n text?: string;\n}\n\n/**\n * Parse an ECP request (method + url) into a command event, or null if it is not\n * a POST command (a GET query, an unknown verb, or a missing argument).\n *\n * @param method the HTTP method\n * @param url the request URL (path plus optional query string)\n * @returns the parsed command, or null\n */\nexport function parseEcpCommand(method: string, url: string): CommandEvent | null {\n if (method !== \"POST\") {\n return null;\n }\n const [path, query] = url.split(\"?\");\n const match = path.match(/^\\/([^/]+)(?:\\/(.+))?$/);\n if (!match) {\n return null;\n }\n const verb = match[1];\n const arg = match[2];\n switch (verb) {\n case \"keypress\":\n case \"keydown\":\n case \"keyup\":\n return arg ? { type: verb, key: normalizeKey(arg) } : null;\n case \"launch\":\n case \"install\":\n return arg ? { type: verb, appId: arg } : null;\n case \"input\":\n case \"search\":\n return { type: verb, text: query ?? \"\" };\n default:\n return null;\n }\n}\n"],
5
+ "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,0BAA6B;AAGtB,MAAM,gBAAgB,CAAC,YAAY,WAAW,SAAS,UAAU,WAAW,SAAS,QAAQ;AAyB7F,SAAS,gBAAgB,QAAgB,KAAkC;AAChF,MAAI,WAAW,QAAQ;AACrB,WAAO;AAAA,EACT;AACA,QAAM,CAAC,MAAM,KAAK,IAAI,IAAI,MAAM,GAAG;AACnC,QAAM,QAAQ,KAAK,MAAM,wBAAwB;AACjD,MAAI,CAAC,OAAO;AACV,WAAO;AAAA,EACT;AACA,QAAM,OAAO,MAAM,CAAC;AACpB,QAAM,MAAM,MAAM,CAAC;AACnB,UAAQ,MAAM;AAAA,IACZ,KAAK;AAAA,IACL,KAAK;AAAA,IACL,KAAK;AACH,aAAO,MAAM,EAAE,MAAM,MAAM,SAAK,kCAAa,GAAG,EAAE,IAAI;AAAA,IACxD,KAAK;AAAA,IACL,KAAK;AACH,aAAO,MAAM,EAAE,MAAM,MAAM,OAAO,IAAI,IAAI;AAAA,IAC5C,KAAK;AAAA,IACL,KAAK;AACH,aAAO,EAAE,MAAM,MAAM,MAAM,wBAAS,GAAG;AAAA,IACzC;AACE,aAAO;AAAA,EACX;AACF;",
6
6
  "names": []
7
7
  }