iobroker.fakeroku 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -7
- package/build/discovery/ssdp-responder.js +1 -0
- package/build/discovery/ssdp-responder.js.map +1 -1
- package/build/ecp/ecp-http-server.js +1 -0
- package/build/ecp/ecp-http-server.js.map +1 -1
- package/build/main.js +31 -9
- package/build/main.js.map +2 -2
- package/io-package.json +20 -15
- package/package.json +12 -4
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
**Release:** [](https://www.npmjs.com/package/iobroker.fakeroku)   [](https://www.npmjs.com/package/iobroker.fakeroku)
|
|
4
4
|
|
|
5
|
-
**Build:** [](https://github.com/iobroker-community-adapters/ioBroker.fakeroku/actions/workflows/test-and-release.yml)   [](LICENSE)
|
|
5
|
+
**Build:** [](https://github.com/iobroker-community-adapters/ioBroker.fakeroku/actions/workflows/test-and-release.yml)   [](LICENSE) [](https://github.com/ioBroker/plugin-sentry#plugin-sentry)
|
|
6
6
|
|
|
7
7
|
**Support:** [](https://ko-fi.com/krobipd) [](https://paypal.me/krobipd)
|
|
8
8
|
|
|
@@ -27,6 +27,12 @@ classic Harmony hub.
|
|
|
27
27
|
- Clean data model per device: a `command` datapoint plus fixed `keys.<Key>` states, all created up front.
|
|
28
28
|
- Several emulated Rokus from a single instance; discovery bound to the chosen network interface; command handling restricted to the LAN.
|
|
29
29
|
|
|
30
|
+
## Sentry / Error reporting
|
|
31
|
+
|
|
32
|
+
**This adapter uses Sentry libraries to automatically report exceptions and code errors to the developers.** Reporting only happens if you have enabled error reporting in the ioBroker diagnostics (**System settings → Diagnostics and error reporting**). Only an anonymous installation ID is transmitted — no name, e-mail address or IP address.
|
|
33
|
+
|
|
34
|
+
For details and how to disable it, see the [Sentry plugin documentation](https://github.com/ioBroker/plugin-sentry#plugin-sentry). Error reporting requires js-controller 3.0 or newer.
|
|
35
|
+
|
|
30
36
|
## Requirements
|
|
31
37
|
|
|
32
38
|
- Node.js >= 22
|
|
@@ -56,6 +62,12 @@ and point it at the ioBroker host.
|
|
|
56
62
|
|
|
57
63
|
## Objects
|
|
58
64
|
|
|
65
|
+
At instance level:
|
|
66
|
+
|
|
67
|
+
| Datapoint | Type | Meaning |
|
|
68
|
+
|---|---|---|
|
|
69
|
+
| `info.connection` | boolean, read-only | `true` only while **every** configured Roku is actually listening. If one of them cannot start — usually because its port is already taken — the instance stays disconnected and the log names the device and the port. |
|
|
70
|
+
|
|
59
71
|
For every emulated Roku (`fakeroku.0.<name>`):
|
|
60
72
|
|
|
61
73
|
| Datapoint | Type | Meaning |
|
|
@@ -81,7 +93,12 @@ becomes `true`, or watch `.command` for the last button as text.
|
|
|
81
93
|
Placeholder for the next version (at the beginning of the line):
|
|
82
94
|
### **WORK IN PROGRESS**
|
|
83
95
|
-->
|
|
84
|
-
### 1.
|
|
96
|
+
### 1.2.0 (2026-08-27)
|
|
97
|
+
- (krobipd) Changed: the instance now reports "not connected" while a configured Roku is missing — a device whose port is taken no longer hides behind the ones that did start.
|
|
98
|
+
- (krobipd) Fixed: stopping the adapter now clears its connection state instead of leaving the instance showing "connected" while nothing is running.
|
|
99
|
+
- (krobipd) New: optional error reporting via Sentry — only active if you enabled diagnostics in ioBroker, and it transmits no personal data.
|
|
100
|
+
|
|
101
|
+
### 1.1.0 (2026-08-10) — stable
|
|
85
102
|
- (krobipd) Several emulated Rokus are now independent at start-up: if one is set to a port already in use, the others still start instead of the whole instance failing.
|
|
86
103
|
- (krobipd) The adapter keeps serving already-paired remotes, and reports its status correctly, even when network discovery cannot start or later drops out.
|
|
87
104
|
- (krobipd) Discovery now covers every network interface on a host that has more than one, so a remote on any of your local networks can find the emulated Rokus.
|
|
@@ -102,11 +119,6 @@ becomes `true`, or watch `.command` for the last button as text.
|
|
|
102
119
|
- (mcm1957) Adapter requires Node.js >= 22 now
|
|
103
120
|
- (mcm1957) Dependencies have been updated
|
|
104
121
|
|
|
105
|
-
### 0.5.0 (2026-07-30)
|
|
106
|
-
- Complete rewrite with the full Roku control surface, including `device-info` with a current Roku version — the part modern remotes check at pairing, beyond what a classic Harmony hub needs
|
|
107
|
-
- New clean data model: a `command` datapoint plus fixed `keys.<Key>` states, all created up front instead of appearing only after the first keypress
|
|
108
|
-
- Discovery binds to the chosen network interface, command handling is restricted to the local network
|
|
109
|
-
|
|
110
122
|
[Older changelogs can be found there](CHANGELOG_OLD.md)
|
|
111
123
|
|
|
112
124
|
## History
|
|
@@ -2,6 +2,6 @@
|
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/discovery/ssdp-responder.ts"],
|
|
4
4
|
"sourcesContent": ["import * as dgram from \"node:dgram\";\nimport type { AdapterLogger } from \"../lib/logger\";\nimport { buildAliveNotify, buildSearchResponse, matchesRokuSearch, type RokuAdvert } from \"./ssdp-messages\";\n\nconst SSDP_PORT = 1900;\nconst MULTICAST_ADDR = \"239.255.255.250\";\n\n/** Configuration for the Roku SSDP responder. */\nexport interface RokuSsdpResponderConfig {\n /** Emulated Rokus to answer for. */\n devices: RokuAdvert[];\n /**\n * Interface IP the outgoing multicast (NOTIFY) is pinned to, or `undefined` to\n * let the OS default route decide (the auto case). A concrete value is only\n * needed on multi-homed hosts \u2014 see the class doc.\n */\n bindIp: string | undefined;\n /** Routable IP announced in the SSDP LOCATION so the controller reaches the ECP server \u2014 never `0.0.0.0`. */\n advertiseIp: string;\n /**\n * Interface IPs to join the multicast group on. With a concrete interface\n * selected this is just that one; in the auto case it is every routable IPv4\n * the host has, so a multi-homed host hears M-SEARCH on all its LANs (like the\n * emulator siblings, which listen on every interface). Empty \u2192 OS default only.\n */\n membershipInterfaces: string[];\n /** Logger. */\n logger: AdapterLogger;\n /**\n * Called at most once if the socket dies AFTER a successful start \u2014 a runtime\n * error the responder cannot recover from. Lets the adapter stop announcing\n * into a dead socket instead of showing a healthy instance with dead discovery.\n */\n onFatalError?: (err: Error) => void;\n}\n\n/**\n * Roku SSDP responder. Answers M-SEARCH(roku:ecp) with Roku's exact response\n * format (built by hand \u2014 node-ssdp appends a `::device` suffix to the USN that\n * Roku never uses) and can proactively announce ssdp:alive.\n *\n * The multicast group is joined on {@link RokuSsdpResponderConfig.membershipInterfaces}\n * \u2014 every routable interface in the auto case, or the single selected one. The\n * outgoing NOTIFY is pinned to {@link RokuSsdpResponderConfig.bindIp} when a\n * concrete interface is chosen (binding sets only the source address, not the\n * multicast egress interface); in the auto case the OS default route is used, which\n * is the honest choice when the user has not selected an interface. The LOCATION\n * always carries the routable {@link RokuSsdpResponderConfig.advertiseIp}.\n *\n * Owns no timers: the adapter drives {@link announce} on a managed interval and\n * bounds {@link start} with a managed timeout.\n */\nexport class RokuSsdpResponder {\n private socket: dgram.Socket | undefined;\n private fatalReported = false;\n\n /**\n * @param config responder configuration\n */\n public constructor(private readonly config: RokuSsdpResponderConfig) {}\n\n /** Bind on 1900, join multicast on the selected interface(s), start answering. Rejects on bind error. */\n public async start(): Promise<void> {\n const socket = dgram.createSocket({ type: \"udp4\", reuseAddr: true });\n this.socket = socket;\n\n await new Promise<void>((resolve, reject) => {\n const onBindError = (err: Error): void => reject(err);\n socket.once(\"error\", onBindError);\n socket.bind(SSDP_PORT, () => {\n socket.removeListener(\"error\", onBindError);\n this.joinMulticast(socket);\n // Pin OUTGOING multicast (NOTIFY) to the chosen interface. Binding the socket\n // only sets the source address; the multicast egress interface is IP_MULTICAST_IF\n // (Node dgram docs), so without this a proactive NOTIFY can leave the wrong NIC on\n // a multi-homed host despite an explicit selection. Only when a concrete interface\n // is chosen \u2014 in the auto case the OS default route is the honest choice.\n if (this.config.bindIp) {\n try {\n socket.setMulticastInterface(this.config.bindIp);\n } catch (e) {\n this.config.logger.warn(\n `SSDP: could not pin multicast egress to ${this.config.bindIp}: ${errMsg(e)} \u2014 NOTIFY may use the default interface`,\n );\n }\n }\n socket.on(\"error\", (err: Error) => this.onSocketError(err));\n socket.on(\"message\", (msg, rinfo) => this.onMessage(msg.toString(\"utf8\"), rinfo.address, rinfo.port));\n resolve();\n });\n });\n\n const join = this.config.membershipInterfaces.length ? this.config.membershipInterfaces.join(\", \") : \"default\";\n this.config.logger.debug(\n `Roku SSDP responder on :${SSDP_PORT}, advertising ${this.config.advertiseIp} (join: ${join})`,\n );\n }\n\n /**\n * Join the multicast group on each selected interface, or on the OS default when\n * none is known.\n *\n * @param socket the bound SSDP socket\n */\n private joinMulticast(socket: dgram.Socket): void {\n const ifaces = this.config.membershipInterfaces;\n if (ifaces.length === 0) {\n this.tryJoin(socket, undefined);\n return;\n }\n for (const ip of ifaces) {\n this.tryJoin(socket, ip);\n }\n }\n\n /**\n * Join the group on one interface; a failure warns but does not throw, so one bad\n * interface cannot stop the responder.\n *\n * @param socket the bound SSDP socket\n * @param iface the interface IP to join on, or undefined for the OS default\n */\n private tryJoin(socket: dgram.Socket, iface: string | undefined): void {\n try {\n socket.addMembership(MULTICAST_ADDR, iface);\n } catch (e) {\n // A join failure is usually an OS routing issue (the chosen interface is not\n // in the multicast table). Don't die: a paired controller still reaches the\n // ECP port by the advertised IP, and NOTIFY may still leave via the default\n // route. Warn so the \"no discovery\" symptom is findable.\n this.config.logger.warn(\n `SSDP multicast join failed on ${iface ?? \"default interface\"}: ${errMsg(e)} \u2014 discovery may be incomplete`,\n );\n }\n }\n\n /**\n * A socket error after a good start \u2014 discovery is dead. Close the socket and tell\n * the adapter once so it can stop announcing and revise its connection state.\n *\n * @param err the socket error\n */\n private onSocketError(err: Error): void {\n this.config.logger.error(`SSDP socket error: ${err.message}`);\n const notify = this.config.onFatalError;\n this.stop();\n if (notify && !this.fatalReported) {\n this.fatalReported = true;\n notify(err);\n }\n }\n\n private onMessage(text: string, address: string, port: number): void {\n if (!matchesRokuSearch(text)) {\n return;\n }\n for (const device of this.config.devices) {\n const response = Buffer.from(buildSearchResponse(device, this.config.advertiseIp));\n this.socket?.send(response, port, address, err => {\n if (err) {\n this.config.logger.warn(`SSDP response send failed: ${err.message}`);\n }\n });\n }\n }\n\n /** Send one proactive ssdp:alive burst for every device. The adapter calls this on a managed interval. */\n public announce(): void {\n if (!this.socket) {\n return;\n }\n for (const device of this.config.devices) {\n const notify = Buffer.from(buildAliveNotify(device, this.config.advertiseIp));\n this.socket.send(notify, SSDP_PORT, MULTICAST_ADDR, err => {\n if (err) {\n this.config.logger.debug(`SSDP NOTIFY send failed: ${err.message}`);\n }\n });\n }\n }\n\n /** Synchronous close \u2014 safe to call from onUnload. Closing the socket drops its memberships. */\n public stop(): void {\n if (this.socket) {\n try {\n this.socket.close();\n } catch {\n // socket already closed\n }\n this.socket = undefined;\n }\n }\n}\n\n/**\n * Message of an unknown thrown value.\n *\n * @param e the caught value\n * @returns its message or a string form\n */\nfunction errMsg(e: unknown): string {\n return e instanceof Error ? e.message : String(e);\n}\n"],
|
|
5
|
-
"mappings": ";;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,YAAuB;AAEvB,2BAA0F;AAE1F,MAAM,YAAY;AAClB,MAAM,iBAAiB;AA+ChB,MAAM,kBAAkB;AAAA;AAAA;AAAA;AAAA,EAOtB,YAA6B,QAAiC;AAAjC;AAAA,EAAkC;AAAA,
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -2,6 +2,6 @@
|
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/ecp/ecp-http-server.ts"],
|
|
4
4
|
"sourcesContent": ["import * as http from \"node:http\";\nimport type { RokuAdvert } from \"../discovery/ssdp-messages\";\nimport type { AdapterLogger } from \"../lib/logger\";\nimport { type CommandEvent, parseEcpCommand } from \"./ecp-command\";\nimport { type AppEntry, buildAppsXml, buildDescXml, buildDeviceInfoXml } from \"./device-info\";\nimport { isLanClient } from \"./lan-guard\";\nimport type { DeviceType } from \"./state-model\";\n\n/** Configuration for one emulated Roku's ECP HTTP server. */\nexport interface EcpServerConfig {\n /** The emulated Roku (uuid + port). */\n device: RokuAdvert;\n /** Display name shown in the description / device-info. */\n friendlyName: string;\n /** Apps advertised at /query/apps. */\n apps: AppEntry[];\n /** The emulated device type (player / tv) \u2014 drives the device-info response. */\n deviceType: DeviceType;\n /** Interface IP to bind the HTTP server to, or `undefined` to bind all interfaces (auto). */\n bindIp: string | undefined;\n /** Logger. */\n logger: AdapterLogger;\n /** Called for every accepted POST command. */\n onCommand: (cmd: CommandEvent) => void;\n}\n\n/**\n * The Roku ECP HTTP server for one emulated device. Serves the UPnP description,\n * /query/device-info (with a current version) and /query/apps; turns POST\n * key/launch/input/search into command events. Unknown GET paths get a clean 404\n * (not the old adapter's empty 200), and commands are accepted only from the LAN.\n */\nexport class EcpHttpServer {\n private server: http.Server | undefined;\n\n /**\n * @param config server configuration\n */\n public constructor(private readonly config: EcpServerConfig) {}\n\n /** Bind the HTTP server to the interface + port. Rejects on bind error. */\n public async start(): Promise<void> {\n const server = http.createServer((req, res) => this.handle(req, res));\n this.server = server;\n await new Promise<void>((resolve, reject) => {\n const onError = (err: Error): void => reject(err);\n server.once(\"error\", onError);\n server.listen(this.config.device.port, this.config.bindIp, () => {\n server.removeListener(\"error\", onError);\n server.on(\"error\", (err: Error) => this.config.logger.error(`ECP server error: ${err.message}`));\n resolve();\n });\n });\n this.config.logger.debug(\n `ECP server \"${this.config.friendlyName}\" on ${this.config.bindIp ?? \"0.0.0.0\"}:${this.config.device.port}`,\n );\n }\n\n private handle(req: http.IncomingMessage, res: http.ServerResponse): void {\n const peer = (req.socket.remoteAddress ?? \"\").replace(/^::ffff:/, \"\") || \"?\";\n if (!isLanClient(req.socket.remoteAddress)) {\n // Debug (not warn): a stray WAN scanner must not spam the log, but when a\n // remote sits on the wrong subnet/VLAN this is the only trace of \"why rejected\".\n this.config.logger.debug(`ECP request from non-LAN ${peer} rejected (403)`);\n res.statusCode = 403;\n res.end();\n return;\n }\n const method = req.method ?? \"GET\";\n const url = req.url ?? \"/\";\n\n if (method === \"GET\") {\n const body = this.routeGet(url);\n if (body === null) {\n res.statusCode = 404;\n res.end();\n return;\n }\n if (url.split(\"?\")[0] === \"/query/device-info\") {\n // The pairing probe \u2014 the first thing a remote asks and the usual failure\n // point (a Sofabaton rejects a too-old version). Visible for diagnosis.\n this.config.logger.debug(`device-info queried from ${peer} (remote pairing/probe)`);\n }\n res.statusCode = 200;\n res.setHeader(\"Content-Type\", \"text/xml; charset=utf-8\");\n res.end(body);\n return;\n }\n\n if (method === \"POST\") {\n req.resume(); // drain any body\n const cmd = parseEcpCommand(\"POST\", url);\n if (!cmd) {\n res.statusCode = 404;\n res.end();\n return;\n }\n // The received command \u2014 without this a keypress leaves no trace at all, so a\n // \"I press a button and nothing happens\" report has nothing to go on.\n const detail = cmd.key ?? cmd.appId ?? cmd.text ?? \"\";\n this.config.logger.debug(`ECP ${cmd.type}${detail ? ` ${detail}` : \"\"} from ${peer}`);\n try {\n this.config.onCommand(cmd);\n } catch (e) {\n this.config.logger.warn(`onCommand failed: ${e instanceof Error ? e.message : String(e)}`);\n }\n res.statusCode = 200;\n res.end();\n return;\n }\n\n res.statusCode = 405;\n res.end();\n }\n\n private routeGet(url: string): string | null {\n switch (url.split(\"?\")[0]) {\n case \"/\":\n return buildDescXml(this.config.device, this.config.friendlyName, this.config.deviceType);\n case \"/query/device-info\":\n return buildDeviceInfoXml(this.config.device, this.config.friendlyName, this.config.deviceType);\n case \"/query/apps\":\n return buildAppsXml(this.config.apps);\n default:\n return null;\n }\n }\n\n /** Synchronous close \u2014 safe from onUnload. */\n public stop(): void {\n if (this.server) {\n try {\n this.server.close();\n // close() only stops accepting new sockets; established keep-alive\n // connections (a controller holding one) keep the event loop busy and can\n // drag the synchronous onUnload toward a SIGKILL. Force them shut (Node >= 22).\n this.server.closeAllConnections();\n } catch {\n // already closed\n }\n this.server = undefined;\n }\n }\n}\n"],
|
|
5
|
-
"mappings": ";;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,WAAsB;AAGtB,yBAAmD;AACnD,yBAA8E;AAC9E,uBAA4B;AA2BrB,MAAM,cAAc;AAAA;AAAA;AAAA;AAAA,EAMlB,YAA6B,QAAyB;AAAzB;AAAA,EAA0B;AAAA,
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/build/main.js
CHANGED
|
@@ -59,6 +59,12 @@ class Fakeroku extends utils.Adapter {
|
|
|
59
59
|
deviceKeys = /* @__PURE__ */ new Map();
|
|
60
60
|
/** Device-manager backend: the emulated Rokus as cards with add/edit/delete. */
|
|
61
61
|
deviceManagement;
|
|
62
|
+
// Construction seams for the two network-facing collaborators. Production uses
|
|
63
|
+
// the real classes; the orchestration tests swap them for fakes so onReady's
|
|
64
|
+
// wiring (per-device isolation, SSDP degradation, timers) is testable without
|
|
65
|
+
// binding a port. Behaviour is unchanged — same constructors, same arguments.
|
|
66
|
+
makeEcpServer = (options) => new import_ecp_http_server.EcpHttpServer(options);
|
|
67
|
+
makeSsdpResponder = (options) => new import_ssdp_responder.RokuSsdpResponder(options);
|
|
62
68
|
/**
|
|
63
69
|
* @param options adapter options passed through by js-controller
|
|
64
70
|
*/
|
|
@@ -102,7 +108,7 @@ class Fakeroku extends utils.Adapter {
|
|
|
102
108
|
const advert = { uuid: d.uuid || (0, import_device_identity.deriveUuid)(d.name), port: Number(d.port) || import_constants.DEFAULT_ECP_PORT };
|
|
103
109
|
try {
|
|
104
110
|
await this.createDeviceStates(deviceId, d.name, keys);
|
|
105
|
-
const server =
|
|
111
|
+
const server = this.makeEcpServer({
|
|
106
112
|
device: advert,
|
|
107
113
|
friendlyName: d.name,
|
|
108
114
|
apps: import_device_info.DEFAULT_APPS,
|
|
@@ -128,7 +134,7 @@ class Fakeroku extends utils.Adapter {
|
|
|
128
134
|
return;
|
|
129
135
|
}
|
|
130
136
|
const membershipInterfaces = bindIp ? [bindIp] : (0, import_detect_ip.detectLocalIPv4s)();
|
|
131
|
-
this.ssdp =
|
|
137
|
+
this.ssdp = this.makeSsdpResponder({
|
|
132
138
|
devices: adverts,
|
|
133
139
|
bindIp,
|
|
134
140
|
advertiseIp,
|
|
@@ -149,10 +155,16 @@ class Fakeroku extends utils.Adapter {
|
|
|
149
155
|
);
|
|
150
156
|
this.ssdp = void 0;
|
|
151
157
|
}
|
|
152
|
-
|
|
153
|
-
this.
|
|
154
|
-
|
|
155
|
-
)
|
|
158
|
+
const allStarted = adverts.length === configured.length;
|
|
159
|
+
await this.setState("info.connection", { val: allStarted, ack: true });
|
|
160
|
+
const where = `advertising on ${advertiseIp}${this.ssdp ? "" : " (discovery off)"}`;
|
|
161
|
+
if (allStarted) {
|
|
162
|
+
this.log.info(`Emulating ${adverts.length} Roku device(s), ${where}`);
|
|
163
|
+
} else {
|
|
164
|
+
this.log.error(
|
|
165
|
+
`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.`
|
|
166
|
+
);
|
|
167
|
+
}
|
|
156
168
|
} catch (e) {
|
|
157
169
|
this.log.error(`onReady failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
158
170
|
}
|
|
@@ -299,7 +311,16 @@ class Fakeroku extends utils.Adapter {
|
|
|
299
311
|
});
|
|
300
312
|
}
|
|
301
313
|
/**
|
|
302
|
-
*
|
|
314
|
+
* Teardown: drop the timers and sockets synchronously, then report done only
|
|
315
|
+
* once the last write has landed.
|
|
316
|
+
*
|
|
317
|
+
* `info.connection` is the only status this adapter carries, so losing that
|
|
318
|
+
* final write leaves the instance showing "connected" while the adapter is
|
|
319
|
+
* off. A fire-and-forget write followed by an immediate callback IS lost —
|
|
320
|
+
* measured across the fleet. Waiting is safe: the manifest deliberately
|
|
321
|
+
* carries no `common.supportedMessages.stopInstance` (with it the host kills
|
|
322
|
+
* the process outright and never runs this method), so the host grants the
|
|
323
|
+
* full `common.stopTimeout` — a second — for the write to complete.
|
|
303
324
|
*
|
|
304
325
|
* @param callback function to invoke once teardown is complete
|
|
305
326
|
*/
|
|
@@ -322,8 +343,9 @@ class Fakeroku extends utils.Adapter {
|
|
|
322
343
|
for (const s of this.ecpServers) {
|
|
323
344
|
s.stop();
|
|
324
345
|
}
|
|
325
|
-
void this.setState("info.connection", { val: false, ack: true })
|
|
326
|
-
|
|
346
|
+
void this.setState("info.connection", { val: false, ack: true }).catch((e) => {
|
|
347
|
+
this.log.debug(`Final connection write failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
348
|
+
}).finally(() => callback());
|
|
327
349
|
} catch {
|
|
328
350
|
callback();
|
|
329
351
|
}
|
package/build/main.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/main.ts"],
|
|
4
|
-
"sourcesContent": ["import * as utils from \"@iobroker/adapter-core\";\nimport { I18n } from \"@iobroker/adapter-core\";\nimport { join } from \"node:path\";\nimport { FakerokuDeviceManagement } from \"./device-management\";\nimport type { RokuAdvert } from \"./discovery/ssdp-messages\";\nimport { RokuSsdpResponder } from \"./discovery/ssdp-responder\";\nimport { DEFAULT_APPS } from \"./ecp/device-info\";\nimport type { CommandEvent } from \"./ecp/ecp-command\";\nimport { EcpHttpServer } from \"./ecp/ecp-http-server\";\nimport { commandToStateWrite, type DeviceType, keysForType } from \"./ecp/state-model\";\nimport { DEFAULT_ECP_PORT } from \"./lib/constants\";\nimport { deriveUuid } from \"./lib/device-identity\";\nimport { detectLocalIPv4s, detectPrimaryIPv4 } from \"./lib/detect-ip\";\nimport { planObjectCleanup } from \"./lib/object-cleanup\";\nimport { sanitizeId } from \"./lib/pure-helpers\";\n\n/** Managed timeout for a stuck SSDP start (a busy port 1900 must not hang onReady). */\nconst SSDP_START_TIMEOUT_MS = 5000;\n/** Proactive ssdp:alive interval so controllers find the device without searching. */\nconst SSDP_NOTIFY_INTERVAL_MS = 300_000;\n/** How long a keypress pulses its keys.<Key> state true before falling back to false. */\nconst KEY_PULSE_MS = 50;\n/** Safety cap for a held key: a keydown with no matching keyup resets after this. */\nconst HOLD_MAX_MS = 30_000;\n\n/**\n * ioBroker.fakeroku \u2014 Roku emulator (input side).\n *\n * Emulates one or more Roku devices on the LAN so that ECP/SSDP remotes\n * (Logitech Harmony or a Sofabaton X1/X2) trigger events in ioBroker:\n * a keypress lands in `<device>.command` and pulses `<device>.keys.<Key>`.\n */\nexport class Fakeroku extends utils.Adapter {\n private ssdp: RokuSsdpResponder | undefined;\n private notifyTimer: ioBroker.Interval | undefined;\n private readonly ecpServers: EcpHttpServer[] = [];\n private readonly pulseTimers = new Set<ioBroker.Timeout>();\n /** Per held key id, its watchdog timer \u2014 so a keydown without a keyup cannot pin it true forever. */\n private readonly holdTimers = new Map<string, ioBroker.Timeout>();\n /** Per device, the key names it exposes \u2014 so a keypress only writes keys this device carries. */\n private readonly deviceKeys = new Map<string, ReadonlySet<string>>();\n /** Device-manager backend: the emulated Rokus as cards with add/edit/delete. */\n private readonly deviceManagement: FakerokuDeviceManagement;\n\n /**\n * @param options adapter options passed through by js-controller\n */\n public constructor(options: Partial<utils.AdapterOptions> = {}) {\n super({\n ...options,\n name: \"fakeroku\",\n });\n\n this.on(\"ready\", this.onReady.bind(this));\n this.on(\"unload\", this.onUnload.bind(this));\n this.deviceManagement = new FakerokuDeviceManagement(this);\n }\n\n /** Create each device's object tree, start its ECP server, then the shared SSDP responder. */\n private async onReady(): Promise<void> {\n try {\n await this.setState(\"info.connection\", { val: false, ack: true });\n await I18n.init(join(this.adapterDir, \"admin\"), this);\n\n // Empty AND \"0.0.0.0\" both mean \"auto\": bind all interfaces, advertise the\n // detected primary IP so the adapter runs without configuration. js-controller\n // never rewrites an existing native default, so instances from before 0.5.1\n // still carry \"\" \u2014 both must take the auto path. A concrete IP is honoured as-is.\n // Migration: a pre-0.5.0 instance has no `networkInterface`, only the old `BIND` \u2014\n // adopt it so a multi-homed host keeps the interface the user had configured.\n const configuredIp = this.config.networkInterface || this.config.BIND;\n const bindIp = configuredIp && configuredIp !== \"0.0.0.0\" ? configuredIp : undefined;\n const advertiseIp = bindIp ?? detectPrimaryIPv4();\n if (!advertiseIp) {\n this.log.warn(\"No routable IPv4 address found to advertise \u2014 set the network interface in the settings.\");\n return;\n }\n\n const configured = (this.config.devices ?? []).filter(d => d && typeof d.name === \"string\" && d.name.length > 0);\n if (configured.length === 0) {\n this.log.warn(\"No emulated Roku devices configured.\");\n return;\n }\n\n const adverts: RokuAdvert[] = [];\n const seenIds = new Set<string>();\n for (const d of configured) {\n const deviceId = sanitizeId(d.name);\n // Two configured names can sanitize to the same object id \u2014 the admin guards\n // against it, but a hand-edited config could still carry it. Skip the duplicate\n // instead of letting two devices fight over one object tree.\n if (seenIds.has(deviceId)) {\n this.log.warn(`Emulated Roku \"${d.name}\" maps to an object id already in use (${deviceId}) \u2014 skipping it.`);\n continue;\n }\n const deviceType: DeviceType = d.type === \"tv\" ? \"tv\" : \"player\";\n const keys = keysForType(deviceType);\n // Adopt the device's persisted uuid (old adapter or an earlier run) so the SSDP\n // identity \u2014 and thus the controller pairing \u2014 survives an update; derive a stable\n // one from the name only for a device that never had one.\n const advert: RokuAdvert = { uuid: d.uuid || deriveUuid(d.name), port: Number(d.port) || DEFAULT_ECP_PORT };\n try {\n await this.createDeviceStates(deviceId, d.name, keys);\n const server = new EcpHttpServer({\n device: advert,\n friendlyName: d.name,\n apps: DEFAULT_APPS,\n deviceType,\n bindIp,\n logger: this.log,\n onCommand: cmd => this.applyCommand(deviceId, cmd),\n });\n await server.start();\n this.ecpServers.push(server);\n this.deviceKeys.set(deviceId, new Set(keys));\n seenIds.add(deviceId);\n adverts.push(advert);\n } catch (e) {\n // One device's failure (a busy ECP port) must not take the others down.\n this.log.warn(\n `Emulated Roku \"${d.name}\" could not start on port ${advert.port}: ${e instanceof Error ? e.message : String(e)} \u2014 skipping it.`,\n );\n }\n }\n\n await this.cleanupOrphans(new Set(configured.map(d => sanitizeId(d.name))));\n\n if (adverts.length === 0) {\n // Nothing is controllable \u2014 leave info.connection false and stop here.\n this.log.error(\"No emulated Roku device could be started \u2014 check the configured ports for conflicts.\");\n return;\n }\n\n // Discovery is only an aid; the ECP servers above already make the adapter\n // controllable. Isolate the SSDP start so a busy port 1900 (or a stuck bind)\n // degrades to \"discovery off, already-paired remotes still work\" instead of\n // failing the whole start-up. In the auto case join every routable interface so\n // a multi-homed host is discoverable on all its LANs; a chosen interface pins\n // both membership and NOTIFY egress.\n const membershipInterfaces = bindIp ? [bindIp] : detectLocalIPv4s();\n this.ssdp = new RokuSsdpResponder({\n devices: adverts,\n bindIp,\n advertiseIp,\n membershipInterfaces,\n logger: this.log,\n onFatalError: () => this.onSsdpFatal(),\n });\n try {\n await this.startWithTimeout(this.ssdp.start(), SSDP_START_TIMEOUT_MS);\n this.ssdp.announce();\n this.notifyTimer = this.setInterval(() => this.ssdp?.announce(), SSDP_NOTIFY_INTERVAL_MS);\n } catch (e) {\n this.log.warn(\n `SSDP discovery unavailable: ${e instanceof Error ? e.message : String(e)} \u2014 already-paired remotes still work; set the network interface if devices are not found.`,\n );\n this.ssdp = undefined;\n }\n\n await this.setState(\"info.connection\", { val: true, ack: true });\n this.log.info(\n `Emulating ${adverts.length} Roku device(s), advertising on ${advertiseIp}${this.ssdp ? \"\" : \" (discovery off)\"}`,\n );\n } catch (e) {\n this.log.error(`onReady failed: ${e instanceof Error ? e.message : String(e)}`);\n }\n }\n\n /**\n * Create the fixed object tree for one emulated Roku: the device, `command` +\n * `commandType`, and one `sensor` boolean state per key the device type exposes \u2014\n * all up front, so the tree is usable before any key is ever pressed.\n *\n * @param deviceId the id-safe device path segment\n * @param friendlyName the configured device name\n * @param keys the key names to create for this device (from its type)\n */\n private async createDeviceStates(deviceId: string, friendlyName: string, keys: readonly string[]): Promise<void> {\n await this.extendObject(deviceId, { type: \"device\", common: { name: friendlyName }, native: {} });\n await this.extendObject(`${deviceId}.command`, {\n type: \"state\",\n common: { name: \"Last command\", type: \"string\", role: \"text\", read: true, write: false, def: \"\" },\n native: {},\n });\n await this.extendObject(`${deviceId}.commandType`, {\n type: \"state\",\n common: { name: \"Last command type\", type: \"string\", role: \"text\", read: true, write: false, def: \"\" },\n native: {},\n });\n await this.extendObject(`${deviceId}.keys`, { type: \"channel\", common: { name: \"keys\" }, native: {} });\n for (const key of keys) {\n await this.extendObject(`${deviceId}.keys.${key}`, {\n type: \"state\",\n // \"sensor\" = generic boolean read-only (active/inactive). The docs suggest\n // button.press for a keypress-as-state, but the repochecker rejects it\n // (E1010 \u2014 not in its role list); sensor is the gate-conformant fit.\n common: { name: key, type: \"boolean\", role: \"sensor\", read: true, write: false, def: false },\n native: {},\n });\n }\n }\n\n /**\n * Remove objects left over from an earlier version or config \u2014 the legacy\n * `apps` node, keys no longer standard, and whole device sub-trees no longer\n * configured (a renamed/removed device). The adapter otherwise only ever\n * creates objects, so without this the tree would accrete stale entries.\n *\n * @param configuredDeviceIds the id-safe names of the currently configured devices\n */\n private async cleanupOrphans(configuredDeviceIds: ReadonlySet<string>): Promise<void> {\n const objects = await this.getAdapterObjectsAsync();\n const prefix = `${this.namespace}.`;\n const existingIds = Object.keys(objects)\n .filter(id => id.startsWith(prefix))\n .map(id => id.slice(prefix.length));\n const toDelete = planObjectCleanup(existingIds, configuredDeviceIds, this.deviceKeys);\n for (const id of toDelete) {\n await this.delObjectAsync(id, { recursive: true }).catch((e: unknown) => {\n this.log.debug(`cleanup: could not delete ${id}: ${e instanceof Error ? e.message : String(e)}`);\n });\n }\n if (toDelete.length > 0) {\n this.log.info(`Removed ${toDelete.length} orphaned object(s) from an earlier version or config.`);\n }\n }\n\n /**\n * Apply a received ECP command to this device's states: record it in `command`\n * / `commandType`, and pulse or hold the standard key if it is one.\n *\n * @param deviceId the id-safe device path segment\n * @param cmd the parsed ECP command\n */\n private applyCommand(deviceId: string, cmd: CommandEvent): void {\n const write = commandToStateWrite(cmd);\n void this.setState(`${deviceId}.command`, { val: write.command, ack: true });\n void this.setState(`${deviceId}.commandType`, { val: write.commandType, ack: true });\n // Only write keys.<Key> if THIS device's type carries the key \u2014 a player has no\n // TV key objects, so a stray TV keypress lands in `command` only, never a missing state.\n const keys = this.deviceKeys.get(deviceId);\n if (write.pulseKey && keys?.has(write.pulseKey)) {\n const id = `${deviceId}.keys.${write.pulseKey}`;\n void this.setState(id, { val: true, ack: true });\n const timer = this.setTimeout(() => {\n if (timer) {\n this.pulseTimers.delete(timer);\n }\n void this.setState(id, { val: false, ack: true });\n }, KEY_PULSE_MS);\n if (timer) {\n this.pulseTimers.add(timer);\n }\n } else if (write.holdKey && keys?.has(write.holdKey.key)) {\n const id = `${deviceId}.keys.${write.holdKey.key}`;\n void this.setState(id, { val: write.holdKey.value, ack: true });\n // A keydown holds the key true until its keyup. Arm a watchdog so a lost keyup\n // (controller disconnects mid-press) cannot pin the key true forever; a keyup\n // clears it, and a repeated keydown re-arms it.\n const pending = this.holdTimers.get(id);\n if (pending) {\n this.clearTimeout(pending);\n this.holdTimers.delete(id);\n }\n if (write.holdKey.value) {\n const timer = this.setTimeout(() => {\n this.holdTimers.delete(id);\n void this.setState(id, { val: false, ack: true });\n }, HOLD_MAX_MS);\n if (timer) {\n this.holdTimers.set(id, timer);\n }\n }\n }\n }\n\n /**\n * The SSDP responder died at runtime (a socket error after a good start). Stop\n * announcing into the dead socket and drop the discovery aid. The ECP servers\n * keep working, so info.connection \u2014 which reflects ECP readiness \u2014 stays true.\n */\n private onSsdpFatal(): void {\n if (this.notifyTimer) {\n this.clearInterval(this.notifyTimer);\n this.notifyTimer = undefined;\n }\n this.ssdp = undefined;\n this.log.warn(\n \"SSDP discovery stopped after a socket error \u2014 already-paired remotes still work; restart the instance to re-enable discovery.\",\n );\n }\n\n /**\n * Bound await: reject if the SSDP start doesn't settle in time, so a stuck\n * port-1900 bind degrades to \"discovery off\" instead of hanging the adapter.\n *\n * @param promise the SSDP start promise\n * @param ms the timeout in milliseconds\n * @returns a promise that settles with the start result or a timeout error\n */\n private startWithTimeout(promise: Promise<void>, ms: number): Promise<void> {\n return new Promise<void>((resolve, reject) => {\n const timer = this.setTimeout(() => reject(new Error(`SSDP start timed out after ${ms} ms`)), ms);\n const clear = (): void => {\n if (timer) {\n this.clearTimeout(timer);\n }\n };\n promise.then(\n () => {\n clear();\n resolve();\n },\n (e: unknown) => {\n clear();\n reject(e instanceof Error ? e : new Error(String(e)));\n },\n );\n });\n }\n\n /**\n * Synchronous teardown \u2014 no await, call the callback immediately (SIGKILL otherwise).\n *\n * @param callback function to invoke once teardown is complete\n */\n private onUnload(callback: () => void): void {\n try {\n if (this.notifyTimer) {\n this.clearInterval(this.notifyTimer);\n this.notifyTimer = undefined;\n }\n for (const t of this.pulseTimers) {\n this.clearTimeout(t);\n }\n this.pulseTimers.clear();\n for (const t of this.holdTimers.values()) {\n this.clearTimeout(t);\n }\n this.holdTimers.clear();\n this.ssdp?.stop();\n for (const s of this.ecpServers) {\n s.stop();\n }\n void this.setState(\"info.connection\", { val: false, ack: true });\n callback();\n } catch {\n callback();\n }\n }\n}\n\nif (require.main !== module) {\n // Export the constructor in compact mode\n module.exports = (options: Partial<utils.AdapterOptions> | undefined) => new Fakeroku(options);\n} else {\n // Start the instance directly\n (() => new Fakeroku())();\n}\n"],
|
|
5
|
-
"mappings": ";;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,YAAuB;AACvB,0BAAqB;AACrB,uBAAqB;AACrB,+BAAyC;AAEzC,4BAAkC;AAClC,yBAA6B;AAE7B,6BAA8B;AAC9B,yBAAkE;AAClE,uBAAiC;AACjC,6BAA2B;AAC3B,uBAAoD;AACpD,4BAAkC;AAClC,0BAA2B;AAG3B,MAAM,wBAAwB;AAE9B,MAAM,0BAA0B;AAEhC,MAAM,eAAe;AAErB,MAAM,cAAc;AASb,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;AAAA;AAAA;AAAA;AAAA,
|
|
4
|
+
"sourcesContent": ["import * as utils from \"@iobroker/adapter-core\";\nimport { I18n } from \"@iobroker/adapter-core\";\nimport { join } from \"node:path\";\nimport { FakerokuDeviceManagement } from \"./device-management\";\nimport type { RokuAdvert } from \"./discovery/ssdp-messages\";\nimport { RokuSsdpResponder } from \"./discovery/ssdp-responder\";\nimport { DEFAULT_APPS } from \"./ecp/device-info\";\nimport type { CommandEvent } from \"./ecp/ecp-command\";\nimport { EcpHttpServer } from \"./ecp/ecp-http-server\";\nimport { commandToStateWrite, type DeviceType, keysForType } from \"./ecp/state-model\";\nimport { DEFAULT_ECP_PORT } from \"./lib/constants\";\nimport { deriveUuid } from \"./lib/device-identity\";\nimport { detectLocalIPv4s, detectPrimaryIPv4 } from \"./lib/detect-ip\";\nimport { planObjectCleanup } from \"./lib/object-cleanup\";\nimport { sanitizeId } from \"./lib/pure-helpers\";\n\n/** Managed timeout for a stuck SSDP start (a busy port 1900 must not hang onReady). */\nconst SSDP_START_TIMEOUT_MS = 5000;\n/** Proactive ssdp:alive interval so controllers find the device without searching. */\nconst SSDP_NOTIFY_INTERVAL_MS = 300_000;\n/** How long a keypress pulses its keys.<Key> state true before falling back to false. */\nconst KEY_PULSE_MS = 50;\n/** Safety cap for a held key: a keydown with no matching keyup resets after this. */\nconst HOLD_MAX_MS = 30_000;\n\n/**\n * ioBroker.fakeroku \u2014 Roku emulator (input side).\n *\n * Emulates one or more Roku devices on the LAN so that ECP/SSDP remotes\n * (Logitech Harmony or a Sofabaton X1/X2) trigger events in ioBroker:\n * a keypress lands in `<device>.command` and pulses `<device>.keys.<Key>`.\n */\nexport class Fakeroku extends utils.Adapter {\n private ssdp: RokuSsdpResponder | undefined;\n private notifyTimer: ioBroker.Interval | undefined;\n private readonly ecpServers: EcpHttpServer[] = [];\n private readonly pulseTimers = new Set<ioBroker.Timeout>();\n /** Per held key id, its watchdog timer \u2014 so a keydown without a keyup cannot pin it true forever. */\n private readonly holdTimers = new Map<string, ioBroker.Timeout>();\n /** Per device, the key names it exposes \u2014 so a keypress only writes keys this device carries. */\n private readonly deviceKeys = new Map<string, ReadonlySet<string>>();\n /** Device-manager backend: the emulated Rokus as cards with add/edit/delete. */\n private readonly deviceManagement: FakerokuDeviceManagement;\n\n // 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\n // Empty AND \"0.0.0.0\" both mean \"auto\": bind all interfaces, advertise the\n // detected primary IP so the adapter runs without configuration. js-controller\n // never rewrites an existing native default, so instances from before 0.5.1\n // still carry \"\" \u2014 both must take the auto path. A concrete IP is honoured as-is.\n // Migration: a pre-0.5.0 instance has no `networkInterface`, only the old `BIND` \u2014\n // adopt it so a multi-homed host keeps the interface the user had configured.\n const configuredIp = this.config.networkInterface || this.config.BIND;\n const bindIp = configuredIp && configuredIp !== \"0.0.0.0\" ? configuredIp : undefined;\n const advertiseIp = bindIp ?? detectPrimaryIPv4();\n if (!advertiseIp) {\n this.log.warn(\"No routable IPv4 address found to advertise \u2014 set the network interface in the settings.\");\n return;\n }\n\n const configured = (this.config.devices ?? []).filter(d => d && typeof d.name === \"string\" && d.name.length > 0);\n if (configured.length === 0) {\n this.log.warn(\"No emulated Roku devices configured.\");\n return;\n }\n\n const adverts: RokuAdvert[] = [];\n const seenIds = new Set<string>();\n for (const d of configured) {\n const deviceId = sanitizeId(d.name);\n // Two configured names can sanitize to the same object id \u2014 the admin guards\n // against it, but a hand-edited config could still carry it. Skip the duplicate\n // instead of letting two devices fight over one object tree.\n if (seenIds.has(deviceId)) {\n this.log.warn(`Emulated Roku \"${d.name}\" maps to an object id already in use (${deviceId}) \u2014 skipping it.`);\n continue;\n }\n const deviceType: DeviceType = d.type === \"tv\" ? \"tv\" : \"player\";\n const keys = keysForType(deviceType);\n // Adopt the device's persisted uuid (old adapter or an earlier run) so the SSDP\n // identity \u2014 and thus the controller pairing \u2014 survives an update; derive a stable\n // one from the name only for a device that never had one.\n const advert: RokuAdvert = { uuid: d.uuid || deriveUuid(d.name), port: Number(d.port) || DEFAULT_ECP_PORT };\n try {\n await this.createDeviceStates(deviceId, d.name, keys);\n const server = 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 this.log.warn(\n `Emulated Roku \"${d.name}\" could not start on port ${advert.port}: ${e instanceof Error ? e.message : String(e)} \u2014 skipping it.`,\n );\n }\n }\n\n await this.cleanupOrphans(new Set(configured.map(d => sanitizeId(d.name))));\n\n if (adverts.length === 0) {\n // Nothing is controllable \u2014 leave info.connection false and stop here.\n this.log.error(\"No emulated Roku device could be started \u2014 check the configured ports for conflicts.\");\n return;\n }\n\n // Discovery is only an aid; the ECP servers above already make the adapter\n // controllable. Isolate the SSDP start so a busy port 1900 (or a stuck bind)\n // degrades to \"discovery off, already-paired remotes still work\" instead of\n // failing the whole start-up. In the auto case join every routable interface so\n // a multi-homed host is discoverable on all its LANs; a chosen interface pins\n // both membership and NOTIFY egress.\n const membershipInterfaces = bindIp ? [bindIp] : detectLocalIPv4s();\n this.ssdp = this.makeSsdpResponder({\n devices: adverts,\n bindIp,\n advertiseIp,\n membershipInterfaces,\n logger: this.log,\n onFatalError: () => this.onSsdpFatal(),\n });\n try {\n await this.startWithTimeout(this.ssdp.start(), SSDP_START_TIMEOUT_MS);\n this.ssdp.announce();\n this.notifyTimer = this.setInterval(() => this.ssdp?.announce(), SSDP_NOTIFY_INTERVAL_MS);\n } catch (e) {\n this.log.warn(\n `SSDP discovery unavailable: ${e instanceof Error ? e.message : String(e)} \u2014 already-paired remotes still work; set the network interface if devices are not found.`,\n );\n this.ssdp = undefined;\n }\n\n // \"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: ${e instanceof Error ? e.message : String(e)}`);\n }\n }\n\n /**\n * Create the fixed object tree for one emulated Roku: the device, `command` +\n * `commandType`, and one `sensor` boolean state per key the device type exposes \u2014\n * all up front, so the tree is usable before any key is ever pressed.\n *\n * @param deviceId the id-safe device path segment\n * @param friendlyName the configured device name\n * @param keys the key names to create for this device (from its type)\n */\n private async createDeviceStates(deviceId: string, friendlyName: string, keys: readonly string[]): Promise<void> {\n await this.extendObject(deviceId, { type: \"device\", common: { name: friendlyName }, native: {} });\n await this.extendObject(`${deviceId}.command`, {\n type: \"state\",\n common: { name: \"Last command\", type: \"string\", role: \"text\", read: true, write: false, def: \"\" },\n native: {},\n });\n await this.extendObject(`${deviceId}.commandType`, {\n type: \"state\",\n common: { name: \"Last command type\", type: \"string\", role: \"text\", read: true, write: false, def: \"\" },\n native: {},\n });\n await this.extendObject(`${deviceId}.keys`, { type: \"channel\", common: { name: \"keys\" }, native: {} });\n for (const key of keys) {\n await this.extendObject(`${deviceId}.keys.${key}`, {\n type: \"state\",\n // \"sensor\" = generic boolean read-only (active/inactive). The docs suggest\n // button.press for a keypress-as-state, but the repochecker rejects it\n // (E1010 \u2014 not in its role list); sensor is the gate-conformant fit.\n common: { name: key, type: \"boolean\", role: \"sensor\", read: true, write: false, def: false },\n native: {},\n });\n }\n }\n\n /**\n * Remove objects left over from an earlier version or config \u2014 the legacy\n * `apps` node, keys no longer standard, and whole device sub-trees no longer\n * configured (a renamed/removed device). The adapter otherwise only ever\n * creates objects, so without this the tree would accrete stale entries.\n *\n * @param configuredDeviceIds the id-safe names of the currently configured devices\n */\n private async cleanupOrphans(configuredDeviceIds: ReadonlySet<string>): Promise<void> {\n const objects = await this.getAdapterObjectsAsync();\n const prefix = `${this.namespace}.`;\n const existingIds = Object.keys(objects)\n .filter(id => id.startsWith(prefix))\n .map(id => id.slice(prefix.length));\n const toDelete = planObjectCleanup(existingIds, configuredDeviceIds, this.deviceKeys);\n for (const id of toDelete) {\n await this.delObjectAsync(id, { recursive: true }).catch((e: unknown) => {\n this.log.debug(`cleanup: could not delete ${id}: ${e instanceof Error ? e.message : String(e)}`);\n });\n }\n if (toDelete.length > 0) {\n this.log.info(`Removed ${toDelete.length} orphaned object(s) from an earlier version or config.`);\n }\n }\n\n /**\n * Apply a received ECP command to this device's states: record it in `command`\n * / `commandType`, and pulse or hold the standard key if it is one.\n *\n * @param deviceId the id-safe device path segment\n * @param cmd the parsed ECP command\n */\n private applyCommand(deviceId: string, cmd: CommandEvent): void {\n const write = commandToStateWrite(cmd);\n void this.setState(`${deviceId}.command`, { val: write.command, ack: true });\n void this.setState(`${deviceId}.commandType`, { val: write.commandType, ack: true });\n // Only write keys.<Key> if THIS device's type carries the key \u2014 a player has no\n // TV key objects, so a stray TV keypress lands in `command` only, never a missing state.\n const keys = this.deviceKeys.get(deviceId);\n if (write.pulseKey && keys?.has(write.pulseKey)) {\n const id = `${deviceId}.keys.${write.pulseKey}`;\n void this.setState(id, { val: true, ack: true });\n const timer = this.setTimeout(() => {\n if (timer) {\n this.pulseTimers.delete(timer);\n }\n void this.setState(id, { val: false, ack: true });\n }, KEY_PULSE_MS);\n if (timer) {\n this.pulseTimers.add(timer);\n }\n } else if (write.holdKey && keys?.has(write.holdKey.key)) {\n const id = `${deviceId}.keys.${write.holdKey.key}`;\n void this.setState(id, { val: write.holdKey.value, ack: true });\n // A keydown holds the key true until its keyup. Arm a watchdog so a lost keyup\n // (controller disconnects mid-press) cannot pin the key true forever; a keyup\n // clears it, and a repeated keydown re-arms it.\n const pending = this.holdTimers.get(id);\n if (pending) {\n this.clearTimeout(pending);\n this.holdTimers.delete(id);\n }\n if (write.holdKey.value) {\n const timer = this.setTimeout(() => {\n this.holdTimers.delete(id);\n void this.setState(id, { val: false, ack: true });\n }, HOLD_MAX_MS);\n if (timer) {\n this.holdTimers.set(id, timer);\n }\n }\n }\n }\n\n /**\n * The SSDP responder died at runtime (a socket error after a good start). Stop\n * announcing into the dead socket and drop the discovery aid. The ECP servers\n * keep working, so info.connection \u2014 which reflects ECP readiness \u2014 stays true.\n */\n private onSsdpFatal(): void {\n if (this.notifyTimer) {\n this.clearInterval(this.notifyTimer);\n this.notifyTimer = undefined;\n }\n this.ssdp = undefined;\n this.log.warn(\n \"SSDP discovery stopped after a socket error \u2014 already-paired remotes still work; restart the instance to re-enable discovery.\",\n );\n }\n\n /**\n * Bound await: reject if the SSDP start doesn't settle in time, so a stuck\n * port-1900 bind degrades to \"discovery off\" instead of hanging the adapter.\n *\n * @param promise the SSDP start promise\n * @param ms the timeout in milliseconds\n * @returns a promise that settles with the start result or a timeout error\n */\n private startWithTimeout(promise: Promise<void>, ms: number): Promise<void> {\n return new Promise<void>((resolve, reject) => {\n const timer = this.setTimeout(() => reject(new Error(`SSDP start timed out after ${ms} ms`)), ms);\n const clear = (): void => {\n if (timer) {\n this.clearTimeout(timer);\n }\n };\n promise.then(\n () => {\n clear();\n resolve();\n },\n (e: unknown) => {\n clear();\n reject(e instanceof Error ? e : new Error(String(e)));\n },\n );\n });\n }\n\n /**\n * 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, so losing that\n * final write leaves the instance showing \"connected\" while the adapter is\n * off. A fire-and-forget write followed by an immediate callback IS lost \u2014\n * measured across the fleet. Waiting is safe: the manifest deliberately\n * carries no `common.supportedMessages.stopInstance` (with it the host kills\n * the process outright and never runs this method), so the host grants the\n * full `common.stopTimeout` \u2014 a second \u2014 for the write to complete.\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: ${e instanceof Error ? e.message : String(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
6
|
"names": ["_a"]
|
|
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.2.0",
|
|
5
5
|
"news": {
|
|
6
|
+
"1.2.0": {
|
|
7
|
+
"en": "Honest status: the instance reports connected only while every configured Roku runs, and clears it when stopped. Plus optional error reporting via Sentry.",
|
|
8
|
+
"de": "Ehrlicher Status: Die Instanz meldet nur dann verbunden, wenn jeder konfigurierte Roku läuft, und setzt ihn beim Stoppen zurück. Dazu optionale Fehlerberichte über Sentry.",
|
|
9
|
+
"ru": "Честный статус: экземпляр сообщает о подключении, только когда работают все настроенные Roku, и сбрасывает его при остановке. Плюс необязательная отправка ошибок через Sentry.",
|
|
10
|
+
"pt": "Estado honesto: a instância só indica ligado enquanto todos os Roku configurados estiverem a funcionar e limpa-o ao parar. Além disso, relatórios de erro opcionais via Sentry.",
|
|
11
|
+
"nl": "Eerlijke status: de instantie meldt alleen verbonden zolang elke geconfigureerde Roku draait en wist dit bij het stoppen. Plus optionele foutrapportage via Sentry.",
|
|
12
|
+
"fr": "Statut honnête : l'instance n'indique connecté que si chaque Roku configuré fonctionne, et l'efface à l'arrêt. Plus des rapports d'erreurs facultatifs via Sentry.",
|
|
13
|
+
"it": "Stato onesto: l'istanza segnala connesso solo finché ogni Roku configurato è in funzione e lo azzera all'arresto. Inoltre segnalazione errori opzionale tramite Sentry.",
|
|
14
|
+
"es": "Estado honesto: la instancia solo indica conectado mientras cada Roku configurado esté funcionando y lo borra al detenerse. Además, informes de errores opcionales mediante Sentry.",
|
|
15
|
+
"pl": "Uczciwy status: instancja zgłasza połączenie tylko wtedy, gdy działa każdy skonfigurowany Roku, i czyści go przy zatrzymaniu. Dodatkowo opcjonalne raportowanie błędów przez Sentry.",
|
|
16
|
+
"uk": "Чесний статус: екземпляр повідомляє про з'єднання, лише поки працює кожен налаштований Roku, і скидає його під час зупинки. Крім того, необов'язкові звіти про помилки через Sentry.",
|
|
17
|
+
"zh-cn": "诚实的状态:仅当每个已配置的 Roku 都在运行时,实例才报告已连接,停止时会清除该状态。此外还提供可选的 Sentry 错误报告。"
|
|
18
|
+
},
|
|
6
19
|
"1.1.0": {
|
|
7
20
|
"en": "More robust: a busy port on one Roku no longer stops the others, and discovery now reaches every network interface even on hosts with several.",
|
|
8
21
|
"de": "Robuster: ein belegter Port an einem Roku stoppt die anderen nicht mehr, und die Erkennung erreicht jetzt jede Netzwerkkarte, auch bei mehreren.",
|
|
@@ -80,19 +93,6 @@
|
|
|
80
93
|
"pl": "Adapter wymaga node.js > = 18 i kontroler js- > = 5 teraz\nZaktualizowano zależności",
|
|
81
94
|
"uk": "Адаптер вимагає node.js >= 18 і js-controller >= 5 тепер\nЗалежність було оновлено",
|
|
82
95
|
"zh-cn": "适配器需要节点.js QQ18和js控制器 QQ 现在5号\n依赖关系已更新"
|
|
83
|
-
},
|
|
84
|
-
"0.2.3": {
|
|
85
|
-
"en": "Adapter requires node.js >= 18 and js-controller >= 5 now\nDependencies have been updated",
|
|
86
|
-
"de": "Adapter benötigt node.js >= 18 und js-controller >= 5 jetzt\nAbhängigkeiten wurden aktualisiert",
|
|
87
|
-
"ru": "Адаптер требует node.js >= 18 и js-контроллер >= 5 сейчас\nЗависимость обновлена",
|
|
88
|
-
"pt": "Adapter requer node.js >= 18 e js-controller >= 5 agora\nAs dependências foram atualizadas",
|
|
89
|
-
"nl": "Voor adaptor zijn node.js < 18 en js-controller > Nu 5\nAfhankelijkheden zijn bijgewerkt",
|
|
90
|
-
"fr": "L'adaptateur nécessite node.js >= 18 et js-controller >= 5 maintenant\nLes dépendances ont été actualisées",
|
|
91
|
-
"it": "Adattatore richiede node.js >= 18 e js-controller >= 5 ora\nLe dipendenze sono state aggiornate",
|
|
92
|
-
"es": "Adaptador requiere node.js ó= 18 y js-controller 5 ahora\nSe han actualizado las dependencias",
|
|
93
|
-
"pl": "Adapter wymaga node.js > = 18 i kontroler js- > = 5 teraz\nZaktualizowano zależności",
|
|
94
|
-
"uk": "Адаптер вимагає node.js >= 18 і js-controller >= 5 тепер\nЗалежність було оновлено",
|
|
95
|
-
"zh-cn": "适配器需要节点.js QQ18和js控制器 QQ 现在5号\n依赖关系已更新"
|
|
96
96
|
}
|
|
97
97
|
},
|
|
98
98
|
"titleLang": {
|
|
@@ -205,7 +205,12 @@
|
|
|
205
205
|
"cancel"
|
|
206
206
|
]
|
|
207
207
|
}
|
|
208
|
-
]
|
|
208
|
+
],
|
|
209
|
+
"plugins": {
|
|
210
|
+
"sentry": {
|
|
211
|
+
"dsn": "https://66f27ceff38c1ce1e6cdcc3095ba06d3@o4511524771266560.ingest.de.sentry.io/4511524773560400"
|
|
212
|
+
}
|
|
213
|
+
}
|
|
209
214
|
},
|
|
210
215
|
"native": {
|
|
211
216
|
"networkInterface": "0.0.0.0",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "iobroker.fakeroku",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.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,10 +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": "^4.1.
|
|
56
|
+
"@vitest/coverage-v8": "^4.1.11",
|
|
57
|
+
"iobroker-adapter-checks": "^0.3.0",
|
|
57
58
|
"rimraf": "^6.1.3",
|
|
58
59
|
"typescript": "~6.0.3",
|
|
59
|
-
"vitest": "^4.1.
|
|
60
|
+
"vitest": "^4.1.11"
|
|
60
61
|
},
|
|
61
62
|
"main": "build/main.js",
|
|
62
63
|
"files": [
|
|
@@ -86,5 +87,12 @@
|
|
|
86
87
|
"bugs": {
|
|
87
88
|
"url": "https://github.com/iobroker-community-adapters/ioBroker.fakeroku/issues"
|
|
88
89
|
},
|
|
89
|
-
"readmeFilename": "README.md"
|
|
90
|
+
"readmeFilename": "README.md",
|
|
91
|
+
"overrides": {
|
|
92
|
+
"mocha": {
|
|
93
|
+
"diff": "^8.0.3",
|
|
94
|
+
"serialize-javascript": "^7.0.5"
|
|
95
|
+
},
|
|
96
|
+
"esbuild": ">=0.25.0"
|
|
97
|
+
}
|
|
90
98
|
}
|