iobroker.fakeroku 1.0.0 → 1.2.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 +25 -9
- package/admin/i18n/de.json +1 -0
- package/admin/i18n/en.json +1 -0
- package/admin/i18n/es.json +1 -0
- package/admin/i18n/fr.json +1 -0
- package/admin/i18n/it.json +1 -0
- package/admin/i18n/nl.json +1 -0
- package/admin/i18n/pl.json +1 -0
- package/admin/i18n/pt.json +1 -0
- package/admin/i18n/ru.json +1 -0
- package/admin/i18n/uk.json +1 -0
- package/admin/i18n/zh-cn.json +1 -0
- package/admin/jsonConfig.json +1 -0
- package/build/device-management.js +13 -4
- package/build/device-management.js.map +2 -2
- package/build/discovery/ssdp-responder.js +66 -16
- package/build/discovery/ssdp-responder.js.map +3 -3
- package/build/ecp/device-info.js.map +1 -1
- package/build/ecp/ecp-http-server.js +12 -3
- package/build/ecp/ecp-http-server.js.map +2 -2
- package/build/lib/constants.js +29 -0
- package/build/lib/constants.js.map +7 -0
- package/build/lib/detect-ip.js +20 -5
- package/build/lib/detect-ip.js.map +2 -2
- package/build/lib/logger.js +17 -0
- package/build/lib/logger.js.map +7 -0
- package/build/main.js +121 -29
- package/build/main.js.map +2 -2
- package/io-package.json +43 -38
- package/package.json +13 -5
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
**Release:** [](https://www.npmjs.com/package/iobroker.fakeroku)   [](https://www.npmjs.com/package/iobroker.fakeroku)
|
|
4
4
|
|
|
5
|
-
**Build:** [](https://github.com/iobroker-community-adapters/ioBroker.fakeroku/actions/workflows/test-and-release.yml)   [](LICENSE)
|
|
5
|
+
**Build:** [](https://github.com/iobroker-community-adapters/ioBroker.fakeroku/actions/workflows/test-and-release.yml)   [](LICENSE) [](https://github.com/ioBroker/plugin-sentry#plugin-sentry)
|
|
6
6
|
|
|
7
7
|
**Support:** [](https://ko-fi.com/krobipd) [](https://paypal.me/krobipd)
|
|
8
8
|
|
|
@@ -27,6 +27,12 @@ classic Harmony hub.
|
|
|
27
27
|
- Clean data model per device: a `command` datapoint plus fixed `keys.<Key>` states, all created up front.
|
|
28
28
|
- Several emulated Rokus from a single instance; discovery bound to the chosen network interface; command handling restricted to the LAN.
|
|
29
29
|
|
|
30
|
+
## Sentry / Error reporting
|
|
31
|
+
|
|
32
|
+
**This adapter uses Sentry libraries to automatically report exceptions and code errors to the developers.** Reporting only happens if you have enabled error reporting in the ioBroker diagnostics (**System settings → Diagnostics and error reporting**). Only an anonymous installation ID is transmitted — no name, e-mail address or IP address.
|
|
33
|
+
|
|
34
|
+
For details and how to disable it, see the [Sentry plugin documentation](https://github.com/ioBroker/plugin-sentry#plugin-sentry). Error reporting requires js-controller 3.0 or newer.
|
|
35
|
+
|
|
30
36
|
## Requirements
|
|
31
37
|
|
|
32
38
|
- Node.js >= 22
|
|
@@ -56,6 +62,12 @@ and point it at the ioBroker host.
|
|
|
56
62
|
|
|
57
63
|
## Objects
|
|
58
64
|
|
|
65
|
+
At instance level:
|
|
66
|
+
|
|
67
|
+
| Datapoint | Type | Meaning |
|
|
68
|
+
|---|---|---|
|
|
69
|
+
| `info.connection` | boolean, read-only | `true` only while **every** configured Roku is actually listening. If one of them cannot start — usually because its port is already taken — the instance stays disconnected and the log names the device and the port. |
|
|
70
|
+
|
|
59
71
|
For every emulated Roku (`fakeroku.0.<name>`):
|
|
60
72
|
|
|
61
73
|
| Datapoint | Type | Meaning |
|
|
@@ -81,6 +93,18 @@ becomes `true`, or watch `.command` for the last button as text.
|
|
|
81
93
|
Placeholder for the next version (at the beginning of the line):
|
|
82
94
|
### **WORK IN PROGRESS**
|
|
83
95
|
-->
|
|
96
|
+
### 1.2.0 (2026-08-27)
|
|
97
|
+
- (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.
|
|
99
|
+
- (krobipd) New: optional error reporting via Sentry — only active if you enabled diagnostics in ioBroker, and it transmits no personal data.
|
|
100
|
+
|
|
101
|
+
### 1.1.0 (2026-08-10) — stable
|
|
102
|
+
- (krobipd) Several emulated Rokus are now independent at start-up: if one is set to a port already in use, the others still start instead of the whole instance failing.
|
|
103
|
+
- (krobipd) The adapter keeps serving already-paired remotes, and reports its status correctly, even when network discovery cannot start or later drops out.
|
|
104
|
+
- (krobipd) Discovery now covers every network interface on a host that has more than one, so a remote on any of your local networks can find the emulated Rokus.
|
|
105
|
+
- (krobipd) The device dialog now refuses a name that would clash with another device or a reserved name, preventing a naming conflict.
|
|
106
|
+
- (krobipd) The adapter description now reads correctly in the non-English and non-German admin languages.
|
|
107
|
+
|
|
84
108
|
### 1.0.0 (2026-08-05)
|
|
85
109
|
- (krobipd) First stable release — version 1.0.0 marks the complete rewrite as the mature, supported version of the adapter.
|
|
86
110
|
- (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.
|
|
@@ -95,14 +119,6 @@ becomes `true`, or watch `.command` for the last button as text.
|
|
|
95
119
|
- (mcm1957) Adapter requires Node.js >= 22 now
|
|
96
120
|
- (mcm1957) Dependencies have been updated
|
|
97
121
|
|
|
98
|
-
### 0.5.0 (2026-07-30)
|
|
99
|
-
- Complete rewrite with the full Roku control surface, including `device-info` with a current Roku version — the part modern remotes check at pairing, beyond what a classic Harmony hub needs
|
|
100
|
-
- New clean data model: a `command` datapoint plus fixed `keys.<Key>` states, all created up front instead of appearing only after the first keypress
|
|
101
|
-
- Discovery binds to the chosen network interface, command handling is restricted to the local network
|
|
102
|
-
|
|
103
|
-
### 0.4.0 (2026-03-07)
|
|
104
|
-
- Adapter requires node.js >= 20, admin >= 7.7.22, js-controller >= 6.0.11
|
|
105
|
-
|
|
106
122
|
[Older changelogs can be found there](CHANGELOG_OLD.md)
|
|
107
123
|
|
|
108
124
|
## History
|
package/admin/i18n/de.json
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"dmDeleteConfirm": "Das Roku-Gerät „%s“ löschen?",
|
|
20
20
|
"deviceNameInUse": "Dieser Name wird bereits von einem anderen Gerät verwendet.",
|
|
21
21
|
"devicePortInUse": "Dieser Port wird bereits von einem anderen Roku verwendet.",
|
|
22
|
+
"deviceNameInvalid": "Dieser Name kann nicht verwendet werden — er ist reserviert oder ergibt dieselbe Objekt-ID wie ein anderes Gerät.",
|
|
22
23
|
"supportHeader": "Unterstützen",
|
|
23
24
|
"aboutInfo": "Wenn dir dieser Adapter nützt, hilft eine kleine Spende, ihn zu pflegen — danke!",
|
|
24
25
|
"donateKofi": "Ko-fi",
|
package/admin/i18n/en.json
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"dmDeleteConfirm": "Delete the Roku device \"%s\"?",
|
|
20
20
|
"deviceNameInUse": "This name is already used by another device.",
|
|
21
21
|
"devicePortInUse": "This port is already used by another Roku.",
|
|
22
|
+
"deviceNameInvalid": "This name can't be used — it is reserved or maps to the same object id as another device.",
|
|
22
23
|
"supportHeader": "Support",
|
|
23
24
|
"aboutInfo": "If this adapter is useful to you, a small donation helps keep it maintained — thank you!",
|
|
24
25
|
"donateKofi": "Ko-fi",
|
package/admin/i18n/es.json
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"dmDeleteConfirm": "¿Eliminar el dispositivo Roku «%s»?",
|
|
20
20
|
"deviceNameInUse": "Este nombre ya lo usa otro dispositivo.",
|
|
21
21
|
"devicePortInUse": "Este puerto ya lo usa otro Roku.",
|
|
22
|
+
"deviceNameInvalid": "Este nombre no se puede usar — está reservado o genera el mismo id de objeto que otro dispositivo.",
|
|
22
23
|
"supportHeader": "Apoyar",
|
|
23
24
|
"aboutInfo": "Si este adaptador te resulta útil, una pequeña donación ayuda a mantenerlo — ¡gracias!",
|
|
24
25
|
"donateKofi": "Ko-fi",
|
package/admin/i18n/fr.json
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"dmDeleteConfirm": "Supprimer l'appareil Roku « %s » ?",
|
|
20
20
|
"deviceNameInUse": "Ce nom est déjà utilisé par un autre appareil.",
|
|
21
21
|
"devicePortInUse": "Ce port est déjà utilisé par un autre Roku.",
|
|
22
|
+
"deviceNameInvalid": "Ce nom ne peut pas être utilisé — il est réservé ou produit le même identifiant d'objet qu'un autre appareil.",
|
|
22
23
|
"supportHeader": "Soutenir",
|
|
23
24
|
"aboutInfo": "Si cet adaptateur vous est utile, un petit don aide à le maintenir — merci !",
|
|
24
25
|
"donateKofi": "Ko-fi",
|
package/admin/i18n/it.json
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"dmDeleteConfirm": "Eliminare il dispositivo Roku «%s»?",
|
|
20
20
|
"deviceNameInUse": "Questo nome è già usato da un altro dispositivo.",
|
|
21
21
|
"devicePortInUse": "Questa porta è già usata da un altro Roku.",
|
|
22
|
+
"deviceNameInvalid": "Questo nome non può essere usato — è riservato o produce lo stesso id oggetto di un altro dispositivo.",
|
|
22
23
|
"supportHeader": "Sostieni",
|
|
23
24
|
"aboutInfo": "Se questo adattatore ti è utile, una piccola donazione aiuta a mantenerlo — grazie!",
|
|
24
25
|
"donateKofi": "Ko-fi",
|
package/admin/i18n/nl.json
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"dmDeleteConfirm": "Het Roku-apparaat „%s“ verwijderen?",
|
|
20
20
|
"deviceNameInUse": "Deze naam wordt al door een ander apparaat gebruikt.",
|
|
21
21
|
"devicePortInUse": "Deze poort wordt al door een andere Roku gebruikt.",
|
|
22
|
+
"deviceNameInvalid": "Deze naam kan niet worden gebruikt — hij is gereserveerd of levert dezelfde object-id op als een ander apparaat.",
|
|
22
23
|
"supportHeader": "Ondersteunen",
|
|
23
24
|
"aboutInfo": "Als deze adapter nuttig voor je is, helpt een kleine donatie om hem te onderhouden — bedankt!",
|
|
24
25
|
"donateKofi": "Ko-fi",
|
package/admin/i18n/pl.json
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"dmDeleteConfirm": "Usunąć urządzenie Roku „%s“?",
|
|
20
20
|
"deviceNameInUse": "Ta nazwa jest już używana przez inne urządzenie.",
|
|
21
21
|
"devicePortInUse": "Ten port jest już używany przez innego Roku.",
|
|
22
|
+
"deviceNameInvalid": "Tej nazwy nie można użyć — jest zarezerwowana lub daje ten sam identyfikator obiektu co inne urządzenie.",
|
|
22
23
|
"supportHeader": "Wsparcie",
|
|
23
24
|
"aboutInfo": "Jeśli ten adapter jest dla Ciebie przydatny, mała darowizna pomaga go utrzymać — dziękuję!",
|
|
24
25
|
"donateKofi": "Ko-fi",
|
package/admin/i18n/pt.json
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"dmDeleteConfirm": "Eliminar o dispositivo Roku «%s»?",
|
|
20
20
|
"deviceNameInUse": "Este nome já é usado por outro dispositivo.",
|
|
21
21
|
"devicePortInUse": "Esta porta já é usada por outro Roku.",
|
|
22
|
+
"deviceNameInvalid": "Este nome não pode ser usado — é reservado ou gera o mesmo id de objeto de outro dispositivo.",
|
|
22
23
|
"supportHeader": "Apoiar",
|
|
23
24
|
"aboutInfo": "Se este adaptador lhe é útil, um pequeno donativo ajuda a mantê-lo — obrigado!",
|
|
24
25
|
"donateKofi": "Ko-fi",
|
package/admin/i18n/ru.json
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"dmDeleteConfirm": "Удалить устройство Roku «%s»?",
|
|
20
20
|
"deviceNameInUse": "Это имя уже используется другим устройством.",
|
|
21
21
|
"devicePortInUse": "Этот порт уже используется другим Roku.",
|
|
22
|
+
"deviceNameInvalid": "Это имя нельзя использовать — оно зарезервировано или даёт такой же идентификатор, как у другого устройства.",
|
|
22
23
|
"supportHeader": "Поддержать",
|
|
23
24
|
"aboutInfo": "Если адаптер вам полезен, небольшое пожертвование помогает его поддерживать — спасибо!",
|
|
24
25
|
"donateKofi": "Ko-fi",
|
package/admin/i18n/uk.json
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"dmDeleteConfirm": "Видалити пристрій Roku «%s»?",
|
|
20
20
|
"deviceNameInUse": "Це ім'я вже використовується іншим пристроєм.",
|
|
21
21
|
"devicePortInUse": "Цей порт уже використовується іншим Roku.",
|
|
22
|
+
"deviceNameInvalid": "Це ім'я не можна використати — воно зарезервоване або дає такий самий ідентифікатор об'єкта, як інший пристрій.",
|
|
22
23
|
"supportHeader": "Підтримати",
|
|
23
24
|
"aboutInfo": "Якщо цей адаптер вам корисний, невелика пожертва допомагає його підтримувати — дякую!",
|
|
24
25
|
"donateKofi": "Ko-fi",
|
package/admin/i18n/zh-cn.json
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"dmDeleteConfirm": "删除 Roku 设备“%s”?",
|
|
20
20
|
"deviceNameInUse": "此名称已被另一台设备使用。",
|
|
21
21
|
"devicePortInUse": "此端口已被另一台 Roku 使用。",
|
|
22
|
+
"deviceNameInvalid": "无法使用此名称——它是保留名称,或与另一设备生成相同的对象 id。",
|
|
22
23
|
"supportHeader": "支持",
|
|
23
24
|
"aboutInfo": "如果此适配器对您有用,一点捐赠有助于维护它——谢谢!",
|
|
24
25
|
"donateKofi": "Ko-fi",
|
package/admin/jsonConfig.json
CHANGED
|
@@ -26,12 +26,14 @@ __export(device_management_exports, {
|
|
|
26
26
|
});
|
|
27
27
|
module.exports = __toCommonJS(device_management_exports);
|
|
28
28
|
var import_dm_utils = require("@iobroker/dm-utils");
|
|
29
|
+
var import_constants = require("./lib/constants");
|
|
29
30
|
var import_device_identity = require("./lib/device-identity");
|
|
30
31
|
var import_i18n = require("./lib/i18n");
|
|
31
|
-
|
|
32
|
+
var import_pure_helpers = require("./lib/pure-helpers");
|
|
33
|
+
const RESERVED_IDS = /* @__PURE__ */ new Set(["info"]);
|
|
32
34
|
function nextFreePort(usedPorts) {
|
|
33
35
|
const taken = new Set(usedPorts);
|
|
34
|
-
let port =
|
|
36
|
+
let port = import_constants.DEFAULT_ECP_PORT;
|
|
35
37
|
while (taken.has(port)) {
|
|
36
38
|
port++;
|
|
37
39
|
}
|
|
@@ -81,12 +83,16 @@ function buildDeviceForm(usedNames, usedPorts) {
|
|
|
81
83
|
}
|
|
82
84
|
function cleanDevice(raw) {
|
|
83
85
|
const name = typeof raw.name === "string" ? raw.name.trim() : "";
|
|
84
|
-
const port = Number(raw.port) ||
|
|
86
|
+
const port = Number(raw.port) || import_constants.DEFAULT_ECP_PORT;
|
|
85
87
|
const type = raw.type === "tv" ? "tv" : "player";
|
|
86
88
|
return { name, port, type };
|
|
87
89
|
}
|
|
88
90
|
function findClash(devices, candidate, exceptIndex) {
|
|
89
91
|
const name = candidate.name.trim().toLowerCase();
|
|
92
|
+
const id = (0, import_pure_helpers.sanitizeId)(candidate.name.trim());
|
|
93
|
+
if (id === "" || RESERVED_IDS.has(id)) {
|
|
94
|
+
return (0, import_i18n.t)("deviceNameInvalid");
|
|
95
|
+
}
|
|
90
96
|
for (let i = 0; i < devices.length; i++) {
|
|
91
97
|
if (i === exceptIndex) {
|
|
92
98
|
continue;
|
|
@@ -94,6 +100,9 @@ function findClash(devices, candidate, exceptIndex) {
|
|
|
94
100
|
if (devices[i].name.trim().toLowerCase() === name) {
|
|
95
101
|
return (0, import_i18n.t)("deviceNameInUse");
|
|
96
102
|
}
|
|
103
|
+
if ((0, import_pure_helpers.sanitizeId)(devices[i].name.trim()) === id) {
|
|
104
|
+
return (0, import_i18n.t)("deviceNameInvalid");
|
|
105
|
+
}
|
|
97
106
|
if (Number(devices[i].port) === candidate.port) {
|
|
98
107
|
return (0, import_i18n.t)("devicePortInUse");
|
|
99
108
|
}
|
|
@@ -147,7 +156,7 @@ class FakerokuDeviceManagement extends import_dm_utils.DeviceManagement {
|
|
|
147
156
|
return {
|
|
148
157
|
id: String(index),
|
|
149
158
|
name: device.name || `Roku ${index + 1}`,
|
|
150
|
-
identifier: String(device.port ||
|
|
159
|
+
identifier: String(device.port || import_constants.DEFAULT_ECP_PORT),
|
|
151
160
|
model: kind,
|
|
152
161
|
actions: [
|
|
153
162
|
{
|
|
@@ -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 { deriveUuid } from \"./lib/device-identity\";\nimport type { DeviceType } from \"./ecp/state-model\";\nimport { t } from \"./lib/i18n\";\n\nconst DEFAULT_PORT = 8060;\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_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_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 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 (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_PORT),\n model: kind,\n actions: [\n {\n id: \"edit\",\n icon: \"edit\",\n description: t(\"dmEdit\"),\n handler: async (id: string, context: ActionContext) => this.editDevice(Number(id), context),\n },\n {\n id: \"delete\",\n icon: \"delete\",\n description: t(\"dmDelete\"),\n handler: async (id: string, context: ActionContext) => this.deleteDevice(Number(id), context),\n },\n ],\n };\n }\n\n /**\n * The \"+ add\" action above the list, plus the label for the port shown on each card.\n *\n * @returns the instance action descriptor\n */\n protected getInstanceInfo(): ReturnType<DeviceManagement[\"getInstanceInfo\"]> {\n return {\n apiVersion: \"v3\",\n identifierLabel: t(\"portLabel\"),\n actions: [{ id: \"add\", icon: \"add\", description: t(\"dmAdd\"), handler: async context => this.addDevice(context) }],\n };\n }\n\n /**\n * Manual add: pre-select a free port, show the form, and append the device with\n * a stable derived uuid.\n *\n * @param context the action context\n * @returns a directive to reload the manager\n */\n private async addDevice(context: ActionContext): Promise<InstanceResult> {\n const devices = await this.readDevices();\n const usedNames = devices.map(d => d.name);\n const usedPorts = devices.map(d => Number(d.port));\n const data = await context.showForm(buildDeviceForm(usedNames, usedPorts), {\n title: t(\"dmAdd\"),\n data: { type: \"player\", port: nextFreePort(usedPorts) },\n });\n if (data && typeof data.name === \"string\" && data.name.trim()) {\n const clean = cleanDevice(data);\n const clash = findClash(devices, clean, -1);\n if (clash) {\n await context.showMessage(clash);\n return { refresh: true };\n }\n devices.push({ ...clean, uuid: deriveUuid(clean.name) });\n await this.writeDevices(devices);\n }\n return { refresh: true };\n }\n\n /**\n * Edit a device via the pre-filled form. Its own name/port are excluded from\n * the clash check, and its uuid is preserved so the pairing survives a rename.\n *\n * @param index the device's list position\n * @param context the action context\n * @returns a directive to reload the list\n */\n private async editDevice(index: number, context: ActionContext): Promise<DeviceResult> {\n const devices = await this.readDevices();\n const current = devices[index];\n if (!current) {\n return { refresh: \"devices\" };\n }\n const usedNames = devices.filter((_, i) => i !== index).map(d => d.name);\n const usedPorts = devices.filter((_, i) => i !== index).map(d => Number(d.port));\n const data = await context.showForm(buildDeviceForm(usedNames, usedPorts), {\n title: t(\"dmEditTitle\"),\n data: { ...current },\n });\n if (data && typeof data.name === \"string\" && data.name.trim()) {\n const clean = cleanDevice(data);\n const clash = findClash(devices, clean, index);\n if (clash) {\n await context.showMessage(clash);\n return { refresh: \"devices\" };\n }\n devices[index] = { ...clean, uuid: current.uuid || deriveUuid(clean.name) };\n await this.writeDevices(devices);\n }\n return { refresh: \"devices\" };\n }\n\n /**\n * Delete a device after confirmation.\n *\n * @param index the device's list position\n * @param context the action context\n * @returns a directive to reload the list\n */\n private async deleteDevice(index: number, context: ActionContext): Promise<DeviceResult> {\n const devices = await this.readDevices();\n const target = devices[index];\n if (!target) {\n return { refresh: \"devices\" };\n }\n const confirmed = await context.showConfirmation(t(\"dmDeleteConfirm\", target.name || \"\"));\n if (confirmed) {\n devices.splice(index, 1);\n await this.writeDevices(devices);\n }\n return { refresh: \"devices\" };\n }\n}\n"],
|
|
5
|
-
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,sBAMO;AACP,6BAA2B;
|
|
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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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -42,38 +42,85 @@ class RokuSsdpResponder {
|
|
|
42
42
|
constructor(config) {
|
|
43
43
|
this.config = config;
|
|
44
44
|
}
|
|
45
|
+
config;
|
|
45
46
|
socket;
|
|
46
|
-
|
|
47
|
+
fatalReported = false;
|
|
48
|
+
/** Bind on 1900, join multicast on the selected interface(s), start answering. Rejects on bind error. */
|
|
47
49
|
async start() {
|
|
48
|
-
var _a;
|
|
49
50
|
const socket = dgram.createSocket({ type: "udp4", reuseAddr: true });
|
|
50
51
|
this.socket = socket;
|
|
51
52
|
await new Promise((resolve, reject) => {
|
|
52
53
|
const onBindError = (err) => reject(err);
|
|
53
54
|
socket.once("error", onBindError);
|
|
54
55
|
socket.bind(SSDP_PORT, () => {
|
|
55
|
-
var _a2;
|
|
56
56
|
socket.removeListener("error", onBindError);
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
57
|
+
this.joinMulticast(socket);
|
|
58
|
+
if (this.config.bindIp) {
|
|
59
|
+
try {
|
|
60
|
+
socket.setMulticastInterface(this.config.bindIp);
|
|
61
|
+
} catch (e) {
|
|
62
|
+
this.config.logger.warn(
|
|
63
|
+
`SSDP: could not pin multicast egress to ${this.config.bindIp}: ${errMsg(e)} \u2014 NOTIFY may use the default interface`
|
|
64
|
+
);
|
|
65
|
+
}
|
|
64
66
|
}
|
|
65
|
-
socket.on("error", (err) =>
|
|
66
|
-
this.config.logger.error(`SSDP socket error: ${err.message}`);
|
|
67
|
-
this.stop();
|
|
68
|
-
});
|
|
67
|
+
socket.on("error", (err) => this.onSocketError(err));
|
|
69
68
|
socket.on("message", (msg, rinfo) => this.onMessage(msg.toString("utf8"), rinfo.address, rinfo.port));
|
|
70
69
|
resolve();
|
|
71
70
|
});
|
|
72
71
|
});
|
|
72
|
+
const join = this.config.membershipInterfaces.length ? this.config.membershipInterfaces.join(", ") : "default";
|
|
73
73
|
this.config.logger.debug(
|
|
74
|
-
`Roku SSDP responder on :${SSDP_PORT}, advertising ${this.config.advertiseIp} (join: ${
|
|
74
|
+
`Roku SSDP responder on :${SSDP_PORT}, advertising ${this.config.advertiseIp} (join: ${join})`
|
|
75
75
|
);
|
|
76
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* Join the multicast group on each selected interface, or on the OS default when
|
|
79
|
+
* none is known.
|
|
80
|
+
*
|
|
81
|
+
* @param socket the bound SSDP socket
|
|
82
|
+
*/
|
|
83
|
+
joinMulticast(socket) {
|
|
84
|
+
const ifaces = this.config.membershipInterfaces;
|
|
85
|
+
if (ifaces.length === 0) {
|
|
86
|
+
this.tryJoin(socket, void 0);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
for (const ip of ifaces) {
|
|
90
|
+
this.tryJoin(socket, ip);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Join the group on one interface; a failure warns but does not throw, so one bad
|
|
95
|
+
* interface cannot stop the responder.
|
|
96
|
+
*
|
|
97
|
+
* @param socket the bound SSDP socket
|
|
98
|
+
* @param iface the interface IP to join on, or undefined for the OS default
|
|
99
|
+
*/
|
|
100
|
+
tryJoin(socket, iface) {
|
|
101
|
+
try {
|
|
102
|
+
socket.addMembership(MULTICAST_ADDR, iface);
|
|
103
|
+
} catch (e) {
|
|
104
|
+
this.config.logger.warn(
|
|
105
|
+
`SSDP multicast join failed on ${iface != null ? iface : "default interface"}: ${errMsg(e)} \u2014 discovery may be incomplete`
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* A socket error after a good start — discovery is dead. Close the socket and tell
|
|
111
|
+
* the adapter once so it can stop announcing and revise its connection state.
|
|
112
|
+
*
|
|
113
|
+
* @param err the socket error
|
|
114
|
+
*/
|
|
115
|
+
onSocketError(err) {
|
|
116
|
+
this.config.logger.error(`SSDP socket error: ${err.message}`);
|
|
117
|
+
const notify = this.config.onFatalError;
|
|
118
|
+
this.stop();
|
|
119
|
+
if (notify && !this.fatalReported) {
|
|
120
|
+
this.fatalReported = true;
|
|
121
|
+
notify(err);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
77
124
|
onMessage(text, address, port) {
|
|
78
125
|
var _a;
|
|
79
126
|
if (!(0, import_ssdp_messages.matchesRokuSearch)(text)) {
|
|
@@ -102,7 +149,7 @@ class RokuSsdpResponder {
|
|
|
102
149
|
});
|
|
103
150
|
}
|
|
104
151
|
}
|
|
105
|
-
/** Synchronous close — safe to call from onUnload. */
|
|
152
|
+
/** Synchronous close — safe to call from onUnload. Closing the socket drops its memberships. */
|
|
106
153
|
stop() {
|
|
107
154
|
if (this.socket) {
|
|
108
155
|
try {
|
|
@@ -113,6 +160,9 @@ class RokuSsdpResponder {
|
|
|
113
160
|
}
|
|
114
161
|
}
|
|
115
162
|
}
|
|
163
|
+
function errMsg(e) {
|
|
164
|
+
return e instanceof Error ? e.message : String(e);
|
|
165
|
+
}
|
|
116
166
|
// Annotate the CommonJS export names for ESM import in node:
|
|
117
167
|
0 && (module.exports = {
|
|
118
168
|
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 { buildAliveNotify, buildSearchResponse, matchesRokuSearch, type RokuAdvert } from \"./ssdp-messages\";\n\nconst SSDP_PORT = 1900;\nconst MULTICAST_ADDR = \"239.255.255.250\";\n\n/**
|
|
5
|
-
"mappings": ";;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,YAAuB;
|
|
6
|
-
"names": [
|
|
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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",
|
|
6
|
+
"names": []
|
|
7
7
|
}
|
|
@@ -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 (no dead 2015 services).
|
|
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, \"&\").replace(/</g, \"<\").replace(/>/g, \">\");\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
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;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -42,6 +42,7 @@ class EcpHttpServer {
|
|
|
42
42
|
constructor(config) {
|
|
43
43
|
this.config = config;
|
|
44
44
|
}
|
|
45
|
+
config;
|
|
45
46
|
server;
|
|
46
47
|
/** Bind the HTTP server to the interface + port. Rejects on bind error. */
|
|
47
48
|
async start() {
|
|
@@ -62,14 +63,16 @@ class EcpHttpServer {
|
|
|
62
63
|
);
|
|
63
64
|
}
|
|
64
65
|
handle(req, res) {
|
|
65
|
-
var _a, _b;
|
|
66
|
+
var _a, _b, _c, _d, _e, _f;
|
|
67
|
+
const peer = ((_a = req.socket.remoteAddress) != null ? _a : "").replace(/^::ffff:/, "") || "?";
|
|
66
68
|
if (!(0, import_lan_guard.isLanClient)(req.socket.remoteAddress)) {
|
|
69
|
+
this.config.logger.debug(`ECP request from non-LAN ${peer} rejected (403)`);
|
|
67
70
|
res.statusCode = 403;
|
|
68
71
|
res.end();
|
|
69
72
|
return;
|
|
70
73
|
}
|
|
71
|
-
const method = (
|
|
72
|
-
const url = (
|
|
74
|
+
const method = (_b = req.method) != null ? _b : "GET";
|
|
75
|
+
const url = (_c = req.url) != null ? _c : "/";
|
|
73
76
|
if (method === "GET") {
|
|
74
77
|
const body = this.routeGet(url);
|
|
75
78
|
if (body === null) {
|
|
@@ -77,6 +80,9 @@ class EcpHttpServer {
|
|
|
77
80
|
res.end();
|
|
78
81
|
return;
|
|
79
82
|
}
|
|
83
|
+
if (url.split("?")[0] === "/query/device-info") {
|
|
84
|
+
this.config.logger.debug(`device-info queried from ${peer} (remote pairing/probe)`);
|
|
85
|
+
}
|
|
80
86
|
res.statusCode = 200;
|
|
81
87
|
res.setHeader("Content-Type", "text/xml; charset=utf-8");
|
|
82
88
|
res.end(body);
|
|
@@ -90,6 +96,8 @@ class EcpHttpServer {
|
|
|
90
96
|
res.end();
|
|
91
97
|
return;
|
|
92
98
|
}
|
|
99
|
+
const detail = (_f = (_e = (_d = cmd.key) != null ? _d : cmd.appId) != null ? _e : cmd.text) != null ? _f : "";
|
|
100
|
+
this.config.logger.debug(`ECP ${cmd.type}${detail ? ` ${detail}` : ""} from ${peer}`);
|
|
93
101
|
try {
|
|
94
102
|
this.config.onCommand(cmd);
|
|
95
103
|
} catch (e) {
|
|
@@ -119,6 +127,7 @@ class EcpHttpServer {
|
|
|
119
127
|
if (this.server) {
|
|
120
128
|
try {
|
|
121
129
|
this.server.close();
|
|
130
|
+
this.server.closeAllConnections();
|
|
122
131
|
} catch {
|
|
123
132
|
}
|
|
124
133
|
this.server = void 0;
|