iobroker.fakeroku 1.8.1 → 1.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +16 -8
  2. package/admin/i18n/de.json +8 -0
  3. package/admin/i18n/en.json +8 -0
  4. package/admin/i18n/es.json +8 -0
  5. package/admin/i18n/fr.json +8 -0
  6. package/admin/i18n/it.json +8 -0
  7. package/admin/i18n/nl.json +8 -0
  8. package/admin/i18n/pl.json +8 -0
  9. package/admin/i18n/pt.json +8 -0
  10. package/admin/i18n/ru.json +8 -0
  11. package/admin/i18n/uk.json +8 -0
  12. package/admin/i18n/zh-cn.json +8 -0
  13. package/build/command-handler.js +172 -0
  14. package/build/command-handler.js.map +7 -0
  15. package/build/device-management.js +6 -7
  16. package/build/device-management.js.map +2 -2
  17. package/build/discovery/ssdp-messages.js +11 -6
  18. package/build/discovery/ssdp-messages.js.map +2 -2
  19. package/build/discovery/ssdp-responder.js +50 -48
  20. package/build/discovery/ssdp-responder.js.map +2 -2
  21. package/build/ecp/device-info.js +3 -6
  22. package/build/ecp/device-info.js.map +2 -2
  23. package/build/ecp/ecp-command.js +0 -3
  24. package/build/ecp/ecp-command.js.map +2 -2
  25. package/build/ecp/ecp-http-server.js +20 -27
  26. package/build/ecp/ecp-http-server.js.map +2 -2
  27. package/build/lib/constants.js +10 -2
  28. package/build/lib/constants.js.map +2 -2
  29. package/build/lib/detect-ip.js +17 -59
  30. package/build/lib/detect-ip.js.map +3 -3
  31. package/build/lib/device-config.js +6 -10
  32. package/build/lib/device-config.js.map +2 -2
  33. package/build/lib/device-identity.js.map +1 -1
  34. package/build/lib/{errors.js → err-text.js} +4 -4
  35. package/build/lib/err-text.js.map +7 -0
  36. package/build/lib/i18n.js +9 -4
  37. package/build/lib/i18n.js.map +2 -2
  38. package/build/lib/known-objects.js +227 -0
  39. package/build/lib/known-objects.js.map +7 -0
  40. package/build/lib/lan-guard.js +1 -1
  41. package/build/lib/lan-guard.js.map +2 -2
  42. package/build/lib/log-throttle.js +53 -0
  43. package/build/lib/log-throttle.js.map +7 -0
  44. package/build/lib/native-key-migration.js +0 -3
  45. package/build/lib/native-key-migration.js.map +2 -2
  46. package/build/lib/pure-helpers.js +4 -1
  47. package/build/lib/pure-helpers.js.map +2 -2
  48. package/build/main.js +191 -221
  49. package/build/main.js.map +2 -2
  50. package/io-package.json +15 -15
  51. package/package.json +5 -5
  52. package/build/lib/errors.js.map +0 -7
package/README.md CHANGED
@@ -6,6 +6,9 @@
6
6
 
7
7
  **Support:** [![Ko-fi](https://img.shields.io/badge/Ko--fi-Support-ff5e5b?logo=ko-fi)](https://ko-fi.com/krobipd) [![PayPal](https://img.shields.io/badge/Donate-PayPal-blue.svg)](https://paypal.me/krobipd)
8
8
 
9
+ > [!IMPORTANT]
10
+ > This adapter cannot be installed from github
11
+
9
12
  Emulates one or more **[Roku](https://www.roku.com/) devices** on your LAN so that ECP/SSDP remotes and
10
13
  controllers — a Logitech Harmony Hub, a Sofabaton X1/X2, Home Assistant's Roku integration,
11
14
  openHAB — can trigger events in ioBroker. It is the **input** counterpart to the Logitech
@@ -20,6 +23,8 @@ so they accept the emulated device instead of giving up on it.
20
23
  > implement. Use a Harmony hub or a Sofabaton — those speak the classic ECP this
21
24
  > adapter serves.
22
25
 
26
+ [🇺🇸 Documentation](./docs/en/README.md) · [🇩🇪 Dokumentation](./docs/de/README.md)
27
+
23
28
  ## Features
24
29
 
25
30
  - Emulates one or more Roku devices on the LAN — the Roku control protocol (ECP) over HTTP plus SSDP discovery on port 1900.
@@ -37,13 +42,11 @@ For details and how to disable it, see the [Sentry plugin documentation](https:/
37
42
 
38
43
  - Node.js >= 22
39
44
  - js-controller >= 7.2.2
40
- - admin >= 8.0.11
41
-
42
- > The adapter CANNOT be installed via GitHub: The adapter must be installed via the ioBroker repository (stable or latest).
45
+ - admin >= 8.0.14
43
46
 
44
47
  ## Ports
45
48
 
46
- - **TCP 8060 (listening, one per emulated Roku, configurable)** — the Roku control protocol (ECP): the remote sends its key presses here. A Harmony or Sofabaton reads the port from the discovery announcement; Home Assistant and Homey always use 8060.
49
+ - **TCP 8060 (listening, one per emulated Roku, configurable)** — the Roku control protocol (ECP): the remote sends its key presses here.
47
50
  - **UDP 1900 (multicast, listening and outgoing)** — SSDP discovery, so a Harmony hub or Sofabaton finds the emulated Rokus; fixed by the UPnP standard.
48
51
 
49
52
  ## Configuration
@@ -100,6 +103,15 @@ becomes `true` — or watch `.command` for the last button as text.
100
103
  Placeholder for the next version (at the beginning of the line):
101
104
  ### **WORK IN PROGRESS**
102
105
  -->
106
+ ### 1.8.2 (2026-10-02)
107
+
108
+ - (krobipd) Changed: a new instance starts switched off — check the settings, then switch it on.
109
+ - (krobipd) Fixed: the README and the user documentation name admin 8.0.14, the version the adapter actually requires.
110
+ - (krobipd) Fixed: the device manager answers right after a restart instead of failing until the translations are loaded.
111
+ - (krobipd) Improved: the type of the last command shows a readable label in the language you set instead of a protocol word.
112
+ - (krobipd) Improved: a start writes only datapoints that changed and reads the key states in one request — no needless updates for history adapters.
113
+ - (krobipd) Improved: the README links the detailed user documentation in English and German.
114
+
103
115
  ### 1.8.1 (2026-09-27)
104
116
 
105
117
  - (krobipd) Improved: the note the Admin shows before an update from 0.x is short now: the apps folder is removed, app launches arrive as launch:<id> in command.
@@ -131,10 +143,6 @@ becomes `true` — or watch `.command` for the last button as text.
131
143
  - (krobipd) Improved: after the host gets a new IP address, remotes find the emulated Rokus again without restarting the instance.
132
144
  - (krobipd) Changed: the network interface setting moved to the standard settings key (bind); the instance restarts once after this update.
133
145
 
134
- ### 1.6.1 (2026-09-07) — stable
135
-
136
- - (krobipd) Changed: installing straight from GitHub is no longer offered — the adapter is built before publishing, so it is installed from the ioBroker repository instead.
137
-
138
146
  [Older changelogs can be found there](CHANGELOG_OLD.md)
139
147
 
140
148
  ## History
@@ -32,6 +32,14 @@
32
32
  "stateLastCommandDesc": "Der zuletzt empfangene Befehl dieses emulierten Roku als Klartext.",
33
33
  "stateLastCommandType": "Art des letzten Befehls",
34
34
  "stateLastCommandTypeDesc": "Um welche Art Befehl es sich handelte: Tastendruck, Taste gehalten oder losgelassen, App-Start oder -Installation, Texteingabe oder Suche.",
35
+ "commandType_none": "Noch kein Befehl",
36
+ "commandType_keypress": "Tastendruck",
37
+ "commandType_keydown": "Taste gehalten",
38
+ "commandType_keyup": "Taste losgelassen",
39
+ "commandType_launch": "App-Start",
40
+ "commandType_install": "App-Installation",
41
+ "commandType_input": "Texteingabe",
42
+ "commandType_search": "Suche",
35
43
  "channelKeys": "Fernbedienungstasten",
36
44
  "channelKeysDesc": "Ein Datenpunkt je Taste, die dieses Gerät anbietet; ein Tastendruck setzt ihn kurz auf wahr."
37
45
  }
@@ -32,6 +32,14 @@
32
32
  "stateLastCommandDesc": "The most recent command this emulated Roku received, as plain text.",
33
33
  "stateLastCommandType": "Last command type",
34
34
  "stateLastCommandTypeDesc": "What kind of command it was: a key press, a key held or released, an app launch or install, a text input or a search.",
35
+ "commandType_none": "No command yet",
36
+ "commandType_keypress": "Key press",
37
+ "commandType_keydown": "Key held",
38
+ "commandType_keyup": "Key released",
39
+ "commandType_launch": "App launch",
40
+ "commandType_install": "App install",
41
+ "commandType_input": "Text input",
42
+ "commandType_search": "Search",
35
43
  "channelKeys": "Remote keys",
36
44
  "channelKeysDesc": "One state per remote key this device offers; a key press pulses it briefly."
37
45
  }
@@ -32,6 +32,14 @@
32
32
  "stateLastCommandDesc": "El comando más reciente que recibió este Roku emulado, como texto simple.",
33
33
  "stateLastCommandType": "Tipo del último comando",
34
34
  "stateLastCommandTypeDesc": "Qué tipo de comando fue: pulsar una tecla, mantenerla o soltarla, iniciar o instalar una aplicación, introducir texto o buscar.",
35
+ "commandType_none": "Aún sin comando",
36
+ "commandType_keypress": "Pulsación de tecla",
37
+ "commandType_keydown": "Tecla mantenida",
38
+ "commandType_keyup": "Tecla soltada",
39
+ "commandType_launch": "Inicio de app",
40
+ "commandType_install": "Instalación de app",
41
+ "commandType_input": "Entrada de texto",
42
+ "commandType_search": "Búsqueda",
35
43
  "channelKeys": "Teclas del mando",
36
44
  "channelKeysDesc": "Un estado por cada tecla que ofrece este dispositivo; una pulsación lo pone brevemente en verdadero."
37
45
  }
@@ -32,6 +32,14 @@
32
32
  "stateLastCommandDesc": "La commande la plus récente reçue par ce Roku émulé, en texte brut.",
33
33
  "stateLastCommandType": "Type de la dernière commande",
34
34
  "stateLastCommandTypeDesc": "De quel type de commande il s'agissait : appui sur une touche, touche maintenue ou relâchée, lancement ou installation d'une application, saisie de texte ou recherche.",
35
+ "commandType_none": "Aucune commande pour l'instant",
36
+ "commandType_keypress": "Appui sur une touche",
37
+ "commandType_keydown": "Touche maintenue",
38
+ "commandType_keyup": "Touche relâchée",
39
+ "commandType_launch": "Lancement d'appli",
40
+ "commandType_install": "Installation d'appli",
41
+ "commandType_input": "Saisie de texte",
42
+ "commandType_search": "Recherche",
35
43
  "channelKeys": "Touches de la télécommande",
36
44
  "channelKeysDesc": "Un état par touche proposée par cet appareil ; un appui le met brièvement à vrai."
37
45
  }
@@ -32,6 +32,14 @@
32
32
  "stateLastCommandDesc": "Il comando più recente ricevuto da questo Roku emulato, come testo semplice.",
33
33
  "stateLastCommandType": "Tipo dell'ultimo comando",
34
34
  "stateLastCommandTypeDesc": "Di che tipo di comando si trattava: pressione di un tasto, tasto tenuto premuto o rilasciato, avvio o installazione di un'app, immissione di testo o ricerca.",
35
+ "commandType_none": "Ancora nessun comando",
36
+ "commandType_keypress": "Pressione tasto",
37
+ "commandType_keydown": "Tasto tenuto premuto",
38
+ "commandType_keyup": "Tasto rilasciato",
39
+ "commandType_launch": "Avvio app",
40
+ "commandType_install": "Installazione app",
41
+ "commandType_input": "Inserimento testo",
42
+ "commandType_search": "Ricerca",
35
43
  "channelKeys": "Tasti del telecomando",
36
44
  "channelKeysDesc": "Uno stato per ogni tasto offerto da questo dispositivo; una pressione lo porta brevemente a vero."
37
45
  }
@@ -32,6 +32,14 @@
32
32
  "stateLastCommandDesc": "De meest recente opdracht die deze geëmuleerde Roku ontving, als platte tekst.",
33
33
  "stateLastCommandType": "Type laatste opdracht",
34
34
  "stateLastCommandTypeDesc": "Wat voor soort opdracht het was: een toetsdruk, een toets ingedrukt of losgelaten, een app starten of installeren, tekstinvoer of zoeken.",
35
+ "commandType_none": "Nog geen opdracht",
36
+ "commandType_keypress": "Toetsdruk",
37
+ "commandType_keydown": "Toets vastgehouden",
38
+ "commandType_keyup": "Toets losgelaten",
39
+ "commandType_launch": "App starten",
40
+ "commandType_install": "App installeren",
41
+ "commandType_input": "Tekstinvoer",
42
+ "commandType_search": "Zoeken",
35
43
  "channelKeys": "Afstandsbedieningstoetsen",
36
44
  "channelKeysDesc": "Eén status per toets die dit apparaat biedt; een toetsdruk zet hem kort op waar."
37
45
  }
@@ -32,6 +32,14 @@
32
32
  "stateLastCommandDesc": "Najnowsze polecenie odebrane przez ten emulowany Roku, jako zwykły tekst.",
33
33
  "stateLastCommandType": "Typ ostatniego polecenia",
34
34
  "stateLastCommandTypeDesc": "Jakiego rodzaju było to polecenie: naciśnięcie klawisza, przytrzymanie lub zwolnienie, uruchomienie lub instalacja aplikacji, wpisanie tekstu albo wyszukiwanie.",
35
+ "commandType_none": "Jeszcze brak polecenia",
36
+ "commandType_keypress": "Naciśnięcie klawisza",
37
+ "commandType_keydown": "Klawisz przytrzymany",
38
+ "commandType_keyup": "Klawisz zwolniony",
39
+ "commandType_launch": "Uruchomienie aplikacji",
40
+ "commandType_install": "Instalacja aplikacji",
41
+ "commandType_input": "Wprowadzanie tekstu",
42
+ "commandType_search": "Wyszukiwanie",
35
43
  "channelKeys": "Klawisze pilota",
36
44
  "channelKeysDesc": "Jeden stan na każdy klawisz oferowany przez to urządzenie; naciśnięcie ustawia go na krótko na wartość true."
37
45
  }
@@ -32,6 +32,14 @@
32
32
  "stateLastCommandDesc": "O comando mais recente recebido por este Roku emulado, em texto simples.",
33
33
  "stateLastCommandType": "Tipo do último comando",
34
34
  "stateLastCommandTypeDesc": "Que tipo de comando foi: pressionar uma tecla, manter ou soltar, iniciar ou instalar uma aplicação, introduzir texto ou pesquisar.",
35
+ "commandType_none": "Ainda sem comando",
36
+ "commandType_keypress": "Toque de tecla",
37
+ "commandType_keydown": "Tecla mantida",
38
+ "commandType_keyup": "Tecla solta",
39
+ "commandType_launch": "Abrir aplicação",
40
+ "commandType_install": "Instalar aplicação",
41
+ "commandType_input": "Entrada de texto",
42
+ "commandType_search": "Pesquisa",
35
43
  "channelKeys": "Teclas do comando",
36
44
  "channelKeysDesc": "Um estado por cada tecla que este dispositivo oferece; uma pressão coloca-o brevemente a verdadeiro."
37
45
  }
@@ -32,6 +32,14 @@
32
32
  "stateLastCommandDesc": "Последняя команда, полученная этим эмулированным Roku, в виде текста.",
33
33
  "stateLastCommandType": "Тип последней команды",
34
34
  "stateLastCommandTypeDesc": "Какого рода была команда: нажатие кнопки, удержание или отпускание, запуск или установка приложения, ввод текста или поиск.",
35
+ "commandType_none": "Команд ещё не было",
36
+ "commandType_keypress": "Нажатие клавиши",
37
+ "commandType_keydown": "Клавиша удерживается",
38
+ "commandType_keyup": "Клавиша отпущена",
39
+ "commandType_launch": "Запуск приложения",
40
+ "commandType_install": "Установка приложения",
41
+ "commandType_input": "Ввод текста",
42
+ "commandType_search": "Поиск",
35
43
  "channelKeys": "Кнопки пульта",
36
44
  "channelKeysDesc": "По одному состоянию на каждую кнопку пульта этого устройства; нажатие кратко переводит его в истину."
37
45
  }
@@ -32,6 +32,14 @@
32
32
  "stateLastCommandDesc": "Найновіша команда, яку отримав цей емульований Roku, у вигляді тексту.",
33
33
  "stateLastCommandType": "Тип останньої команди",
34
34
  "stateLastCommandTypeDesc": "Якого роду була команда: натискання кнопки, утримання чи відпускання, запуск або встановлення застосунку, введення тексту чи пошук.",
35
+ "commandType_none": "Команд ще не було",
36
+ "commandType_keypress": "Натискання клавіші",
37
+ "commandType_keydown": "Клавіша утримується",
38
+ "commandType_keyup": "Клавішу відпущено",
39
+ "commandType_launch": "Запуск застосунку",
40
+ "commandType_install": "Встановлення застосунку",
41
+ "commandType_input": "Введення тексту",
42
+ "commandType_search": "Пошук",
35
43
  "channelKeys": "Кнопки пульта",
36
44
  "channelKeysDesc": "По одному стану на кожну кнопку, яку пропонує цей пристрій; натискання ненадовго переводить його в істину."
37
45
  }
@@ -32,6 +32,14 @@
32
32
  "stateLastCommandDesc": "该模拟 Roku 最近收到的命令,以纯文本表示。",
33
33
  "stateLastCommandType": "最后一条命令的类型",
34
34
  "stateLastCommandTypeDesc": "命令的种类:按键、按住或松开按键、启动或安装应用、输入文本或搜索。",
35
+ "commandType_none": "尚无命令",
36
+ "commandType_keypress": "按键",
37
+ "commandType_keydown": "按住按键",
38
+ "commandType_keyup": "松开按键",
39
+ "commandType_launch": "启动应用",
40
+ "commandType_install": "安装应用",
41
+ "commandType_input": "文本输入",
42
+ "commandType_search": "搜索",
35
43
  "channelKeys": "遥控器按键",
36
44
  "channelKeysDesc": "该设备提供的每个遥控器按键各有一个状态;按下时会短暂置为真。"
37
45
  }
@@ -0,0 +1,172 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+ var command_handler_exports = {};
20
+ __export(command_handler_exports, {
21
+ CommandHandler: () => CommandHandler,
22
+ HOLD_MAX_MS: () => HOLD_MAX_MS,
23
+ KEY_PULSE_MS: () => KEY_PULSE_MS,
24
+ MAX_COMMANDS_PER_SECOND: () => MAX_COMMANDS_PER_SECOND,
25
+ RATE_WARN_INTERVAL_MS: () => RATE_WARN_INTERVAL_MS
26
+ });
27
+ module.exports = __toCommonJS(command_handler_exports);
28
+ var import_state_model = require("./ecp/state-model");
29
+ var import_log_throttle = require("./lib/log-throttle");
30
+ var import_rate_gate = require("./lib/rate-gate");
31
+ const KEY_PULSE_MS = 50;
32
+ const HOLD_MAX_MS = 3e4;
33
+ const MAX_COMMANDS_PER_SECOND = 25;
34
+ const RATE_WARN_INTERVAL_MS = 6e4;
35
+ class CommandHandler {
36
+ /**
37
+ * @param host the adapter's writer, timers and log
38
+ */
39
+ constructor(host) {
40
+ this.host = host;
41
+ }
42
+ host;
43
+ pulseTimers = /* @__PURE__ */ new Set();
44
+ /** Per held key id, its watchdog timer — so a keydown without a keyup cannot pin it true forever. */
45
+ holdTimers = /* @__PURE__ */ new Map();
46
+ gates = /* @__PURE__ */ new Map();
47
+ rateWarnings = new import_log_throttle.LogThrottle(RATE_WARN_INTERVAL_MS);
48
+ /**
49
+ * Apply a received ECP command to the device's states: record it in `command` / `commandType`, and pulse or hold
50
+ * the standard key if the device carries it. The server logs only what this accepted, so the rate gate covers the
51
+ * log as well as the states database.
52
+ *
53
+ * @param device the emulated Roku the command is for
54
+ * @param cmd the parsed ECP command
55
+ * @returns true if the command was applied, false if the rate gate dropped it
56
+ */
57
+ apply(device, cmd) {
58
+ var _a;
59
+ const write = (0, import_state_model.commandToStateWrite)(cmd);
60
+ const releaseOf = ((_a = write.holdKey) == null ? void 0 : _a.value) === false ? `${device.id}.keys.${write.holdKey.key}` : null;
61
+ const isRelease = releaseOf !== null && this.holdTimers.has(releaseOf);
62
+ if (!isRelease && !this.admit(device)) {
63
+ return false;
64
+ }
65
+ this.host.writeState(`${device.id}.command`, write.command);
66
+ this.host.writeState(`${device.id}.commandType`, write.commandType);
67
+ if (write.pulseKey && device.keys.has(write.pulseKey)) {
68
+ this.pulse(`${device.id}.keys.${write.pulseKey}`);
69
+ } else if (write.holdKey && device.keys.has(write.holdKey.key)) {
70
+ this.hold(`${device.id}.keys.${write.holdKey.key}`, write.holdKey.value);
71
+ }
72
+ return true;
73
+ }
74
+ /** Disarm every timer — the adapter is going down. */
75
+ dispose() {
76
+ for (const timer of this.pulseTimers) {
77
+ this.host.clearTimeout(timer);
78
+ }
79
+ this.pulseTimers.clear();
80
+ for (const timer of this.holdTimers.values()) {
81
+ this.host.clearTimeout(timer);
82
+ }
83
+ this.holdTimers.clear();
84
+ this.gates.clear();
85
+ }
86
+ /**
87
+ * A keypress: true now, false {@link KEY_PULSE_MS} later. A keypress on a HELD key ends the hold, or its watchdog
88
+ * would later write a release for a key the pulse already released.
89
+ *
90
+ * @param id the full key state id
91
+ */
92
+ pulse(id) {
93
+ this.clearHoldTimer(id);
94
+ this.host.writeState(id, true);
95
+ const timer = this.host.setTimeout(() => {
96
+ if (timer) {
97
+ this.pulseTimers.delete(timer);
98
+ }
99
+ if (!this.holdTimers.has(id)) {
100
+ this.host.writeState(id, false);
101
+ }
102
+ }, KEY_PULSE_MS);
103
+ if (timer) {
104
+ this.pulseTimers.add(timer);
105
+ }
106
+ }
107
+ /**
108
+ * A keydown holds the key true until its keyup; a watchdog releases it after {@link HOLD_MAX_MS} when the keyup is
109
+ * lost. A repeated keydown re-arms it.
110
+ *
111
+ * @param id the full key state id
112
+ * @param down true for keydown, false for keyup
113
+ */
114
+ hold(id, down) {
115
+ this.host.writeState(id, down);
116
+ this.clearHoldTimer(id);
117
+ if (down) {
118
+ const timer = this.host.setTimeout(() => {
119
+ this.holdTimers.delete(id);
120
+ this.host.writeState(id, false);
121
+ }, HOLD_MAX_MS);
122
+ if (timer) {
123
+ this.holdTimers.set(id, timer);
124
+ }
125
+ }
126
+ }
127
+ /**
128
+ * Disarm the hold watchdog of one key, if it has one.
129
+ *
130
+ * @param id the full key state id
131
+ */
132
+ clearHoldTimer(id) {
133
+ const timer = this.holdTimers.get(id);
134
+ if (timer) {
135
+ this.host.clearTimeout(timer);
136
+ this.holdTimers.delete(id);
137
+ }
138
+ }
139
+ /**
140
+ * The rate gate: {@link MAX_COMMANDS_PER_SECOND} per device, the excess dropped and reported once per
141
+ * {@link RATE_WARN_INTERVAL_MS}. Every accepted command costs three writes plus one when the pulse ends.
142
+ *
143
+ * @param device the emulated Roku
144
+ * @returns true if the command may be applied
145
+ */
146
+ admit(device) {
147
+ const now = Date.now();
148
+ let gate = this.gates.get(device.id);
149
+ if (!gate) {
150
+ gate = new import_rate_gate.RateGate(MAX_COMMANDS_PER_SECOND, now);
151
+ this.gates.set(device.id, gate);
152
+ }
153
+ if (gate.allow(now)) {
154
+ return true;
155
+ }
156
+ if (this.rateWarnings.due(device.id, now)) {
157
+ this.host.warn(
158
+ `Emulated Roku "${device.name}" receives more than ${MAX_COMMANDS_PER_SECOND} commands per second \u2014 dropping the excess (a misbehaving controller?)`
159
+ );
160
+ }
161
+ return false;
162
+ }
163
+ }
164
+ // Annotate the CommonJS export names for ESM import in node:
165
+ 0 && (module.exports = {
166
+ CommandHandler,
167
+ HOLD_MAX_MS,
168
+ KEY_PULSE_MS,
169
+ MAX_COMMANDS_PER_SECOND,
170
+ RATE_WARN_INTERVAL_MS
171
+ });
172
+ //# sourceMappingURL=command-handler.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/command-handler.ts"],
4
+ "sourcesContent": ["import type { CommandEvent } from \"./ecp/ecp-command\";\nimport { commandToStateWrite } from \"./ecp/state-model\";\nimport { LogThrottle } from \"./lib/log-throttle\";\nimport { RateGate } from \"./lib/rate-gate\";\n\n/** How long a keypress pulses its keys.<Key> state true before falling back to false. */\nexport const KEY_PULSE_MS = 50;\n/** Safety cap for a held key: a keydown with no matching keyup resets after this. */\nexport const HOLD_MAX_MS = 30_000;\n/** Commands accepted per second and emulated Roku; the excess is dropped (see lib/rate-gate.ts). */\nexport const MAX_COMMANDS_PER_SECOND = 25;\n/** How often at most the dropped-commands warning repeats per device. */\nexport const RATE_WARN_INTERVAL_MS = 60_000;\n\n/** The emulated Roku a command is for. */\nexport interface CommandTarget {\n /** The id-safe device path segment. */\n readonly id: string;\n /** The configured device name, for the warning the user reads. */\n readonly name: string;\n /** The key names this device's type carries \u2014 only those have a state to write. */\n readonly keys: ReadonlySet<string>;\n}\n\n/** What the handler needs from the adapter: writing, the managed timers and the log. */\nexport interface CommandHost {\n /** Fire-and-forget state write (relative id). */\n writeState(id: string, val: string | boolean): void;\n /**\n * The adapter's managed timeout \u2014 undefined while the adapter shuts down. A property, not a method signature: the\n * repochecker reads a timer method signature as a plain timer call (S5005).\n */\n setTimeout: (callback: () => void, ms: number) => ioBroker.Timeout | undefined;\n /** The adapter's managed clearTimeout. */\n clearTimeout: (timer: ioBroker.Timeout) => void;\n /** A warning the user should see. */\n warn(message: string): void;\n}\n\n/**\n * Turns the ECP commands of every emulated Roku into state writes: `command` / `commandType`, a pulse for a keypress,\n * a hold with a watchdog for keydown/keyup \u2014 behind a rate gate per device, the write-flood protection for the states\n * database. Owns its timers and releases them in {@link dispose}.\n */\nexport class CommandHandler {\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 private readonly gates = new Map<string, RateGate>();\n private readonly rateWarnings = new LogThrottle(RATE_WARN_INTERVAL_MS);\n\n /**\n * @param host the adapter's writer, timers and log\n */\n public constructor(private readonly host: CommandHost) {}\n\n /**\n * Apply a received ECP command to the device's states: record it in `command` / `commandType`, and pulse or hold\n * the standard key if the device carries it. The server logs only what this accepted, so the rate gate covers the\n * log as well as the states database.\n *\n * @param device the emulated Roku the command is for\n * @param cmd the parsed ECP command\n * @returns true if the command was applied, false if the rate gate dropped it\n */\n public apply(device: CommandTarget, cmd: CommandEvent): boolean {\n const write = commandToStateWrite(cmd);\n // The release of a key that is actually HELD never passes the rate gate: a dropped keyup would leave the key true\n // until the watchdog, so the flood protection would falsify the tree. Asked by the held state, not by the request,\n // so a flood of keyups for a key nobody holds still meets the gate.\n const releaseOf = write.holdKey?.value === false ? `${device.id}.keys.${write.holdKey.key}` : null;\n const isRelease = releaseOf !== null && this.holdTimers.has(releaseOf);\n if (!isRelease && !this.admit(device)) {\n return false;\n }\n this.host.writeState(`${device.id}.command`, write.command);\n this.host.writeState(`${device.id}.commandType`, write.commandType);\n // A key only this device's type lacks lands in `command` alone \u2014 there is no state to write.\n if (write.pulseKey && device.keys.has(write.pulseKey)) {\n this.pulse(`${device.id}.keys.${write.pulseKey}`);\n } else if (write.holdKey && device.keys.has(write.holdKey.key)) {\n this.hold(`${device.id}.keys.${write.holdKey.key}`, write.holdKey.value);\n }\n return true;\n }\n\n /** Disarm every timer \u2014 the adapter is going down. */\n public dispose(): void {\n for (const timer of this.pulseTimers) {\n this.host.clearTimeout(timer);\n }\n this.pulseTimers.clear();\n for (const timer of this.holdTimers.values()) {\n this.host.clearTimeout(timer);\n }\n this.holdTimers.clear();\n this.gates.clear();\n }\n\n /**\n * A keypress: true now, false {@link KEY_PULSE_MS} later. A keypress on a HELD key ends the hold, or its watchdog\n * would later write a release for a key the pulse already released.\n *\n * @param id the full key state id\n */\n private pulse(id: string): void {\n this.clearHoldTimer(id);\n this.host.writeState(id, true);\n const timer = this.host.setTimeout(() => {\n if (timer) {\n this.pulseTimers.delete(timer);\n }\n // A keydown inside the pulse window owns the key now \u2014 its keyup writes the release.\n if (!this.holdTimers.has(id)) {\n this.host.writeState(id, false);\n }\n }, KEY_PULSE_MS);\n if (timer) {\n this.pulseTimers.add(timer);\n }\n }\n\n /**\n * A keydown holds the key true until its keyup; a watchdog releases it after {@link HOLD_MAX_MS} when the keyup is\n * lost. A repeated keydown re-arms it.\n *\n * @param id the full key state id\n * @param down true for keydown, false for keyup\n */\n private hold(id: string, down: boolean): void {\n this.host.writeState(id, down);\n this.clearHoldTimer(id);\n if (down) {\n const timer = this.host.setTimeout(() => {\n this.holdTimers.delete(id);\n this.host.writeState(id, false);\n }, HOLD_MAX_MS);\n if (timer) {\n this.holdTimers.set(id, timer);\n }\n }\n }\n\n /**\n * Disarm the hold watchdog of one key, if it has one.\n *\n * @param id the full key state id\n */\n private clearHoldTimer(id: string): void {\n const timer = this.holdTimers.get(id);\n if (timer) {\n this.host.clearTimeout(timer);\n this.holdTimers.delete(id);\n }\n }\n\n /**\n * The rate gate: {@link MAX_COMMANDS_PER_SECOND} per device, the excess dropped and reported once per\n * {@link RATE_WARN_INTERVAL_MS}. Every accepted command costs three writes plus one when the pulse ends.\n *\n * @param device the emulated Roku\n * @returns true if the command may be applied\n */\n private admit(device: CommandTarget): boolean {\n const now = Date.now();\n let gate = this.gates.get(device.id);\n if (!gate) {\n gate = new RateGate(MAX_COMMANDS_PER_SECOND, now);\n this.gates.set(device.id, gate);\n }\n if (gate.allow(now)) {\n return true;\n }\n if (this.rateWarnings.due(device.id, now)) {\n this.host.warn(\n `Emulated Roku \"${device.name}\" 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"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
@@ -29,7 +29,7 @@ var import_device_config = require("./lib/device-config");
29
29
  var import_device_identity = require("./lib/device-identity");
30
30
  var import_i18n = require("./lib/i18n");
31
31
  var import_pure_helpers = require("./lib/pure-helpers");
32
- const ID_EXPRESSION = "(data.name||'').trim().replace(/[^A-Za-z0-9\\-_]/g,'_')";
32
+ const ID_EXPRESSION = `(data.name||'').trim().replace(/${import_pure_helpers.UNSAFE_ID_CHAR.source}/g,'_')`;
33
33
  function buildDeviceForm(usedNames, usedPorts, usedIds) {
34
34
  const nameList = JSON.stringify(usedNames.map((n) => n.trim().toLowerCase()));
35
35
  const portList = JSON.stringify([...usedPorts]);
@@ -98,7 +98,7 @@ function cleanDevice(raw) {
98
98
  }
99
99
  class FakerokuDeviceManagement extends import_dm_utils.DeviceManagement {
100
100
  get objId() {
101
- return `system.adapter.${this.adapter.namespace}`;
101
+ return (0, import_constants.instanceObjectId)(this.adapter.namespace);
102
102
  }
103
103
  /**
104
104
  * Read the device list from the live config object, through the shared normaliser — against
@@ -185,8 +185,7 @@ class FakerokuDeviceManagement extends import_dm_utils.DeviceManagement {
185
185
  * the runtime gives when two names map to one object id.
186
186
  *
187
187
  * The name needs no fallback: a row without a usable one never becomes a DeviceRow
188
- * (lib/device-config.ts drops it), so `Roku <n>` was a branch nothing could reach —
189
- * the mutation run of 1.6.0 found it by having no test that could fail on its removal.
188
+ * (lib/device-config.ts drops it).
190
189
  *
191
190
  * @param device the stored device
192
191
  * @returns the card descriptor
@@ -226,8 +225,8 @@ class FakerokuDeviceManagement extends import_dm_utils.DeviceManagement {
226
225
  };
227
226
  }
228
227
  /**
229
- * Manual add: pre-select a free port, show the form, and append the device with
230
- * a stable derived uuid.
228
+ * Manual add: pre-select a free port, show the form, and append the device with its own
229
+ * random identity and a fixed object id.
231
230
  *
232
231
  * @param context the action context
233
232
  * @returns a directive to reload the manager
@@ -236,7 +235,7 @@ class FakerokuDeviceManagement extends import_dm_utils.DeviceManagement {
236
235
  const devices = await this.readDevices();
237
236
  const usedNames = devices.map((d) => d.name);
238
237
  const usedPorts = devices.map((d) => d.port);
239
- const usedIds = devices.map(import_device_config.deviceObjectId);
238
+ const usedIds = devices.map((d) => d.objectId);
240
239
  const form = buildDeviceForm(usedNames, usedPorts, usedIds);
241
240
  const data = await context.showForm(form.schema, {
242
241
  title: (0, import_i18n.t)("dmAdd"),
@@ -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 { RESERVED_IDS } from \"./lib/constants\";\nimport {\n deviceObjectId,\n deviceTreeOf,\n findClash,\n nextFreePort,\n normalizePort,\n normalizeType,\n toDeviceRows,\n type DeviceRow,\n} from \"./lib/device-config\";\nimport { randomIdentity } from \"./lib/device-identity\";\nimport { t } from \"./lib/i18n\";\nimport { sanitizeId } from \"./lib/pure-helpers\";\n\n/**\n * One emulated Roku as stored in the adapter's native.devices \u2014 the manifest's own\n * element type, so the manager and the runtime (main.ts) read the same shape.\n * `uuid` is the stable SSDP identity, carried through edits so the pairing survives.\n */\ntype RokuDeviceConfig = ioBroker.AdapterConfig[\"devices\"][number];\n\n/** Manager directive: reload the whole view. */\ntype InstanceResult = { refresh: boolean };\n/** Manager directive: reload the device list. */\ntype DeviceResult = { refresh: \"devices\" };\n\n/**\n * The object id a name would take, as JavaScript the admin evaluates inside a validator.\n * Mirrors `sanitizeId` (lib/pure-helpers.ts) \u2014 the dialog has to answer the same question\n * as the runtime, and it cannot call into the adapter's code.\n */\nconst ID_EXPRESSION = \"(data.name||'').trim().replace(/[^A-Za-z0-9\\\\-_]/g,'_')\";\n\n/** The add/edit form for one emulated Roku, and the rule that keeps its OK button off. */\nexport interface DeviceForm {\n /** The jsonConfig panel schema. */\n schema: JsonFormSchema;\n /**\n * The expression the admin evaluates to disable OK (dm-gui-components `getOkButton`: OK is\n * switched off ONLY by this rule or by an unchanged form \u2014 a field validator marks the field,\n * it never stops the button).\n */\n applyDisabledRule: string;\n}\n\n/**\n * The add/edit form for one emulated Roku: name, ECP port and device type.\n *\n * The name rules the backend knows \u2014 not empty, not taken, and for a NEW device not an id that\n * is reserved or that another device already occupies \u2014 and the port rule are expressions the\n * admin evaluates. They mark the field red (`validator`), switch OK off (the returned\n * `applyDisabledRule`) and show the reason as a line of text (`staticText` with `hidden`) \u2014 the\n * text fields show no validator message of their own. `findClash` still answers for anyone\n * bypassing the dialog. 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 * @param usedIds object ids the OTHER devices occupy \u2014 for a new device; `null` when editing,\n * since an existing device keeps its object id whatever it is renamed to\n * @returns the schema and the OK rule\n */\nexport function buildDeviceForm(\n usedNames: readonly string[],\n usedPorts: readonly number[],\n usedIds: readonly string[] | null,\n): DeviceForm {\n const nameList = JSON.stringify(usedNames.map(n => n.trim().toLowerCase()));\n const portList = JSON.stringify([...usedPorts]);\n const nameFilled = \"(data.name||'').trim().length>0\";\n const nameFree = `!${nameList}.includes((data.name||'').trim().toLowerCase())`;\n // Reserved ids first, then the object ids the other devices already occupy.\n const idFree =\n usedIds === null\n ? \"true\"\n : `${ID_EXPRESSION}.length>0 && !${JSON.stringify([...RESERVED_IDS, ...usedIds])}.includes(${ID_EXPRESSION})`;\n const nameOk = `(${nameFilled} && ${nameFree} && ${idFree})`;\n const portOk = `(!${portList}.includes(Number(data.port)))`;\n return {\n applyDisabledRule: `!(${nameOk} && ${portOk})`,\n schema: {\n type: \"panel\",\n items: {\n name: {\n type: \"text\",\n label: t(\"deviceName\"),\n validator: nameOk,\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: portOk,\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 _nameRejected: {\n type: \"staticText\",\n text: t(\"deviceNameRejected\"),\n hidden: `!${nameFilled} || ${nameOk}`,\n style: { color: \"#d32f2f\" },\n sm: 12,\n },\n _portRejected: {\n type: \"staticText\",\n text: t(\"devicePortInUse\"),\n hidden: portOk,\n style: { color: \"#d32f2f\" },\n sm: 12,\n },\n _portHint: { type: \"staticText\", text: t(\"devicePortHint\"), sm: 12 },\n _typeHint: { type: \"staticText\", text: t(\"deviceTypeHint\"), sm: 12 },\n },\n },\n };\n}\n\n/**\n * Normalise raw form values into a clean device (trimmed name, valid port, valid type) so\n * native.devices stays tidy. Same rules as reading a stored row (lib/device-config.ts); the\n * uuid and the object id are 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\" | \"objectId\"> {\n const name = typeof raw.name === \"string\" ? raw.name.trim() : \"\";\n return { name, port: normalizePort(raw.port), type: normalizeType(raw.type) };\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, through the shared normaliser \u2014 against\n * the object tree the installation has, exactly like the runtime, so a row without a stored\n * object id or type resolves to the tree it already owns (lib/device-config.ts).\n *\n * Every row keeps BOTH names: the trimmed one the card shows and the next write stores,\n * and the stored one the identity was resolved from. Nothing here re-derives an identity\n * from the display name \u2014 that is what unpaired the remote before.\n *\n * @returns the configured devices (normalised), or an empty list\n */\n private async readDevices(): Promise<DeviceRow[]> {\n const obj = await this.adapter.getForeignObjectAsync(this.objId);\n const devices = (obj?.native as { devices?: unknown } | undefined)?.devices;\n const objects = await this.adapter.getAdapterObjectsAsync();\n const prefix = `${this.adapter.namespace}.`;\n const relative = new Map<string, string | undefined>();\n for (const [id, o] of Object.entries(objects)) {\n if (id.startsWith(prefix)) {\n relative.set(id.slice(prefix.length), o?.type);\n }\n }\n return (\n toDeviceRows(\n devices,\n deviceTreeOf(relative.keys(), id => relative.get(id)),\n ) ?? []\n );\n }\n\n /**\n * Persist the device list.\n *\n * \u26A0\uFE0F The full array only replaces the stored one because the key is literally called\n * `devices`: js-controller clears the old array before merging for exactly four names\n * (`common.members`, `native.repositories`, `native.certificates`, `native.devices` \u2014\n * adapter.ts `_extendForeignObject`). Under any other name `extend(true, \u2026)` would merge\n * arrays element-wise, and deleting the second of two devices would leave both in place.\n * Renaming this config key silently breaks deletion; `device-management.test.ts` pins it.\n *\n * Writing `native.*` restarts the adapter, which re-creates the object trees and servers.\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 * The stored shape of a row the user did NOT touch: its name exactly as it was, the\n * normalised port and type, and the identity and object id resolved when the row was read.\n *\n * The name stays untouched on purpose. Writing the trimmed form would move that device's\n * object id on the next start \u2014 a tree wandering, and every script pointing into it\n * breaking, because someone edited a different card. Trimming happens where the user\n * pressed save, nowhere else. Persisting the identity is safe by contrast: it is exactly\n * the value the runtime is already advertising for that row, just no longer derived \u2014 and the\n * same holds for the object id and for a type read from the tree.\n *\n * @param row the normalised row\n * @returns the row as it is persisted\n */\n private static toStored(row: DeviceRow): RokuDeviceConfig {\n return { name: row.storedName, port: row.port, type: row.type, uuid: row.identity, objectId: row.objectId };\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 for (const device of devices) {\n context.addDevice(this.toDeviceInfo(device));\n }\n }\n\n /**\n * Build one device card. The model (Player/TV) and the ECP port each get their\n * own line \u2014 the port via `identifier` (labelled in getInstanceInfo). No\n * manufacturer line: it is always \"Roku\" and tells the user nothing for an emulator.\n *\n * The card is keyed by the device's SSDP identity, not by its list position: a position\n * is only valid for as long as the view is fresh, so a second admin tab (or a list that\n * changed underneath) would send edit/delete to a different device than the card clicked.\n * Two hand-edited rows can share an identity; the first one wins, which is the same answer\n * the runtime gives when two names map to one object id.\n *\n * The name needs no fallback: a row without a usable one never becomes a DeviceRow\n * (lib/device-config.ts drops it), so `Roku <n>` was a branch nothing could reach \u2014\n * the mutation run of 1.6.0 found it by having no test that could fail on its removal.\n *\n * @param device the stored device\n * @returns the card descriptor\n */\n private toDeviceInfo(device: DeviceRow): DeviceInfo<string> {\n return {\n id: device.identity,\n name: device.name,\n identifier: String(device.port),\n model: t(device.type === \"tv\" ? \"deviceTypeTv\" : \"deviceTypePlayer\"),\n actions: [\n {\n id: \"edit\",\n icon: \"edit\",\n description: t(\"dmEdit\"),\n handler: async (id: string, context: ActionContext) => this.editDevice(id, context),\n },\n {\n id: \"delete\",\n icon: \"delete\",\n description: t(\"dmDelete\"),\n handler: async (id: string, context: ActionContext) => this.deleteDevice(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 => d.port);\n const usedIds = devices.map(deviceObjectId);\n const form = buildDeviceForm(usedNames, usedPorts, usedIds);\n const data = await context.showForm(form.schema, {\n title: t(\"dmAdd\"),\n data: { type: \"player\", port: nextFreePort(usedPorts) },\n applyDisabledRule: form.applyDisabledRule,\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 const stored = devices.map(d => FakerokuDeviceManagement.toStored(d));\n // A new device has never been announced: it gets its own identity (not one derived from\n // its name, which every installation with the same name would share) and its object id\n // is fixed from now on.\n stored.push({ ...clean, uuid: randomIdentity(), objectId: sanitizeId(clean.name) });\n await this.writeDevices(stored);\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 identity is preserved so the pairing survives a rename.\n *\n * The identity comes from the row as it was READ (lib/device-config.ts resolves it from\n * the stored name), never derived here: deriving it from the name in the form would move\n * the SSDP identity on a plain rename \u2014 and deriving it from the trimmed stored name would\n * move it even when the user changed nothing at all.\n *\n * @param cardId the card's id \u2014 the device's identity\n * @param context the action context\n * @returns a directive to reload the list\n */\n private async editDevice(cardId: string, context: ActionContext): Promise<DeviceResult> {\n const devices = await this.readDevices();\n const index = devices.findIndex(d => d.identity === cardId);\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 => d.port);\n // The object id stays whatever the device is renamed to \u2014 no id rule for an edit.\n const form = buildDeviceForm(usedNames, usedPorts, null);\n const data = await context.showForm(form.schema, {\n title: t(\"dmEditTitle\"),\n data: { name: current.name, port: current.port, type: current.type },\n applyDisabledRule: form.applyDisabledRule,\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 const stored = devices.map(d => FakerokuDeviceManagement.toStored(d));\n // Same identity (the remote stays paired), same object id (the tree, its rooms and history\n // stay where they are) \u2014 a rename changes the name the card and the object show.\n stored[index] = { ...clean, uuid: current.identity, objectId: current.objectId };\n await this.writeDevices(stored);\n }\n return { refresh: \"devices\" };\n }\n\n /**\n * Delete a device after confirmation.\n *\n * @param cardId the card's id \u2014 the device's identity\n * @param context the action context\n * @returns a directive to reload the list\n */\n private async deleteDevice(cardId: string, context: ActionContext): Promise<DeviceResult> {\n const devices = await this.readDevices();\n const index = devices.findIndex(d => d.identity === cardId);\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 const stored = devices.map(d => FakerokuDeviceManagement.toStored(d));\n stored.splice(index, 1);\n await this.writeDevices(stored);\n }\n return { refresh: \"devices\" };\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import {\n DeviceManagement,\n type ActionContext,\n type DeviceInfo,\n type DeviceLoadContext,\n type JsonFormSchema,\n} from \"@iobroker/dm-utils\";\nimport { instanceObjectId, RESERVED_IDS } from \"./lib/constants\";\nimport {\n deviceTreeOf,\n findClash,\n nextFreePort,\n normalizePort,\n normalizeType,\n toDeviceRows,\n type DeviceRow,\n} from \"./lib/device-config\";\nimport { randomIdentity } from \"./lib/device-identity\";\nimport { t } from \"./lib/i18n\";\nimport { sanitizeId, UNSAFE_ID_CHAR } from \"./lib/pure-helpers\";\n\n/**\n * One emulated Roku as stored in the adapter's native.devices \u2014 the manifest's own\n * element type, so the manager and the runtime (main.ts) read the same shape.\n * `uuid` is the stable SSDP identity, carried through edits so the pairing survives.\n */\ntype RokuDeviceConfig = ioBroker.AdapterConfig[\"devices\"][number];\n\n/** Manager directive: reload the whole view. */\ntype InstanceResult = { refresh: boolean };\n/** Manager directive: reload the device list. */\ntype DeviceResult = { refresh: \"devices\" };\n\n/**\n * The object id a name would take, as JavaScript the admin evaluates inside a validator \u2014 built from the same pattern\n * as `sanitizeId`, because the dialog has to answer the same question as the runtime and cannot call into its code.\n */\nconst ID_EXPRESSION = `(data.name||'').trim().replace(/${UNSAFE_ID_CHAR.source}/g,'_')`;\n\n/** The add/edit form for one emulated Roku, and the rule that keeps its OK button off. */\nexport interface DeviceForm {\n /** The jsonConfig panel schema. */\n schema: JsonFormSchema;\n /**\n * The expression the admin evaluates to disable OK (dm-gui-components `getOkButton`: OK is\n * switched off ONLY by this rule or by an unchanged form \u2014 a field validator marks the field,\n * it never stops the button).\n */\n applyDisabledRule: string;\n}\n\n/**\n * The add/edit form for one emulated Roku: name, ECP port and device type.\n *\n * The name rules the backend knows \u2014 not empty, not taken, and for a NEW device not an id that\n * is reserved or that another device already occupies \u2014 and the port rule are expressions the\n * admin evaluates. They mark the field red (`validator`), switch OK off (the returned\n * `applyDisabledRule`) and show the reason as a line of text (`staticText` with `hidden`) \u2014 the\n * text fields show no validator message of their own. `findClash` still answers for anyone\n * bypassing the dialog. 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 * @param usedIds object ids the OTHER devices occupy \u2014 for a new device; `null` when editing,\n * since an existing device keeps its object id whatever it is renamed to\n * @returns the schema and the OK rule\n */\nexport function buildDeviceForm(\n usedNames: readonly string[],\n usedPorts: readonly number[],\n usedIds: readonly string[] | null,\n): DeviceForm {\n const nameList = JSON.stringify(usedNames.map(n => n.trim().toLowerCase()));\n const portList = JSON.stringify([...usedPorts]);\n const nameFilled = \"(data.name||'').trim().length>0\";\n const nameFree = `!${nameList}.includes((data.name||'').trim().toLowerCase())`;\n // Reserved ids first, then the object ids the other devices already occupy.\n const idFree =\n usedIds === null\n ? \"true\"\n : `${ID_EXPRESSION}.length>0 && !${JSON.stringify([...RESERVED_IDS, ...usedIds])}.includes(${ID_EXPRESSION})`;\n const nameOk = `(${nameFilled} && ${nameFree} && ${idFree})`;\n const portOk = `(!${portList}.includes(Number(data.port)))`;\n return {\n applyDisabledRule: `!(${nameOk} && ${portOk})`,\n schema: {\n type: \"panel\",\n items: {\n name: {\n type: \"text\",\n label: t(\"deviceName\"),\n validator: nameOk,\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: portOk,\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 _nameRejected: {\n type: \"staticText\",\n text: t(\"deviceNameRejected\"),\n hidden: `!${nameFilled} || ${nameOk}`,\n style: { color: \"#d32f2f\" },\n sm: 12,\n },\n _portRejected: {\n type: \"staticText\",\n text: t(\"devicePortInUse\"),\n hidden: portOk,\n style: { color: \"#d32f2f\" },\n sm: 12,\n },\n _portHint: { type: \"staticText\", text: t(\"devicePortHint\"), sm: 12 },\n _typeHint: { type: \"staticText\", text: t(\"deviceTypeHint\"), sm: 12 },\n },\n },\n };\n}\n\n/**\n * Normalise raw form values into a clean device (trimmed name, valid port, valid type) so\n * native.devices stays tidy. Same rules as reading a stored row (lib/device-config.ts); the\n * uuid and the object id are 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\" | \"objectId\"> {\n const name = typeof raw.name === \"string\" ? raw.name.trim() : \"\";\n return { name, port: normalizePort(raw.port), type: normalizeType(raw.type) };\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 instanceObjectId(this.adapter.namespace);\n }\n\n /**\n * Read the device list from the live config object, through the shared normaliser \u2014 against\n * the object tree the installation has, exactly like the runtime, so a row without a stored\n * object id or type resolves to the tree it already owns (lib/device-config.ts).\n *\n * Every row keeps BOTH names: the trimmed one the card shows and the next write stores,\n * and the stored one the identity was resolved from. Nothing here re-derives an identity\n * from the display name \u2014 that is what unpaired the remote before.\n *\n * @returns the configured devices (normalised), or an empty list\n */\n private async readDevices(): Promise<DeviceRow[]> {\n const obj = await this.adapter.getForeignObjectAsync(this.objId);\n const devices = (obj?.native as { devices?: unknown } | undefined)?.devices;\n const objects = await this.adapter.getAdapterObjectsAsync();\n const prefix = `${this.adapter.namespace}.`;\n const relative = new Map<string, string | undefined>();\n for (const [id, o] of Object.entries(objects)) {\n if (id.startsWith(prefix)) {\n relative.set(id.slice(prefix.length), o?.type);\n }\n }\n return (\n toDeviceRows(\n devices,\n deviceTreeOf(relative.keys(), id => relative.get(id)),\n ) ?? []\n );\n }\n\n /**\n * Persist the device list.\n *\n * \u26A0\uFE0F The full array only replaces the stored one because the key is literally called\n * `devices`: js-controller clears the old array before merging for exactly four names\n * (`common.members`, `native.repositories`, `native.certificates`, `native.devices` \u2014\n * adapter.ts `_extendForeignObject`). Under any other name `extend(true, \u2026)` would merge\n * arrays element-wise, and deleting the second of two devices would leave both in place.\n * Renaming this config key silently breaks deletion; `device-management.test.ts` pins it.\n *\n * Writing `native.*` restarts the adapter, which re-creates the object trees and servers.\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 * The stored shape of a row the user did NOT touch: its name exactly as it was, the\n * normalised port and type, and the identity and object id resolved when the row was read.\n *\n * The name stays untouched on purpose. Writing the trimmed form would move that device's\n * object id on the next start \u2014 a tree wandering, and every script pointing into it\n * breaking, because someone edited a different card. Trimming happens where the user\n * pressed save, nowhere else. Persisting the identity is safe by contrast: it is exactly\n * the value the runtime is already advertising for that row, just no longer derived \u2014 and the\n * same holds for the object id and for a type read from the tree.\n *\n * @param row the normalised row\n * @returns the row as it is persisted\n */\n private static toStored(row: DeviceRow): RokuDeviceConfig {\n return { name: row.storedName, port: row.port, type: row.type, uuid: row.identity, objectId: row.objectId };\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 for (const device of devices) {\n context.addDevice(this.toDeviceInfo(device));\n }\n }\n\n /**\n * Build one device card. The model (Player/TV) and the ECP port each get their\n * own line \u2014 the port via `identifier` (labelled in getInstanceInfo). No\n * manufacturer line: it is always \"Roku\" and tells the user nothing for an emulator.\n *\n * The card is keyed by the device's SSDP identity, not by its list position: a position\n * is only valid for as long as the view is fresh, so a second admin tab (or a list that\n * changed underneath) would send edit/delete to a different device than the card clicked.\n * Two hand-edited rows can share an identity; the first one wins, which is the same answer\n * the runtime gives when two names map to one object id.\n *\n * The name needs no fallback: a row without a usable one never becomes a DeviceRow\n * (lib/device-config.ts drops it).\n *\n * @param device the stored device\n * @returns the card descriptor\n */\n private toDeviceInfo(device: DeviceRow): DeviceInfo<string> {\n return {\n id: device.identity,\n name: device.name,\n identifier: String(device.port),\n model: t(device.type === \"tv\" ? \"deviceTypeTv\" : \"deviceTypePlayer\"),\n actions: [\n {\n id: \"edit\",\n icon: \"edit\",\n description: t(\"dmEdit\"),\n handler: async (id: string, context: ActionContext) => this.editDevice(id, context),\n },\n {\n id: \"delete\",\n icon: \"delete\",\n description: t(\"dmDelete\"),\n handler: async (id: string, context: ActionContext) => this.deleteDevice(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 its own\n * random identity and a fixed object id.\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 => d.port);\n const usedIds = devices.map(d => d.objectId);\n const form = buildDeviceForm(usedNames, usedPorts, usedIds);\n const data = await context.showForm(form.schema, {\n title: t(\"dmAdd\"),\n data: { type: \"player\", port: nextFreePort(usedPorts) },\n applyDisabledRule: form.applyDisabledRule,\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 const stored = devices.map(d => FakerokuDeviceManagement.toStored(d));\n // A new device has never been announced: it gets its own identity (not one derived from\n // its name, which every installation with the same name would share) and its object id\n // is fixed from now on.\n stored.push({ ...clean, uuid: randomIdentity(), objectId: sanitizeId(clean.name) });\n await this.writeDevices(stored);\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 identity is preserved so the pairing survives a rename.\n *\n * The identity comes from the row as it was READ (lib/device-config.ts resolves it from\n * the stored name), never derived here: deriving it from the name in the form would move\n * the SSDP identity on a plain rename \u2014 and deriving it from the trimmed stored name would\n * move it even when the user changed nothing at all.\n *\n * @param cardId the card's id \u2014 the device's identity\n * @param context the action context\n * @returns a directive to reload the list\n */\n private async editDevice(cardId: string, context: ActionContext): Promise<DeviceResult> {\n const devices = await this.readDevices();\n const index = devices.findIndex(d => d.identity === cardId);\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 => d.port);\n // The object id stays whatever the device is renamed to \u2014 no id rule for an edit.\n const form = buildDeviceForm(usedNames, usedPorts, null);\n const data = await context.showForm(form.schema, {\n title: t(\"dmEditTitle\"),\n data: { name: current.name, port: current.port, type: current.type },\n applyDisabledRule: form.applyDisabledRule,\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 const stored = devices.map(d => FakerokuDeviceManagement.toStored(d));\n // Same identity (the remote stays paired), same object id (the tree, its rooms and history\n // stay where they are) \u2014 a rename changes the name the card and the object show.\n stored[index] = { ...clean, uuid: current.identity, objectId: current.objectId };\n await this.writeDevices(stored);\n }\n return { refresh: \"devices\" };\n }\n\n /**\n * Delete a device after confirmation.\n *\n * @param cardId the card's id \u2014 the device's identity\n * @param context the action context\n * @returns a directive to reload the list\n */\n private async deleteDevice(cardId: string, context: ActionContext): Promise<DeviceResult> {\n const devices = await this.readDevices();\n const index = devices.findIndex(d => d.identity === cardId);\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 const stored = devices.map(d => FakerokuDeviceManagement.toStored(d));\n stored.splice(index, 1);\n await this.writeDevices(stored);\n }\n return { refresh: \"devices\" };\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }