iobroker.fakeroku 0.6.0 → 1.1.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 +22 -6
- 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 +65 -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 +11 -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 +95 -25
- package/build/main.js.map +2 -2
- package/io-package.json +80 -25
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -81,15 +81,31 @@ becomes `true`, or watch `.command` for the last button as text.
|
|
|
81
81
|
Placeholder for the next version (at the beginning of the line):
|
|
82
82
|
### **WORK IN PROGRESS**
|
|
83
83
|
-->
|
|
84
|
+
### 1.1.0 (2026-08-10)
|
|
85
|
+
- (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.
|
|
86
|
+
- (krobipd) The adapter keeps serving already-paired remotes, and reports its status correctly, even when network discovery cannot start or later drops out.
|
|
87
|
+
- (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.
|
|
88
|
+
- (krobipd) The device dialog now refuses a name that would clash with another device or a reserved name, preventing a naming conflict.
|
|
89
|
+
- (krobipd) The adapter description now reads correctly in the non-English and non-German admin languages.
|
|
90
|
+
|
|
91
|
+
### 1.0.0 (2026-08-05)
|
|
92
|
+
- (krobipd) First stable release — version 1.0.0 marks the complete rewrite as the mature, supported version of the adapter.
|
|
93
|
+
- (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.
|
|
94
|
+
|
|
84
95
|
### 0.6.0 (2026-08-05)
|
|
85
|
-
- Complete rewrite. The adapter now answers the full Roku control surface — including device-info with a current Roku version — so Logitech Harmony and Sofabaton remotes pair and work reliably.
|
|
86
|
-
- Works out of the box: it detects the network address to advertise on its own, no manual interface picking.
|
|
87
|
-
- Manage multiple emulated Rokus from the admin UI, each as a Player or a TV.
|
|
88
|
-
- 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.
|
|
96
|
+
- (krobipd) Complete rewrite. The adapter now answers the full Roku control surface — including device-info with a current Roku version — so Logitech Harmony and Sofabaton remotes pair and work reliably.
|
|
97
|
+
- (krobipd) Works out of the box: it detects the network address to advertise on its own, no manual interface picking.
|
|
98
|
+
- (krobipd) Manage multiple emulated Rokus from the admin UI, each as a Player or a TV.
|
|
99
|
+
- (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.
|
|
89
100
|
|
|
90
101
|
### 0.5.1 (2026-08-05)
|
|
91
|
-
- Adapter requires Node.js >= 22 now
|
|
92
|
-
- Dependencies have been updated
|
|
102
|
+
- (mcm1957) Adapter requires Node.js >= 22 now
|
|
103
|
+
- (mcm1957) Dependencies have been updated
|
|
104
|
+
|
|
105
|
+
### 0.5.0 (2026-07-30)
|
|
106
|
+
- 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
|
|
107
|
+
- New clean data model: a `command` datapoint plus fixed `keys.<Key>` states, all created up front instead of appearing only after the first keypress
|
|
108
|
+
- Discovery binds to the chosen network interface, command handling is restricted to the local network
|
|
93
109
|
|
|
94
110
|
[Older changelogs can be found there](CHANGELOG_OLD.md)
|
|
95
111
|
|
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
|
}
|
|
@@ -43,37 +43,83 @@ class RokuSsdpResponder {
|
|
|
43
43
|
this.config = config;
|
|
44
44
|
}
|
|
45
45
|
socket;
|
|
46
|
-
|
|
46
|
+
fatalReported = false;
|
|
47
|
+
/** Bind on 1900, join multicast on the selected interface(s), start answering. Rejects on bind error. */
|
|
47
48
|
async start() {
|
|
48
|
-
var _a;
|
|
49
49
|
const socket = dgram.createSocket({ type: "udp4", reuseAddr: true });
|
|
50
50
|
this.socket = socket;
|
|
51
51
|
await new Promise((resolve, reject) => {
|
|
52
52
|
const onBindError = (err) => reject(err);
|
|
53
53
|
socket.once("error", onBindError);
|
|
54
54
|
socket.bind(SSDP_PORT, () => {
|
|
55
|
-
var _a2;
|
|
56
55
|
socket.removeListener("error", onBindError);
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
56
|
+
this.joinMulticast(socket);
|
|
57
|
+
if (this.config.bindIp) {
|
|
58
|
+
try {
|
|
59
|
+
socket.setMulticastInterface(this.config.bindIp);
|
|
60
|
+
} catch (e) {
|
|
61
|
+
this.config.logger.warn(
|
|
62
|
+
`SSDP: could not pin multicast egress to ${this.config.bindIp}: ${errMsg(e)} \u2014 NOTIFY may use the default interface`
|
|
63
|
+
);
|
|
64
|
+
}
|
|
64
65
|
}
|
|
65
|
-
socket.on("error", (err) =>
|
|
66
|
-
this.config.logger.error(`SSDP socket error: ${err.message}`);
|
|
67
|
-
this.stop();
|
|
68
|
-
});
|
|
66
|
+
socket.on("error", (err) => this.onSocketError(err));
|
|
69
67
|
socket.on("message", (msg, rinfo) => this.onMessage(msg.toString("utf8"), rinfo.address, rinfo.port));
|
|
70
68
|
resolve();
|
|
71
69
|
});
|
|
72
70
|
});
|
|
71
|
+
const join = this.config.membershipInterfaces.length ? this.config.membershipInterfaces.join(", ") : "default";
|
|
73
72
|
this.config.logger.debug(
|
|
74
|
-
`Roku SSDP responder on :${SSDP_PORT}, advertising ${this.config.advertiseIp} (join: ${
|
|
73
|
+
`Roku SSDP responder on :${SSDP_PORT}, advertising ${this.config.advertiseIp} (join: ${join})`
|
|
75
74
|
);
|
|
76
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* Join the multicast group on each selected interface, or on the OS default when
|
|
78
|
+
* none is known.
|
|
79
|
+
*
|
|
80
|
+
* @param socket the bound SSDP socket
|
|
81
|
+
*/
|
|
82
|
+
joinMulticast(socket) {
|
|
83
|
+
const ifaces = this.config.membershipInterfaces;
|
|
84
|
+
if (ifaces.length === 0) {
|
|
85
|
+
this.tryJoin(socket, void 0);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
for (const ip of ifaces) {
|
|
89
|
+
this.tryJoin(socket, ip);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Join the group on one interface; a failure warns but does not throw, so one bad
|
|
94
|
+
* interface cannot stop the responder.
|
|
95
|
+
*
|
|
96
|
+
* @param socket the bound SSDP socket
|
|
97
|
+
* @param iface the interface IP to join on, or undefined for the OS default
|
|
98
|
+
*/
|
|
99
|
+
tryJoin(socket, iface) {
|
|
100
|
+
try {
|
|
101
|
+
socket.addMembership(MULTICAST_ADDR, iface);
|
|
102
|
+
} catch (e) {
|
|
103
|
+
this.config.logger.warn(
|
|
104
|
+
`SSDP multicast join failed on ${iface != null ? iface : "default interface"}: ${errMsg(e)} \u2014 discovery may be incomplete`
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* A socket error after a good start — discovery is dead. Close the socket and tell
|
|
110
|
+
* the adapter once so it can stop announcing and revise its connection state.
|
|
111
|
+
*
|
|
112
|
+
* @param err the socket error
|
|
113
|
+
*/
|
|
114
|
+
onSocketError(err) {
|
|
115
|
+
this.config.logger.error(`SSDP socket error: ${err.message}`);
|
|
116
|
+
const notify = this.config.onFatalError;
|
|
117
|
+
this.stop();
|
|
118
|
+
if (notify && !this.fatalReported) {
|
|
119
|
+
this.fatalReported = true;
|
|
120
|
+
notify(err);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
77
123
|
onMessage(text, address, port) {
|
|
78
124
|
var _a;
|
|
79
125
|
if (!(0, import_ssdp_messages.matchesRokuSearch)(text)) {
|
|
@@ -102,7 +148,7 @@ class RokuSsdpResponder {
|
|
|
102
148
|
});
|
|
103
149
|
}
|
|
104
150
|
}
|
|
105
|
-
/** Synchronous close — safe to call from onUnload. */
|
|
151
|
+
/** Synchronous close — safe to call from onUnload. Closing the socket drops its memberships. */
|
|
106
152
|
stop() {
|
|
107
153
|
if (this.socket) {
|
|
108
154
|
try {
|
|
@@ -113,6 +159,9 @@ class RokuSsdpResponder {
|
|
|
113
159
|
}
|
|
114
160
|
}
|
|
115
161
|
}
|
|
162
|
+
function errMsg(e) {
|
|
163
|
+
return e instanceof Error ? e.message : String(e);
|
|
164
|
+
}
|
|
116
165
|
// Annotate the CommonJS export names for ESM import in node:
|
|
117
166
|
0 && (module.exports = {
|
|
118
167
|
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
|
}
|
|
@@ -62,14 +62,16 @@ class EcpHttpServer {
|
|
|
62
62
|
);
|
|
63
63
|
}
|
|
64
64
|
handle(req, res) {
|
|
65
|
-
var _a, _b;
|
|
65
|
+
var _a, _b, _c, _d, _e, _f;
|
|
66
|
+
const peer = ((_a = req.socket.remoteAddress) != null ? _a : "").replace(/^::ffff:/, "") || "?";
|
|
66
67
|
if (!(0, import_lan_guard.isLanClient)(req.socket.remoteAddress)) {
|
|
68
|
+
this.config.logger.debug(`ECP request from non-LAN ${peer} rejected (403)`);
|
|
67
69
|
res.statusCode = 403;
|
|
68
70
|
res.end();
|
|
69
71
|
return;
|
|
70
72
|
}
|
|
71
|
-
const method = (
|
|
72
|
-
const url = (
|
|
73
|
+
const method = (_b = req.method) != null ? _b : "GET";
|
|
74
|
+
const url = (_c = req.url) != null ? _c : "/";
|
|
73
75
|
if (method === "GET") {
|
|
74
76
|
const body = this.routeGet(url);
|
|
75
77
|
if (body === null) {
|
|
@@ -77,6 +79,9 @@ class EcpHttpServer {
|
|
|
77
79
|
res.end();
|
|
78
80
|
return;
|
|
79
81
|
}
|
|
82
|
+
if (url.split("?")[0] === "/query/device-info") {
|
|
83
|
+
this.config.logger.debug(`device-info queried from ${peer} (remote pairing/probe)`);
|
|
84
|
+
}
|
|
80
85
|
res.statusCode = 200;
|
|
81
86
|
res.setHeader("Content-Type", "text/xml; charset=utf-8");
|
|
82
87
|
res.end(body);
|
|
@@ -90,6 +95,8 @@ class EcpHttpServer {
|
|
|
90
95
|
res.end();
|
|
91
96
|
return;
|
|
92
97
|
}
|
|
98
|
+
const detail = (_f = (_e = (_d = cmd.key) != null ? _d : cmd.appId) != null ? _e : cmd.text) != null ? _f : "";
|
|
99
|
+
this.config.logger.debug(`ECP ${cmd.type}${detail ? ` ${detail}` : ""} from ${peer}`);
|
|
93
100
|
try {
|
|
94
101
|
this.config.onCommand(cmd);
|
|
95
102
|
} catch (e) {
|
|
@@ -119,6 +126,7 @@ class EcpHttpServer {
|
|
|
119
126
|
if (this.server) {
|
|
120
127
|
try {
|
|
121
128
|
this.server.close();
|
|
129
|
+
this.server.closeAllConnections();
|
|
122
130
|
} catch {
|
|
123
131
|
}
|
|
124
132
|
this.server = void 0;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/ecp/ecp-http-server.ts"],
|
|
4
|
-
"sourcesContent": ["import * as http from \"node:http\";\nimport type { RokuAdvert } from \"../discovery/ssdp-messages\";\nimport type {
|
|
5
|
-
"mappings": ";;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,WAAsB;AAGtB,yBAAmD;AACnD,yBAA8E;AAC9E,uBAA4B;AA2BrB,MAAM,cAAc;AAAA;AAAA;AAAA;AAAA,EAMlB,YAA6B,QAAyB;AAAzB;AAAA,EAA0B;AAAA,EALtD;AAAA;AAAA,EAQR,MAAa,QAAuB;AAzCtC;AA0CI,UAAM,SAAS,KAAK,aAAa,CAAC,KAAK,QAAQ,KAAK,OAAO,KAAK,GAAG,CAAC;AACpE,SAAK,SAAS;AACd,UAAM,IAAI,QAAc,CAAC,SAAS,WAAW;AAC3C,YAAM,UAAU,CAAC,QAAqB,OAAO,GAAG;AAChD,aAAO,KAAK,SAAS,OAAO;AAC5B,aAAO,OAAO,KAAK,OAAO,OAAO,MAAM,KAAK,OAAO,QAAQ,MAAM;AAC/D,eAAO,eAAe,SAAS,OAAO;AACtC,eAAO,GAAG,SAAS,CAAC,QAAe,KAAK,OAAO,OAAO,MAAM,qBAAqB,IAAI,OAAO,EAAE,CAAC;AAC/F,gBAAQ;AAAA,MACV,CAAC;AAAA,IACH,CAAC;AACD,SAAK,OAAO,OAAO;AAAA,MACjB,eAAe,KAAK,OAAO,YAAY,SAAQ,UAAK,OAAO,WAAZ,YAAsB,SAAS,IAAI,KAAK,OAAO,OAAO,IAAI;AAAA,IAC3G;AAAA,EACF;AAAA,EAEQ,OAAO,KAA2B,KAAgC;AA1D5E;AA2DI,QAAI,KAAC,8BAAY,IAAI,OAAO,aAAa,GAAG;
|
|
4
|
+
"sourcesContent": ["import * as http from \"node:http\";\nimport type { RokuAdvert } from \"../discovery/ssdp-messages\";\nimport type { AdapterLogger } from \"../lib/logger\";\nimport { type CommandEvent, parseEcpCommand } from \"./ecp-command\";\nimport { type AppEntry, buildAppsXml, buildDescXml, buildDeviceInfoXml } from \"./device-info\";\nimport { isLanClient } from \"./lan-guard\";\nimport type { DeviceType } from \"./state-model\";\n\n/** Configuration for one emulated Roku's ECP HTTP server. */\nexport interface EcpServerConfig {\n /** The emulated Roku (uuid + port). */\n device: RokuAdvert;\n /** Display name shown in the description / device-info. */\n friendlyName: string;\n /** Apps advertised at /query/apps. */\n apps: AppEntry[];\n /** The emulated device type (player / tv) \u2014 drives the device-info response. */\n deviceType: DeviceType;\n /** Interface IP to bind the HTTP server to, or `undefined` to bind all interfaces (auto). */\n bindIp: string | undefined;\n /** Logger. */\n logger: AdapterLogger;\n /** Called for every accepted POST command. */\n onCommand: (cmd: CommandEvent) => void;\n}\n\n/**\n * The Roku ECP HTTP server for one emulated device. Serves the UPnP description,\n * /query/device-info (with a current version) and /query/apps; turns POST\n * key/launch/input/search into command events. Unknown GET paths get a clean 404\n * (not the old adapter's empty 200), and commands are accepted only from the LAN.\n */\nexport class EcpHttpServer {\n private server: http.Server | undefined;\n\n /**\n * @param config server configuration\n */\n public constructor(private readonly config: EcpServerConfig) {}\n\n /** Bind the HTTP server to the interface + port. Rejects on bind error. */\n public async start(): Promise<void> {\n const server = http.createServer((req, res) => this.handle(req, res));\n this.server = server;\n await new Promise<void>((resolve, reject) => {\n const onError = (err: Error): void => reject(err);\n server.once(\"error\", onError);\n server.listen(this.config.device.port, this.config.bindIp, () => {\n server.removeListener(\"error\", onError);\n server.on(\"error\", (err: Error) => this.config.logger.error(`ECP server error: ${err.message}`));\n resolve();\n });\n });\n this.config.logger.debug(\n `ECP server \"${this.config.friendlyName}\" on ${this.config.bindIp ?? \"0.0.0.0\"}:${this.config.device.port}`,\n );\n }\n\n private handle(req: http.IncomingMessage, res: http.ServerResponse): void {\n const peer = (req.socket.remoteAddress ?? \"\").replace(/^::ffff:/, \"\") || \"?\";\n if (!isLanClient(req.socket.remoteAddress)) {\n // Debug (not warn): a stray WAN scanner must not spam the log, but when a\n // remote sits on the wrong subnet/VLAN this is the only trace of \"why rejected\".\n this.config.logger.debug(`ECP request from non-LAN ${peer} rejected (403)`);\n res.statusCode = 403;\n res.end();\n return;\n }\n const method = req.method ?? \"GET\";\n const url = req.url ?? \"/\";\n\n if (method === \"GET\") {\n const body = this.routeGet(url);\n if (body === null) {\n res.statusCode = 404;\n res.end();\n return;\n }\n if (url.split(\"?\")[0] === \"/query/device-info\") {\n // The pairing probe \u2014 the first thing a remote asks and the usual failure\n // point (a Sofabaton rejects a too-old version). Visible for diagnosis.\n this.config.logger.debug(`device-info queried from ${peer} (remote pairing/probe)`);\n }\n res.statusCode = 200;\n res.setHeader(\"Content-Type\", \"text/xml; charset=utf-8\");\n res.end(body);\n return;\n }\n\n if (method === \"POST\") {\n req.resume(); // drain any body\n const cmd = parseEcpCommand(\"POST\", url);\n if (!cmd) {\n res.statusCode = 404;\n res.end();\n return;\n }\n // The received command \u2014 without this a keypress leaves no trace at all, so a\n // \"I press a button and nothing happens\" report has nothing to go on.\n const detail = cmd.key ?? cmd.appId ?? cmd.text ?? \"\";\n this.config.logger.debug(`ECP ${cmd.type}${detail ? ` ${detail}` : \"\"} from ${peer}`);\n try {\n this.config.onCommand(cmd);\n } catch (e) {\n this.config.logger.warn(`onCommand failed: ${e instanceof Error ? e.message : String(e)}`);\n }\n res.statusCode = 200;\n res.end();\n return;\n }\n\n res.statusCode = 405;\n res.end();\n }\n\n private routeGet(url: string): string | null {\n switch (url.split(\"?\")[0]) {\n case \"/\":\n return buildDescXml(this.config.device, this.config.friendlyName, this.config.deviceType);\n case \"/query/device-info\":\n return buildDeviceInfoXml(this.config.device, this.config.friendlyName, this.config.deviceType);\n case \"/query/apps\":\n return buildAppsXml(this.config.apps);\n default:\n return null;\n }\n }\n\n /** Synchronous close \u2014 safe from onUnload. */\n public stop(): void {\n if (this.server) {\n try {\n this.server.close();\n // close() only stops accepting new sockets; established keep-alive\n // connections (a controller holding one) keep the event loop busy and can\n // drag the synchronous onUnload toward a SIGKILL. Force them shut (Node >= 22).\n this.server.closeAllConnections();\n } catch {\n // already closed\n }\n this.server = undefined;\n }\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
var constants_exports = {};
|
|
20
|
+
__export(constants_exports, {
|
|
21
|
+
DEFAULT_ECP_PORT: () => DEFAULT_ECP_PORT
|
|
22
|
+
});
|
|
23
|
+
module.exports = __toCommonJS(constants_exports);
|
|
24
|
+
const DEFAULT_ECP_PORT = 8060;
|
|
25
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
26
|
+
0 && (module.exports = {
|
|
27
|
+
DEFAULT_ECP_PORT
|
|
28
|
+
});
|
|
29
|
+
//# sourceMappingURL=constants.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../src/lib/constants.ts"],
|
|
4
|
+
"sourcesContent": ["/**\n * Shared constants for the Roku emulator \u2014 kept in one place so the runtime\n * (main.ts) and the admin device-manager (device-management.ts) cannot drift apart.\n */\n\n/**\n * The real-Roku ECP port and this adapter's default. A controller reads the\n * actual port from the SSDP advertisement, so any free port works; 8060 is the\n * risk-free default (it also covers controllers that assume the standard port).\n */\nexport const DEFAULT_ECP_PORT = 8060;\n"],
|
|
5
|
+
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAUO,MAAM,mBAAmB;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
package/build/lib/detect-ip.js
CHANGED
|
@@ -18,28 +18,43 @@ var __copyProps = (to, from, except, desc) => {
|
|
|
18
18
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
19
|
var detect_ip_exports = {};
|
|
20
20
|
__export(detect_ip_exports, {
|
|
21
|
+
detectLocalIPv4s: () => detectLocalIPv4s,
|
|
21
22
|
detectPrimaryIPv4: () => detectPrimaryIPv4,
|
|
23
|
+
listNonInternalIPv4s: () => listNonInternalIPv4s,
|
|
22
24
|
pickPrimaryIPv4: () => pickPrimaryIPv4
|
|
23
25
|
});
|
|
24
26
|
module.exports = __toCommonJS(detect_ip_exports);
|
|
25
27
|
var import_node_os = require("node:os");
|
|
26
|
-
function
|
|
28
|
+
function isRoutableIPv4(addr) {
|
|
29
|
+
const isV4 = addr.family === "IPv4" || addr.family === 4;
|
|
30
|
+
return isV4 && !addr.internal;
|
|
31
|
+
}
|
|
32
|
+
function listNonInternalIPv4s(interfaces) {
|
|
33
|
+
const out = [];
|
|
27
34
|
for (const addrs of Object.values(interfaces)) {
|
|
28
35
|
for (const addr of addrs != null ? addrs : []) {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
return addr.address;
|
|
36
|
+
if (isRoutableIPv4(addr)) {
|
|
37
|
+
out.push(addr.address);
|
|
32
38
|
}
|
|
33
39
|
}
|
|
34
40
|
}
|
|
35
|
-
return
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
function pickPrimaryIPv4(interfaces) {
|
|
44
|
+
var _a;
|
|
45
|
+
return (_a = listNonInternalIPv4s(interfaces)[0]) != null ? _a : "";
|
|
36
46
|
}
|
|
37
47
|
function detectPrimaryIPv4() {
|
|
38
48
|
return pickPrimaryIPv4((0, import_node_os.networkInterfaces)());
|
|
39
49
|
}
|
|
50
|
+
function detectLocalIPv4s() {
|
|
51
|
+
return listNonInternalIPv4s((0, import_node_os.networkInterfaces)());
|
|
52
|
+
}
|
|
40
53
|
// Annotate the CommonJS export names for ESM import in node:
|
|
41
54
|
0 && (module.exports = {
|
|
55
|
+
detectLocalIPv4s,
|
|
42
56
|
detectPrimaryIPv4,
|
|
57
|
+
listNonInternalIPv4s,
|
|
43
58
|
pickPrimaryIPv4
|
|
44
59
|
});
|
|
45
60
|
//# sourceMappingURL=detect-ip.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/detect-ip.ts"],
|
|
4
|
-
"sourcesContent": ["import { networkInterfaces, type NetworkInterfaceInfo } from \"node:os\";\n\n/**\n *
|
|
5
|
-
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,qBAA6D;
|
|
4
|
+
"sourcesContent": ["import { networkInterfaces, type NetworkInterfaceInfo } from \"node:os\";\n\n/**\n * Is this a routable (non-internal) IPv4 address? Node typed `family` as the\n * string \"IPv4\" historically and as the number 4 since v18 \u2014 accept both so\n * detection works across runtimes.\n *\n * @param addr one entry from an os.networkInterfaces() list\n * @returns true for a non-internal IPv4 address\n */\nfunction isRoutableIPv4(addr: NetworkInterfaceInfo): boolean {\n const isV4 = addr.family === \"IPv4\" || (addr.family as unknown as number) === 4;\n return isV4 && !addr.internal;\n}\n\n/**\n * Every non-internal IPv4 address of the given interface map, in enumeration\n * order. Pure \u2014 takes the interface map so it can be unit-tested without real\n * network cards.\n *\n * @param interfaces the OS network-interface map (os.networkInterfaces() shape)\n * @returns every routable IPv4 address (may be empty)\n */\nexport function listNonInternalIPv4s(interfaces: NodeJS.Dict<NetworkInterfaceInfo[]>): string[] {\n const out: string[] = [];\n for (const addrs of Object.values(interfaces)) {\n for (const addr of addrs ?? []) {\n if (isRoutableIPv4(addr)) {\n out.push(addr.address);\n }\n }\n }\n return out;\n}\n\n/**\n * Pick the first non-internal IPv4 from a set of OS network interfaces. Pure \u2014\n * takes the interface map so it can be unit-tested without real network cards.\n *\n * @param interfaces the OS network-interface map (os.networkInterfaces() shape)\n * @returns the first routable IPv4 address, or \"\" if none is found\n */\nexport function pickPrimaryIPv4(interfaces: NodeJS.Dict<NetworkInterfaceInfo[]>): string {\n return listNonInternalIPv4s(interfaces)[0] ?? \"\";\n}\n\n/**\n * Best-effort primary IPv4 of the host \u2014 used as the advertised SSDP LOCATION\n * when no interface is configured, so a controller gets a routable IP instead of\n * the bind wildcard (`0.0.0.0` is not reachable). Returns \"\" when the host has no\n * routable IPv4 (e.g. no network), which the caller treats as \"cannot advertise\".\n *\n * @returns the primary IPv4 address, or \"\" if none is found\n */\nexport function detectPrimaryIPv4(): string {\n return pickPrimaryIPv4(networkInterfaces());\n}\n\n/**\n * All routable IPv4 addresses of the host \u2014 the interfaces to join the SSDP\n * multicast group on when no specific interface is configured, so discovery\n * works on every LAN the host is on. Without this a multi-homed host only hears\n * M-SEARCH on the OS default interface. Empty when the host has no routable IPv4.\n *\n * @returns every routable IPv4 address of the host (may be empty)\n */\nexport function detectLocalIPv4s(): string[] {\n return listNonInternalIPv4s(networkInterfaces());\n}\n"],
|
|
5
|
+
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,qBAA6D;AAU7D,SAAS,eAAe,MAAqC;AAC3D,QAAM,OAAO,KAAK,WAAW,UAAW,KAAK,WAAiC;AAC9E,SAAO,QAAQ,CAAC,KAAK;AACvB;AAUO,SAAS,qBAAqB,YAA2D;AAC9F,QAAM,MAAgB,CAAC;AACvB,aAAW,SAAS,OAAO,OAAO,UAAU,GAAG;AAC7C,eAAW,QAAQ,wBAAS,CAAC,GAAG;AAC9B,UAAI,eAAe,IAAI,GAAG;AACxB,YAAI,KAAK,KAAK,OAAO;AAAA,MACvB;AAAA,IACF;AAAA,EACF;AACA,SAAO;AACT;AASO,SAAS,gBAAgB,YAAyD;AA1CzF;AA2CE,UAAO,0BAAqB,UAAU,EAAE,CAAC,MAAlC,YAAuC;AAChD;AAUO,SAAS,oBAA4B;AAC1C,SAAO,oBAAgB,kCAAkB,CAAC;AAC5C;AAUO,SAAS,mBAA6B;AAC3C,SAAO,yBAAqB,kCAAkB,CAAC;AACjD;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __copyProps = (to, from, except, desc) => {
|
|
7
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
8
|
+
for (let key of __getOwnPropNames(from))
|
|
9
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
10
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
11
|
+
}
|
|
12
|
+
return to;
|
|
13
|
+
};
|
|
14
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
15
|
+
var logger_exports = {};
|
|
16
|
+
module.exports = __toCommonJS(logger_exports);
|
|
17
|
+
//# sourceMappingURL=logger.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../src/lib/logger.ts"],
|
|
4
|
+
"sourcesContent": ["/**\n * The minimal logger shape the network components need \u2014 satisfied by the\n * adapter's `this.log`. Kept in a neutral module so the SSDP responder and the\n * ECP HTTP server both depend on it without depending on each other.\n */\nexport type AdapterLogger = Pick<ioBroker.Log, \"debug\" | \"warn\" | \"error\">;\n"],
|
|
5
|
+
"mappings": ";;;;;;;;;;;;;;AAAA;AAAA;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
package/build/main.js
CHANGED
|
@@ -39,6 +39,7 @@ var import_ssdp_responder = require("./discovery/ssdp-responder");
|
|
|
39
39
|
var import_device_info = require("./ecp/device-info");
|
|
40
40
|
var import_ecp_http_server = require("./ecp/ecp-http-server");
|
|
41
41
|
var import_state_model = require("./ecp/state-model");
|
|
42
|
+
var import_constants = require("./lib/constants");
|
|
42
43
|
var import_device_identity = require("./lib/device-identity");
|
|
43
44
|
var import_detect_ip = require("./lib/detect-ip");
|
|
44
45
|
var import_object_cleanup = require("./lib/object-cleanup");
|
|
@@ -46,12 +47,14 @@ var import_pure_helpers = require("./lib/pure-helpers");
|
|
|
46
47
|
const SSDP_START_TIMEOUT_MS = 5e3;
|
|
47
48
|
const SSDP_NOTIFY_INTERVAL_MS = 3e5;
|
|
48
49
|
const KEY_PULSE_MS = 50;
|
|
49
|
-
const
|
|
50
|
+
const HOLD_MAX_MS = 3e4;
|
|
50
51
|
class Fakeroku extends utils.Adapter {
|
|
51
52
|
ssdp;
|
|
52
53
|
notifyTimer;
|
|
53
54
|
ecpServers = [];
|
|
54
55
|
pulseTimers = /* @__PURE__ */ new Set();
|
|
56
|
+
/** Per held key id, its watchdog timer — so a keydown without a keyup cannot pin it true forever. */
|
|
57
|
+
holdTimers = /* @__PURE__ */ new Map();
|
|
55
58
|
/** Per device, the key names it exposes — so a keypress only writes keys this device carries. */
|
|
56
59
|
deviceKeys = /* @__PURE__ */ new Map();
|
|
57
60
|
/** Device-manager backend: the emulated Rokus as cards with add/edit/delete. */
|
|
@@ -87,36 +90,69 @@ class Fakeroku extends utils.Adapter {
|
|
|
87
90
|
return;
|
|
88
91
|
}
|
|
89
92
|
const adverts = [];
|
|
93
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
90
94
|
for (const d of configured) {
|
|
91
95
|
const deviceId = (0, import_pure_helpers.sanitizeId)(d.name);
|
|
96
|
+
if (seenIds.has(deviceId)) {
|
|
97
|
+
this.log.warn(`Emulated Roku "${d.name}" maps to an object id already in use (${deviceId}) \u2014 skipping it.`);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
92
100
|
const deviceType = d.type === "tv" ? "tv" : "player";
|
|
93
101
|
const keys = (0, import_state_model.keysForType)(deviceType);
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
102
|
+
const advert = { uuid: d.uuid || (0, import_device_identity.deriveUuid)(d.name), port: Number(d.port) || import_constants.DEFAULT_ECP_PORT };
|
|
103
|
+
try {
|
|
104
|
+
await this.createDeviceStates(deviceId, d.name, keys);
|
|
105
|
+
const server = new import_ecp_http_server.EcpHttpServer({
|
|
106
|
+
device: advert,
|
|
107
|
+
friendlyName: d.name,
|
|
108
|
+
apps: import_device_info.DEFAULT_APPS,
|
|
109
|
+
deviceType,
|
|
110
|
+
bindIp,
|
|
111
|
+
logger: this.log,
|
|
112
|
+
onCommand: (cmd) => this.applyCommand(deviceId, cmd)
|
|
113
|
+
});
|
|
114
|
+
await server.start();
|
|
115
|
+
this.ecpServers.push(server);
|
|
116
|
+
this.deviceKeys.set(deviceId, new Set(keys));
|
|
117
|
+
seenIds.add(deviceId);
|
|
118
|
+
adverts.push(advert);
|
|
119
|
+
} catch (e) {
|
|
120
|
+
this.log.warn(
|
|
121
|
+
`Emulated Roku "${d.name}" could not start on port ${advert.port}: ${e instanceof Error ? e.message : String(e)} \u2014 skipping it.`
|
|
122
|
+
);
|
|
123
|
+
}
|
|
109
124
|
}
|
|
110
125
|
await this.cleanupOrphans(new Set(configured.map((d) => (0, import_pure_helpers.sanitizeId)(d.name))));
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
126
|
+
if (adverts.length === 0) {
|
|
127
|
+
this.log.error("No emulated Roku device could be started \u2014 check the configured ports for conflicts.");
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const membershipInterfaces = bindIp ? [bindIp] : (0, import_detect_ip.detectLocalIPv4s)();
|
|
131
|
+
this.ssdp = new import_ssdp_responder.RokuSsdpResponder({
|
|
132
|
+
devices: adverts,
|
|
133
|
+
bindIp,
|
|
134
|
+
advertiseIp,
|
|
135
|
+
membershipInterfaces,
|
|
136
|
+
logger: this.log,
|
|
137
|
+
onFatalError: () => this.onSsdpFatal()
|
|
138
|
+
});
|
|
139
|
+
try {
|
|
140
|
+
await this.startWithTimeout(this.ssdp.start(), SSDP_START_TIMEOUT_MS);
|
|
141
|
+
this.ssdp.announce();
|
|
142
|
+
this.notifyTimer = this.setInterval(() => {
|
|
143
|
+
var _a2;
|
|
144
|
+
return (_a2 = this.ssdp) == null ? void 0 : _a2.announce();
|
|
145
|
+
}, SSDP_NOTIFY_INTERVAL_MS);
|
|
146
|
+
} catch (e) {
|
|
147
|
+
this.log.warn(
|
|
148
|
+
`SSDP discovery unavailable: ${e instanceof Error ? e.message : String(e)} \u2014 already-paired remotes still work; set the network interface if devices are not found.`
|
|
149
|
+
);
|
|
150
|
+
this.ssdp = void 0;
|
|
151
|
+
}
|
|
118
152
|
await this.setState("info.connection", { val: true, ack: true });
|
|
119
|
-
this.log.info(
|
|
153
|
+
this.log.info(
|
|
154
|
+
`Emulating ${adverts.length} Roku device(s), advertising on ${advertiseIp}${this.ssdp ? "" : " (discovery off)"}`
|
|
155
|
+
);
|
|
120
156
|
} catch (e) {
|
|
121
157
|
this.log.error(`onReady failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
122
158
|
}
|
|
@@ -201,8 +237,38 @@ class Fakeroku extends utils.Adapter {
|
|
|
201
237
|
this.pulseTimers.add(timer);
|
|
202
238
|
}
|
|
203
239
|
} else if (write.holdKey && (keys == null ? void 0 : keys.has(write.holdKey.key))) {
|
|
204
|
-
|
|
240
|
+
const id = `${deviceId}.keys.${write.holdKey.key}`;
|
|
241
|
+
void this.setState(id, { val: write.holdKey.value, ack: true });
|
|
242
|
+
const pending = this.holdTimers.get(id);
|
|
243
|
+
if (pending) {
|
|
244
|
+
this.clearTimeout(pending);
|
|
245
|
+
this.holdTimers.delete(id);
|
|
246
|
+
}
|
|
247
|
+
if (write.holdKey.value) {
|
|
248
|
+
const timer = this.setTimeout(() => {
|
|
249
|
+
this.holdTimers.delete(id);
|
|
250
|
+
void this.setState(id, { val: false, ack: true });
|
|
251
|
+
}, HOLD_MAX_MS);
|
|
252
|
+
if (timer) {
|
|
253
|
+
this.holdTimers.set(id, timer);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* The SSDP responder died at runtime (a socket error after a good start). Stop
|
|
260
|
+
* announcing into the dead socket and drop the discovery aid. The ECP servers
|
|
261
|
+
* keep working, so info.connection — which reflects ECP readiness — stays true.
|
|
262
|
+
*/
|
|
263
|
+
onSsdpFatal() {
|
|
264
|
+
if (this.notifyTimer) {
|
|
265
|
+
this.clearInterval(this.notifyTimer);
|
|
266
|
+
this.notifyTimer = void 0;
|
|
205
267
|
}
|
|
268
|
+
this.ssdp = void 0;
|
|
269
|
+
this.log.warn(
|
|
270
|
+
"SSDP discovery stopped after a socket error \u2014 already-paired remotes still work; restart the instance to re-enable discovery."
|
|
271
|
+
);
|
|
206
272
|
}
|
|
207
273
|
/**
|
|
208
274
|
* Bound await: reject if the SSDP start doesn't settle in time, so a stuck
|
|
@@ -248,6 +314,10 @@ class Fakeroku extends utils.Adapter {
|
|
|
248
314
|
this.clearTimeout(t);
|
|
249
315
|
}
|
|
250
316
|
this.pulseTimers.clear();
|
|
317
|
+
for (const t of this.holdTimers.values()) {
|
|
318
|
+
this.clearTimeout(t);
|
|
319
|
+
}
|
|
320
|
+
this.holdTimers.clear();
|
|
251
321
|
(_a = this.ssdp) == null ? void 0 : _a.stop();
|
|
252
322
|
for (const s of this.ecpServers) {
|
|
253
323
|
s.stop();
|
package/build/main.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/main.ts"],
|
|
4
|
-
"sourcesContent": ["import * as utils from \"@iobroker/adapter-core\";\nimport { I18n } from \"@iobroker/adapter-core\";\nimport { join } from \"node:path\";\nimport { FakerokuDeviceManagement } from \"./device-management\";\nimport type { RokuAdvert } from \"./discovery/ssdp-messages\";\nimport { RokuSsdpResponder } from \"./discovery/ssdp-responder\";\nimport { DEFAULT_APPS } from \"./ecp/device-info\";\nimport type { CommandEvent } from \"./ecp/ecp-command\";\nimport { EcpHttpServer } from \"./ecp/ecp-http-server\";\nimport { commandToStateWrite, type DeviceType, keysForType } from \"./ecp/state-model\";\nimport { deriveUuid } from \"./lib/device-identity\";\nimport { detectPrimaryIPv4 } from \"./lib/detect-ip\";\nimport { planObjectCleanup } from \"./lib/object-cleanup\";\nimport { sanitizeId } from \"./lib/pure-helpers\";\n\n/** Managed timeout for a stuck SSDP start (a busy port 1900 must not hang onReady). */\nconst SSDP_START_TIMEOUT_MS = 5000;\n/** Proactive ssdp:alive interval so controllers find the device without searching. */\nconst SSDP_NOTIFY_INTERVAL_MS = 300_000;\n/** How long a keypress pulses its keys.<Key> state true before falling back to false. */\nconst KEY_PULSE_MS = 50;\nconst DEFAULT_ECP_PORT = 8060;\n\n/**\n * ioBroker.fakeroku \u2014 Roku emulator (input side).\n *\n * Emulates one or more Roku devices on the LAN so that ECP/SSDP remotes\n * (Logitech Harmony or a Sofabaton X1/X2) trigger events in ioBroker:\n * a keypress lands in `<device>.command` and pulses `<device>.keys.<Key>`.\n */\nexport class Fakeroku extends utils.Adapter {\n private ssdp: RokuSsdpResponder | undefined;\n private notifyTimer: ioBroker.Interval | undefined;\n private readonly ecpServers: EcpHttpServer[] = [];\n private readonly pulseTimers = new Set<ioBroker.Timeout>();\n /** Per device, the key names it exposes \u2014 so a keypress only writes keys this device carries. */\n private readonly deviceKeys = new Map<string, ReadonlySet<string>>();\n /** Device-manager backend: the emulated Rokus as cards with add/edit/delete. */\n private readonly deviceManagement: FakerokuDeviceManagement;\n\n /**\n * @param options adapter options passed through by js-controller\n */\n public constructor(options: Partial<utils.AdapterOptions> = {}) {\n super({\n ...options,\n name: \"fakeroku\",\n });\n\n this.on(\"ready\", this.onReady.bind(this));\n this.on(\"unload\", this.onUnload.bind(this));\n this.deviceManagement = new FakerokuDeviceManagement(this);\n }\n\n /** Create each device's object tree, start its ECP server, then the shared SSDP responder. */\n private async onReady(): Promise<void> {\n try {\n await this.setState(\"info.connection\", { val: false, ack: true });\n await I18n.init(join(this.adapterDir, \"admin\"), this);\n\n // Empty AND \"0.0.0.0\" both mean \"auto\": bind all interfaces, advertise the\n // detected primary IP so the adapter runs without configuration. js-controller\n // never rewrites an existing native default, so instances from before 0.5.1\n // still carry \"\" \u2014 both must take the auto path. A concrete IP is honoured as-is.\n // Migration: a pre-0.5.0 instance has no `networkInterface`, only the old `BIND` \u2014\n // adopt it so a multi-homed host keeps the interface the user had configured.\n const configuredIp = this.config.networkInterface || this.config.BIND;\n const bindIp = configuredIp && configuredIp !== \"0.0.0.0\" ? configuredIp : undefined;\n const advertiseIp = bindIp ?? detectPrimaryIPv4();\n if (!advertiseIp) {\n this.log.warn(\"No routable IPv4 address found to advertise \u2014 set the network interface in the settings.\");\n return;\n }\n\n const configured = (this.config.devices ?? []).filter(d => d && typeof d.name === \"string\" && d.name.length > 0);\n if (configured.length === 0) {\n this.log.warn(\"No emulated Roku devices configured.\");\n return;\n }\n\n const adverts: RokuAdvert[] = [];\n for (const d of configured) {\n const deviceId = sanitizeId(d.name);\n const deviceType: DeviceType = d.type === \"tv\" ? \"tv\" : \"player\";\n const keys = keysForType(deviceType);\n this.deviceKeys.set(deviceId, new Set(keys));\n // Adopt the device's persisted uuid (old adapter or an earlier run) so the SSDP\n // identity \u2014 and thus the controller pairing \u2014 survives an update; derive a stable\n // one from the name only for a device that never had one.\n const advert: RokuAdvert = { uuid: d.uuid || deriveUuid(d.name), port: Number(d.port) || DEFAULT_ECP_PORT };\n await this.createDeviceStates(deviceId, d.name, keys);\n const server = new EcpHttpServer({\n device: advert,\n friendlyName: d.name,\n apps: DEFAULT_APPS,\n deviceType,\n bindIp,\n logger: this.log,\n onCommand: cmd => this.applyCommand(deviceId, cmd),\n });\n await server.start();\n this.ecpServers.push(server);\n adverts.push(advert);\n }\n\n await this.cleanupOrphans(new Set(configured.map(d => sanitizeId(d.name))));\n\n this.ssdp = new RokuSsdpResponder({ devices: adverts, bindIp, advertiseIp, logger: this.log });\n await this.startWithTimeout(this.ssdp.start(), SSDP_START_TIMEOUT_MS);\n this.ssdp.announce();\n this.notifyTimer = this.setInterval(() => this.ssdp?.announce(), SSDP_NOTIFY_INTERVAL_MS);\n\n await this.setState(\"info.connection\", { val: true, ack: true });\n this.log.info(`Emulating ${adverts.length} Roku device(s), advertising on ${advertiseIp}`);\n } catch (e) {\n this.log.error(`onReady failed: ${e instanceof Error ? e.message : String(e)}`);\n }\n }\n\n /**\n * Create the fixed object tree for one emulated Roku: the device, `command` +\n * `commandType`, and one `sensor` boolean state per key the device type exposes \u2014\n * all up front, so the tree is usable before any key is ever pressed.\n *\n * @param deviceId the id-safe device path segment\n * @param friendlyName the configured device name\n * @param keys the key names to create for this device (from its type)\n */\n private async createDeviceStates(deviceId: string, friendlyName: string, keys: readonly string[]): Promise<void> {\n await this.extendObject(deviceId, { type: \"device\", common: { name: friendlyName }, native: {} });\n await this.extendObject(`${deviceId}.command`, {\n type: \"state\",\n common: { name: \"Last command\", type: \"string\", role: \"text\", read: true, write: false, def: \"\" },\n native: {},\n });\n await this.extendObject(`${deviceId}.commandType`, {\n type: \"state\",\n common: { name: \"Last command type\", type: \"string\", role: \"text\", read: true, write: false, def: \"\" },\n native: {},\n });\n await this.extendObject(`${deviceId}.keys`, { type: \"channel\", common: { name: \"keys\" }, native: {} });\n for (const key of keys) {\n await this.extendObject(`${deviceId}.keys.${key}`, {\n type: \"state\",\n // \"sensor\" = generic boolean read-only (active/inactive). The docs suggest\n // button.press for a keypress-as-state, but the repochecker rejects it\n // (E1010 \u2014 not in its role list); sensor is the gate-conformant fit.\n common: { name: key, type: \"boolean\", role: \"sensor\", read: true, write: false, def: false },\n native: {},\n });\n }\n }\n\n /**\n * Remove objects left over from an earlier version or config \u2014 the legacy\n * `apps` node, keys no longer standard, and whole device sub-trees no longer\n * configured (a renamed/removed device). The adapter otherwise only ever\n * creates objects, so without this the tree would accrete stale entries.\n *\n * @param configuredDeviceIds the id-safe names of the currently configured devices\n */\n private async cleanupOrphans(configuredDeviceIds: ReadonlySet<string>): Promise<void> {\n const objects = await this.getAdapterObjectsAsync();\n const prefix = `${this.namespace}.`;\n const existingIds = Object.keys(objects)\n .filter(id => id.startsWith(prefix))\n .map(id => id.slice(prefix.length));\n const toDelete = planObjectCleanup(existingIds, configuredDeviceIds, this.deviceKeys);\n for (const id of toDelete) {\n await this.delObjectAsync(id, { recursive: true }).catch((e: unknown) => {\n this.log.debug(`cleanup: could not delete ${id}: ${e instanceof Error ? e.message : String(e)}`);\n });\n }\n if (toDelete.length > 0) {\n this.log.info(`Removed ${toDelete.length} orphaned object(s) from an earlier version or config.`);\n }\n }\n\n /**\n * Apply a received ECP command to this device's states: record it in `command`\n * / `commandType`, and pulse or hold the standard key if it is one.\n *\n * @param deviceId the id-safe device path segment\n * @param cmd the parsed ECP command\n */\n private applyCommand(deviceId: string, cmd: CommandEvent): void {\n const write = commandToStateWrite(cmd);\n void this.setState(`${deviceId}.command`, { val: write.command, ack: true });\n void this.setState(`${deviceId}.commandType`, { val: write.commandType, ack: true });\n // Only write keys.<Key> if THIS device's type carries the key \u2014 a player has no\n // TV key objects, so a stray TV keypress lands in `command` only, never a missing state.\n const keys = this.deviceKeys.get(deviceId);\n if (write.pulseKey && keys?.has(write.pulseKey)) {\n const id = `${deviceId}.keys.${write.pulseKey}`;\n void this.setState(id, { val: true, ack: true });\n const timer = this.setTimeout(() => {\n if (timer) {\n this.pulseTimers.delete(timer);\n }\n void this.setState(id, { val: false, ack: true });\n }, KEY_PULSE_MS);\n if (timer) {\n this.pulseTimers.add(timer);\n }\n } else if (write.holdKey && keys?.has(write.holdKey.key)) {\n void this.setState(`${deviceId}.keys.${write.holdKey.key}`, { val: write.holdKey.value, ack: true });\n }\n }\n\n /**\n * Bound await: reject if the SSDP start doesn't settle in time, so a stuck\n * port-1900 bind degrades to \"discovery off\" instead of hanging the adapter.\n *\n * @param promise the SSDP start promise\n * @param ms the timeout in milliseconds\n * @returns a promise that settles with the start result or a timeout error\n */\n private startWithTimeout(promise: Promise<void>, ms: number): Promise<void> {\n return new Promise<void>((resolve, reject) => {\n const timer = this.setTimeout(() => reject(new Error(`SSDP start timed out after ${ms} ms`)), ms);\n const clear = (): void => {\n if (timer) {\n this.clearTimeout(timer);\n }\n };\n promise.then(\n () => {\n clear();\n resolve();\n },\n (e: unknown) => {\n clear();\n reject(e instanceof Error ? e : new Error(String(e)));\n },\n );\n });\n }\n\n /**\n * Synchronous teardown \u2014 no await, call the callback immediately (SIGKILL otherwise).\n *\n * @param callback function to invoke once teardown is complete\n */\n private onUnload(callback: () => void): void {\n try {\n if (this.notifyTimer) {\n this.clearInterval(this.notifyTimer);\n this.notifyTimer = undefined;\n }\n for (const t of this.pulseTimers) {\n this.clearTimeout(t);\n }\n this.pulseTimers.clear();\n this.ssdp?.stop();\n for (const s of this.ecpServers) {\n s.stop();\n }\n void this.setState(\"info.connection\", { val: false, ack: true });\n callback();\n } catch {\n callback();\n }\n }\n}\n\nif (require.main !== module) {\n // Export the constructor in compact mode\n module.exports = (options: Partial<utils.AdapterOptions> | undefined) => new Fakeroku(options);\n} else {\n // Start the instance directly\n (() => new Fakeroku())();\n}\n"],
|
|
5
|
-
"mappings": ";;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,YAAuB;AACvB,0BAAqB;AACrB,uBAAqB;AACrB,+BAAyC;AAEzC,4BAAkC;AAClC,yBAA6B;AAE7B,6BAA8B;AAC9B,yBAAkE;AAClE,6BAA2B;AAC3B,
|
|
4
|
+
"sourcesContent": ["import * as utils from \"@iobroker/adapter-core\";\nimport { I18n } from \"@iobroker/adapter-core\";\nimport { join } from \"node:path\";\nimport { FakerokuDeviceManagement } from \"./device-management\";\nimport type { RokuAdvert } from \"./discovery/ssdp-messages\";\nimport { RokuSsdpResponder } from \"./discovery/ssdp-responder\";\nimport { DEFAULT_APPS } from \"./ecp/device-info\";\nimport type { CommandEvent } from \"./ecp/ecp-command\";\nimport { EcpHttpServer } from \"./ecp/ecp-http-server\";\nimport { commandToStateWrite, type DeviceType, keysForType } from \"./ecp/state-model\";\nimport { DEFAULT_ECP_PORT } from \"./lib/constants\";\nimport { deriveUuid } from \"./lib/device-identity\";\nimport { detectLocalIPv4s, detectPrimaryIPv4 } from \"./lib/detect-ip\";\nimport { planObjectCleanup } from \"./lib/object-cleanup\";\nimport { sanitizeId } from \"./lib/pure-helpers\";\n\n/** Managed timeout for a stuck SSDP start (a busy port 1900 must not hang onReady). */\nconst SSDP_START_TIMEOUT_MS = 5000;\n/** Proactive ssdp:alive interval so controllers find the device without searching. */\nconst SSDP_NOTIFY_INTERVAL_MS = 300_000;\n/** How long a keypress pulses its keys.<Key> state true before falling back to false. */\nconst KEY_PULSE_MS = 50;\n/** Safety cap for a held key: a keydown with no matching keyup resets after this. */\nconst HOLD_MAX_MS = 30_000;\n\n/**\n * ioBroker.fakeroku \u2014 Roku emulator (input side).\n *\n * Emulates one or more Roku devices on the LAN so that ECP/SSDP remotes\n * (Logitech Harmony or a Sofabaton X1/X2) trigger events in ioBroker:\n * a keypress lands in `<device>.command` and pulses `<device>.keys.<Key>`.\n */\nexport class Fakeroku extends utils.Adapter {\n private ssdp: RokuSsdpResponder | undefined;\n private notifyTimer: ioBroker.Interval | undefined;\n private readonly ecpServers: EcpHttpServer[] = [];\n private readonly pulseTimers = new Set<ioBroker.Timeout>();\n /** Per held key id, its watchdog timer \u2014 so a keydown without a keyup cannot pin it true forever. */\n private readonly holdTimers = new Map<string, ioBroker.Timeout>();\n /** Per device, the key names it exposes \u2014 so a keypress only writes keys this device carries. */\n private readonly deviceKeys = new Map<string, ReadonlySet<string>>();\n /** Device-manager backend: the emulated Rokus as cards with add/edit/delete. */\n private readonly deviceManagement: FakerokuDeviceManagement;\n\n /**\n * @param options adapter options passed through by js-controller\n */\n public constructor(options: Partial<utils.AdapterOptions> = {}) {\n super({\n ...options,\n name: \"fakeroku\",\n });\n\n this.on(\"ready\", this.onReady.bind(this));\n this.on(\"unload\", this.onUnload.bind(this));\n this.deviceManagement = new FakerokuDeviceManagement(this);\n }\n\n /** Create each device's object tree, start its ECP server, then the shared SSDP responder. */\n private async onReady(): Promise<void> {\n try {\n await this.setState(\"info.connection\", { val: false, ack: true });\n await I18n.init(join(this.adapterDir, \"admin\"), this);\n\n // Empty AND \"0.0.0.0\" both mean \"auto\": bind all interfaces, advertise the\n // detected primary IP so the adapter runs without configuration. js-controller\n // never rewrites an existing native default, so instances from before 0.5.1\n // still carry \"\" \u2014 both must take the auto path. A concrete IP is honoured as-is.\n // Migration: a pre-0.5.0 instance has no `networkInterface`, only the old `BIND` \u2014\n // adopt it so a multi-homed host keeps the interface the user had configured.\n const configuredIp = this.config.networkInterface || this.config.BIND;\n const bindIp = configuredIp && configuredIp !== \"0.0.0.0\" ? configuredIp : undefined;\n const advertiseIp = bindIp ?? detectPrimaryIPv4();\n if (!advertiseIp) {\n this.log.warn(\"No routable IPv4 address found to advertise \u2014 set the network interface in the settings.\");\n return;\n }\n\n const configured = (this.config.devices ?? []).filter(d => d && typeof d.name === \"string\" && d.name.length > 0);\n if (configured.length === 0) {\n this.log.warn(\"No emulated Roku devices configured.\");\n return;\n }\n\n const adverts: RokuAdvert[] = [];\n const seenIds = new Set<string>();\n for (const d of configured) {\n const deviceId = sanitizeId(d.name);\n // Two configured names can sanitize to the same object id \u2014 the admin guards\n // against it, but a hand-edited config could still carry it. Skip the duplicate\n // instead of letting two devices fight over one object tree.\n if (seenIds.has(deviceId)) {\n this.log.warn(`Emulated Roku \"${d.name}\" maps to an object id already in use (${deviceId}) \u2014 skipping it.`);\n continue;\n }\n const deviceType: DeviceType = d.type === \"tv\" ? \"tv\" : \"player\";\n const keys = keysForType(deviceType);\n // Adopt the device's persisted uuid (old adapter or an earlier run) so the SSDP\n // identity \u2014 and thus the controller pairing \u2014 survives an update; derive a stable\n // one from the name only for a device that never had one.\n const advert: RokuAdvert = { uuid: d.uuid || deriveUuid(d.name), port: Number(d.port) || DEFAULT_ECP_PORT };\n try {\n await this.createDeviceStates(deviceId, d.name, keys);\n const server = new EcpHttpServer({\n device: advert,\n friendlyName: d.name,\n apps: DEFAULT_APPS,\n deviceType,\n bindIp,\n logger: this.log,\n onCommand: cmd => this.applyCommand(deviceId, cmd),\n });\n await server.start();\n this.ecpServers.push(server);\n this.deviceKeys.set(deviceId, new Set(keys));\n seenIds.add(deviceId);\n adverts.push(advert);\n } catch (e) {\n // One device's failure (a busy ECP port) must not take the others down.\n this.log.warn(\n `Emulated Roku \"${d.name}\" could not start on port ${advert.port}: ${e instanceof Error ? e.message : String(e)} \u2014 skipping it.`,\n );\n }\n }\n\n await this.cleanupOrphans(new Set(configured.map(d => sanitizeId(d.name))));\n\n if (adverts.length === 0) {\n // Nothing is controllable \u2014 leave info.connection false and stop here.\n this.log.error(\"No emulated Roku device could be started \u2014 check the configured ports for conflicts.\");\n return;\n }\n\n // Discovery is only an aid; the ECP servers above already make the adapter\n // controllable. Isolate the SSDP start so a busy port 1900 (or a stuck bind)\n // degrades to \"discovery off, already-paired remotes still work\" instead of\n // failing the whole start-up. In the auto case join every routable interface so\n // a multi-homed host is discoverable on all its LANs; a chosen interface pins\n // both membership and NOTIFY egress.\n const membershipInterfaces = bindIp ? [bindIp] : detectLocalIPv4s();\n this.ssdp = new RokuSsdpResponder({\n devices: adverts,\n bindIp,\n advertiseIp,\n membershipInterfaces,\n logger: this.log,\n onFatalError: () => this.onSsdpFatal(),\n });\n try {\n await this.startWithTimeout(this.ssdp.start(), SSDP_START_TIMEOUT_MS);\n this.ssdp.announce();\n this.notifyTimer = this.setInterval(() => this.ssdp?.announce(), SSDP_NOTIFY_INTERVAL_MS);\n } catch (e) {\n this.log.warn(\n `SSDP discovery unavailable: ${e instanceof Error ? e.message : String(e)} \u2014 already-paired remotes still work; set the network interface if devices are not found.`,\n );\n this.ssdp = undefined;\n }\n\n await this.setState(\"info.connection\", { val: true, ack: true });\n this.log.info(\n `Emulating ${adverts.length} Roku device(s), advertising on ${advertiseIp}${this.ssdp ? \"\" : \" (discovery off)\"}`,\n );\n } catch (e) {\n this.log.error(`onReady failed: ${e instanceof Error ? e.message : String(e)}`);\n }\n }\n\n /**\n * Create the fixed object tree for one emulated Roku: the device, `command` +\n * `commandType`, and one `sensor` boolean state per key the device type exposes \u2014\n * all up front, so the tree is usable before any key is ever pressed.\n *\n * @param deviceId the id-safe device path segment\n * @param friendlyName the configured device name\n * @param keys the key names to create for this device (from its type)\n */\n private async createDeviceStates(deviceId: string, friendlyName: string, keys: readonly string[]): Promise<void> {\n await this.extendObject(deviceId, { type: \"device\", common: { name: friendlyName }, native: {} });\n await this.extendObject(`${deviceId}.command`, {\n type: \"state\",\n common: { name: \"Last command\", type: \"string\", role: \"text\", read: true, write: false, def: \"\" },\n native: {},\n });\n await this.extendObject(`${deviceId}.commandType`, {\n type: \"state\",\n common: { name: \"Last command type\", type: \"string\", role: \"text\", read: true, write: false, def: \"\" },\n native: {},\n });\n await this.extendObject(`${deviceId}.keys`, { type: \"channel\", common: { name: \"keys\" }, native: {} });\n for (const key of keys) {\n await this.extendObject(`${deviceId}.keys.${key}`, {\n type: \"state\",\n // \"sensor\" = generic boolean read-only (active/inactive). The docs suggest\n // button.press for a keypress-as-state, but the repochecker rejects it\n // (E1010 \u2014 not in its role list); sensor is the gate-conformant fit.\n common: { name: key, type: \"boolean\", role: \"sensor\", read: true, write: false, def: false },\n native: {},\n });\n }\n }\n\n /**\n * Remove objects left over from an earlier version or config \u2014 the legacy\n * `apps` node, keys no longer standard, and whole device sub-trees no longer\n * configured (a renamed/removed device). The adapter otherwise only ever\n * creates objects, so without this the tree would accrete stale entries.\n *\n * @param configuredDeviceIds the id-safe names of the currently configured devices\n */\n private async cleanupOrphans(configuredDeviceIds: ReadonlySet<string>): Promise<void> {\n const objects = await this.getAdapterObjectsAsync();\n const prefix = `${this.namespace}.`;\n const existingIds = Object.keys(objects)\n .filter(id => id.startsWith(prefix))\n .map(id => id.slice(prefix.length));\n const toDelete = planObjectCleanup(existingIds, configuredDeviceIds, this.deviceKeys);\n for (const id of toDelete) {\n await this.delObjectAsync(id, { recursive: true }).catch((e: unknown) => {\n this.log.debug(`cleanup: could not delete ${id}: ${e instanceof Error ? e.message : String(e)}`);\n });\n }\n if (toDelete.length > 0) {\n this.log.info(`Removed ${toDelete.length} orphaned object(s) from an earlier version or config.`);\n }\n }\n\n /**\n * Apply a received ECP command to this device's states: record it in `command`\n * / `commandType`, and pulse or hold the standard key if it is one.\n *\n * @param deviceId the id-safe device path segment\n * @param cmd the parsed ECP command\n */\n private applyCommand(deviceId: string, cmd: CommandEvent): void {\n const write = commandToStateWrite(cmd);\n void this.setState(`${deviceId}.command`, { val: write.command, ack: true });\n void this.setState(`${deviceId}.commandType`, { val: write.commandType, ack: true });\n // Only write keys.<Key> if THIS device's type carries the key \u2014 a player has no\n // TV key objects, so a stray TV keypress lands in `command` only, never a missing state.\n const keys = this.deviceKeys.get(deviceId);\n if (write.pulseKey && keys?.has(write.pulseKey)) {\n const id = `${deviceId}.keys.${write.pulseKey}`;\n void this.setState(id, { val: true, ack: true });\n const timer = this.setTimeout(() => {\n if (timer) {\n this.pulseTimers.delete(timer);\n }\n void this.setState(id, { val: false, ack: true });\n }, KEY_PULSE_MS);\n if (timer) {\n this.pulseTimers.add(timer);\n }\n } else if (write.holdKey && keys?.has(write.holdKey.key)) {\n const id = `${deviceId}.keys.${write.holdKey.key}`;\n void this.setState(id, { val: write.holdKey.value, ack: true });\n // A keydown holds the key true until its keyup. Arm a watchdog so a lost keyup\n // (controller disconnects mid-press) cannot pin the key true forever; a keyup\n // clears it, and a repeated keydown re-arms it.\n const pending = this.holdTimers.get(id);\n if (pending) {\n this.clearTimeout(pending);\n this.holdTimers.delete(id);\n }\n if (write.holdKey.value) {\n const timer = this.setTimeout(() => {\n this.holdTimers.delete(id);\n void this.setState(id, { val: false, ack: true });\n }, HOLD_MAX_MS);\n if (timer) {\n this.holdTimers.set(id, timer);\n }\n }\n }\n }\n\n /**\n * The SSDP responder died at runtime (a socket error after a good start). Stop\n * announcing into the dead socket and drop the discovery aid. The ECP servers\n * keep working, so info.connection \u2014 which reflects ECP readiness \u2014 stays true.\n */\n private onSsdpFatal(): void {\n if (this.notifyTimer) {\n this.clearInterval(this.notifyTimer);\n this.notifyTimer = undefined;\n }\n this.ssdp = undefined;\n this.log.warn(\n \"SSDP discovery stopped after a socket error \u2014 already-paired remotes still work; restart the instance to re-enable discovery.\",\n );\n }\n\n /**\n * Bound await: reject if the SSDP start doesn't settle in time, so a stuck\n * port-1900 bind degrades to \"discovery off\" instead of hanging the adapter.\n *\n * @param promise the SSDP start promise\n * @param ms the timeout in milliseconds\n * @returns a promise that settles with the start result or a timeout error\n */\n private startWithTimeout(promise: Promise<void>, ms: number): Promise<void> {\n return new Promise<void>((resolve, reject) => {\n const timer = this.setTimeout(() => reject(new Error(`SSDP start timed out after ${ms} ms`)), ms);\n const clear = (): void => {\n if (timer) {\n this.clearTimeout(timer);\n }\n };\n promise.then(\n () => {\n clear();\n resolve();\n },\n (e: unknown) => {\n clear();\n reject(e instanceof Error ? e : new Error(String(e)));\n },\n );\n });\n }\n\n /**\n * Synchronous teardown \u2014 no await, call the callback immediately (SIGKILL otherwise).\n *\n * @param callback function to invoke once teardown is complete\n */\n private onUnload(callback: () => void): void {\n try {\n if (this.notifyTimer) {\n this.clearInterval(this.notifyTimer);\n this.notifyTimer = undefined;\n }\n for (const t of this.pulseTimers) {\n this.clearTimeout(t);\n }\n this.pulseTimers.clear();\n for (const t of this.holdTimers.values()) {\n this.clearTimeout(t);\n }\n this.holdTimers.clear();\n this.ssdp?.stop();\n for (const s of this.ecpServers) {\n s.stop();\n }\n void this.setState(\"info.connection\", { val: false, ack: true });\n callback();\n } catch {\n callback();\n }\n }\n}\n\nif (require.main !== module) {\n // Export the constructor in compact mode\n module.exports = (options: Partial<utils.AdapterOptions> | undefined) => new Fakeroku(options);\n} else {\n // Start the instance directly\n (() => new Fakeroku())();\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["_a"]
|
|
7
7
|
}
|
package/io-package.json
CHANGED
|
@@ -1,8 +1,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"common": {
|
|
3
3
|
"name": "fakeroku",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "1.1.0",
|
|
5
5
|
"news": {
|
|
6
|
+
"1.1.0": {
|
|
7
|
+
"en": "More robust: a busy port on one Roku no longer stops the others, and discovery now reaches every network interface even on hosts with several.",
|
|
8
|
+
"de": "Robuster: ein belegter Port an einem Roku stoppt die anderen nicht mehr, und die Erkennung erreicht jetzt jede Netzwerkkarte, auch bei mehreren.",
|
|
9
|
+
"ru": "Надёжнее: занятый порт одного Roku больше не останавливает остальные, а обнаружение теперь охватывает все сетевые интерфейсы, даже если их несколько.",
|
|
10
|
+
"pt": "Mais robusto: uma porta ocupada num Roku já não impede os outros, e a deteção abrange agora todas as interfaces de rede, mesmo com várias.",
|
|
11
|
+
"nl": "Robuuster: een bezette poort op één Roku stopt de andere niet meer, en detectie bereikt nu elke netwerkkaart, ook bij meerdere.",
|
|
12
|
+
"fr": "Plus robuste : un port occupé sur un Roku n'arrête plus les autres, et la découverte couvre désormais chaque interface réseau, même s'il y en a plusieurs.",
|
|
13
|
+
"it": "Più robusto: una porta occupata su un Roku non blocca più gli altri e il rilevamento copre ora ogni interfaccia di rete, anche se sono più di una.",
|
|
14
|
+
"es": "Más robusto: un puerto ocupado en un Roku ya no detiene a los demás, y la detección abarca ahora cada interfaz de red, incluso si hay varias.",
|
|
15
|
+
"pl": "Solidniej: zajęty port jednego Roku nie zatrzymuje już pozostałych, a wykrywanie obejmuje teraz każdy interfejs sieciowy, nawet jeśli jest ich kilka.",
|
|
16
|
+
"uk": "Надійніше: зайнятий порт одного Roku більше не зупиняє інші, а виявлення тепер охоплює кожен мережевий інтерфейс, навіть якщо їх кілька.",
|
|
17
|
+
"zh-cn": "更稳健:某个 Roku 的端口被占用不再导致其他设备无法启动,且发现现在覆盖每个网络接口,即使有多个网卡。"
|
|
18
|
+
},
|
|
19
|
+
"1.0.0": {
|
|
20
|
+
"en": "Version 1.0.0 marks the rewrite as stable. On update, a one-time notice explains the remote-key data points are now boolean instead of text, so check scripts and visualizations that read them.",
|
|
21
|
+
"de": "Version 1.0.0 macht den Neubau zur stabilen Version. Beim Update erklärt ein einmaliger Hinweis, dass die Tasten jetzt boolean statt Text sind — prüfe Skripte und Visualisierungen.",
|
|
22
|
+
"ru": "Версия 1.0.0 делает переработку стабильной. При обновлении однократное уведомление поясняет, что кнопки теперь boolean вместо текста — проверьте скрипты и визуализации.",
|
|
23
|
+
"pt": "A versão 1.0.0 torna a reescrita estável. Ao atualizar, um aviso único explica que as teclas agora são boolean em vez de texto — verifique scripts e visualizações.",
|
|
24
|
+
"nl": "Versie 1.0.0 maakt de herbouw stabiel. Bij het updaten legt een eenmalige melding uit dat de knoppen nu boolean zijn in plaats van tekst — controleer scripts en visualisaties.",
|
|
25
|
+
"fr": "La version 1.0.0 stabilise la réécriture. À la mise à jour, un avis unique explique que les touches sont désormais boolean au lieu de texte — vérifiez scripts et visualisations.",
|
|
26
|
+
"it": "La versione 1.0.0 rende stabile la riscrittura. All'aggiornamento, un avviso una tantum spiega che i tasti ora sono boolean invece di testo — controlla script e visualizzazioni.",
|
|
27
|
+
"es": "La versión 1.0.0 estabiliza la reescritura. Al actualizar, un aviso único explica que las teclas ahora son boolean en vez de texto — revisa scripts y visualizaciones.",
|
|
28
|
+
"pl": "Wersja 1.0.0 stabilizuje przebudowę. Przy aktualizacji jednorazowe powiadomienie wyjaśnia, że przyciski są teraz boolean zamiast tekstu — sprawdź skrypty i wizualizacje.",
|
|
29
|
+
"uk": "Версія 1.0.0 робить переробку стабільною. Під час оновлення одноразове сповіщення пояснює, що кнопки тепер boolean замість тексту — перевірте скрипти та візуалізації.",
|
|
30
|
+
"zh-cn": "版本 1.0.0 将重写标记为稳定版。更新时一次性提示会说明按键现在为 boolean 而非文本——请检查读取它们的脚本和可视化。"
|
|
31
|
+
},
|
|
6
32
|
"0.6.0": {
|
|
7
33
|
"en": "Complete rewrite. The adapter now answers the full Roku control surface including device-info with a current Roku version, so Logitech Harmony and Sofabaton remotes work reliably. It runs out of the box, lets you manage several emulated Rokus (Player or TV) from the admin UI, and uses a cleaner object tree with correct data types.",
|
|
8
34
|
"de": "Komplett neu aufgebaut. Der Adapter bedient jetzt die volle Roku-Steueroberfläche inklusive device-info mit aktueller Roku-Version, sodass Logitech Harmony und Sofabaton zuverlässig funktionieren. Er läuft ohne Einrichtung, mehrere emulierte Rokus (Player oder TV) lassen sich im Admin verwalten, und der Objektbaum ist sauberer mit korrekten Datentypen.",
|
|
@@ -67,19 +93,6 @@
|
|
|
67
93
|
"pl": "Adapter wymaga node.js > = 18 i kontroler js- > = 5 teraz\nZaktualizowano zależności",
|
|
68
94
|
"uk": "Адаптер вимагає node.js >= 18 і js-controller >= 5 тепер\nЗалежність було оновлено",
|
|
69
95
|
"zh-cn": "适配器需要节点.js QQ18和js控制器 QQ 现在5号\n依赖关系已更新"
|
|
70
|
-
},
|
|
71
|
-
"0.2.2": {
|
|
72
|
-
"en": "fixed issues with controller v5",
|
|
73
|
-
"de": "problemstellungen mit controller v5",
|
|
74
|
-
"ru": "фиксированные проблемы с контроллером v5",
|
|
75
|
-
"pt": "problemas fixos com controlador v5",
|
|
76
|
-
"nl": "vertaling:",
|
|
77
|
-
"fr": "problèmes fixes avec controller v5",
|
|
78
|
-
"it": "problemi fissi con controller v5",
|
|
79
|
-
"es": "problemas fijos con controlador v5",
|
|
80
|
-
"pl": "zastrzeżenia regulatora v5",
|
|
81
|
-
"uk": "фіксовані питання з контролером v5",
|
|
82
|
-
"zh-cn": "a. 控制容忍的固定问题"
|
|
83
96
|
}
|
|
84
97
|
},
|
|
85
98
|
"titleLang": {
|
|
@@ -98,15 +111,15 @@
|
|
|
98
111
|
"desc": {
|
|
99
112
|
"en": "Emulates Roku devices so ECP/SSDP remotes (Harmony, Sofabaton) trigger events in ioBroker",
|
|
100
113
|
"de": "Emuliert Roku-Geräte, damit ECP/SSDP-Fernbedienungen (Harmony, Sofabaton) Ereignisse in ioBroker auslösen",
|
|
101
|
-
"ru": "
|
|
102
|
-
"pt": "
|
|
103
|
-
"nl": "
|
|
104
|
-
"fr": "
|
|
105
|
-
"it": "
|
|
106
|
-
"es": "
|
|
107
|
-
"pl": "
|
|
108
|
-
"uk": "
|
|
109
|
-
"zh-cn": "
|
|
114
|
+
"ru": "Эмулирует устройства Roku, чтобы пульты ECP/SSDP (Harmony, Sofabaton) запускали события в ioBroker",
|
|
115
|
+
"pt": "Emula dispositivos Roku para que controles remotos ECP/SSDP (Harmony, Sofabaton) acionem eventos no ioBroker",
|
|
116
|
+
"nl": "Emuleert Roku-apparaten zodat ECP/SSDP-afstandsbedieningen (Harmony, Sofabaton) gebeurtenissen in ioBroker activeren",
|
|
117
|
+
"fr": "Émule des appareils Roku pour que les télécommandes ECP/SSDP (Harmony, Sofabaton) déclenchent des événements dans ioBroker",
|
|
118
|
+
"it": "Emula dispositivi Roku in modo che i telecomandi ECP/SSDP (Harmony, Sofabaton) attivino eventi in ioBroker",
|
|
119
|
+
"es": "Emula dispositivos Roku para que los mandos ECP/SSDP (Harmony, Sofabaton) activen eventos en ioBroker",
|
|
120
|
+
"pl": "Emuluje urządzenia Roku, aby piloty ECP/SSDP (Harmony, Sofabaton) wyzwalały zdarzenia w ioBroker",
|
|
121
|
+
"uk": "Емулює пристрої Roku, щоб пульти ECP/SSDP (Harmony, Sofabaton) запускали події в ioBroker",
|
|
122
|
+
"zh-cn": "模拟 Roku 设备,使 ECP/SSDP 遥控器(Harmony、Sofabaton)在 ioBroker 中触发事件"
|
|
110
123
|
},
|
|
111
124
|
"keywords": [
|
|
112
125
|
"fakeroku",
|
|
@@ -150,13 +163,55 @@
|
|
|
150
163
|
},
|
|
151
164
|
"supportedMessages": {
|
|
152
165
|
"deviceManager": true
|
|
153
|
-
}
|
|
166
|
+
},
|
|
167
|
+
"messages": [
|
|
168
|
+
{
|
|
169
|
+
"condition": {
|
|
170
|
+
"operand": "and",
|
|
171
|
+
"rules": [
|
|
172
|
+
"oldVersion<1.0.0",
|
|
173
|
+
"newVersion>=1.0.0"
|
|
174
|
+
]
|
|
175
|
+
},
|
|
176
|
+
"title": {
|
|
177
|
+
"en": "Major change: new data types",
|
|
178
|
+
"de": "Große Änderung: neue Datentypen",
|
|
179
|
+
"ru": "Важное изменение: новые типы данных",
|
|
180
|
+
"pt": "Mudança importante: novos tipos de dados",
|
|
181
|
+
"nl": "Grote wijziging: nieuwe datatypes",
|
|
182
|
+
"fr": "Changement majeur : nouveaux types de données",
|
|
183
|
+
"it": "Modifica importante: nuovi tipi di dati",
|
|
184
|
+
"es": "Cambio importante: nuevos tipos de datos",
|
|
185
|
+
"pl": "Ważna zmiana: nowe typy danych",
|
|
186
|
+
"uk": "Важлива зміна: нові типи даних",
|
|
187
|
+
"zh-cn": "重大变更:新的数据类型"
|
|
188
|
+
},
|
|
189
|
+
"text": {
|
|
190
|
+
"en": "fakeroku has been completely rebuilt. The data points now use real types instead of text (e.g. the remote keys as boolean). Please check any scripts and visualizations that read them.",
|
|
191
|
+
"de": "fakeroku wurde komplett neu gebaut. Die Datenpunkte haben jetzt echte Datentypen statt Text (z. B. die Tasten als boolean). Bitte prüfe Skripte und Visualisierungen, die darauf zugreifen.",
|
|
192
|
+
"ru": "fakeroku полностью переработан. Точки данных теперь используют настоящие типы вместо текста (например, кнопки как boolean). Проверьте скрипты и визуализации, которые их читают.",
|
|
193
|
+
"pt": "O fakeroku foi completamente reconstruído. Os pontos de dados agora usam tipos reais em vez de texto (por exemplo, os botões como boolean). Verifique os scripts e as visualizações que os utilizam.",
|
|
194
|
+
"nl": "fakeroku is volledig opnieuw opgebouwd. De datapunten gebruiken nu echte typen in plaats van tekst (bijv. de knoppen als boolean). Controleer scripts en visualisaties die ze gebruiken.",
|
|
195
|
+
"fr": "fakeroku a été entièrement reconstruit. Les points de données utilisent désormais de vrais types au lieu de texte (par exemple les touches en boolean). Veuillez vérifier les scripts et visualisations qui les utilisent.",
|
|
196
|
+
"it": "fakeroku è stato completamente ricostruito. I punti dati ora usano tipi reali invece del testo (ad es. i tasti come boolean). Controlla script e visualizzazioni che li utilizzano.",
|
|
197
|
+
"es": "fakeroku se ha reconstruido por completo. Los puntos de datos ahora usan tipos reales en lugar de texto (por ejemplo, los botones como boolean). Revisa los scripts y las visualizaciones que los utilizan.",
|
|
198
|
+
"pl": "fakeroku został całkowicie przebudowany. Punkty danych używają teraz prawdziwych typów zamiast tekstu (np. przyciski jako boolean). Sprawdź skrypty i wizualizacje, które z nich korzystają.",
|
|
199
|
+
"uk": "fakeroku повністю перебудовано. Точки даних тепер використовують справжні типи замість тексту (наприклад, кнопки як boolean). Перевірте скрипти та візуалізації, які їх читають.",
|
|
200
|
+
"zh-cn": "fakeroku 已完全重建。数据点现在使用真实类型而非文本(例如按键为 boolean)。请检查读取它们的脚本和可视化。"
|
|
201
|
+
},
|
|
202
|
+
"level": "warn",
|
|
203
|
+
"buttons": [
|
|
204
|
+
"agree",
|
|
205
|
+
"cancel"
|
|
206
|
+
]
|
|
207
|
+
}
|
|
208
|
+
]
|
|
154
209
|
},
|
|
155
210
|
"native": {
|
|
156
211
|
"networkInterface": "0.0.0.0",
|
|
157
212
|
"devices": [
|
|
158
213
|
{
|
|
159
|
-
"name": "
|
|
214
|
+
"name": "Roku",
|
|
160
215
|
"port": 8060,
|
|
161
216
|
"type": "player"
|
|
162
217
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "iobroker.fakeroku",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "ioBroker adapter that emulates Roku devices, letting ECP/SSDP remotes (Logitech Harmony, Sofabaton) trigger events in ioBroker",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "krobi",
|
|
@@ -50,9 +50,9 @@
|
|
|
50
50
|
"@iobroker/adapter-dev": "^1.5.0",
|
|
51
51
|
"@iobroker/eslint-config": "^2.3.4",
|
|
52
52
|
"@iobroker/testing": "^5.3.0",
|
|
53
|
-
"@types/node": "^22",
|
|
54
53
|
"@tsconfig/node22": "^22.0.5",
|
|
55
54
|
"@types/iobroker": "npm:@iobroker/types@^7.2.2",
|
|
55
|
+
"@types/node": "^22",
|
|
56
56
|
"@vitest/coverage-v8": "^4.1.10",
|
|
57
57
|
"rimraf": "^6.1.3",
|
|
58
58
|
"typescript": "~6.0.3",
|