iobroker.fakeroku 1.4.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -4
- package/build/device-management.js.map +2 -2
- package/build/ecp/ecp-http-server.js +33 -5
- package/build/ecp/ecp-http-server.js.map +2 -2
- package/build/lib/detect-ip.js +7 -2
- package/build/lib/detect-ip.js.map +2 -2
- package/build/main.js +68 -8
- package/build/main.js.map +3 -3
- package/io-package.json +14 -14
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -92,6 +92,12 @@ becomes `true` — or watch `.command` for the last button as text.
|
|
|
92
92
|
Placeholder for the next version (at the beginning of the line):
|
|
93
93
|
### **WORK IN PROGRESS**
|
|
94
94
|
-->
|
|
95
|
+
### 1.5.0 (2026-09-03)
|
|
96
|
+
|
|
97
|
+
- (krobipd) Fixed: deleting the last emulated Roku left all of its datapoints behind for good. They are now removed whenever the configuration says a device is gone.
|
|
98
|
+
- (krobipd) Fixed: on a host running Docker the adapter could announce itself under a container address no remote can reach. A real network address is preferred now.
|
|
99
|
+
- (krobipd) Fixed: an emulated Roku whose server died while running left the instance showing "connected". It now reports the failure and names the device.
|
|
100
|
+
|
|
95
101
|
### 1.4.0 (2026-09-03)
|
|
96
102
|
- (krobipd) Fixed: renaming an emulated Roku could change its identity on the network, so a paired Harmony or Sofabaton lost the device and had to be set up again.
|
|
97
103
|
- (krobipd) Fixed: a remote key that was pressed when the adapter stopped stayed on for good. All key datapoints are now released at start-up, so the next press works again.
|
|
@@ -121,10 +127,6 @@ becomes `true` — or watch `.command` for the last button as text.
|
|
|
121
127
|
- (krobipd) The device dialog now refuses a name that would clash with another device or a reserved name, preventing a naming conflict.
|
|
122
128
|
- (krobipd) The adapter description now reads correctly in the non-English and non-German admin languages.
|
|
123
129
|
|
|
124
|
-
### 1.0.0 (2026-08-05)
|
|
125
|
-
- (krobipd) First stable release — version 1.0.0 marks the complete rewrite as the mature, supported version of the adapter.
|
|
126
|
-
- (krobipd) Upgrading from an older version now shows a one-time notice that the button data points changed from text to real boolean values, so scripts and visualizations can be checked.
|
|
127
|
-
|
|
128
130
|
[Older changelogs can be found there](CHANGELOG_OLD.md)
|
|
129
131
|
|
|
130
132
|
## History
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/device-management.ts"],
|
|
4
|
-
"sourcesContent": ["import {\n DeviceManagement,\n type ActionContext,\n type DeviceInfo,\n type DeviceLoadContext,\n type JsonFormSchema,\n} from \"@iobroker/dm-utils\";\nimport { DEFAULT_ECP_PORT, RESERVED_IDS } from \"./lib/constants\";\nimport { deriveUuid, resolveDeviceUuid } from \"./lib/device-identity\";\nimport { t } from \"./lib/i18n\";\nimport { sanitizeId } from \"./lib/pure-helpers\";\nimport type { DeviceType } from \"./ecp/state-model\";\n\n/**\n * One emulated Roku as stored in the adapter's native.devices \u2014 the manifest's own\n * element type, so the manager and the runtime (main.ts) read the same shape.\n * `uuid` is the stable SSDP identity, carried through edits so the pairing survives.\n * A row without one (the manifest default, or a hand-written config) is identified\n * by `deriveUuid(name)` at runtime, so an edit must derive from the OLD name.\n */\ntype RokuDeviceConfig = ioBroker.AdapterConfig[\"devices\"][number];\n\n/** Manager directive: reload the whole view. */\ntype InstanceResult = { refresh: boolean };\n/** Manager directive: reload the device list. */\ntype DeviceResult = { refresh: \"devices\" };\n\n/**\n * The lowest free ECP port at or above the real-Roku default, so a newly added\n * device never pre-selects a port another emulated Roku already uses.\n *\n * @param usedPorts the ports already taken by other devices\n * @returns the first free port >= 8060\n */\nexport function nextFreePort(usedPorts: readonly number[]): number {\n const taken = new Set(usedPorts);\n let port = DEFAULT_ECP_PORT;\n while (taken.has(port)) {\n port++;\n }\n return port;\n}\n\n/**\n * The add/edit form for one emulated Roku: name, ECP port and device type. The\n * name and port carry a live validator against the values already in use (the\n * OK button greys out on a clash); a hint explains that each Roku needs its own\n * port. Labels are resolved translation objects so the embedded form is\n * language-correct.\n *\n * @param usedNames names taken by OTHER devices (the edited device is excluded)\n * @param usedPorts ports taken by OTHER devices\n * @returns the jsonConfig panel schema for one device\n */\nexport function buildDeviceForm(usedNames: readonly string[], usedPorts: readonly number[]): JsonFormSchema {\n const nameList = JSON.stringify(usedNames.map(n => n.trim().toLowerCase()));\n const portList = JSON.stringify([...usedPorts]);\n return {\n type: \"panel\",\n items: {\n name: {\n type: \"text\",\n label: t(\"deviceName\"),\n validator: `!${nameList}.includes((data.name||'').trim().toLowerCase())`,\n validatorErrorText: t(\"deviceNameInUse\"),\n validatorNoSaveOnError: true,\n sm: 12,\n md: 6,\n },\n port: {\n type: \"number\",\n label: t(\"devicePort\"),\n min: 1,\n max: 65535,\n validator: `!${portList}.includes(Number(data.port))`,\n validatorErrorText: t(\"devicePortInUse\"),\n validatorNoSaveOnError: true,\n sm: 12,\n md: 3,\n },\n type: {\n type: \"select\",\n label: t(\"deviceTypeLabel\"),\n default: \"player\",\n options: [\n { label: t(\"deviceTypePlayer\"), value: \"player\" },\n { label: t(\"deviceTypeTv\"), value: \"tv\" },\n ],\n sm: 12,\n md: 3,\n },\n _portHint: { type: \"staticText\", text: t(\"devicePortHint\"), sm: 12 },\n _typeHint: { type: \"staticText\", text: t(\"deviceTypeHint\"), sm: 12 },\n },\n } as unknown as JsonFormSchema;\n}\n\n/**\n * Normalise raw form values into a clean device (trimmed name, numeric port,\n * valid type) so native.devices stays tidy. The uuid is set by the caller.\n *\n * @param raw the submitted form values\n * @returns the normalised device config (without uuid)\n */\nexport function cleanDevice(raw: Record<string, unknown>): Omit<RokuDeviceConfig, \"uuid\"> {\n const name = typeof raw.name === \"string\" ? raw.name.trim() : \"\";\n const port = Number(raw.port) || DEFAULT_ECP_PORT;\n const type: DeviceType = raw.type === \"tv\" ? \"tv\" : \"player\";\n return { name, port, type };\n}\n\n/**\n * A name/port clash against the other devices, as a ready-to-show message \u2014 the\n * backend safety net behind the form validator (the dialog validator may not\n * fire in every admin version; this never lets a duplicate through).\n *\n * @param devices the full current device list\n * @param candidate the name+port being added/edited\n * @param candidate.name the candidate device name\n * @param candidate.port the candidate ECP port\n * @param exceptIndex the list position to ignore (the device being edited), or -1\n * @returns a translated clash message, or null if free\n */\nexport function findClash(\n devices: readonly RokuDeviceConfig[],\n candidate: { name: string; port: number },\n exceptIndex: number,\n): ioBroker.StringOrTranslated | null {\n const name = candidate.name.trim().toLowerCase();\n // The object-tree path is sanitizeId(name); guard the two ways it can go wrong\n // regardless of the plain-name check: a name that sanitizes to a reserved id\n // (\"info\" would collide with the adapter's own channel), and two different\n // names that sanitize to the SAME id (\"My Roku\" and \"My*Roku\" \u2192 \"My_Roku\").\n const id = sanitizeId(candidate.name.trim());\n if (id === \"\" || RESERVED_IDS.has(id)) {\n return t(\"deviceNameInvalid\");\n }\n for (let i = 0; i < devices.length; i++) {\n if (i === exceptIndex) {\n continue;\n }\n if (devices[i].name.trim().toLowerCase() === name) {\n return t(\"deviceNameInUse\");\n }\n if (sanitizeId(devices[i].name.trim()) === id) {\n return t(\"deviceNameInvalid\");\n }\n if (Number(devices[i].port) === candidate.port) {\n return t(\"devicePortInUse\");\n }\n }\n return null;\n}\n\n/**\n * ioBroker device-manager backend: the emulated Rokus as cards with manual\n * add/edit/delete \u2014 no scan, the devices are purely user-defined. Owns no state;\n * it reads and writes the adapter's own `native.devices` config array.\n */\nexport class FakerokuDeviceManagement extends DeviceManagement {\n private get objId(): string {\n return `system.adapter.${this.adapter.namespace}`;\n }\n\n /**\n * Read the device list from the live config object.\n *\n * @returns the configured devices (normalised), or an empty list\n */\n private async readDevices(): Promise<RokuDeviceConfig[]> {\n const obj = await this.adapter.getForeignObjectAsync(this.objId);\n const devices = (obj?.native as { devices?: unknown } | undefined)?.devices;\n if (!Array.isArray(devices)) {\n return [];\n }\n // native.devices is user-editable (expert mode, CLI). Normalise every row the\n // same way the dialog does, so a hand-edited entry (numeric name, missing port)\n // shows up as a card the user can fix instead of throwing inside add/edit.\n return devices\n .filter((d): d is Record<string, unknown> => typeof d === \"object\" && d !== null)\n .map(d => {\n const clean = cleanDevice(d);\n return typeof d.uuid === \"string\" && d.uuid ? { ...clean, uuid: d.uuid } : clean;\n });\n }\n\n /**\n * Persist the device list. Writing `native.*` restarts the adapter, which\n * re-creates the object trees and servers with the new devices.\n *\n * @param devices the full device list to store\n */\n private async writeDevices(devices: RokuDeviceConfig[]): Promise<void> {\n await this.adapter.extendForeignObjectAsync(this.objId, { native: { devices } });\n }\n\n /**\n * Populate the manager with one card per configured device.\n *\n * @param context the load context\n */\n protected async loadDevices(context: DeviceLoadContext<string>): Promise<void> {\n const devices = await this.readDevices();\n devices.forEach((device, index) => context.addDevice(this.toDeviceInfo(device, index)));\n }\n\n /**\n * Build one device card. The model (Player/TV) and the ECP port each get their\n * own line \u2014 the port via `identifier` (labelled in getInstanceInfo). No\n * manufacturer line: it is always \"Roku\" and tells the user nothing for an emulator.\n * The row comes from readDevices(), so the port is already a number; only the name\n * can still be empty (a row saved without one) and gets a numbered stand-in.\n *\n * @param device the stored device\n * @param index its list position \u2014 the (per-session stable) card id\n * @returns the card descriptor\n */\n private toDeviceInfo(device: RokuDeviceConfig, index: number): DeviceInfo<string> {\n const kind = device.type === \"tv\" ? \"TV\" : \"Player\";\n return {\n id: String(index),\n name: device.name || `Roku ${index + 1}`,\n identifier: String(device.port),\n model: kind,\n actions: [\n {\n id: \"edit\",\n icon: \"edit\",\n description: t(\"dmEdit\"),\n handler: async (id: string, context: ActionContext) => this.editDevice(Number(id), context),\n },\n {\n id: \"delete\",\n icon: \"delete\",\n description: t(\"dmDelete\"),\n handler: async (id: string, context: ActionContext) => this.deleteDevice(Number(id), context),\n },\n ],\n };\n }\n\n /**\n * The \"+ add\" action above the list, plus the label for the port shown on each card.\n *\n * @returns the instance action descriptor\n */\n protected getInstanceInfo(): ReturnType<DeviceManagement[\"getInstanceInfo\"]> {\n return {\n apiVersion: \"v3\",\n identifierLabel: t(\"portLabel\"),\n actions: [{ id: \"add\", icon: \"add\", description: t(\"dmAdd\"), handler: async context => this.addDevice(context) }],\n };\n }\n\n /**\n * Manual add: pre-select a free port, show the form, and append the device with\n * a stable derived uuid.\n *\n * @param context the action context\n * @returns a directive to reload the manager\n */\n private async addDevice(context: ActionContext): Promise<InstanceResult> {\n const devices = await this.readDevices();\n const usedNames = devices.map(d => d.name);\n const usedPorts = devices.map(d => Number(d.port));\n const data = await context.showForm(buildDeviceForm(usedNames, usedPorts), {\n title: t(\"dmAdd\"),\n data: { type: \"player\", port: nextFreePort(usedPorts) },\n });\n if (data && typeof data.name === \"string\" && data.name.trim()) {\n const clean = cleanDevice(data);\n const clash = findClash(devices, clean, -1);\n if (clash) {\n await context.showMessage(clash);\n return { refresh: true };\n }\n devices.push({ ...clean, uuid: deriveUuid(clean.name) });\n await this.writeDevices(devices);\n }\n return { refresh: true };\n }\n\n /**\n * Edit a device via the pre-filled form. Its own name/port are excluded from\n * the clash check, and its uuid is preserved so the pairing survives a rename.\n *\n * A row that carries no uuid yet \u2014 the manifest's default device, or anything\n * written before 0.8.0 \u2014 gets one derived from its PREVIOUS name, because that\n * is the identity main.ts is advertising right now (it derives from the stored\n * name and never writes the value back). Deriving from the NEW name here would\n * change the SSDP identity on a plain rename and silently unpair the remote.\n *\n * @param index the device's list position\n * @param context the action context\n * @returns a directive to reload the list\n */\n private async editDevice(index: number, context: ActionContext): Promise<DeviceResult> {\n const devices = await this.readDevices();\n const current = devices[index];\n if (!current) {\n return { refresh: \"devices\" };\n }\n const usedNames = devices.filter((_, i) => i !== index).map(d => d.name);\n const usedPorts = devices.filter((_, i) => i !== index).map(d => Number(d.port));\n const data = await context.showForm(buildDeviceForm(usedNames, usedPorts), {\n title: t(\"dmEditTitle\"),\n data: { ...current },\n });\n if (data && typeof data.name === \"string\" && data.name.trim()) {\n const clean = cleanDevice(data);\n const clash = findClash(devices, clean, index);\n if (clash) {\n await context.showMessage(clash);\n return { refresh: \"devices\" };\n }\n devices[index] = { ...clean, uuid: resolveDeviceUuid(current) };\n await this.writeDevices(devices);\n }\n return { refresh: \"devices\" };\n }\n\n /**\n * Delete a device after confirmation.\n *\n * @param index the device's list position\n * @param context the action context\n * @returns a directive to reload the list\n */\n private async deleteDevice(index: number, context: ActionContext): Promise<DeviceResult> {\n const devices = await this.readDevices();\n const target = devices[index];\n if (!target) {\n return { refresh: \"devices\" };\n }\n const confirmed = await context.showConfirmation(t(\"dmDeleteConfirm\", target.name || \"\"));\n if (confirmed) {\n devices.splice(index, 1);\n await this.writeDevices(devices);\n }\n return { refresh: \"devices\" };\n }\n}\n"],
|
|
5
|
-
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,sBAMO;AACP,uBAA+C;AAC/C,6BAA8C;AAC9C,kBAAkB;AAClB,0BAA2B;
|
|
4
|
+
"sourcesContent": ["import {\n DeviceManagement,\n type ActionContext,\n type DeviceInfo,\n type DeviceLoadContext,\n type JsonFormSchema,\n} from \"@iobroker/dm-utils\";\nimport { DEFAULT_ECP_PORT, RESERVED_IDS } from \"./lib/constants\";\nimport { deriveUuid, resolveDeviceUuid } from \"./lib/device-identity\";\nimport { t } from \"./lib/i18n\";\nimport { sanitizeId } from \"./lib/pure-helpers\";\nimport type { DeviceType } from \"./ecp/state-model\";\n\n/**\n * One emulated Roku as stored in the adapter's native.devices \u2014 the manifest's own\n * element type, so the manager and the runtime (main.ts) read the same shape.\n * `uuid` is the stable SSDP identity, carried through edits so the pairing survives.\n * A row without one (the manifest default, or a hand-written config) is identified\n * by its name at runtime, so an edit resolves the identity through the SAME helper\n * the runtime uses (`resolveDeviceUuid`) instead of computing a second answer.\n */\ntype RokuDeviceConfig = ioBroker.AdapterConfig[\"devices\"][number];\n\n/** Manager directive: reload the whole view. */\ntype InstanceResult = { refresh: boolean };\n/** Manager directive: reload the device list. */\ntype DeviceResult = { refresh: \"devices\" };\n\n/**\n * The lowest free ECP port at or above the real-Roku default, so a newly added\n * device never pre-selects a port another emulated Roku already uses.\n *\n * @param usedPorts the ports already taken by other devices\n * @returns the first free port >= 8060\n */\nexport function nextFreePort(usedPorts: readonly number[]): number {\n const taken = new Set(usedPorts);\n let port = DEFAULT_ECP_PORT;\n while (taken.has(port)) {\n port++;\n }\n return port;\n}\n\n/**\n * The add/edit form for one emulated Roku: name, ECP port and device type. The\n * name and port carry a live validator against the values already in use (the\n * OK button greys out on a clash); a hint explains that each Roku needs its own\n * port. Labels are resolved translation objects so the embedded form is\n * language-correct.\n *\n * @param usedNames names taken by OTHER devices (the edited device is excluded)\n * @param usedPorts ports taken by OTHER devices\n * @returns the jsonConfig panel schema for one device\n */\nexport function buildDeviceForm(usedNames: readonly string[], usedPorts: readonly number[]): JsonFormSchema {\n const nameList = JSON.stringify(usedNames.map(n => n.trim().toLowerCase()));\n const portList = JSON.stringify([...usedPorts]);\n return {\n type: \"panel\",\n items: {\n name: {\n type: \"text\",\n label: t(\"deviceName\"),\n validator: `!${nameList}.includes((data.name||'').trim().toLowerCase())`,\n validatorErrorText: t(\"deviceNameInUse\"),\n validatorNoSaveOnError: true,\n sm: 12,\n md: 6,\n },\n port: {\n type: \"number\",\n label: t(\"devicePort\"),\n min: 1,\n max: 65535,\n validator: `!${portList}.includes(Number(data.port))`,\n validatorErrorText: t(\"devicePortInUse\"),\n validatorNoSaveOnError: true,\n sm: 12,\n md: 3,\n },\n type: {\n type: \"select\",\n label: t(\"deviceTypeLabel\"),\n default: \"player\",\n options: [\n { label: t(\"deviceTypePlayer\"), value: \"player\" },\n { label: t(\"deviceTypeTv\"), value: \"tv\" },\n ],\n sm: 12,\n md: 3,\n },\n _portHint: { type: \"staticText\", text: t(\"devicePortHint\"), sm: 12 },\n _typeHint: { type: \"staticText\", text: t(\"deviceTypeHint\"), sm: 12 },\n },\n } as unknown as JsonFormSchema;\n}\n\n/**\n * Normalise raw form values into a clean device (trimmed name, numeric port,\n * valid type) so native.devices stays tidy. The uuid is set by the caller.\n *\n * @param raw the submitted form values\n * @returns the normalised device config (without uuid)\n */\nexport function cleanDevice(raw: Record<string, unknown>): Omit<RokuDeviceConfig, \"uuid\"> {\n const name = typeof raw.name === \"string\" ? raw.name.trim() : \"\";\n const port = Number(raw.port) || DEFAULT_ECP_PORT;\n const type: DeviceType = raw.type === \"tv\" ? \"tv\" : \"player\";\n return { name, port, type };\n}\n\n/**\n * A name/port clash against the other devices, as a ready-to-show message \u2014 the\n * backend safety net behind the form validator (the dialog validator may not\n * fire in every admin version; this never lets a duplicate through).\n *\n * @param devices the full current device list\n * @param candidate the name+port being added/edited\n * @param candidate.name the candidate device name\n * @param candidate.port the candidate ECP port\n * @param exceptIndex the list position to ignore (the device being edited), or -1\n * @returns a translated clash message, or null if free\n */\nexport function findClash(\n devices: readonly RokuDeviceConfig[],\n candidate: { name: string; port: number },\n exceptIndex: number,\n): ioBroker.StringOrTranslated | null {\n const name = candidate.name.trim().toLowerCase();\n // The object-tree path is sanitizeId(name); guard the two ways it can go wrong\n // regardless of the plain-name check: a name that sanitizes to a reserved id\n // (\"info\" would collide with the adapter's own channel), and two different\n // names that sanitize to the SAME id (\"My Roku\" and \"My*Roku\" \u2192 \"My_Roku\").\n const id = sanitizeId(candidate.name.trim());\n if (id === \"\" || RESERVED_IDS.has(id)) {\n return t(\"deviceNameInvalid\");\n }\n for (let i = 0; i < devices.length; i++) {\n if (i === exceptIndex) {\n continue;\n }\n if (devices[i].name.trim().toLowerCase() === name) {\n return t(\"deviceNameInUse\");\n }\n if (sanitizeId(devices[i].name.trim()) === id) {\n return t(\"deviceNameInvalid\");\n }\n if (Number(devices[i].port) === candidate.port) {\n return t(\"devicePortInUse\");\n }\n }\n return null;\n}\n\n/**\n * ioBroker device-manager backend: the emulated Rokus as cards with manual\n * add/edit/delete \u2014 no scan, the devices are purely user-defined. Owns no state;\n * it reads and writes the adapter's own `native.devices` config array.\n */\nexport class FakerokuDeviceManagement extends DeviceManagement {\n private get objId(): string {\n return `system.adapter.${this.adapter.namespace}`;\n }\n\n /**\n * Read the device list from the live config object.\n *\n * @returns the configured devices (normalised), or an empty list\n */\n private async readDevices(): Promise<RokuDeviceConfig[]> {\n const obj = await this.adapter.getForeignObjectAsync(this.objId);\n const devices = (obj?.native as { devices?: unknown } | undefined)?.devices;\n if (!Array.isArray(devices)) {\n return [];\n }\n // native.devices is user-editable (expert mode, CLI). Normalise every row the\n // same way the dialog does, so a hand-edited entry (numeric name, missing port)\n // shows up as a card the user can fix instead of throwing inside add/edit.\n return devices\n .filter((d): d is Record<string, unknown> => typeof d === \"object\" && d !== null)\n .map(d => {\n const clean = cleanDevice(d);\n return typeof d.uuid === \"string\" && d.uuid ? { ...clean, uuid: d.uuid } : clean;\n });\n }\n\n /**\n * Persist the device list. Writing `native.*` restarts the adapter, which\n * re-creates the object trees and servers with the new devices.\n *\n * @param devices the full device list to store\n */\n private async writeDevices(devices: RokuDeviceConfig[]): Promise<void> {\n await this.adapter.extendForeignObjectAsync(this.objId, { native: { devices } });\n }\n\n /**\n * Populate the manager with one card per configured device.\n *\n * @param context the load context\n */\n protected async loadDevices(context: DeviceLoadContext<string>): Promise<void> {\n const devices = await this.readDevices();\n devices.forEach((device, index) => context.addDevice(this.toDeviceInfo(device, index)));\n }\n\n /**\n * Build one device card. The model (Player/TV) and the ECP port each get their\n * own line \u2014 the port via `identifier` (labelled in getInstanceInfo). No\n * manufacturer line: it is always \"Roku\" and tells the user nothing for an emulator.\n * The row comes from readDevices(), so the port is already a number; only the name\n * can still be empty (a row saved without one) and gets a numbered stand-in.\n *\n * @param device the stored device\n * @param index its list position \u2014 the (per-session stable) card id\n * @returns the card descriptor\n */\n private toDeviceInfo(device: RokuDeviceConfig, index: number): DeviceInfo<string> {\n const kind = device.type === \"tv\" ? \"TV\" : \"Player\";\n return {\n id: String(index),\n name: device.name || `Roku ${index + 1}`,\n identifier: String(device.port),\n model: kind,\n actions: [\n {\n id: \"edit\",\n icon: \"edit\",\n description: t(\"dmEdit\"),\n handler: async (id: string, context: ActionContext) => this.editDevice(Number(id), context),\n },\n {\n id: \"delete\",\n icon: \"delete\",\n description: t(\"dmDelete\"),\n handler: async (id: string, context: ActionContext) => this.deleteDevice(Number(id), context),\n },\n ],\n };\n }\n\n /**\n * The \"+ add\" action above the list, plus the label for the port shown on each card.\n *\n * @returns the instance action descriptor\n */\n protected getInstanceInfo(): ReturnType<DeviceManagement[\"getInstanceInfo\"]> {\n return {\n apiVersion: \"v3\",\n identifierLabel: t(\"portLabel\"),\n actions: [{ id: \"add\", icon: \"add\", description: t(\"dmAdd\"), handler: async context => this.addDevice(context) }],\n };\n }\n\n /**\n * Manual add: pre-select a free port, show the form, and append the device with\n * a stable derived uuid.\n *\n * @param context the action context\n * @returns a directive to reload the manager\n */\n private async addDevice(context: ActionContext): Promise<InstanceResult> {\n const devices = await this.readDevices();\n const usedNames = devices.map(d => d.name);\n const usedPorts = devices.map(d => Number(d.port));\n const data = await context.showForm(buildDeviceForm(usedNames, usedPorts), {\n title: t(\"dmAdd\"),\n data: { type: \"player\", port: nextFreePort(usedPorts) },\n });\n if (data && typeof data.name === \"string\" && data.name.trim()) {\n const clean = cleanDevice(data);\n const clash = findClash(devices, clean, -1);\n if (clash) {\n await context.showMessage(clash);\n return { refresh: true };\n }\n devices.push({ ...clean, uuid: deriveUuid(clean.name) });\n await this.writeDevices(devices);\n }\n return { refresh: true };\n }\n\n /**\n * Edit a device via the pre-filled form. Its own name/port are excluded from\n * the clash check, and its uuid is preserved so the pairing survives a rename.\n *\n * A row that carries no uuid yet \u2014 the manifest's default device, or anything\n * written before 0.8.0 \u2014 gets one derived from its PREVIOUS name, because that\n * is the identity main.ts is advertising right now (it derives from the stored\n * name and never writes the value back). Deriving from the NEW name here would\n * change the SSDP identity on a plain rename and silently unpair the remote.\n *\n * @param index the device's list position\n * @param context the action context\n * @returns a directive to reload the list\n */\n private async editDevice(index: number, context: ActionContext): Promise<DeviceResult> {\n const devices = await this.readDevices();\n const current = devices[index];\n if (!current) {\n return { refresh: \"devices\" };\n }\n const usedNames = devices.filter((_, i) => i !== index).map(d => d.name);\n const usedPorts = devices.filter((_, i) => i !== index).map(d => Number(d.port));\n const data = await context.showForm(buildDeviceForm(usedNames, usedPorts), {\n title: t(\"dmEditTitle\"),\n data: { ...current },\n });\n if (data && typeof data.name === \"string\" && data.name.trim()) {\n const clean = cleanDevice(data);\n const clash = findClash(devices, clean, index);\n if (clash) {\n await context.showMessage(clash);\n return { refresh: \"devices\" };\n }\n devices[index] = { ...clean, uuid: resolveDeviceUuid(current) };\n await this.writeDevices(devices);\n }\n return { refresh: \"devices\" };\n }\n\n /**\n * Delete a device after confirmation.\n *\n * @param index the device's list position\n * @param context the action context\n * @returns a directive to reload the list\n */\n private async deleteDevice(index: number, context: ActionContext): Promise<DeviceResult> {\n const devices = await this.readDevices();\n const target = devices[index];\n if (!target) {\n return { refresh: \"devices\" };\n }\n const confirmed = await context.showConfirmation(t(\"dmDeleteConfirm\", target.name || \"\"));\n if (confirmed) {\n devices.splice(index, 1);\n await this.writeDevices(devices);\n }\n return { refresh: \"devices\" };\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -36,6 +36,8 @@ var import_errors = require("../lib/errors");
|
|
|
36
36
|
var import_lan_guard = require("../lib/lan-guard");
|
|
37
37
|
var import_ecp_command = require("./ecp-command");
|
|
38
38
|
var import_device_info = require("./device-info");
|
|
39
|
+
const MAX_LOGGED_DETAIL = 120;
|
|
40
|
+
const NON_LAN_LOG_INTERVAL_MS = 6e4;
|
|
39
41
|
class EcpHttpServer {
|
|
40
42
|
/**
|
|
41
43
|
* @param config server configuration
|
|
@@ -45,6 +47,10 @@ class EcpHttpServer {
|
|
|
45
47
|
}
|
|
46
48
|
config;
|
|
47
49
|
server;
|
|
50
|
+
/** When the non-LAN rejection was last logged — a scanner must not fill the log. */
|
|
51
|
+
nonLanLoggedAt = 0;
|
|
52
|
+
/** Whether the fatal-error callback has already fired — it reports once, not per event. */
|
|
53
|
+
fatalReported = false;
|
|
48
54
|
/** Bind the HTTP server to the interface + port. Rejects on bind error. */
|
|
49
55
|
async start() {
|
|
50
56
|
var _a;
|
|
@@ -55,7 +61,7 @@ class EcpHttpServer {
|
|
|
55
61
|
server.once("error", onError);
|
|
56
62
|
server.listen(this.config.device.port, this.config.bindIp, () => {
|
|
57
63
|
server.removeListener("error", onError);
|
|
58
|
-
server.on("error", (err) => this.
|
|
64
|
+
server.on("error", (err) => this.onRuntimeError(err));
|
|
59
65
|
resolve();
|
|
60
66
|
});
|
|
61
67
|
});
|
|
@@ -63,11 +69,30 @@ class EcpHttpServer {
|
|
|
63
69
|
`ECP server "${this.config.friendlyName}" on ${(_a = this.config.bindIp) != null ? _a : "0.0.0.0"}:${this.config.device.port}`
|
|
64
70
|
);
|
|
65
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* A server error after a good start — this emulated Roku is gone. Report it once
|
|
74
|
+
* so the adapter can revise `info.connection` instead of showing a healthy
|
|
75
|
+
* instance whose device answers nothing; the other devices keep running.
|
|
76
|
+
*
|
|
77
|
+
* @param err the server error
|
|
78
|
+
*/
|
|
79
|
+
onRuntimeError(err) {
|
|
80
|
+
this.config.logger.error(`ECP server "${this.config.friendlyName}" error: ${err.message}`);
|
|
81
|
+
const notify = this.config.onFatalError;
|
|
82
|
+
if (notify && !this.fatalReported) {
|
|
83
|
+
this.fatalReported = true;
|
|
84
|
+
notify(err);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
66
87
|
handle(req, res) {
|
|
67
88
|
var _a, _b, _c, _d, _e, _f;
|
|
68
89
|
const peer = ((_a = req.socket.remoteAddress) != null ? _a : "").replace(/^::ffff:/, "") || "?";
|
|
69
90
|
if (!(0, import_lan_guard.isLanClient)(req.socket.remoteAddress)) {
|
|
70
|
-
|
|
91
|
+
const now = Date.now();
|
|
92
|
+
if (now - this.nonLanLoggedAt >= NON_LAN_LOG_INTERVAL_MS) {
|
|
93
|
+
this.nonLanLoggedAt = now;
|
|
94
|
+
this.config.logger.debug(`ECP request from non-LAN ${peer} rejected (403)`);
|
|
95
|
+
}
|
|
71
96
|
res.statusCode = 403;
|
|
72
97
|
res.end();
|
|
73
98
|
return;
|
|
@@ -96,13 +121,16 @@ class EcpHttpServer {
|
|
|
96
121
|
res.end();
|
|
97
122
|
return;
|
|
98
123
|
}
|
|
99
|
-
|
|
100
|
-
this.config.logger.debug(`ECP ${cmd.type}${detail ? ` ${detail}` : ""} from ${peer}`);
|
|
124
|
+
let accepted = false;
|
|
101
125
|
try {
|
|
102
|
-
this.config.onCommand(cmd);
|
|
126
|
+
accepted = this.config.onCommand(cmd);
|
|
103
127
|
} catch (e) {
|
|
104
128
|
this.config.logger.warn(`onCommand failed: ${(0, import_errors.errText)(e)}`);
|
|
105
129
|
}
|
|
130
|
+
if (accepted) {
|
|
131
|
+
const detail = ((_f = (_e = (_d = cmd.key) != null ? _d : cmd.appId) != null ? _e : cmd.text) != null ? _f : "").replace(new RegExp("\\p{Cc}", "gu"), "?").slice(0, MAX_LOGGED_DETAIL);
|
|
132
|
+
this.config.logger.debug(`ECP ${cmd.type}${detail ? ` ${detail}` : ""} from ${peer}`);
|
|
133
|
+
}
|
|
106
134
|
res.statusCode = 200;
|
|
107
135
|
res.end();
|
|
108
136
|
return;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/ecp/ecp-http-server.ts"],
|
|
4
|
-
"sourcesContent": ["import * as http from \"node:http\";\nimport type { RokuAdvert } from \"../discovery/ssdp-messages\";\nimport { errText } from \"../lib/errors\";\nimport { isLanClient } from \"../lib/lan-guard\";\nimport type { AdapterLogger } from \"../lib/logger\";\nimport { type CommandEvent, parseEcpCommand } from \"./ecp-command\";\nimport { type AppEntry, buildAppsXml, buildDescXml, buildDeviceInfoXml } from \"./device-info\";\nimport type { DeviceType } from \"./state-model\";\n\n/** Configuration for one emulated Roku's ECP HTTP server. */\nexport interface EcpServerConfig {\n /** The emulated Roku (uuid + port). */\n device: RokuAdvert;\n /** Display name shown in the description / device-info. */\n friendlyName: string;\n /** Apps advertised at /query/apps. */\n apps: AppEntry[];\n /** The emulated device type (player / tv) \u2014 drives the device-info response. */\n deviceType: DeviceType;\n /** Interface IP to bind the HTTP server to, or `undefined` to bind all interfaces (auto). */\n bindIp: string | undefined;\n /** Logger. */\n logger: AdapterLogger;\n
|
|
5
|
-
"mappings": ";;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,WAAsB;AAEtB,oBAAwB;AACxB,uBAA4B;AAE5B,yBAAmD;AACnD,yBAA8E;
|
|
4
|
+
"sourcesContent": ["import * as http from \"node:http\";\nimport type { RokuAdvert } from \"../discovery/ssdp-messages\";\nimport { errText } from \"../lib/errors\";\nimport { isLanClient } from \"../lib/lan-guard\";\nimport type { AdapterLogger } from \"../lib/logger\";\nimport { type CommandEvent, parseEcpCommand } from \"./ecp-command\";\nimport { type AppEntry, buildAppsXml, buildDescXml, buildDeviceInfoXml } from \"./device-info\";\nimport type { DeviceType } from \"./state-model\";\n\n/** Configuration for one emulated Roku's ECP HTTP server. */\nexport interface EcpServerConfig {\n /** The emulated Roku (uuid + port). */\n device: RokuAdvert;\n /** Display name shown in the description / device-info. */\n friendlyName: string;\n /** Apps advertised at /query/apps. */\n apps: AppEntry[];\n /** The emulated device type (player / tv) \u2014 drives the device-info response. */\n deviceType: DeviceType;\n /** Interface IP to bind the HTTP server to, or `undefined` to bind all interfaces (auto). */\n bindIp: string | undefined;\n /** Logger. */\n logger: AdapterLogger;\n /**\n * Called for every parsed POST command. Returns whether the command was actually\n * applied \u2014 a `false` means the adapter's rate gate dropped it, and the server\n * then keeps quiet about it too (see {@link EcpHttpServer.handle}).\n */\n onCommand: (cmd: CommandEvent) => boolean;\n /**\n * Called at most once if the server dies AFTER a successful start \u2014 a runtime\n * error it cannot recover from. Without it the adapter would keep reporting a\n * connected instance while this emulated Roku no longer answers anything, and\n * `info.connection` means \"EVERY configured Roku is listening\". Mirrors the SSDP\n * responder, which has carried this callback since 1.1.0.\n */\n onFatalError?: (err: Error) => void;\n}\n\n/**\n * Longest command detail written to the log. The text comes from the request URL,\n * which a LAN client controls up to Node's header limit (16 KiB) \u2014 the `command`\n * state is capped at 500 characters for exactly that reason, and the log line\n * beside it must not be the way around the cap.\n */\nconst MAX_LOGGED_DETAIL = 120;\n\n/** How often at most the \"rejected a non-LAN request\" line is written. */\nconst NON_LAN_LOG_INTERVAL_MS = 60_000;\n\n/**\n * The Roku ECP HTTP server for one emulated device. Serves the UPnP description,\n * /query/device-info (with a current version) and /query/apps; turns POST\n * key/launch/input/search into command events. Unknown GET paths get a clean 404\n * (not the old adapter's empty 200), and commands are accepted only from the LAN.\n */\nexport class EcpHttpServer {\n private server: http.Server | undefined;\n /** When the non-LAN rejection was last logged \u2014 a scanner must not fill the log. */\n private nonLanLoggedAt = 0;\n /** Whether the fatal-error callback has already fired \u2014 it reports once, not per event. */\n private fatalReported = false;\n\n /**\n * @param config server configuration\n */\n public constructor(private readonly config: EcpServerConfig) {}\n\n /** Bind the HTTP server to the interface + port. Rejects on bind error. */\n public async start(): Promise<void> {\n const server = http.createServer((req, res) => this.handle(req, res));\n this.server = server;\n await new Promise<void>((resolve, reject) => {\n const onError = (err: Error): void => reject(err);\n server.once(\"error\", onError);\n server.listen(this.config.device.port, this.config.bindIp, () => {\n server.removeListener(\"error\", onError);\n server.on(\"error\", (err: Error) => this.onRuntimeError(err));\n resolve();\n });\n });\n this.config.logger.debug(\n `ECP server \"${this.config.friendlyName}\" on ${this.config.bindIp ?? \"0.0.0.0\"}:${this.config.device.port}`,\n );\n }\n\n /**\n * A server error after a good start \u2014 this emulated Roku is gone. Report it once\n * so the adapter can revise `info.connection` instead of showing a healthy\n * instance whose device answers nothing; the other devices keep running.\n *\n * @param err the server error\n */\n private onRuntimeError(err: Error): void {\n this.config.logger.error(`ECP server \"${this.config.friendlyName}\" error: ${err.message}`);\n const notify = this.config.onFatalError;\n if (notify && !this.fatalReported) {\n this.fatalReported = true;\n notify(err);\n }\n }\n\n private handle(req: http.IncomingMessage, res: http.ServerResponse): void {\n const peer = (req.socket.remoteAddress ?? \"\").replace(/^::ffff:/, \"\") || \"?\";\n if (!isLanClient(req.socket.remoteAddress)) {\n // Debug (not warn): a stray WAN scanner must not spam the log, but when a\n // remote sits on the wrong subnet/VLAN this is the only trace of \"why rejected\".\n // Throttled: a scanner sends thousands of requests, and every one of them\n // would otherwise cost a log line \u2014 the very flood the rate gate exists for.\n const now = Date.now();\n if (now - this.nonLanLoggedAt >= NON_LAN_LOG_INTERVAL_MS) {\n this.nonLanLoggedAt = now;\n this.config.logger.debug(`ECP request from non-LAN ${peer} rejected (403)`);\n }\n res.statusCode = 403;\n res.end();\n return;\n }\n const method = req.method ?? \"GET\";\n const url = req.url ?? \"/\";\n\n if (method === \"GET\") {\n const body = this.routeGet(url);\n if (body === null) {\n res.statusCode = 404;\n res.end();\n return;\n }\n if (url.split(\"?\")[0] === \"/query/device-info\") {\n // The pairing probe \u2014 the first thing a remote asks and the usual failure\n // point (a Sofabaton rejects a too-old version). Visible for diagnosis.\n this.config.logger.debug(`device-info queried from ${peer} (remote pairing/probe)`);\n }\n res.statusCode = 200;\n res.setHeader(\"Content-Type\", \"text/xml; charset=utf-8\");\n res.end(body);\n return;\n }\n\n if (method === \"POST\") {\n // The body is never read: Node discards an unconsumed body when the response\n // finishes, so the keep-alive connection stays usable (guarded by a test).\n const cmd = parseEcpCommand(\"POST\", url);\n if (!cmd) {\n res.statusCode = 404;\n res.end();\n return;\n }\n // Apply FIRST, log second. The adapter's rate gate sits inside onCommand, so\n // logging before it would leave the log wide open for exactly the flood the\n // gate exists to stop: a device sending a thousand presses a second would\n // write a thousand lines while only 25 reach the states database.\n let accepted = false;\n try {\n accepted = this.config.onCommand(cmd);\n } catch (e) {\n this.config.logger.warn(`onCommand failed: ${errText(e)}`);\n }\n if (accepted) {\n // The received command \u2014 without this a keypress leaves no trace at all, so a\n // \"I press a button and nothing happens\" report has nothing to go on.\n // Control characters (a decoded `Lit_%0A`) are replaced so one request stays one\n // log line, and the text is capped like the state value it accompanies.\n const detail = (cmd.key ?? cmd.appId ?? cmd.text ?? \"\").replace(/\\p{Cc}/gu, \"?\").slice(0, MAX_LOGGED_DETAIL);\n this.config.logger.debug(`ECP ${cmd.type}${detail ? ` ${detail}` : \"\"} from ${peer}`);\n }\n res.statusCode = 200;\n res.end();\n return;\n }\n\n res.statusCode = 405;\n res.end();\n }\n\n private routeGet(url: string): string | null {\n switch (url.split(\"?\")[0]) {\n case \"/\":\n return buildDescXml(this.config.device, this.config.friendlyName, this.config.deviceType);\n case \"/query/device-info\":\n return buildDeviceInfoXml(this.config.device, this.config.friendlyName, this.config.deviceType);\n case \"/query/apps\":\n return buildAppsXml(this.config.apps);\n default:\n return null;\n }\n }\n\n /** Synchronous close \u2014 safe from onUnload. */\n public stop(): void {\n if (this.server) {\n try {\n this.server.close();\n // close() only stops accepting new sockets; established keep-alive\n // connections (a controller holding one) keep the event loop busy and can\n // drag the synchronous onUnload toward a SIGKILL. Force them shut (Node >= 22).\n this.server.closeAllConnections();\n } catch {\n // already closed\n }\n this.server = undefined;\n }\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/build/lib/detect-ip.js
CHANGED
|
@@ -42,9 +42,14 @@ function listNonInternalIPv4s(interfaces) {
|
|
|
42
42
|
}
|
|
43
43
|
return out;
|
|
44
44
|
}
|
|
45
|
+
const CONTAINER_BRIDGE_PREFIXES = ["172.17.", "172.18."];
|
|
46
|
+
function isContainerBridge(address) {
|
|
47
|
+
return CONTAINER_BRIDGE_PREFIXES.some((prefix) => address.startsWith(prefix));
|
|
48
|
+
}
|
|
45
49
|
function pickPrimaryIPv4(interfaces) {
|
|
46
|
-
var _a;
|
|
47
|
-
|
|
50
|
+
var _a, _b;
|
|
51
|
+
const addresses = listNonInternalIPv4s(interfaces);
|
|
52
|
+
return (_b = (_a = addresses.find((address) => !isContainerBridge(address))) != null ? _a : addresses[0]) != null ? _b : "";
|
|
48
53
|
}
|
|
49
54
|
function detectPrimaryIPv4() {
|
|
50
55
|
return pickPrimaryIPv4((0, import_node_os.networkInterfaces)());
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/detect-ip.ts"],
|
|
4
|
-
"sourcesContent": ["import { networkInterfaces, type NetworkInterfaceInfo } from \"node:os\";\n\n/**\n * Is this a routable (non-internal) IPv4 address?\n *\n * @param addr one entry from an os.networkInterfaces() list\n * @returns true for a non-internal IPv4 address\n */\nfunction isRoutableIPv4(addr: NetworkInterfaceInfo): boolean {\n return addr.family === \"IPv4\" && !addr.internal;\n}\n\n/**\n * Every non-internal IPv4 address of the given interface map, in enumeration\n * order. Pure \u2014 takes the interface map so it can be unit-tested without real\n * network cards.\n *\n * @param interfaces the OS network-interface map (os.networkInterfaces() shape)\n * @returns every routable IPv4 address (may be empty)\n */\nexport function listNonInternalIPv4s(interfaces: NodeJS.Dict<NetworkInterfaceInfo[]>): string[] {\n const out: string[] = [];\n for (const addrs of Object.values(interfaces)) {\n for (const addr of addrs ?? []) {\n if (isRoutableIPv4(addr)) {\n out.push(addr.address);\n }\n }\n }\n return out;\n}\n\n/**\n *
|
|
5
|
-
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,qBAA6D;AAQ7D,SAAS,eAAe,MAAqC;AAC3D,SAAO,KAAK,WAAW,UAAU,CAAC,KAAK;AACzC;AAUO,SAAS,qBAAqB,YAA2D;AAC9F,QAAM,MAAgB,CAAC;AACvB,aAAW,SAAS,OAAO,OAAO,UAAU,GAAG;AAC7C,eAAW,QAAQ,wBAAS,CAAC,GAAG;AAC9B,UAAI,eAAe,IAAI,GAAG;AACxB,YAAI,KAAK,KAAK,OAAO;AAAA,MACvB;AAAA,IACF;AAAA,EACF;AACA,SAAO;AACT;
|
|
4
|
+
"sourcesContent": ["import { networkInterfaces, type NetworkInterfaceInfo } from \"node:os\";\n\n/**\n * Is this a routable (non-internal) IPv4 address?\n *\n * @param addr one entry from an os.networkInterfaces() list\n * @returns true for a non-internal IPv4 address\n */\nfunction isRoutableIPv4(addr: NetworkInterfaceInfo): boolean {\n return addr.family === \"IPv4\" && !addr.internal;\n}\n\n/**\n * Every non-internal IPv4 address of the given interface map, in enumeration\n * order. Pure \u2014 takes the interface map so it can be unit-tested without real\n * network cards.\n *\n * @param interfaces the OS network-interface map (os.networkInterfaces() shape)\n * @returns every routable IPv4 address (may be empty)\n */\nexport function listNonInternalIPv4s(interfaces: NodeJS.Dict<NetworkInterfaceInfo[]>): string[] {\n const out: string[] = [];\n for (const addrs of Object.values(interfaces)) {\n for (const addr of addrs ?? []) {\n if (isRoutableIPv4(addr)) {\n out.push(addr.address);\n }\n }\n }\n return out;\n}\n\n/**\n * The container-bridge networks Docker hands out by default: `docker0` (172.17.x.x)\n * and the first user-defined / compose bridge (172.18.x.x). They are non-internal\n * IPv4 addresses like any other, but nothing on the LAN can reach them.\n */\nconst CONTAINER_BRIDGE_PREFIXES = [\"172.17.\", \"172.18.\"];\n\n/**\n * Is this address one of Docker's default bridge networks?\n *\n * @param address an IPv4 address\n * @returns true for a Docker default-bridge address\n */\nfunction isContainerBridge(address: string): boolean {\n return CONTAINER_BRIDGE_PREFIXES.some(prefix => address.startsWith(prefix));\n}\n\n/**\n * Pick the address to advertise from a set of OS network interfaces: the first\n * routable IPv4 that is NOT a Docker default bridge, and only as a last resort a\n * bridge address. Pure \u2014 takes the interface map so it can be unit-tested without\n * real network cards.\n *\n * Why the exception exists: an ioBroker host commonly runs Docker, and `docker0`\n * (172.17.x.x) or the first compose bridge (172.18.x.x) can come first in the\n * interface enumeration. Advertising one of them puts an address into every SSDP\n * answer and NOTIFY that no remote on the LAN can reach \u2014 while the adapter starts\n * green and reports \"advertising on 172.17.0.1\", which looks like success. The\n * sibling emulator hit exactly this (hassemu v1.21.0) and fixed it the same way, so\n * the fleet gives one answer instead of two.\n *\n * @param interfaces the OS network-interface map (os.networkInterfaces() shape)\n * @returns the routable IPv4 address to advertise, or \"\" if none is found\n */\nexport function pickPrimaryIPv4(interfaces: NodeJS.Dict<NetworkInterfaceInfo[]>): string {\n const addresses = listNonInternalIPv4s(interfaces);\n return addresses.find(address => !isContainerBridge(address)) ?? addresses[0] ?? \"\";\n}\n\n/**\n * Best-effort primary IPv4 of the host \u2014 used as the advertised SSDP LOCATION\n * when no interface is configured, so a controller gets a routable IP instead of\n * the bind wildcard (`0.0.0.0` is not reachable). Returns \"\" when the host has no\n * routable IPv4 (e.g. no network), which the caller treats as \"cannot advertise\".\n *\n * @returns the primary IPv4 address, or \"\" if none is found\n */\nexport function detectPrimaryIPv4(): string {\n return pickPrimaryIPv4(networkInterfaces());\n}\n\n/**\n * All routable IPv4 addresses of the host \u2014 the interfaces to join the SSDP\n * multicast group on when no specific interface is configured, so discovery\n * works on every LAN the host is on. Without this a multi-homed host only hears\n * M-SEARCH on the OS default interface. Empty when the host has no routable IPv4.\n *\n * @returns every routable IPv4 address of the host (may be empty)\n */\nexport function detectLocalIPv4s(): string[] {\n return listNonInternalIPv4s(networkInterfaces());\n}\n\n/**\n * Expand an IPv6 address to its eight four-digit groups, resolving the `::`\n * shorthand and dropping a `%zone` suffix. Returns null for anything that is not\n * a plain IPv6 address (an IPv4-mapped form, a malformed value).\n *\n * @param address the IPv6 address text\n * @returns the eight normalised groups, or null\n */\nfunction expandIPv6(address: string): string[] | null {\n const bare = address.toLowerCase().split(\"%\")[0];\n if (!bare.includes(\":\") || bare.includes(\".\")) {\n return null; // not IPv6, or an IPv4-mapped/embedded form\n }\n const halves = bare.split(\"::\");\n if (halves.length > 2) {\n return null;\n }\n const head = halves[0] ? halves[0].split(\":\") : [];\n const tail = halves.length === 2 ? (halves[1] ? halves[1].split(\":\") : []) : [];\n const groups =\n halves.length === 2 ? [...head, ...Array<string>(8 - head.length - tail.length).fill(\"0\"), ...tail] : head;\n if (groups.length !== 8 || groups.some(g => !/^[0-9a-f]{1,4}$/.test(g))) {\n return null;\n }\n return groups.map(g => g.padStart(4, \"0\"));\n}\n\n/**\n * The /64 network prefix of an IPv6 address \u2014 its first four groups. A /64 is\n * exactly one network segment (the size SLAAC assigns), so two addresses sharing\n * it are on the same link.\n *\n * @param address the IPv6 address text\n * @returns the normalised prefix (e.g. \"2003:00e1:1f28:9a00\"), or null\n */\nexport function ipv6Prefix64(address: string): string | null {\n const groups = expandIPv6(address);\n return groups ? groups.slice(0, 4).join(\":\") : null;\n}\n\n/**\n * The /64 prefixes of every non-internal IPv6 address of the given interface map.\n * Pure \u2014 takes the interface map so it can be unit-tested without real network\n * cards.\n *\n * These are what makes a globally routable IPv6 client recognisable as local: on\n * a modern connection the router hands every device in the house an address out\n * of the provider's block, which looks exactly like an internet address. Only the\n * shared prefix tells \"the TV in the living room\" from \"a host on the internet\".\n *\n * @param interfaces the OS network-interface map (os.networkInterfaces() shape)\n * @returns every /64 prefix the host itself is in (may be empty), de-duplicated\n */\nexport function listLocalIPv6Prefixes(interfaces: NodeJS.Dict<NetworkInterfaceInfo[]>): string[] {\n const out = new Set<string>();\n for (const addrs of Object.values(interfaces)) {\n for (const addr of addrs ?? []) {\n if (addr.family !== \"IPv6\" || addr.internal) {\n continue;\n }\n const prefix = ipv6Prefix64(addr.address);\n if (prefix) {\n out.add(prefix);\n }\n }\n }\n return [...out];\n}\n\n/**\n * The /64 prefixes of the host's own IPv6 addresses \u2014 the LAN guard's notion of\n * \"my network\" for a globally routable client.\n *\n * @returns every /64 prefix the host is in (may be empty)\n */\nexport function detectLocalIPv6Prefixes(): string[] {\n return listLocalIPv6Prefixes(networkInterfaces());\n}\n"],
|
|
5
|
+
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,qBAA6D;AAQ7D,SAAS,eAAe,MAAqC;AAC3D,SAAO,KAAK,WAAW,UAAU,CAAC,KAAK;AACzC;AAUO,SAAS,qBAAqB,YAA2D;AAC9F,QAAM,MAAgB,CAAC;AACvB,aAAW,SAAS,OAAO,OAAO,UAAU,GAAG;AAC7C,eAAW,QAAQ,wBAAS,CAAC,GAAG;AAC9B,UAAI,eAAe,IAAI,GAAG;AACxB,YAAI,KAAK,KAAK,OAAO;AAAA,MACvB;AAAA,IACF;AAAA,EACF;AACA,SAAO;AACT;AAOA,MAAM,4BAA4B,CAAC,WAAW,SAAS;AAQvD,SAAS,kBAAkB,SAA0B;AACnD,SAAO,0BAA0B,KAAK,YAAU,QAAQ,WAAW,MAAM,CAAC;AAC5E;AAmBO,SAAS,gBAAgB,YAAyD;AAlEzF;AAmEE,QAAM,YAAY,qBAAqB,UAAU;AACjD,UAAO,qBAAU,KAAK,aAAW,CAAC,kBAAkB,OAAO,CAAC,MAArD,YAA0D,UAAU,CAAC,MAArE,YAA0E;AACnF;AAUO,SAAS,oBAA4B;AAC1C,SAAO,oBAAgB,kCAAkB,CAAC;AAC5C;AAUO,SAAS,mBAA6B;AAC3C,SAAO,yBAAqB,kCAAkB,CAAC;AACjD;AAUA,SAAS,WAAW,SAAkC;AACpD,QAAM,OAAO,QAAQ,YAAY,EAAE,MAAM,GAAG,EAAE,CAAC;AAC/C,MAAI,CAAC,KAAK,SAAS,GAAG,KAAK,KAAK,SAAS,GAAG,GAAG;AAC7C,WAAO;AAAA,EACT;AACA,QAAM,SAAS,KAAK,MAAM,IAAI;AAC9B,MAAI,OAAO,SAAS,GAAG;AACrB,WAAO;AAAA,EACT;AACA,QAAM,OAAO,OAAO,CAAC,IAAI,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;AACjD,QAAM,OAAO,OAAO,WAAW,IAAK,OAAO,CAAC,IAAI,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC,IAAK,CAAC;AAC9E,QAAM,SACJ,OAAO,WAAW,IAAI,CAAC,GAAG,MAAM,GAAG,MAAc,IAAI,KAAK,SAAS,KAAK,MAAM,EAAE,KAAK,GAAG,GAAG,GAAG,IAAI,IAAI;AACxG,MAAI,OAAO,WAAW,KAAK,OAAO,KAAK,OAAK,CAAC,kBAAkB,KAAK,CAAC,CAAC,GAAG;AACvE,WAAO;AAAA,EACT;AACA,SAAO,OAAO,IAAI,OAAK,EAAE,SAAS,GAAG,GAAG,CAAC;AAC3C;AAUO,SAAS,aAAa,SAAgC;AAC3D,QAAM,SAAS,WAAW,OAAO;AACjC,SAAO,SAAS,OAAO,MAAM,GAAG,CAAC,EAAE,KAAK,GAAG,IAAI;AACjD;AAeO,SAAS,sBAAsB,YAA2D;AAC/F,QAAM,MAAM,oBAAI,IAAY;AAC5B,aAAW,SAAS,OAAO,OAAO,UAAU,GAAG;AAC7C,eAAW,QAAQ,wBAAS,CAAC,GAAG;AAC9B,UAAI,KAAK,WAAW,UAAU,KAAK,UAAU;AAC3C;AAAA,MACF;AACA,YAAM,SAAS,aAAa,KAAK,OAAO;AACxC,UAAI,QAAQ;AACV,YAAI,IAAI,MAAM;AAAA,MAChB;AAAA,IACF;AAAA,EACF;AACA,SAAO,CAAC,GAAG,GAAG;AAChB;AAQO,SAAS,0BAAoC;AAClD,SAAO,0BAAsB,kCAAkB,CAAC;AAClD;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/build/main.js
CHANGED
|
@@ -67,7 +67,14 @@ class Fakeroku extends utils.Adapter {
|
|
|
67
67
|
commandGates = /* @__PURE__ */ new Map();
|
|
68
68
|
/** Per device, when the dropped-commands warning was last written. */
|
|
69
69
|
rateWarnedAt = /* @__PURE__ */ new Map();
|
|
70
|
-
/**
|
|
70
|
+
/**
|
|
71
|
+
* Device-manager backend: the emulated Rokus as cards with add/edit/delete.
|
|
72
|
+
*
|
|
73
|
+
* Nothing reads this field, and that is not an oversight: dm-utils subscribes to
|
|
74
|
+
* the adapter's `message` event from its own constructor, so creating the object
|
|
75
|
+
* IS the wiring. The field keeps it visible — and owned — instead of leaving a
|
|
76
|
+
* bare `new` in the constructor that reads like a mistake.
|
|
77
|
+
*/
|
|
71
78
|
deviceManagement;
|
|
72
79
|
// Construction seams for the two network-facing collaborators. Production uses
|
|
73
80
|
// the real classes; the orchestration tests swap them for fakes so onReady's
|
|
@@ -89,21 +96,25 @@ class Fakeroku extends utils.Adapter {
|
|
|
89
96
|
}
|
|
90
97
|
/** Create each device's object tree, start its ECP server, then the shared SSDP responder. */
|
|
91
98
|
async onReady() {
|
|
92
|
-
var _a;
|
|
93
99
|
try {
|
|
94
100
|
await this.setState("info.connection", { val: false, ack: true });
|
|
95
101
|
await import_adapter_core.I18n.init((0, import_node_path.join)(this.adapterDir, "admin"), this);
|
|
96
102
|
await this.refreshOwnObjects();
|
|
103
|
+
const devicesKnown = Array.isArray(this.config.devices);
|
|
104
|
+
const configured = (devicesKnown ? this.config.devices : []).filter(
|
|
105
|
+
(d) => d && typeof d.name === "string" && d.name.length > 0
|
|
106
|
+
);
|
|
97
107
|
const configuredIp = this.config.networkInterface || this.config.BIND;
|
|
98
108
|
const bindIp = configuredIp && configuredIp !== "0.0.0.0" ? configuredIp : void 0;
|
|
99
109
|
const advertiseIp = bindIp != null ? bindIp : (0, import_detect_ip.detectPrimaryIPv4)();
|
|
100
110
|
if (!advertiseIp) {
|
|
101
111
|
this.log.warn("No routable IPv4 address found to advertise \u2014 set the network interface in the settings.");
|
|
112
|
+
await this.sweepOrphans(devicesKnown, configured);
|
|
102
113
|
return;
|
|
103
114
|
}
|
|
104
|
-
const configured = ((_a = this.config.devices) != null ? _a : []).filter((d) => d && typeof d.name === "string" && d.name.length > 0);
|
|
105
115
|
if (configured.length === 0) {
|
|
106
116
|
this.log.warn("No emulated Roku devices configured.");
|
|
117
|
+
await this.sweepOrphans(devicesKnown, configured);
|
|
107
118
|
return;
|
|
108
119
|
}
|
|
109
120
|
const adverts = [];
|
|
@@ -137,7 +148,8 @@ class Fakeroku extends utils.Adapter {
|
|
|
137
148
|
deviceType,
|
|
138
149
|
bindIp,
|
|
139
150
|
logger: this.log,
|
|
140
|
-
onCommand: (cmd) => this.applyCommand(deviceId, cmd)
|
|
151
|
+
onCommand: (cmd) => this.applyCommand(deviceId, cmd),
|
|
152
|
+
onFatalError: () => this.onEcpFatal(d.name)
|
|
141
153
|
});
|
|
142
154
|
await server.start();
|
|
143
155
|
this.ecpServers.push(server);
|
|
@@ -151,7 +163,7 @@ class Fakeroku extends utils.Adapter {
|
|
|
151
163
|
);
|
|
152
164
|
}
|
|
153
165
|
}
|
|
154
|
-
await this.
|
|
166
|
+
await this.sweepOrphans(devicesKnown, configured);
|
|
155
167
|
if (adverts.length === 0) {
|
|
156
168
|
this.log.error("No emulated Roku device could be started \u2014 check the configured ports for conflicts.");
|
|
157
169
|
return;
|
|
@@ -170,8 +182,8 @@ class Fakeroku extends utils.Adapter {
|
|
|
170
182
|
await this.startWithTimeout(ssdp.start(), SSDP_START_TIMEOUT_MS);
|
|
171
183
|
ssdp.announce();
|
|
172
184
|
this.notifyTimer = this.setInterval(() => {
|
|
173
|
-
var
|
|
174
|
-
return (
|
|
185
|
+
var _a;
|
|
186
|
+
return (_a = this.ssdp) == null ? void 0 : _a.announce();
|
|
175
187
|
}, SSDP_NOTIFY_INTERVAL_MS);
|
|
176
188
|
} catch (e) {
|
|
177
189
|
this.log.warn(
|
|
@@ -298,6 +310,32 @@ class Fakeroku extends utils.Adapter {
|
|
|
298
310
|
}
|
|
299
311
|
await Promise.all(keys.map((key) => this.setStateChangedAsync(`${deviceId}.keys.${key}`, { val: false, ack: true })));
|
|
300
312
|
}
|
|
313
|
+
/**
|
|
314
|
+
* Run the orphan sweep for the current configuration — from EVERY exit of
|
|
315
|
+
* onReady, which is the point of it existing as its own method.
|
|
316
|
+
*
|
|
317
|
+
* The sweep used to sit on the happy path only, so removing the last emulated
|
|
318
|
+
* Roku (or a host without a routable address) left its device object, its
|
|
319
|
+
* `command` / `commandType` and its 16–27 key states behind with nothing that
|
|
320
|
+
* would ever remove them: the user deletes a device in the admin and keeps its
|
|
321
|
+
* datapoints forever. The adapter answers for its own datapoints
|
|
322
|
+
* (`feedback_adapter_verantwortet_datenpunkte`), so the sweep runs whenever the
|
|
323
|
+
* configuration could be read.
|
|
324
|
+
*
|
|
325
|
+
* `devicesKnown` is that condition, and it is not pedantry: with no `devices`
|
|
326
|
+
* key at all (a never-configured instance, or a config we could not read) an
|
|
327
|
+
* empty set would mean "nothing is configured, delete everything" — trading a
|
|
328
|
+
* tree that stays for a tree that is gone.
|
|
329
|
+
*
|
|
330
|
+
* @param devicesKnown whether `native.devices` was readable as a list
|
|
331
|
+
* @param configured the configured devices with a usable name
|
|
332
|
+
*/
|
|
333
|
+
async sweepOrphans(devicesKnown, configured) {
|
|
334
|
+
if (!devicesKnown) {
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
await this.cleanupOrphans(new Set(configured.map((d) => (0, import_pure_helpers.sanitizeId)(d.name))));
|
|
338
|
+
}
|
|
301
339
|
/**
|
|
302
340
|
* Remove objects left over from an earlier version or config — the legacy
|
|
303
341
|
* `apps` node, keys no longer standard, and whole device sub-trees no longer
|
|
@@ -324,12 +362,16 @@ class Fakeroku extends utils.Adapter {
|
|
|
324
362
|
* Apply a received ECP command to this device's states: record it in `command`
|
|
325
363
|
* / `commandType`, and pulse or hold the standard key if it is one.
|
|
326
364
|
*
|
|
365
|
+
* Returns whether the command was applied: the ECP server logs only what got
|
|
366
|
+
* through, so the rate gate covers the log as well as the states database.
|
|
367
|
+
*
|
|
327
368
|
* @param deviceId the id-safe device path segment
|
|
328
369
|
* @param cmd the parsed ECP command
|
|
370
|
+
* @returns true if the command was applied, false if the rate gate dropped it
|
|
329
371
|
*/
|
|
330
372
|
applyCommand(deviceId, cmd) {
|
|
331
373
|
if (!this.admitCommand(deviceId)) {
|
|
332
|
-
return;
|
|
374
|
+
return false;
|
|
333
375
|
}
|
|
334
376
|
const write = (0, import_state_model.commandToStateWrite)(cmd);
|
|
335
377
|
this.writeState(`${deviceId}.command`, write.command);
|
|
@@ -365,6 +407,7 @@ class Fakeroku extends utils.Adapter {
|
|
|
365
407
|
}
|
|
366
408
|
}
|
|
367
409
|
}
|
|
410
|
+
return true;
|
|
368
411
|
}
|
|
369
412
|
/**
|
|
370
413
|
* The rate gate in front of every state write: MAX_COMMANDS_PER_SECOND per device,
|
|
@@ -408,6 +451,23 @@ class Fakeroku extends utils.Adapter {
|
|
|
408
451
|
this.log.debug(`State write ${id} failed: ${(0, import_errors.errText)(e)}`);
|
|
409
452
|
});
|
|
410
453
|
}
|
|
454
|
+
/**
|
|
455
|
+
* One emulated Roku's ECP server died at runtime (a server error after a good
|
|
456
|
+
* start). That device answers nothing any more, so the instance is no longer
|
|
457
|
+
* "every configured Roku is listening" — revise the status instead of leaving a
|
|
458
|
+
* green instance behind a device that is gone. The other devices keep running,
|
|
459
|
+
* and the message names the one that failed so the user knows which.
|
|
460
|
+
*
|
|
461
|
+
* @param friendlyName the configured name of the device whose server died
|
|
462
|
+
*/
|
|
463
|
+
onEcpFatal(friendlyName) {
|
|
464
|
+
this.log.error(
|
|
465
|
+
`Emulated Roku "${friendlyName}" stopped answering after a server error \u2014 restart the instance to bring it back.`
|
|
466
|
+
);
|
|
467
|
+
this.setState("info.connection", { val: false, ack: true }).catch((e) => {
|
|
468
|
+
this.log.debug(`Connection state write failed: ${(0, import_errors.errText)(e)}`);
|
|
469
|
+
});
|
|
470
|
+
}
|
|
411
471
|
/**
|
|
412
472
|
* The SSDP responder died at runtime (a socket error after a good start). Stop
|
|
413
473
|
* announcing into the dead socket and drop the discovery aid. The ECP servers
|
package/build/main.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/main.ts"],
|
|
4
|
-
"sourcesContent": ["import * as utils from \"@iobroker/adapter-core\";\nimport { I18n } from \"@iobroker/adapter-core\";\nimport { join } from \"node:path\";\nimport { FakerokuDeviceManagement } from \"./device-management\";\nimport type { RokuAdvert } from \"./discovery/ssdp-messages\";\nimport { RokuSsdpResponder } from \"./discovery/ssdp-responder\";\nimport { DEFAULT_APPS } from \"./ecp/device-info\";\nimport { COMMAND_TYPES, type CommandEvent } from \"./ecp/ecp-command\";\nimport { EcpHttpServer } from \"./ecp/ecp-http-server\";\nimport { commandToStateWrite, type DeviceType, keysForType } from \"./ecp/state-model\";\nimport { DEFAULT_ECP_PORT, RESERVED_IDS } from \"./lib/constants\";\nimport { resolveDeviceUuid } from \"./lib/device-identity\";\nimport { detectLocalIPv4s, detectPrimaryIPv4 } from \"./lib/detect-ip\";\nimport { errText } from \"./lib/errors\";\nimport { tDesc, tName, tRaw } from \"./lib/i18n\";\nimport { planObjectCleanup } from \"./lib/object-cleanup\";\nimport { sanitizeId } from \"./lib/pure-helpers\";\nimport { RateGate } from \"./lib/rate-gate\";\n\n/** Managed timeout for a stuck SSDP start (a busy port 1900 must not hang onReady). */\nconst SSDP_START_TIMEOUT_MS = 5000;\n/** Proactive ssdp:alive interval so controllers find the device without searching. */\nconst SSDP_NOTIFY_INTERVAL_MS = 300_000;\n/** How long a keypress pulses its keys.<Key> state true before falling back to false. */\nconst KEY_PULSE_MS = 50;\n/** Safety cap for a held key: a keydown with no matching keyup resets after this. */\nconst HOLD_MAX_MS = 30_000;\n/** Commands accepted per second and emulated Roku; the excess is dropped (see lib/rate-gate.ts). */\nconst MAX_COMMANDS_PER_SECOND = 25;\n/** How often at most the dropped-commands warning repeats per device. */\nconst RATE_WARN_INTERVAL_MS = 60_000;\n\n/**\n * ioBroker.fakeroku \u2014 Roku emulator (input side).\n *\n * Emulates one or more Roku devices on the LAN so that ECP/SSDP remotes\n * (Logitech Harmony or a Sofabaton X1/X2) trigger events in ioBroker:\n * a keypress lands in `<device>.command` and pulses `<device>.keys.<Key>`.\n */\nexport class Fakeroku extends utils.Adapter {\n private ssdp: RokuSsdpResponder | undefined;\n private notifyTimer: ioBroker.Interval | undefined;\n private readonly ecpServers: EcpHttpServer[] = [];\n private readonly pulseTimers = new Set<ioBroker.Timeout>();\n /** Per held key id, its watchdog timer \u2014 so a keydown without a keyup cannot pin it true forever. */\n private readonly holdTimers = new Map<string, ioBroker.Timeout>();\n /** Per device, the key names it exposes \u2014 so a keypress only writes keys this device carries. */\n private readonly deviceKeys = new Map<string, ReadonlySet<string>>();\n /** Per device, its command rate gate \u2014 the write-flood protection for the states database. */\n private readonly commandGates = new Map<string, RateGate>();\n /** Per device, when the dropped-commands warning was last written. */\n private readonly rateWarnedAt = new Map<string, number>();\n /** Device-manager backend: the emulated Rokus as cards with add/edit/delete. */\n private readonly deviceManagement: FakerokuDeviceManagement;\n\n // Construction seams for the two network-facing collaborators. Production uses\n // the real classes; the orchestration tests swap them for fakes so onReady's\n // wiring (per-device isolation, SSDP degradation, timers) is testable without\n // binding a port. Behaviour is unchanged \u2014 same constructors, same arguments.\n private makeEcpServer: (options: ConstructorParameters<typeof EcpHttpServer>[0]) => EcpHttpServer = options =>\n new EcpHttpServer(options);\n private makeSsdpResponder: (options: ConstructorParameters<typeof RokuSsdpResponder>[0]) => RokuSsdpResponder =\n options => new RokuSsdpResponder(options);\n\n /**\n * @param options adapter options passed through by js-controller\n */\n public constructor(options: Partial<utils.AdapterOptions> = {}) {\n super({\n ...options,\n name: \"fakeroku\",\n });\n\n this.on(\"ready\", this.onReady.bind(this));\n this.on(\"unload\", this.onUnload.bind(this));\n this.deviceManagement = new FakerokuDeviceManagement(this);\n }\n\n /** Create each device's object tree, start its ECP server, then the shared SSDP responder. */\n private async onReady(): Promise<void> {\n try {\n await this.setState(\"info.connection\", { val: false, ack: true });\n await I18n.init(join(this.adapterDir, \"admin\"), this);\n await this.refreshOwnObjects();\n\n // Empty AND \"0.0.0.0\" both mean \"auto\": bind all interfaces, advertise the\n // detected primary IP so the adapter runs without configuration. js-controller\n // never rewrites an existing native default, so instances from before 0.5.1\n // still carry \"\" \u2014 both must take the auto path. A concrete IP is honoured as-is.\n // Migration: a pre-0.5.0 instance has no `networkInterface`, only the old `BIND` \u2014\n // adopt it so a multi-homed host keeps the interface the user had configured.\n const configuredIp = this.config.networkInterface || this.config.BIND;\n const bindIp = configuredIp && configuredIp !== \"0.0.0.0\" ? configuredIp : undefined;\n const advertiseIp = bindIp ?? detectPrimaryIPv4();\n if (!advertiseIp) {\n this.log.warn(\"No routable IPv4 address found to advertise \u2014 set the network interface in the settings.\");\n return;\n }\n\n const configured = (this.config.devices ?? []).filter(d => d && typeof d.name === \"string\" && d.name.length > 0);\n if (configured.length === 0) {\n this.log.warn(\"No emulated Roku devices configured.\");\n return;\n }\n\n const adverts: RokuAdvert[] = [];\n const seenIds = new Set<string>();\n for (const d of configured) {\n const deviceId = sanitizeId(d.name);\n // Two configured names can sanitize to the same object id \u2014 the admin guards\n // against it, but a hand-edited config could still carry it. Skip the duplicate\n // instead of letting two devices fight over one object tree.\n if (seenIds.has(deviceId)) {\n this.log.warn(`Emulated Roku \"${d.name}\" maps to an object id already in use (${deviceId}) \u2014 skipping it.`);\n continue;\n }\n // The dialog refuses these names, but native.devices is hand-editable. A\n // device called \"info\" would rewrite the adapter's own info channel into a\n // device object and hang its command/keys states under info.connection.\n if (RESERVED_IDS.has(deviceId)) {\n this.log.warn(\n `Emulated Roku \"${d.name}\" maps to the object id \"${deviceId}\", which the adapter reserves for its own status \u2014 skipping it.`,\n );\n continue;\n }\n const deviceType: DeviceType = d.type === \"tv\" ? \"tv\" : \"player\";\n const keys = keysForType(deviceType);\n // Adopt the device's persisted uuid (old adapter or an earlier run) so the SSDP\n // identity \u2014 and thus the controller pairing \u2014 survives an update; derive a stable\n // one from the name only for a device that never had one. resolveDeviceUuid is the\n // single source for that decision, shared with the device manager so an edit\n // persists exactly what is advertised here (lib/device-identity.ts).\n const uuid = resolveDeviceUuid(d);\n if (d.uuid && uuid !== d.uuid) {\n this.log.warn(`Emulated Roku \"${d.name}\" has an unusable device id in its config \u2014 using a derived one.`);\n }\n const advert: RokuAdvert = { uuid, port: Number(d.port) || DEFAULT_ECP_PORT };\n let server: EcpHttpServer | undefined;\n try {\n await this.createDeviceStates(deviceId, d.name, keys);\n server = this.makeEcpServer({\n device: advert,\n friendlyName: d.name,\n apps: DEFAULT_APPS,\n deviceType,\n bindIp,\n logger: this.log,\n onCommand: cmd => this.applyCommand(deviceId, cmd),\n });\n await server.start();\n this.ecpServers.push(server);\n this.deviceKeys.set(deviceId, new Set(keys));\n seenIds.add(deviceId);\n adverts.push(advert);\n } catch (e) {\n // One device's failure (a busy ECP port) must not take the others down.\n // Close whatever the failed start left behind, so nothing outlives this turn.\n server?.stop();\n this.log.warn(\n `Emulated Roku \"${d.name}\" could not start on port ${advert.port}: ${errText(e)} \u2014 skipping it.`,\n );\n }\n }\n\n await this.cleanupOrphans(new Set(configured.map(d => sanitizeId(d.name))));\n\n if (adverts.length === 0) {\n // Nothing is controllable \u2014 leave info.connection false and stop here.\n this.log.error(\"No emulated Roku device could be started \u2014 check the configured ports for conflicts.\");\n return;\n }\n\n // Discovery is only an aid; the ECP servers above already make the adapter\n // controllable. Isolate the SSDP start so a busy port 1900 (or a stuck bind)\n // degrades to \"discovery off, already-paired remotes still work\" instead of\n // failing the whole start-up. In the auto case join every routable interface so\n // a multi-homed host is discoverable on all its LANs; a chosen interface pins\n // both membership and NOTIFY egress.\n const membershipInterfaces = bindIp ? [bindIp] : detectLocalIPv4s();\n const ssdp = this.makeSsdpResponder({\n devices: adverts,\n bindIp,\n advertiseIp,\n membershipInterfaces,\n logger: this.log,\n onFatalError: () => this.onSsdpFatal(),\n });\n this.ssdp = ssdp;\n try {\n await this.startWithTimeout(ssdp.start(), SSDP_START_TIMEOUT_MS);\n ssdp.announce();\n this.notifyTimer = this.setInterval(() => this.ssdp?.announce(), SSDP_NOTIFY_INTERVAL_MS);\n } catch (e) {\n this.log.warn(\n `SSDP discovery unavailable: ${errText(e)} \u2014 already-paired remotes still work; set the network interface if devices are not found.`,\n );\n // A start that only timed out can still bind later. Close it, or the socket\n // outlives the reference dropped here and keeps answering with nobody to stop it.\n ssdp.stop();\n this.ssdp = undefined;\n }\n\n // \"Connected\" means EVERY configured Roku is listening, not just some of\n // them. A device whose port is taken (or whose name collides) is skipped\n // above with a warning naming it; reporting \"connected\" anyway would hide\n // a broken configuration behind the devices that did come up, and the user\n // would only find out when the remote stops working. Config rows without a\n // usable name are filtered out before this point and deliberately do not\n // count \u2014 every device in `configured` that fails has said so in the log.\n const allStarted = adverts.length === configured.length;\n await this.setState(\"info.connection\", { val: allStarted, ack: true });\n const where = `advertising on ${advertiseIp}${this.ssdp ? \"\" : \" (discovery off)\"}`;\n if (allStarted) {\n this.log.info(`Emulating ${adverts.length} Roku device(s), ${where}`);\n } else {\n this.log.error(\n `Only ${adverts.length} of ${configured.length} configured Roku device(s) could be started, ${where} \u2014 fix the cause reported above; the instance stays disconnected until every device runs.`,\n );\n }\n } catch (e) {\n this.log.error(`onReady failed: ${errText(e)}`);\n }\n }\n\n /**\n * Re-apply the adapter's OWN objects \u2014 the `info` channel and `info.connection`\n * \u2014 on every start.\n *\n * js-controller creates the manifest's instanceObjects only where they are\n * missing, so a changed name or description never reaches an installation that\n * already has them: the manifest would be correct and the real tree unchanged.\n * extendObject is what carries the change into an existing tree, so an update\n * always lands on every datapoint, not just on fresh installs.\n *\n * It also repairs the `info` channel after a hand-edited device row named\n * \"info\" turned it into a device object (see the reserved-id guard in onReady).\n */\n private async refreshOwnObjects(): Promise<void> {\n await this.extendObject(\"info\", {\n type: \"channel\",\n common: { name: tName(\"channelInfo\") },\n native: {},\n });\n await this.extendObject(\"info.connection\", {\n type: \"state\",\n common: {\n name: tName(\"connectionStatus\"),\n desc: tDesc(\"connectionStatusDesc\"),\n type: \"boolean\",\n role: \"indicator.connected\",\n read: true,\n write: false,\n def: false,\n },\n native: {},\n });\n }\n\n /**\n * Create the fixed object tree for one emulated Roku: the device, `command` +\n * `commandType`, and one `sensor` boolean state per key the device type exposes \u2014\n * all up front, so the tree is usable before any key is ever pressed.\n *\n * Every key state is also RESET to false here. A key is a momentary signal, but\n * nothing writes its release when the adapter goes down: a keypress pulses true\n * and schedules the false 50 ms later, a keydown holds true until its keyup, and\n * onUnload drops both timers without writing. So a stop inside that window \u2014 or\n * a crash, or a controller that never sent its keyup \u2014 leaves the key true in\n * the database for good, and a rule watching for the next press never sees an\n * edge again. The reset belongs on STARTUP, not into onUnload: only startup also\n * covers the crash, and 27 writes per device would eat the shutdown budget that\n * today comfortably carries a single one. setStateChanged writes only where the\n * value actually differs, so a healthy tree costs nothing.\n *\n * @param deviceId the id-safe device path segment\n * @param friendlyName the configured device name\n * @param keys the key names to create for this device (from its type)\n */\n private async createDeviceStates(deviceId: string, friendlyName: string, keys: readonly string[]): Promise<void> {\n // The device name is the user's own text \u2014 nothing to translate, but it must\n // still BE a translation object like every other common.name (tRaw).\n await this.extendObject(deviceId, { type: \"device\", common: { name: tRaw(friendlyName) }, native: {} });\n await this.extendObject(`${deviceId}.command`, {\n type: \"state\",\n common: {\n name: tName(\"stateLastCommand\"),\n desc: tDesc(\"stateLastCommandDesc\"),\n type: \"string\",\n role: \"text\",\n read: true,\n write: false,\n def: \"\",\n },\n native: {},\n });\n await this.extendObject(`${deviceId}.commandType`, {\n type: \"state\",\n common: {\n name: tName(\"stateLastCommandType\"),\n desc: tDesc(\"stateLastCommandTypeDesc\"),\n type: \"string\",\n role: \"text\",\n read: true,\n write: false,\n def: \"\",\n // The fixed verb list, so the admin shows the value as a label. Plain strings\n // only \u2014 a translation object here crashes the admin's object view.\n states: Object.fromEntries(COMMAND_TYPES.map(verb => [verb, verb])),\n },\n native: {},\n });\n await this.extendObject(`${deviceId}.keys`, {\n type: \"channel\",\n common: { name: tName(\"channelKeys\"), desc: tDesc(\"channelKeysDesc\") },\n native: {},\n });\n for (const key of keys) {\n await this.extendObject(`${deviceId}.keys.${key}`, {\n type: \"state\",\n // \"sensor\" = generic boolean read-only (active/inactive). The docs suggest\n // button.press for a keypress-as-state, but the repochecker rejects it\n // (E1010 \u2014 not in its role list); sensor is the gate-conformant fit.\n // The name is the ECP key identifier and identical in every language, but\n // it still has to BE a translation object (tRaw), never a bare string.\n // No desc: the key name already says everything there is to say.\n common: { name: tRaw(key), type: \"boolean\", role: \"sensor\", read: true, write: false, def: false },\n native: {},\n });\n }\n await Promise.all(keys.map(key => this.setStateChangedAsync(`${deviceId}.keys.${key}`, { val: false, ack: true })));\n }\n\n /**\n * Remove objects left over from an earlier version or config \u2014 the legacy\n * `apps` node, keys no longer standard, and whole device sub-trees no longer\n * configured (a renamed/removed device). The adapter otherwise only ever\n * creates objects, so without this the tree would accrete stale entries.\n *\n * @param configuredDeviceIds the id-safe names of the currently configured devices\n */\n private async cleanupOrphans(configuredDeviceIds: ReadonlySet<string>): Promise<void> {\n const objects = await this.getAdapterObjectsAsync();\n const prefix = `${this.namespace}.`;\n const existingIds = Object.keys(objects)\n .filter(id => id.startsWith(prefix))\n .map(id => id.slice(prefix.length));\n const toDelete = planObjectCleanup(existingIds, configuredDeviceIds, this.deviceKeys);\n for (const id of toDelete) {\n await this.delObjectAsync(id, { recursive: true }).catch((e: unknown) => {\n this.log.debug(`cleanup: could not delete ${id}: ${errText(e)}`);\n });\n }\n if (toDelete.length > 0) {\n this.log.debug(`Removed ${toDelete.length} orphaned object(s) from an earlier version or config.`);\n }\n }\n\n /**\n * Apply a received ECP command to this device's states: record it in `command`\n * / `commandType`, and pulse or hold the standard key if it is one.\n *\n * @param deviceId the id-safe device path segment\n * @param cmd the parsed ECP command\n */\n private applyCommand(deviceId: string, cmd: CommandEvent): void {\n if (!this.admitCommand(deviceId)) {\n return;\n }\n const write = commandToStateWrite(cmd);\n this.writeState(`${deviceId}.command`, write.command);\n this.writeState(`${deviceId}.commandType`, write.commandType);\n // Only write keys.<Key> if THIS device's type carries the key \u2014 a player has no\n // TV key objects, so a stray TV keypress lands in `command` only, never a missing state.\n const keys = this.deviceKeys.get(deviceId);\n if (write.pulseKey && keys?.has(write.pulseKey)) {\n const id = `${deviceId}.keys.${write.pulseKey}`;\n this.writeState(id, true);\n const timer = this.setTimeout(() => {\n if (timer) {\n this.pulseTimers.delete(timer);\n }\n this.writeState(id, false);\n }, KEY_PULSE_MS);\n if (timer) {\n this.pulseTimers.add(timer);\n }\n } else if (write.holdKey && keys?.has(write.holdKey.key)) {\n const id = `${deviceId}.keys.${write.holdKey.key}`;\n this.writeState(id, write.holdKey.value);\n // A keydown holds the key true until its keyup. Arm a watchdog so a lost keyup\n // (controller disconnects mid-press) cannot pin the key true forever; a keyup\n // clears it, and a repeated keydown re-arms it.\n const pending = this.holdTimers.get(id);\n if (pending) {\n this.clearTimeout(pending);\n this.holdTimers.delete(id);\n }\n if (write.holdKey.value) {\n const timer = this.setTimeout(() => {\n this.holdTimers.delete(id);\n this.writeState(id, false);\n }, HOLD_MAX_MS);\n if (timer) {\n this.holdTimers.set(id, timer);\n }\n }\n }\n }\n\n /**\n * The rate gate in front of every state write: MAX_COMMANDS_PER_SECOND per device,\n * the excess is dropped and reported once per RATE_WARN_INTERVAL_MS. Every accepted\n * command costs the states database three writes plus one more when the pulse\n * ends \u2014 a flooding device in the LAN would otherwise slow the whole host.\n *\n * @param deviceId the id-safe device path segment\n * @returns true if the command may be applied\n */\n private admitCommand(deviceId: string): boolean {\n const now = Date.now();\n let gate = this.commandGates.get(deviceId);\n if (!gate) {\n gate = new RateGate(MAX_COMMANDS_PER_SECOND, now);\n this.commandGates.set(deviceId, gate);\n }\n if (gate.allow(now)) {\n return true;\n }\n if (now - (this.rateWarnedAt.get(deviceId) ?? 0) >= RATE_WARN_INTERVAL_MS) {\n this.rateWarnedAt.set(deviceId, now);\n this.log.warn(\n `Emulated Roku \"${deviceId}\" receives more than ${MAX_COMMANDS_PER_SECOND} commands per second \u2014 dropping the excess (a misbehaving controller?)`,\n );\n }\n return false;\n }\n\n /**\n * Fire-and-forget state write for the command hot path. The states exist (created\n * up front in createDeviceStates), so there is no read-before-write; a rejection \u2014\n * the states database already closed while a remote still sends \u2014 is traced at\n * debug, because an unhandled one would crash the adapter over one lost keypress.\n *\n * @param id the state id relative to the namespace\n * @param val the value to write\n */\n private writeState(id: string, val: string | boolean): void {\n this.setState(id, { val, ack: true }).catch((e: unknown) => {\n this.log.debug(`State write ${id} failed: ${errText(e)}`);\n });\n }\n\n /**\n * The SSDP responder died at runtime (a socket error after a good start). Stop\n * announcing into the dead socket and drop the discovery aid. The ECP servers\n * keep working, so info.connection \u2014 which reflects ECP readiness \u2014 stays true.\n */\n private onSsdpFatal(): void {\n if (this.notifyTimer) {\n this.clearInterval(this.notifyTimer);\n this.notifyTimer = undefined;\n }\n this.ssdp = undefined;\n this.log.warn(\n \"SSDP discovery stopped after a socket error \u2014 already-paired remotes still work; restart the instance to re-enable discovery.\",\n );\n }\n\n /**\n * Bound await: reject if the SSDP start doesn't settle in time, so a stuck\n * port-1900 bind degrades to \"discovery off\" instead of hanging the adapter.\n *\n * @param promise the SSDP start promise\n * @param ms the timeout in milliseconds\n * @returns a promise that settles with the start result or a timeout error\n */\n private startWithTimeout(promise: Promise<void>, ms: number): Promise<void> {\n return new Promise<void>((resolve, reject) => {\n const timer = this.setTimeout(() => reject(new Error(`SSDP start timed out after ${ms} ms`)), ms);\n const clear = (): void => {\n if (timer) {\n this.clearTimeout(timer);\n }\n };\n promise.then(\n () => {\n clear();\n resolve();\n },\n (e: unknown) => {\n clear();\n // Lint (prefer-promise-reject-errors) wants an Error here; errText downstream\n // would cope with anything, so this is the rule's shape, not a second safeguard.\n reject(e instanceof Error ? e : new Error(String(e)));\n },\n );\n });\n }\n\n /**\n * Teardown: drop the timers and sockets synchronously, then report done only\n * once the last write has landed.\n *\n * `info.connection` is the only status this adapter carries, and nothing else\n * resets it: the host means to, but writes its reset to the namespace root\n * instead of the datapoint (js-controller#3472). So if the final write is\n * lost, the instance shows \"connected\" while the adapter is off.\n *\n * A fire-and-forget write plus an immediate callback is a race, not a\n * guaranteed loss \u2014 measured on 1.1.0, it still arrived, because without\n * `common.supportedMessages.stopInstance` the process ends in an orderly way\n * and flushes what is pending. Waiting closes the race for the slow or busy\n * case, and it is safe for the same reason: no `stopInstance` means the host\n * grants the full `common.stopTimeout` instead of killing the process.\n *\n * @param callback function to invoke once teardown is complete\n */\n private onUnload(callback: () => void): void {\n try {\n if (this.notifyTimer) {\n this.clearInterval(this.notifyTimer);\n this.notifyTimer = undefined;\n }\n for (const t of this.pulseTimers) {\n this.clearTimeout(t);\n }\n this.pulseTimers.clear();\n for (const t of this.holdTimers.values()) {\n this.clearTimeout(t);\n }\n this.holdTimers.clear();\n this.ssdp?.stop();\n for (const s of this.ecpServers) {\n s.stop();\n }\n void this.setState(\"info.connection\", { val: false, ack: true })\n // A rejected write must not become an unhandled rejection \u2014 that is a\n // crash (exit code 6) instead of an orderly stop. The trace stays at\n // debug: it explains a stale \"connected\" afterwards, and nobody can act\n // on it while the adapter is already going down.\n .catch((e: unknown) => {\n this.log.debug(`Final connection write failed: ${errText(e)}`);\n })\n .finally(() => callback());\n } catch {\n callback();\n }\n }\n}\n\nif (require.main !== module) {\n // Export the constructor in compact mode\n module.exports = (options: Partial<utils.AdapterOptions> | undefined) => new Fakeroku(options);\n} else {\n // Start the instance directly\n (() => new Fakeroku())();\n}\n"],
|
|
5
|
-
"mappings": ";;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,YAAuB;AACvB,0BAAqB;AACrB,uBAAqB;AACrB,+BAAyC;AAEzC,4BAAkC;AAClC,yBAA6B;AAC7B,yBAAiD;AACjD,6BAA8B;AAC9B,yBAAkE;AAClE,uBAA+C;AAC/C,6BAAkC;AAClC,uBAAoD;AACpD,oBAAwB;AACxB,kBAAmC;AACnC,4BAAkC;AAClC,0BAA2B;AAC3B,uBAAyB;AAGzB,MAAM,wBAAwB;AAE9B,MAAM,0BAA0B;AAEhC,MAAM,eAAe;AAErB,MAAM,cAAc;AAEpB,MAAM,0BAA0B;AAEhC,MAAM,wBAAwB;AASvB,MAAM,iBAAiB,MAAM,QAAQ;AAAA,EAClC;AAAA,EACA;AAAA,EACS,aAA8B,CAAC;AAAA,EAC/B,cAAc,oBAAI,IAAsB;AAAA;AAAA,EAExC,aAAa,oBAAI,IAA8B;AAAA;AAAA,EAE/C,aAAa,oBAAI,IAAiC;AAAA;AAAA,EAElD,eAAe,oBAAI,IAAsB;AAAA;AAAA,EAEzC,eAAe,oBAAI,IAAoB;AAAA;AAAA,
|
|
6
|
-
"names": [
|
|
4
|
+
"sourcesContent": ["import * as utils from \"@iobroker/adapter-core\";\nimport { I18n } from \"@iobroker/adapter-core\";\nimport { join } from \"node:path\";\nimport { FakerokuDeviceManagement } from \"./device-management\";\nimport type { RokuAdvert } from \"./discovery/ssdp-messages\";\nimport { RokuSsdpResponder } from \"./discovery/ssdp-responder\";\nimport { DEFAULT_APPS } from \"./ecp/device-info\";\nimport { COMMAND_TYPES, type CommandEvent } from \"./ecp/ecp-command\";\nimport { EcpHttpServer } from \"./ecp/ecp-http-server\";\nimport { commandToStateWrite, type DeviceType, keysForType } from \"./ecp/state-model\";\nimport { DEFAULT_ECP_PORT, RESERVED_IDS } from \"./lib/constants\";\nimport { resolveDeviceUuid } from \"./lib/device-identity\";\nimport { detectLocalIPv4s, detectPrimaryIPv4 } from \"./lib/detect-ip\";\nimport { errText } from \"./lib/errors\";\nimport { tDesc, tName, tRaw } from \"./lib/i18n\";\nimport { planObjectCleanup } from \"./lib/object-cleanup\";\nimport { sanitizeId } from \"./lib/pure-helpers\";\nimport { RateGate } from \"./lib/rate-gate\";\n\n/** Managed timeout for a stuck SSDP start (a busy port 1900 must not hang onReady). */\nconst SSDP_START_TIMEOUT_MS = 5000;\n/** Proactive ssdp:alive interval so controllers find the device without searching. */\nconst SSDP_NOTIFY_INTERVAL_MS = 300_000;\n/** How long a keypress pulses its keys.<Key> state true before falling back to false. */\nconst KEY_PULSE_MS = 50;\n/** Safety cap for a held key: a keydown with no matching keyup resets after this. */\nconst HOLD_MAX_MS = 30_000;\n/** Commands accepted per second and emulated Roku; the excess is dropped (see lib/rate-gate.ts). */\nconst MAX_COMMANDS_PER_SECOND = 25;\n/** How often at most the dropped-commands warning repeats per device. */\nconst RATE_WARN_INTERVAL_MS = 60_000;\n\n/**\n * ioBroker.fakeroku \u2014 Roku emulator (input side).\n *\n * Emulates one or more Roku devices on the LAN so that ECP/SSDP remotes\n * (Logitech Harmony or a Sofabaton X1/X2) trigger events in ioBroker:\n * a keypress lands in `<device>.command` and pulses `<device>.keys.<Key>`.\n */\nexport class Fakeroku extends utils.Adapter {\n private ssdp: RokuSsdpResponder | undefined;\n private notifyTimer: ioBroker.Interval | undefined;\n private readonly ecpServers: EcpHttpServer[] = [];\n private readonly pulseTimers = new Set<ioBroker.Timeout>();\n /** Per held key id, its watchdog timer \u2014 so a keydown without a keyup cannot pin it true forever. */\n private readonly holdTimers = new Map<string, ioBroker.Timeout>();\n /** Per device, the key names it exposes \u2014 so a keypress only writes keys this device carries. */\n private readonly deviceKeys = new Map<string, ReadonlySet<string>>();\n /** Per device, its command rate gate \u2014 the write-flood protection for the states database. */\n private readonly commandGates = new Map<string, RateGate>();\n /** Per device, when the dropped-commands warning was last written. */\n private readonly rateWarnedAt = new Map<string, number>();\n /**\n * Device-manager backend: the emulated Rokus as cards with add/edit/delete.\n *\n * Nothing reads this field, and that is not an oversight: dm-utils subscribes to\n * the adapter's `message` event from its own constructor, so creating the object\n * IS the wiring. The field keeps it visible \u2014 and owned \u2014 instead of leaving a\n * bare `new` in the constructor that reads like a mistake.\n */\n private readonly deviceManagement: FakerokuDeviceManagement;\n\n // Construction seams for the two network-facing collaborators. Production uses\n // the real classes; the orchestration tests swap them for fakes so onReady's\n // wiring (per-device isolation, SSDP degradation, timers) is testable without\n // binding a port. Behaviour is unchanged \u2014 same constructors, same arguments.\n private makeEcpServer: (options: ConstructorParameters<typeof EcpHttpServer>[0]) => EcpHttpServer = options =>\n new EcpHttpServer(options);\n private makeSsdpResponder: (options: ConstructorParameters<typeof RokuSsdpResponder>[0]) => RokuSsdpResponder =\n options => new RokuSsdpResponder(options);\n\n /**\n * @param options adapter options passed through by js-controller\n */\n public constructor(options: Partial<utils.AdapterOptions> = {}) {\n super({\n ...options,\n name: \"fakeroku\",\n });\n\n this.on(\"ready\", this.onReady.bind(this));\n this.on(\"unload\", this.onUnload.bind(this));\n this.deviceManagement = new FakerokuDeviceManagement(this);\n }\n\n /** Create each device's object tree, start its ECP server, then the shared SSDP responder. */\n private async onReady(): Promise<void> {\n try {\n await this.setState(\"info.connection\", { val: false, ack: true });\n await I18n.init(join(this.adapterDir, \"admin\"), this);\n await this.refreshOwnObjects();\n\n // Read the device list BEFORE anything can return early: the orphan sweep below\n // has to know the configured set at every exit, or a tree the user just deleted\n // stays in the database for good (there is no second path that removes it).\n // `devicesKnown` separates \"the user removed the last device\" (an empty array \u2014\n // sweep) from \"there is no devices key at all\" (never configured, or a config we\n // could not read \u2014 sweep nothing, or we would delete a tree we cannot account for).\n const devicesKnown = Array.isArray(this.config.devices);\n const configured = (devicesKnown ? this.config.devices : []).filter(\n d => d && typeof d.name === \"string\" && d.name.length > 0,\n );\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 // Sweep even here: what belongs in the tree is decided by the configuration,\n // not by the network. A missing cable is no reason to keep a deleted device.\n await this.sweepOrphans(devicesKnown, configured);\n return;\n }\n\n if (configured.length === 0) {\n this.log.warn(\"No emulated Roku devices configured.\");\n await this.sweepOrphans(devicesKnown, configured);\n return;\n }\n\n const adverts: RokuAdvert[] = [];\n const seenIds = new Set<string>();\n for (const d of configured) {\n const deviceId = sanitizeId(d.name);\n // Two configured names can sanitize to the same object id \u2014 the admin guards\n // against it, but a hand-edited config could still carry it. Skip the duplicate\n // instead of letting two devices fight over one object tree.\n if (seenIds.has(deviceId)) {\n this.log.warn(`Emulated Roku \"${d.name}\" maps to an object id already in use (${deviceId}) \u2014 skipping it.`);\n continue;\n }\n // The dialog refuses these names, but native.devices is hand-editable. A\n // device called \"info\" would rewrite the adapter's own info channel into a\n // device object and hang its command/keys states under info.connection.\n if (RESERVED_IDS.has(deviceId)) {\n this.log.warn(\n `Emulated Roku \"${d.name}\" maps to the object id \"${deviceId}\", which the adapter reserves for its own status \u2014 skipping it.`,\n );\n continue;\n }\n const deviceType: DeviceType = d.type === \"tv\" ? \"tv\" : \"player\";\n const keys = keysForType(deviceType);\n // Adopt the device's persisted uuid (old adapter or an earlier run) so the SSDP\n // identity \u2014 and thus the controller pairing \u2014 survives an update; derive a stable\n // one from the name only for a device that never had one. resolveDeviceUuid is the\n // single source for that decision, shared with the device manager so an edit\n // persists exactly what is advertised here (lib/device-identity.ts).\n const uuid = resolveDeviceUuid(d);\n if (d.uuid && uuid !== d.uuid) {\n this.log.warn(`Emulated Roku \"${d.name}\" has an unusable device id in its config \u2014 using a derived one.`);\n }\n const advert: RokuAdvert = { uuid, port: Number(d.port) || DEFAULT_ECP_PORT };\n let server: EcpHttpServer | undefined;\n try {\n await this.createDeviceStates(deviceId, d.name, keys);\n server = this.makeEcpServer({\n device: advert,\n friendlyName: d.name,\n apps: DEFAULT_APPS,\n deviceType,\n bindIp,\n logger: this.log,\n onCommand: cmd => this.applyCommand(deviceId, cmd),\n onFatalError: () => this.onEcpFatal(d.name),\n });\n await server.start();\n this.ecpServers.push(server);\n this.deviceKeys.set(deviceId, new Set(keys));\n seenIds.add(deviceId);\n adverts.push(advert);\n } catch (e) {\n // One device's failure (a busy ECP port) must not take the others down.\n // Close whatever the failed start left behind, so nothing outlives this turn.\n server?.stop();\n this.log.warn(\n `Emulated Roku \"${d.name}\" could not start on port ${advert.port}: ${errText(e)} \u2014 skipping it.`,\n );\n }\n }\n\n await this.sweepOrphans(devicesKnown, configured);\n\n if (adverts.length === 0) {\n // Nothing is controllable \u2014 leave info.connection false and stop here.\n this.log.error(\"No emulated Roku device could be started \u2014 check the configured ports for conflicts.\");\n return;\n }\n\n // Discovery is only an aid; the ECP servers above already make the adapter\n // controllable. Isolate the SSDP start so a busy port 1900 (or a stuck bind)\n // degrades to \"discovery off, already-paired remotes still work\" instead of\n // failing the whole start-up. In the auto case join every routable interface so\n // a multi-homed host is discoverable on all its LANs; a chosen interface pins\n // both membership and NOTIFY egress.\n const membershipInterfaces = bindIp ? [bindIp] : detectLocalIPv4s();\n const ssdp = this.makeSsdpResponder({\n devices: adverts,\n bindIp,\n advertiseIp,\n membershipInterfaces,\n logger: this.log,\n onFatalError: () => this.onSsdpFatal(),\n });\n this.ssdp = ssdp;\n try {\n await this.startWithTimeout(ssdp.start(), SSDP_START_TIMEOUT_MS);\n ssdp.announce();\n this.notifyTimer = this.setInterval(() => this.ssdp?.announce(), SSDP_NOTIFY_INTERVAL_MS);\n } catch (e) {\n this.log.warn(\n `SSDP discovery unavailable: ${errText(e)} \u2014 already-paired remotes still work; set the network interface if devices are not found.`,\n );\n // A start that only timed out can still bind later. Close it, or the socket\n // outlives the reference dropped here and keeps answering with nobody to stop it.\n ssdp.stop();\n this.ssdp = undefined;\n }\n\n // \"Connected\" means EVERY configured Roku is listening, not just some of\n // them. A device whose port is taken (or whose name collides) is skipped\n // above with a warning naming it; reporting \"connected\" anyway would hide\n // a broken configuration behind the devices that did come up, and the user\n // would only find out when the remote stops working. Config rows without a\n // usable name are filtered out before this point and deliberately do not\n // count \u2014 every device in `configured` that fails has said so in the log.\n const allStarted = adverts.length === configured.length;\n await this.setState(\"info.connection\", { val: allStarted, ack: true });\n const where = `advertising on ${advertiseIp}${this.ssdp ? \"\" : \" (discovery off)\"}`;\n if (allStarted) {\n this.log.info(`Emulating ${adverts.length} Roku device(s), ${where}`);\n } else {\n this.log.error(\n `Only ${adverts.length} of ${configured.length} configured Roku device(s) could be started, ${where} \u2014 fix the cause reported above; the instance stays disconnected until every device runs.`,\n );\n }\n } catch (e) {\n this.log.error(`onReady failed: ${errText(e)}`);\n }\n }\n\n /**\n * Re-apply the adapter's OWN objects \u2014 the `info` channel and `info.connection`\n * \u2014 on every start.\n *\n * js-controller creates the manifest's instanceObjects only where they are\n * missing, so a changed name or description never reaches an installation that\n * already has them: the manifest would be correct and the real tree unchanged.\n * extendObject is what carries the change into an existing tree, so an update\n * always lands on every datapoint, not just on fresh installs.\n *\n * It also repairs the `info` channel after a hand-edited device row named\n * \"info\" turned it into a device object (see the reserved-id guard in onReady).\n */\n private async refreshOwnObjects(): Promise<void> {\n await this.extendObject(\"info\", {\n type: \"channel\",\n common: { name: tName(\"channelInfo\") },\n native: {},\n });\n await this.extendObject(\"info.connection\", {\n type: \"state\",\n common: {\n name: tName(\"connectionStatus\"),\n desc: tDesc(\"connectionStatusDesc\"),\n type: \"boolean\",\n role: \"indicator.connected\",\n read: true,\n write: false,\n def: false,\n },\n native: {},\n });\n }\n\n /**\n * Create the fixed object tree for one emulated Roku: the device, `command` +\n * `commandType`, and one `sensor` boolean state per key the device type exposes \u2014\n * all up front, so the tree is usable before any key is ever pressed.\n *\n * Every key state is also RESET to false here. A key is a momentary signal, but\n * nothing writes its release when the adapter goes down: a keypress pulses true\n * and schedules the false 50 ms later, a keydown holds true until its keyup, and\n * onUnload drops both timers without writing. So a stop inside that window \u2014 or\n * a crash, or a controller that never sent its keyup \u2014 leaves the key true in\n * the database for good, and a rule watching for the next press never sees an\n * edge again. The reset belongs on STARTUP, not into onUnload: only startup also\n * covers the crash, and 27 writes per device would eat the shutdown budget that\n * today comfortably carries a single one. setStateChanged writes only where the\n * value actually differs, so a healthy tree costs nothing.\n *\n * @param deviceId the id-safe device path segment\n * @param friendlyName the configured device name\n * @param keys the key names to create for this device (from its type)\n */\n private async createDeviceStates(deviceId: string, friendlyName: string, keys: readonly string[]): Promise<void> {\n // The device name is the user's own text \u2014 nothing to translate, but it must\n // still BE a translation object like every other common.name (tRaw).\n await this.extendObject(deviceId, { type: \"device\", common: { name: tRaw(friendlyName) }, native: {} });\n await this.extendObject(`${deviceId}.command`, {\n type: \"state\",\n common: {\n name: tName(\"stateLastCommand\"),\n desc: tDesc(\"stateLastCommandDesc\"),\n type: \"string\",\n role: \"text\",\n read: true,\n write: false,\n def: \"\",\n },\n native: {},\n });\n await this.extendObject(`${deviceId}.commandType`, {\n type: \"state\",\n common: {\n name: tName(\"stateLastCommandType\"),\n desc: tDesc(\"stateLastCommandTypeDesc\"),\n type: \"string\",\n role: \"text\",\n read: true,\n write: false,\n def: \"\",\n // The fixed verb list, so the admin shows the value as a label. Plain strings\n // only \u2014 a translation object here crashes the admin's object view.\n states: Object.fromEntries(COMMAND_TYPES.map(verb => [verb, verb])),\n },\n native: {},\n });\n await this.extendObject(`${deviceId}.keys`, {\n type: \"channel\",\n common: { name: tName(\"channelKeys\"), desc: tDesc(\"channelKeysDesc\") },\n native: {},\n });\n for (const key of keys) {\n await this.extendObject(`${deviceId}.keys.${key}`, {\n type: \"state\",\n // \"sensor\" = generic boolean read-only (active/inactive). The docs suggest\n // button.press for a keypress-as-state, but the repochecker rejects it\n // (E1010 \u2014 not in its role list); sensor is the gate-conformant fit.\n // The name is the ECP key identifier and identical in every language, but\n // it still has to BE a translation object (tRaw), never a bare string.\n // No desc: the key name already says everything there is to say.\n common: { name: tRaw(key), type: \"boolean\", role: \"sensor\", read: true, write: false, def: false },\n native: {},\n });\n }\n await Promise.all(keys.map(key => this.setStateChangedAsync(`${deviceId}.keys.${key}`, { val: false, ack: true })));\n }\n\n /**\n * Run the orphan sweep for the current configuration \u2014 from EVERY exit of\n * onReady, which is the point of it existing as its own method.\n *\n * The sweep used to sit on the happy path only, so removing the last emulated\n * Roku (or a host without a routable address) left its device object, its\n * `command` / `commandType` and its 16\u201327 key states behind with nothing that\n * would ever remove them: the user deletes a device in the admin and keeps its\n * datapoints forever. The adapter answers for its own datapoints\n * (`feedback_adapter_verantwortet_datenpunkte`), so the sweep runs whenever the\n * configuration could be read.\n *\n * `devicesKnown` is that condition, and it is not pedantry: with no `devices`\n * key at all (a never-configured instance, or a config we could not read) an\n * empty set would mean \"nothing is configured, delete everything\" \u2014 trading a\n * tree that stays for a tree that is gone.\n *\n * @param devicesKnown whether `native.devices` was readable as a list\n * @param configured the configured devices with a usable name\n */\n private async sweepOrphans(\n devicesKnown: boolean,\n configured: readonly ioBroker.AdapterConfig[\"devices\"][number][],\n ): Promise<void> {\n if (!devicesKnown) {\n return;\n }\n await this.cleanupOrphans(new Set(configured.map(d => sanitizeId(d.name))));\n }\n\n /**\n * Remove objects left over from an earlier version or config \u2014 the legacy\n * `apps` node, keys no longer standard, and whole device sub-trees no longer\n * configured (a renamed/removed device). The adapter otherwise only ever\n * creates objects, so without this the tree would accrete stale entries.\n *\n * @param configuredDeviceIds the id-safe names of the currently configured devices\n */\n private async cleanupOrphans(configuredDeviceIds: ReadonlySet<string>): Promise<void> {\n const objects = await this.getAdapterObjectsAsync();\n const prefix = `${this.namespace}.`;\n const existingIds = Object.keys(objects)\n .filter(id => id.startsWith(prefix))\n .map(id => id.slice(prefix.length));\n const toDelete = planObjectCleanup(existingIds, configuredDeviceIds, this.deviceKeys);\n for (const id of toDelete) {\n await this.delObjectAsync(id, { recursive: true }).catch((e: unknown) => {\n this.log.debug(`cleanup: could not delete ${id}: ${errText(e)}`);\n });\n }\n if (toDelete.length > 0) {\n this.log.debug(`Removed ${toDelete.length} orphaned object(s) from an earlier version or config.`);\n }\n }\n\n /**\n * Apply a received ECP command to this device's states: record it in `command`\n * / `commandType`, and pulse or hold the standard key if it is one.\n *\n * Returns whether the command was applied: the ECP server logs only what got\n * through, so the rate gate covers the log as well as the states database.\n *\n * @param deviceId the id-safe device path segment\n * @param cmd the parsed ECP command\n * @returns true if the command was applied, false if the rate gate dropped it\n */\n private applyCommand(deviceId: string, cmd: CommandEvent): boolean {\n if (!this.admitCommand(deviceId)) {\n return false;\n }\n const write = commandToStateWrite(cmd);\n this.writeState(`${deviceId}.command`, write.command);\n this.writeState(`${deviceId}.commandType`, write.commandType);\n // Only write keys.<Key> if THIS device's type carries the key \u2014 a player has no\n // TV key objects, so a stray TV keypress lands in `command` only, never a missing state.\n const keys = this.deviceKeys.get(deviceId);\n if (write.pulseKey && keys?.has(write.pulseKey)) {\n const id = `${deviceId}.keys.${write.pulseKey}`;\n this.writeState(id, true);\n const timer = this.setTimeout(() => {\n if (timer) {\n this.pulseTimers.delete(timer);\n }\n this.writeState(id, false);\n }, KEY_PULSE_MS);\n if (timer) {\n this.pulseTimers.add(timer);\n }\n } else if (write.holdKey && keys?.has(write.holdKey.key)) {\n const id = `${deviceId}.keys.${write.holdKey.key}`;\n this.writeState(id, write.holdKey.value);\n // A keydown holds the key true until its keyup. Arm a watchdog so a lost keyup\n // (controller disconnects mid-press) cannot pin the key true forever; a keyup\n // clears it, and a repeated keydown re-arms it.\n const pending = this.holdTimers.get(id);\n if (pending) {\n this.clearTimeout(pending);\n this.holdTimers.delete(id);\n }\n if (write.holdKey.value) {\n const timer = this.setTimeout(() => {\n this.holdTimers.delete(id);\n this.writeState(id, false);\n }, HOLD_MAX_MS);\n if (timer) {\n this.holdTimers.set(id, timer);\n }\n }\n }\n return true;\n }\n\n /**\n * The rate gate in front of every state write: MAX_COMMANDS_PER_SECOND per device,\n * the excess is dropped and reported once per RATE_WARN_INTERVAL_MS. Every accepted\n * command costs the states database three writes plus one more when the pulse\n * ends \u2014 a flooding device in the LAN would otherwise slow the whole host.\n *\n * @param deviceId the id-safe device path segment\n * @returns true if the command may be applied\n */\n private admitCommand(deviceId: string): boolean {\n const now = Date.now();\n let gate = this.commandGates.get(deviceId);\n if (!gate) {\n gate = new RateGate(MAX_COMMANDS_PER_SECOND, now);\n this.commandGates.set(deviceId, gate);\n }\n if (gate.allow(now)) {\n return true;\n }\n if (now - (this.rateWarnedAt.get(deviceId) ?? 0) >= RATE_WARN_INTERVAL_MS) {\n this.rateWarnedAt.set(deviceId, now);\n this.log.warn(\n `Emulated Roku \"${deviceId}\" receives more than ${MAX_COMMANDS_PER_SECOND} commands per second \u2014 dropping the excess (a misbehaving controller?)`,\n );\n }\n return false;\n }\n\n /**\n * Fire-and-forget state write for the command hot path. The states exist (created\n * up front in createDeviceStates), so there is no read-before-write; a rejection \u2014\n * the states database already closed while a remote still sends \u2014 is traced at\n * debug, because an unhandled one would crash the adapter over one lost keypress.\n *\n * @param id the state id relative to the namespace\n * @param val the value to write\n */\n private writeState(id: string, val: string | boolean): void {\n this.setState(id, { val, ack: true }).catch((e: unknown) => {\n this.log.debug(`State write ${id} failed: ${errText(e)}`);\n });\n }\n\n /**\n * One emulated Roku's ECP server died at runtime (a server error after a good\n * start). That device answers nothing any more, so the instance is no longer\n * \"every configured Roku is listening\" \u2014 revise the status instead of leaving a\n * green instance behind a device that is gone. The other devices keep running,\n * and the message names the one that failed so the user knows which.\n *\n * @param friendlyName the configured name of the device whose server died\n */\n private onEcpFatal(friendlyName: string): void {\n this.log.error(\n `Emulated Roku \"${friendlyName}\" stopped answering after a server error \u2014 restart the instance to bring it back.`,\n );\n this.setState(\"info.connection\", { val: false, ack: true }).catch((e: unknown) => {\n this.log.debug(`Connection state write failed: ${errText(e)}`);\n });\n }\n\n /**\n * The SSDP responder died at runtime (a socket error after a good start). Stop\n * announcing into the dead socket and drop the discovery aid. The ECP servers\n * keep working, so info.connection \u2014 which reflects ECP readiness \u2014 stays true.\n */\n private onSsdpFatal(): void {\n if (this.notifyTimer) {\n this.clearInterval(this.notifyTimer);\n this.notifyTimer = undefined;\n }\n this.ssdp = undefined;\n this.log.warn(\n \"SSDP discovery stopped after a socket error \u2014 already-paired remotes still work; restart the instance to re-enable discovery.\",\n );\n }\n\n /**\n * Bound await: reject if the SSDP start doesn't settle in time, so a stuck\n * port-1900 bind degrades to \"discovery off\" instead of hanging the adapter.\n *\n * @param promise the SSDP start promise\n * @param ms the timeout in milliseconds\n * @returns a promise that settles with the start result or a timeout error\n */\n private startWithTimeout(promise: Promise<void>, ms: number): Promise<void> {\n return new Promise<void>((resolve, reject) => {\n const timer = this.setTimeout(() => reject(new Error(`SSDP start timed out after ${ms} ms`)), ms);\n const clear = (): void => {\n if (timer) {\n this.clearTimeout(timer);\n }\n };\n promise.then(\n () => {\n clear();\n resolve();\n },\n (e: unknown) => {\n clear();\n // Lint (prefer-promise-reject-errors) wants an Error here; errText downstream\n // would cope with anything, so this is the rule's shape, not a second safeguard.\n reject(e instanceof Error ? e : new Error(String(e)));\n },\n );\n });\n }\n\n /**\n * Teardown: drop the timers and sockets synchronously, then report done only\n * once the last write has landed.\n *\n * `info.connection` is the only status this adapter carries, and nothing else\n * resets it: the host means to, but writes its reset to the namespace root\n * instead of the datapoint (js-controller#3472). So if the final write is\n * lost, the instance shows \"connected\" while the adapter is off.\n *\n * A fire-and-forget write plus an immediate callback is a race, not a\n * guaranteed loss \u2014 measured on 1.1.0, it still arrived, because without\n * `common.supportedMessages.stopInstance` the process ends in an orderly way\n * and flushes what is pending. Waiting closes the race for the slow or busy\n * case, and it is safe for the same reason: no `stopInstance` means the host\n * grants the full `common.stopTimeout` instead of killing the process.\n *\n * @param callback function to invoke once teardown is complete\n */\n private onUnload(callback: () => void): void {\n try {\n if (this.notifyTimer) {\n this.clearInterval(this.notifyTimer);\n this.notifyTimer = undefined;\n }\n for (const t of this.pulseTimers) {\n this.clearTimeout(t);\n }\n this.pulseTimers.clear();\n for (const t of this.holdTimers.values()) {\n this.clearTimeout(t);\n }\n this.holdTimers.clear();\n this.ssdp?.stop();\n for (const s of this.ecpServers) {\n s.stop();\n }\n void this.setState(\"info.connection\", { val: false, ack: true })\n // A rejected write must not become an unhandled rejection \u2014 that is a\n // crash (exit code 6) instead of an orderly stop. The trace stays at\n // debug: it explains a stale \"connected\" afterwards, and nobody can act\n // on it while the adapter is already going down.\n .catch((e: unknown) => {\n this.log.debug(`Final connection write failed: ${errText(e)}`);\n })\n .finally(() => callback());\n } catch {\n callback();\n }\n }\n}\n\nif (require.main !== module) {\n // Export the constructor in compact mode\n module.exports = (options: Partial<utils.AdapterOptions> | undefined) => new Fakeroku(options);\n} else {\n // Start the instance directly\n (() => new Fakeroku())();\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
7
|
}
|
package/io-package.json
CHANGED
|
@@ -1,8 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"common": {
|
|
3
3
|
"name": "fakeroku",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.5.0",
|
|
5
5
|
"news": {
|
|
6
|
+
"1.5.0": {
|
|
7
|
+
"en": "Deleting the last emulated Roku now removes its datapoints, a host running Docker no longer announces an unreachable address, and a failed device is reported.",
|
|
8
|
+
"de": "Das Löschen des letzten emulierten Roku entfernt jetzt seine Datenpunkte, ein Rechner mit Docker kündigt keine unerreichbare Adresse mehr an, ein Ausfall wird gemeldet.",
|
|
9
|
+
"ru": "Удаление последнего эмулируемого Roku теперь удаляет его точки данных, хост с Docker не объявляет недоступный адрес, а об отказе устройства сообщается.",
|
|
10
|
+
"pt": "Eliminar o último Roku emulado remove agora os seus pontos de dados, um sistema com Docker não anuncia um endereço inacessível e uma falha é comunicada.",
|
|
11
|
+
"nl": "Het verwijderen van de laatste geëmuleerde Roku wist nu zijn datapunten, een host met Docker kondigt geen onbereikbaar adres meer aan en een uitval wordt gemeld.",
|
|
12
|
+
"fr": "La suppression du dernier Roku émulé retire ses points de données, un hôte avec Docker n'annonce plus une adresse injoignable et une panne est signalée.",
|
|
13
|
+
"it": "L'eliminazione dell'ultimo Roku emulato rimuove i suoi punti dati, un host con Docker non annuncia più un indirizzo irraggiungibile e un guasto viene segnalato.",
|
|
14
|
+
"es": "Al eliminar el último Roku emulado se borran sus puntos de datos, un equipo con Docker ya no anuncia una dirección inalcanzable y se informa de un fallo.",
|
|
15
|
+
"pl": "Usunięcie ostatniego emulowanego Roku usuwa jego punkty danych, host z Dockerem nie ogłasza już nieosiągalnego adresu, a awaria jest zgłaszana.",
|
|
16
|
+
"uk": "Видалення останнього емульованого Roku прибирає його точки даних, хост із Docker не оголошує недосяжну адресу, а про відмову повідомляється.",
|
|
17
|
+
"zh-cn": "删除最后一个模拟 Roku 会一并移除其数据点,运行 Docker 的主机不再公告无法访问的地址,设备失效会被报告。"
|
|
18
|
+
},
|
|
6
19
|
"1.4.0": {
|
|
7
20
|
"en": "Renaming a Roku keeps the remote paired, a stuck key is released at start-up, all datapoints get translated names, and IPv6 remotes from your network are accepted.",
|
|
8
21
|
"de": "Umbenennen behält die Kopplung der Fernbedienung, eine hängende Taste wird beim Start freigegeben, alle Datenpunkte bekommen übersetzte Namen, IPv6-Fernbedienungen werden akzeptiert.",
|
|
@@ -80,19 +93,6 @@
|
|
|
80
93
|
"pl": "Całkowicie przepisany. Adapter obsługuje teraz pełną powierzchnię sterowania Roku, w tym device-info z aktualną wersją Roku, dzięki czemu piloty Logitech Harmony i Sofabaton działają niezawodnie. Działa od razu, pozwala zarządzać wieloma emulowanymi Roku (Player lub TV) z interfejsu administratora i używa czystszego drzewa obiektów z poprawnymi typami danych.",
|
|
81
94
|
"uk": "Повністю переписано. Адаптер тепер підтримує весь набір команд керування Roku, включно з device-info з актуальною версією Roku, тож пульти Logitech Harmony і Sofabaton працюють надійно. Він працює без налаштування, дозволяє керувати кількома емульованими Roku (Player або TV) з інтерфейсу адміністратора та використовує чистіше дерево об'єктів із правильними типами даних.",
|
|
82
95
|
"zh-cn": "完全重写。适配器现在可响应完整的 Roku 控制接口,包括带有当前 Roku 版本的 device-info,因此 Logitech Harmony 和 Sofabaton 遥控器可以可靠地工作。开箱即用,可从管理界面管理多个模拟 Roku(Player 或 TV),并使用具有正确数据类型的更简洁的对象树。"
|
|
83
|
-
},
|
|
84
|
-
"0.5.1": {
|
|
85
|
-
"en": "Adapter requires node.js >= 22 now\nDependencies have been updated",
|
|
86
|
-
"de": "Der Adapter benötigt jetzt Node.js >= 22.\nDie Abhängigkeiten wurden aktualisiert.",
|
|
87
|
-
"ru": "Для работы адаптера требуется Node.js версии >= 22.\nЗависимости обновлены.",
|
|
88
|
-
"pt": "O adaptador agora requer Node.js >= 22.\nAs dependências foram atualizadas.",
|
|
89
|
-
"nl": "De adapter vereist nu Node.js versie 22 of hoger.\nAfhankelijkheden zijn bijgewerkt.",
|
|
90
|
-
"fr": "L'adaptateur nécessite désormais Node.js >= 22.\nLes dépendances ont été mises à jour",
|
|
91
|
-
"it": "L'adattatore ora richiede Node.js >= 22\nLe dipendenze sono state aggiornate",
|
|
92
|
-
"es": "El adaptador ahora requiere node.js >= 22.\nSe han actualizado las dependencias.",
|
|
93
|
-
"pl": "Adapter wymaga teraz node.js >= 22\nZaktualizowano zależności",
|
|
94
|
-
"uk": "Адаптер тепер вимагає node.js >= 22\nЗалежності оновлено",
|
|
95
|
-
"zh-cn": "适配器现在需要 node.js 版本 >= 22。\n依赖项已更新"
|
|
96
96
|
}
|
|
97
97
|
},
|
|
98
98
|
"titleLang": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "iobroker.fakeroku",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.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",
|
|
@@ -53,11 +53,11 @@
|
|
|
53
53
|
"@tsconfig/node22": "^22.0.5",
|
|
54
54
|
"@types/iobroker": "npm:@iobroker/types@^7.2.2",
|
|
55
55
|
"@types/node": "^22",
|
|
56
|
-
"@vitest/coverage-v8": "^
|
|
56
|
+
"@vitest/coverage-v8": "^5.0.0",
|
|
57
57
|
"iobroker-adapter-checks": "^0.3.0",
|
|
58
58
|
"rimraf": "^6.1.3",
|
|
59
59
|
"typescript": "~6.0.3",
|
|
60
|
-
"vitest": "^
|
|
60
|
+
"vitest": "^5.0.0"
|
|
61
61
|
},
|
|
62
62
|
"main": "build/main.js",
|
|
63
63
|
"files": [
|
|
@@ -79,8 +79,8 @@
|
|
|
79
79
|
"coverage": "vitest run --coverage",
|
|
80
80
|
"lint": "eslint",
|
|
81
81
|
"lint:fix": "eslint --fix",
|
|
82
|
-
"format": "prettier --write .",
|
|
83
|
-
"format:check": "prettier --check .",
|
|
82
|
+
"format": "prettier --write . \"!build/**\" \"!.github/**\" \"!io-package.json\" \"!README.md\" \"!CHANGELOG_OLD.md\"",
|
|
83
|
+
"format:check": "prettier --check . \"!build/**\" \"!.github/**\" \"!io-package.json\" \"!README.md\" \"!CHANGELOG_OLD.md\"",
|
|
84
84
|
"release": "release-script",
|
|
85
85
|
"translate": "translate-adapter"
|
|
86
86
|
},
|