iobroker.fakeroku 1.4.0 → 1.6.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 +20 -11
- package/admin/i18n/de.json +1 -0
- package/admin/i18n/en.json +1 -0
- package/admin/i18n/es.json +1 -0
- package/admin/i18n/fr.json +1 -0
- package/admin/i18n/it.json +1 -0
- package/admin/i18n/nl.json +1 -0
- package/admin/i18n/pl.json +1 -0
- package/admin/i18n/pt.json +1 -0
- package/admin/i18n/ru.json +1 -0
- package/admin/i18n/uk.json +1 -0
- package/admin/i18n/zh-cn.json +1 -0
- package/build/device-management.js +86 -84
- package/build/device-management.js.map +2 -2
- package/build/discovery/ssdp-messages.js +23 -9
- package/build/discovery/ssdp-messages.js.map +2 -2
- package/build/discovery/ssdp-responder.js +80 -4
- package/build/discovery/ssdp-responder.js.map +2 -2
- package/build/ecp/device-info.js +12 -2
- package/build/ecp/device-info.js.map +2 -2
- package/build/ecp/ecp-http-server.js +39 -5
- package/build/ecp/ecp-http-server.js.map +2 -2
- package/build/lib/detect-ip.js +15 -3
- package/build/lib/detect-ip.js.map +2 -2
- package/build/lib/device-config.js +119 -0
- package/build/lib/device-config.js.map +7 -0
- package/build/lib/object-cleanup.js +14 -0
- package/build/lib/object-cleanup.js.map +2 -2
- package/build/main.js +459 -164
- package/build/main.js.map +3 -3
- package/io-package.json +28 -29
- package/package.json +10 -8
package/README.md
CHANGED
|
@@ -92,6 +92,26 @@ becomes `true` — or watch `.command` for the last button as text.
|
|
|
92
92
|
Placeholder for the next version (at the beginning of the line):
|
|
93
93
|
### **WORK IN PROGRESS**
|
|
94
94
|
-->
|
|
95
|
+
|
|
96
|
+
### 1.6.0 (2026-09-07)
|
|
97
|
+
|
|
98
|
+
- (krobipd) Fixed: saving a device in the admin could change its identity on the network, so a paired Harmony or Sofabaton lost it.
|
|
99
|
+
- (krobipd) Fixed: with the device list open twice, editing or deleting a card could hit a different emulated Roku than the one clicked.
|
|
100
|
+
- (krobipd) Fixed: releasing a key was dropped while the adapter shed a flood of commands, so the key could stay pressed for half a minute.
|
|
101
|
+
- (krobipd) Fixed: an ECP port still held after a restart left that device dead until you restarted the instance; it is retried every minute now.
|
|
102
|
+
- (krobipd) Fixed: stopping the instance now takes the emulated Rokus out of the remote's list instead of leaving them there for up to an hour.
|
|
103
|
+
- (krobipd) Fixed: an emulated Roku whose server died is no longer offered for discovery.
|
|
104
|
+
- (krobipd) Fixed: a configured port no server can bind falls back to 8060 instead of leaving the device unstarted.
|
|
105
|
+
- (krobipd) Changed: the device dialog refuses a reserved or colliding name right away instead of reporting it after saving.
|
|
106
|
+
- (krobipd) Changed: the adapter can now run in compact mode, sharing one process with other adapters instead of claiming its own.
|
|
107
|
+
- (krobipd) Changed: more than one instance may run on the same machine again; only the ports have to differ.
|
|
108
|
+
|
|
109
|
+
### 1.5.0 (2026-09-03)
|
|
110
|
+
|
|
111
|
+
- (krobipd) Fixed: deleting the last emulated Roku left all of its datapoints behind for good. They are now removed whenever the configuration says a device is gone.
|
|
112
|
+
- (krobipd) Fixed: on a host running Docker the adapter could announce itself under a container address no remote can reach. A real network address is preferred now.
|
|
113
|
+
- (krobipd) Fixed: an emulated Roku whose server died while running left the instance showing "connected". It now reports the failure and names the device.
|
|
114
|
+
|
|
95
115
|
### 1.4.0 (2026-09-03)
|
|
96
116
|
- (krobipd) Fixed: renaming an emulated Roku could change its identity on the network, so a paired Harmony or Sofabaton lost the device and had to be set up again.
|
|
97
117
|
- (krobipd) Fixed: a remote key that was pressed when the adapter stopped stayed on for good. All key datapoints are now released at start-up, so the next press works again.
|
|
@@ -114,17 +134,6 @@ becomes `true` — or watch `.command` for the last button as text.
|
|
|
114
134
|
- (krobipd) Improved: shutdown now waits for its last write to land before reporting done, so the connection state cannot be lost on a slow or busy system.
|
|
115
135
|
- (krobipd) New: optional error reporting via Sentry — only active if you enabled diagnostics in ioBroker, and it transmits no personal data.
|
|
116
136
|
|
|
117
|
-
### 1.1.0 (2026-08-10) — stable
|
|
118
|
-
- (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.
|
|
119
|
-
- (krobipd) The adapter keeps serving already-paired remotes, and reports its status correctly, even when network discovery cannot start or later drops out.
|
|
120
|
-
- (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.
|
|
121
|
-
- (krobipd) The device dialog now refuses a name that would clash with another device or a reserved name, preventing a naming conflict.
|
|
122
|
-
- (krobipd) The adapter description now reads correctly in the non-English and non-German admin languages.
|
|
123
|
-
|
|
124
|
-
### 1.0.0 (2026-08-05)
|
|
125
|
-
- (krobipd) First stable release — version 1.0.0 marks the complete rewrite as the mature, supported version of the adapter.
|
|
126
|
-
- (krobipd) Upgrading from an older version now shows a one-time notice that the button data points changed from text to real boolean values, so scripts and visualizations can be checked.
|
|
127
|
-
|
|
128
137
|
[Older changelogs can be found there](CHANGELOG_OLD.md)
|
|
129
138
|
|
|
130
139
|
## History
|
package/admin/i18n/de.json
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"deviceNameInUse": "Dieser Name wird bereits von einem anderen Gerät verwendet.",
|
|
21
21
|
"devicePortInUse": "Dieser Port wird bereits von einem anderen Roku verwendet.",
|
|
22
22
|
"deviceNameInvalid": "Dieser Name kann nicht verwendet werden — er ist reserviert oder ergibt dieselbe Objekt-ID wie ein anderes Gerät.",
|
|
23
|
+
"deviceNameRejected": "Dieser Name ist nicht verwendbar: bereits vergeben, für den Adapter reserviert oder er ergibt dieselbe Objekt-ID wie ein anderes Gerät.",
|
|
23
24
|
"supportHeader": "Unterstützen",
|
|
24
25
|
"aboutInfo": "Wenn dir dieser Adapter nützt, hilft eine kleine Spende, ihn zu pflegen — danke!",
|
|
25
26
|
"donateKofi": "Ko-fi",
|
package/admin/i18n/en.json
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"deviceNameInUse": "This name is already used by another device.",
|
|
21
21
|
"devicePortInUse": "This port is already used by another Roku.",
|
|
22
22
|
"deviceNameInvalid": "This name can't be used — it is reserved or maps to the same object id as another device.",
|
|
23
|
+
"deviceNameRejected": "This name can't be used: it is already taken, reserved for the adapter, or maps to the same object id as another device.",
|
|
23
24
|
"supportHeader": "Support",
|
|
24
25
|
"aboutInfo": "If this adapter is useful to you, a small donation helps keep it maintained — thank you!",
|
|
25
26
|
"donateKofi": "Ko-fi",
|
package/admin/i18n/es.json
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"deviceNameInUse": "Este nombre ya lo usa otro dispositivo.",
|
|
21
21
|
"devicePortInUse": "Este puerto ya lo usa otro Roku.",
|
|
22
22
|
"deviceNameInvalid": "Este nombre no se puede usar — está reservado o genera el mismo id de objeto que otro dispositivo.",
|
|
23
|
+
"deviceNameRejected": "Este nombre no se puede usar: ya está en uso, está reservado para el adaptador o genera el mismo id de objeto que otro dispositivo.",
|
|
23
24
|
"supportHeader": "Apoyar",
|
|
24
25
|
"aboutInfo": "Si este adaptador te resulta útil, una pequeña donación ayuda a mantenerlo — ¡gracias!",
|
|
25
26
|
"donateKofi": "Ko-fi",
|
package/admin/i18n/fr.json
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"deviceNameInUse": "Ce nom est déjà utilisé par un autre appareil.",
|
|
21
21
|
"devicePortInUse": "Ce port est déjà utilisé par un autre Roku.",
|
|
22
22
|
"deviceNameInvalid": "Ce nom ne peut pas être utilisé — il est réservé ou produit le même identifiant d'objet qu'un autre appareil.",
|
|
23
|
+
"deviceNameRejected": "Ce nom ne peut pas être utilisé : il est déjà pris, réservé à l'adaptateur ou donne le même identifiant d'objet qu'un autre appareil.",
|
|
23
24
|
"supportHeader": "Soutenir",
|
|
24
25
|
"aboutInfo": "Si cet adaptateur vous est utile, un petit don aide à le maintenir — merci !",
|
|
25
26
|
"donateKofi": "Ko-fi",
|
package/admin/i18n/it.json
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"deviceNameInUse": "Questo nome è già usato da un altro dispositivo.",
|
|
21
21
|
"devicePortInUse": "Questa porta è già usata da un altro Roku.",
|
|
22
22
|
"deviceNameInvalid": "Questo nome non può essere usato — è riservato o produce lo stesso id oggetto di un altro dispositivo.",
|
|
23
|
+
"deviceNameRejected": "Questo nome non può essere usato: è già in uso, riservato all'adattatore oppure produce lo stesso id oggetto di un altro dispositivo.",
|
|
23
24
|
"supportHeader": "Sostieni",
|
|
24
25
|
"aboutInfo": "Se questo adattatore ti è utile, una piccola donazione aiuta a mantenerlo — grazie!",
|
|
25
26
|
"donateKofi": "Ko-fi",
|
package/admin/i18n/nl.json
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"deviceNameInUse": "Deze naam wordt al door een ander apparaat gebruikt.",
|
|
21
21
|
"devicePortInUse": "Deze poort wordt al door een andere Roku gebruikt.",
|
|
22
22
|
"deviceNameInvalid": "Deze naam kan niet worden gebruikt — hij is gereserveerd of levert dezelfde object-id op als een ander apparaat.",
|
|
23
|
+
"deviceNameRejected": "Deze naam kan niet worden gebruikt: hij is al in gebruik, gereserveerd voor de adapter of levert dezelfde object-id op als een ander apparaat.",
|
|
23
24
|
"supportHeader": "Ondersteunen",
|
|
24
25
|
"aboutInfo": "Als deze adapter nuttig voor je is, helpt een kleine donatie om hem te onderhouden — bedankt!",
|
|
25
26
|
"donateKofi": "Ko-fi",
|
package/admin/i18n/pl.json
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"deviceNameInUse": "Ta nazwa jest już używana przez inne urządzenie.",
|
|
21
21
|
"devicePortInUse": "Ten port jest już używany przez innego Roku.",
|
|
22
22
|
"deviceNameInvalid": "Tej nazwy nie można użyć — jest zarezerwowana lub daje ten sam identyfikator obiektu co inne urządzenie.",
|
|
23
|
+
"deviceNameRejected": "Tej nazwy nie można użyć: jest już zajęta, zarezerwowana dla adaptera lub daje ten sam identyfikator obiektu co inne urządzenie.",
|
|
23
24
|
"supportHeader": "Wsparcie",
|
|
24
25
|
"aboutInfo": "Jeśli ten adapter jest dla Ciebie przydatny, mała darowizna pomaga go utrzymać — dziękuję!",
|
|
25
26
|
"donateKofi": "Ko-fi",
|
package/admin/i18n/pt.json
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"deviceNameInUse": "Este nome já é usado por outro dispositivo.",
|
|
21
21
|
"devicePortInUse": "Esta porta já é usada por outro Roku.",
|
|
22
22
|
"deviceNameInvalid": "Este nome não pode ser usado — é reservado ou gera o mesmo id de objeto de outro dispositivo.",
|
|
23
|
+
"deviceNameRejected": "Este nome não pode ser usado: já está em uso, é reservado pelo adaptador ou resulta no mesmo id de objeto de outro dispositivo.",
|
|
23
24
|
"supportHeader": "Apoiar",
|
|
24
25
|
"aboutInfo": "Se este adaptador lhe é útil, um pequeno donativo ajuda a mantê-lo — obrigado!",
|
|
25
26
|
"donateKofi": "Ko-fi",
|
package/admin/i18n/ru.json
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"deviceNameInUse": "Это имя уже используется другим устройством.",
|
|
21
21
|
"devicePortInUse": "Этот порт уже используется другим Roku.",
|
|
22
22
|
"deviceNameInvalid": "Это имя нельзя использовать — оно зарезервировано или даёт такой же идентификатор, как у другого устройства.",
|
|
23
|
+
"deviceNameRejected": "Это имя нельзя использовать: оно уже занято, зарезервировано адаптером или даёт тот же идентификатор объекта, что и другое устройство.",
|
|
23
24
|
"supportHeader": "Поддержать",
|
|
24
25
|
"aboutInfo": "Если адаптер вам полезен, небольшое пожертвование помогает его поддерживать — спасибо!",
|
|
25
26
|
"donateKofi": "Ko-fi",
|
package/admin/i18n/uk.json
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"deviceNameInUse": "Це ім'я вже використовується іншим пристроєм.",
|
|
21
21
|
"devicePortInUse": "Цей порт уже використовується іншим Roku.",
|
|
22
22
|
"deviceNameInvalid": "Це ім'я не можна використати — воно зарезервоване або дає такий самий ідентифікатор об'єкта, як інший пристрій.",
|
|
23
|
+
"deviceNameRejected": "Це ім'я не можна використати: воно вже зайняте, зарезервоване для адаптера або дає той самий ідентифікатор об'єкта, що й інший пристрій.",
|
|
23
24
|
"supportHeader": "Підтримати",
|
|
24
25
|
"aboutInfo": "Якщо цей адаптер вам корисний, невелика пожертва допомагає його підтримувати — дякую!",
|
|
25
26
|
"donateKofi": "Ko-fi",
|
package/admin/i18n/zh-cn.json
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"deviceNameInUse": "此名称已被另一台设备使用。",
|
|
21
21
|
"devicePortInUse": "此端口已被另一台 Roku 使用。",
|
|
22
22
|
"deviceNameInvalid": "无法使用此名称——它是保留名称,或与另一设备生成相同的对象 id。",
|
|
23
|
+
"deviceNameRejected": "此名称无法使用:它已被占用、为适配器保留,或与另一设备生成相同的对象 ID。",
|
|
23
24
|
"supportHeader": "支持",
|
|
24
25
|
"aboutInfo": "如果此适配器对您有用,一点捐赠有助于维护它——谢谢!",
|
|
25
26
|
"donateKofi": "Ko-fi",
|
|
@@ -20,26 +20,19 @@ var device_management_exports = {};
|
|
|
20
20
|
__export(device_management_exports, {
|
|
21
21
|
FakerokuDeviceManagement: () => FakerokuDeviceManagement,
|
|
22
22
|
buildDeviceForm: () => buildDeviceForm,
|
|
23
|
-
cleanDevice: () => cleanDevice
|
|
24
|
-
findClash: () => findClash,
|
|
25
|
-
nextFreePort: () => nextFreePort
|
|
23
|
+
cleanDevice: () => cleanDevice
|
|
26
24
|
});
|
|
27
25
|
module.exports = __toCommonJS(device_management_exports);
|
|
28
26
|
var import_dm_utils = require("@iobroker/dm-utils");
|
|
29
27
|
var import_constants = require("./lib/constants");
|
|
28
|
+
var import_device_config = require("./lib/device-config");
|
|
30
29
|
var import_device_identity = require("./lib/device-identity");
|
|
31
30
|
var import_i18n = require("./lib/i18n");
|
|
32
31
|
var import_pure_helpers = require("./lib/pure-helpers");
|
|
33
|
-
|
|
34
|
-
const taken = new Set(usedPorts);
|
|
35
|
-
let port = import_constants.DEFAULT_ECP_PORT;
|
|
36
|
-
while (taken.has(port)) {
|
|
37
|
-
port++;
|
|
38
|
-
}
|
|
39
|
-
return port;
|
|
40
|
-
}
|
|
32
|
+
const ID_EXPRESSION = "(data.name||'').trim().replace(/[^A-Za-z0-9\\-_]/g,'_')";
|
|
41
33
|
function buildDeviceForm(usedNames, usedPorts) {
|
|
42
34
|
const nameList = JSON.stringify(usedNames.map((n) => n.trim().toLowerCase()));
|
|
35
|
+
const idList = JSON.stringify([...import_constants.RESERVED_IDS, ...usedNames.map((n) => (0, import_pure_helpers.sanitizeId)(n.trim()))]);
|
|
43
36
|
const portList = JSON.stringify([...usedPorts]);
|
|
44
37
|
return {
|
|
45
38
|
type: "panel",
|
|
@@ -47,8 +40,8 @@ function buildDeviceForm(usedNames, usedPorts) {
|
|
|
47
40
|
name: {
|
|
48
41
|
type: "text",
|
|
49
42
|
label: (0, import_i18n.t)("deviceName"),
|
|
50
|
-
validator:
|
|
51
|
-
validatorErrorText: (0, import_i18n.t)("
|
|
43
|
+
validator: `${ID_EXPRESSION}.length>0 && !${nameList}.includes((data.name||'').trim().toLowerCase()) && !${idList}.includes(${ID_EXPRESSION})`,
|
|
44
|
+
validatorErrorText: (0, import_i18n.t)("deviceNameRejected"),
|
|
52
45
|
validatorNoSaveOnError: true,
|
|
53
46
|
sm: 12,
|
|
54
47
|
md: 6
|
|
@@ -82,62 +75,60 @@ function buildDeviceForm(usedNames, usedPorts) {
|
|
|
82
75
|
}
|
|
83
76
|
function cleanDevice(raw) {
|
|
84
77
|
const name = typeof raw.name === "string" ? raw.name.trim() : "";
|
|
85
|
-
|
|
86
|
-
const type = raw.type === "tv" ? "tv" : "player";
|
|
87
|
-
return { name, port, type };
|
|
88
|
-
}
|
|
89
|
-
function findClash(devices, candidate, exceptIndex) {
|
|
90
|
-
const name = candidate.name.trim().toLowerCase();
|
|
91
|
-
const id = (0, import_pure_helpers.sanitizeId)(candidate.name.trim());
|
|
92
|
-
if (id === "" || import_constants.RESERVED_IDS.has(id)) {
|
|
93
|
-
return (0, import_i18n.t)("deviceNameInvalid");
|
|
94
|
-
}
|
|
95
|
-
for (let i = 0; i < devices.length; i++) {
|
|
96
|
-
if (i === exceptIndex) {
|
|
97
|
-
continue;
|
|
98
|
-
}
|
|
99
|
-
if (devices[i].name.trim().toLowerCase() === name) {
|
|
100
|
-
return (0, import_i18n.t)("deviceNameInUse");
|
|
101
|
-
}
|
|
102
|
-
if ((0, import_pure_helpers.sanitizeId)(devices[i].name.trim()) === id) {
|
|
103
|
-
return (0, import_i18n.t)("deviceNameInvalid");
|
|
104
|
-
}
|
|
105
|
-
if (Number(devices[i].port) === candidate.port) {
|
|
106
|
-
return (0, import_i18n.t)("devicePortInUse");
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
return null;
|
|
78
|
+
return { name, port: (0, import_device_config.normalizePort)(raw.port), type: (0, import_device_config.normalizeType)(raw.type) };
|
|
110
79
|
}
|
|
111
80
|
class FakerokuDeviceManagement extends import_dm_utils.DeviceManagement {
|
|
112
81
|
get objId() {
|
|
113
82
|
return `system.adapter.${this.adapter.namespace}`;
|
|
114
83
|
}
|
|
115
84
|
/**
|
|
116
|
-
* Read the device list from the live config object.
|
|
85
|
+
* Read the device list from the live config object, through the shared normaliser.
|
|
86
|
+
*
|
|
87
|
+
* Every row keeps BOTH names: the trimmed one the card shows and the next write stores,
|
|
88
|
+
* and the stored one the identity was resolved from. Nothing here re-derives an identity
|
|
89
|
+
* from the display name — that is what unpaired the remote before (lib/device-config.ts).
|
|
117
90
|
*
|
|
118
91
|
* @returns the configured devices (normalised), or an empty list
|
|
119
92
|
*/
|
|
120
93
|
async readDevices() {
|
|
121
|
-
var _a;
|
|
94
|
+
var _a, _b;
|
|
122
95
|
const obj = await this.adapter.getForeignObjectAsync(this.objId);
|
|
123
96
|
const devices = (_a = obj == null ? void 0 : obj.native) == null ? void 0 : _a.devices;
|
|
124
|
-
|
|
125
|
-
return [];
|
|
126
|
-
}
|
|
127
|
-
return devices.filter((d) => typeof d === "object" && d !== null).map((d) => {
|
|
128
|
-
const clean = cleanDevice(d);
|
|
129
|
-
return typeof d.uuid === "string" && d.uuid ? { ...clean, uuid: d.uuid } : clean;
|
|
130
|
-
});
|
|
97
|
+
return (_b = (0, import_device_config.toDeviceRows)(devices)) != null ? _b : [];
|
|
131
98
|
}
|
|
132
99
|
/**
|
|
133
|
-
* Persist the device list.
|
|
134
|
-
*
|
|
100
|
+
* Persist the device list.
|
|
101
|
+
*
|
|
102
|
+
* ⚠️ The full array only replaces the stored one because the key is literally called
|
|
103
|
+
* `devices`: js-controller clears the old array before merging for exactly four names
|
|
104
|
+
* (`common.members`, `native.repositories`, `native.certificates`, `native.devices` —
|
|
105
|
+
* adapter.ts `_extendForeignObject`). Under any other name `extend(true, …)` would merge
|
|
106
|
+
* arrays element-wise, and deleting the second of two devices would leave both in place.
|
|
107
|
+
* Renaming this config key silently breaks deletion; `device-management.test.ts` pins it.
|
|
108
|
+
*
|
|
109
|
+
* Writing `native.*` restarts the adapter, which re-creates the object trees and servers.
|
|
135
110
|
*
|
|
136
111
|
* @param devices the full device list to store
|
|
137
112
|
*/
|
|
138
113
|
async writeDevices(devices) {
|
|
139
114
|
await this.adapter.extendForeignObjectAsync(this.objId, { native: { devices } });
|
|
140
115
|
}
|
|
116
|
+
/**
|
|
117
|
+
* The stored shape of a row the user did NOT touch: its name exactly as it was, the
|
|
118
|
+
* normalised port and type, and the identity resolved when the row was read.
|
|
119
|
+
*
|
|
120
|
+
* The name stays untouched on purpose. Writing the trimmed form would move that device's
|
|
121
|
+
* object id on the next start — a tree wandering, and every script pointing into it
|
|
122
|
+
* breaking, because someone edited a different card. Trimming happens where the user
|
|
123
|
+
* pressed save, nowhere else. Persisting the identity is safe by contrast: it is exactly
|
|
124
|
+
* the value the runtime is already advertising for that row, just no longer derived.
|
|
125
|
+
*
|
|
126
|
+
* @param row the normalised row
|
|
127
|
+
* @returns the row as it is persisted
|
|
128
|
+
*/
|
|
129
|
+
static toStored(row) {
|
|
130
|
+
return { name: row.storedName, port: row.port, type: row.type, uuid: row.identity };
|
|
131
|
+
}
|
|
141
132
|
/**
|
|
142
133
|
* Populate the manager with one card per configured device.
|
|
143
134
|
*
|
|
@@ -145,24 +136,33 @@ class FakerokuDeviceManagement extends import_dm_utils.DeviceManagement {
|
|
|
145
136
|
*/
|
|
146
137
|
async loadDevices(context) {
|
|
147
138
|
const devices = await this.readDevices();
|
|
148
|
-
|
|
139
|
+
for (const device of devices) {
|
|
140
|
+
context.addDevice(this.toDeviceInfo(device));
|
|
141
|
+
}
|
|
149
142
|
}
|
|
150
143
|
/**
|
|
151
144
|
* Build one device card. The model (Player/TV) and the ECP port each get their
|
|
152
145
|
* own line — the port via `identifier` (labelled in getInstanceInfo). No
|
|
153
146
|
* manufacturer line: it is always "Roku" and tells the user nothing for an emulator.
|
|
154
|
-
*
|
|
155
|
-
*
|
|
147
|
+
*
|
|
148
|
+
* The card is keyed by the device's SSDP identity, not by its list position: a position
|
|
149
|
+
* is only valid for as long as the view is fresh, so a second admin tab (or a list that
|
|
150
|
+
* changed underneath) would send edit/delete to a different device than the card clicked.
|
|
151
|
+
* Two hand-edited rows can share an identity; the first one wins, which is the same answer
|
|
152
|
+
* the runtime gives when two names map to one object id.
|
|
153
|
+
*
|
|
154
|
+
* The name needs no fallback: a row without a usable one never becomes a DeviceRow
|
|
155
|
+
* (lib/device-config.ts drops it), so `Roku <n>` was a branch nothing could reach —
|
|
156
|
+
* the mutation run of 1.6.0 found it by having no test that could fail on its removal.
|
|
156
157
|
*
|
|
157
158
|
* @param device the stored device
|
|
158
|
-
* @param index its list position — the (per-session stable) card id
|
|
159
159
|
* @returns the card descriptor
|
|
160
160
|
*/
|
|
161
|
-
toDeviceInfo(device
|
|
161
|
+
toDeviceInfo(device) {
|
|
162
162
|
const kind = device.type === "tv" ? "TV" : "Player";
|
|
163
163
|
return {
|
|
164
|
-
id:
|
|
165
|
-
name: device.name
|
|
164
|
+
id: device.identity,
|
|
165
|
+
name: device.name,
|
|
166
166
|
identifier: String(device.port),
|
|
167
167
|
model: kind,
|
|
168
168
|
actions: [
|
|
@@ -170,13 +170,13 @@ class FakerokuDeviceManagement extends import_dm_utils.DeviceManagement {
|
|
|
170
170
|
id: "edit",
|
|
171
171
|
icon: "edit",
|
|
172
172
|
description: (0, import_i18n.t)("dmEdit"),
|
|
173
|
-
handler: async (id, context) => this.editDevice(
|
|
173
|
+
handler: async (id, context) => this.editDevice(id, context)
|
|
174
174
|
},
|
|
175
175
|
{
|
|
176
176
|
id: "delete",
|
|
177
177
|
icon: "delete",
|
|
178
178
|
description: (0, import_i18n.t)("dmDelete"),
|
|
179
|
-
handler: async (id, context) => this.deleteDevice(
|
|
179
|
+
handler: async (id, context) => this.deleteDevice(id, context)
|
|
180
180
|
}
|
|
181
181
|
]
|
|
182
182
|
};
|
|
@@ -203,78 +203,82 @@ class FakerokuDeviceManagement extends import_dm_utils.DeviceManagement {
|
|
|
203
203
|
async addDevice(context) {
|
|
204
204
|
const devices = await this.readDevices();
|
|
205
205
|
const usedNames = devices.map((d) => d.name);
|
|
206
|
-
const usedPorts = devices.map((d) =>
|
|
206
|
+
const usedPorts = devices.map((d) => d.port);
|
|
207
207
|
const data = await context.showForm(buildDeviceForm(usedNames, usedPorts), {
|
|
208
208
|
title: (0, import_i18n.t)("dmAdd"),
|
|
209
|
-
data: { type: "player", port: nextFreePort(usedPorts) }
|
|
209
|
+
data: { type: "player", port: (0, import_device_config.nextFreePort)(usedPorts) }
|
|
210
210
|
});
|
|
211
211
|
if (data && typeof data.name === "string" && data.name.trim()) {
|
|
212
212
|
const clean = cleanDevice(data);
|
|
213
|
-
const clash = findClash(devices, clean, -1);
|
|
213
|
+
const clash = (0, import_device_config.findClash)(devices, clean, -1);
|
|
214
214
|
if (clash) {
|
|
215
215
|
await context.showMessage(clash);
|
|
216
216
|
return { refresh: true };
|
|
217
217
|
}
|
|
218
|
-
devices.
|
|
219
|
-
|
|
218
|
+
const stored = devices.map((d) => FakerokuDeviceManagement.toStored(d));
|
|
219
|
+
stored.push({ ...clean, uuid: (0, import_device_identity.deriveUuid)(clean.name) });
|
|
220
|
+
await this.writeDevices(stored);
|
|
220
221
|
}
|
|
221
222
|
return { refresh: true };
|
|
222
223
|
}
|
|
223
224
|
/**
|
|
224
225
|
* Edit a device via the pre-filled form. Its own name/port are excluded from
|
|
225
|
-
* the clash check, and its
|
|
226
|
+
* the clash check, and its identity is preserved so the pairing survives a rename.
|
|
226
227
|
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
* change the SSDP identity on a plain rename and silently unpair the remote.
|
|
228
|
+
* The identity comes from the row as it was READ (lib/device-config.ts resolves it from
|
|
229
|
+
* the stored name), never derived here: deriving it from the name in the form would move
|
|
230
|
+
* the SSDP identity on a plain rename — and deriving it from the trimmed stored name would
|
|
231
|
+
* move it even when the user changed nothing at all.
|
|
232
232
|
*
|
|
233
|
-
* @param
|
|
233
|
+
* @param cardId the card's id — the device's identity
|
|
234
234
|
* @param context the action context
|
|
235
235
|
* @returns a directive to reload the list
|
|
236
236
|
*/
|
|
237
|
-
async editDevice(
|
|
237
|
+
async editDevice(cardId, context) {
|
|
238
238
|
const devices = await this.readDevices();
|
|
239
|
+
const index = devices.findIndex((d) => d.identity === cardId);
|
|
239
240
|
const current = devices[index];
|
|
240
241
|
if (!current) {
|
|
241
242
|
return { refresh: "devices" };
|
|
242
243
|
}
|
|
243
244
|
const usedNames = devices.filter((_, i) => i !== index).map((d) => d.name);
|
|
244
|
-
const usedPorts = devices.filter((_, i) => i !== index).map((d) =>
|
|
245
|
+
const usedPorts = devices.filter((_, i) => i !== index).map((d) => d.port);
|
|
245
246
|
const data = await context.showForm(buildDeviceForm(usedNames, usedPorts), {
|
|
246
247
|
title: (0, import_i18n.t)("dmEditTitle"),
|
|
247
|
-
data: {
|
|
248
|
+
data: { name: current.name, port: current.port, type: current.type }
|
|
248
249
|
});
|
|
249
250
|
if (data && typeof data.name === "string" && data.name.trim()) {
|
|
250
251
|
const clean = cleanDevice(data);
|
|
251
|
-
const clash = findClash(devices, clean, index);
|
|
252
|
+
const clash = (0, import_device_config.findClash)(devices, clean, index);
|
|
252
253
|
if (clash) {
|
|
253
254
|
await context.showMessage(clash);
|
|
254
255
|
return { refresh: "devices" };
|
|
255
256
|
}
|
|
256
|
-
|
|
257
|
-
|
|
257
|
+
const stored = devices.map((d) => FakerokuDeviceManagement.toStored(d));
|
|
258
|
+
stored[index] = { ...clean, uuid: current.identity };
|
|
259
|
+
await this.writeDevices(stored);
|
|
258
260
|
}
|
|
259
261
|
return { refresh: "devices" };
|
|
260
262
|
}
|
|
261
263
|
/**
|
|
262
264
|
* Delete a device after confirmation.
|
|
263
265
|
*
|
|
264
|
-
* @param
|
|
266
|
+
* @param cardId the card's id — the device's identity
|
|
265
267
|
* @param context the action context
|
|
266
268
|
* @returns a directive to reload the list
|
|
267
269
|
*/
|
|
268
|
-
async deleteDevice(
|
|
270
|
+
async deleteDevice(cardId, context) {
|
|
269
271
|
const devices = await this.readDevices();
|
|
272
|
+
const index = devices.findIndex((d) => d.identity === cardId);
|
|
270
273
|
const target = devices[index];
|
|
271
274
|
if (!target) {
|
|
272
275
|
return { refresh: "devices" };
|
|
273
276
|
}
|
|
274
|
-
const confirmed = await context.showConfirmation((0, import_i18n.t)("dmDeleteConfirm", target.name
|
|
277
|
+
const confirmed = await context.showConfirmation((0, import_i18n.t)("dmDeleteConfirm", target.name));
|
|
275
278
|
if (confirmed) {
|
|
276
|
-
devices.
|
|
277
|
-
|
|
279
|
+
const stored = devices.map((d) => FakerokuDeviceManagement.toStored(d));
|
|
280
|
+
stored.splice(index, 1);
|
|
281
|
+
await this.writeDevices(stored);
|
|
278
282
|
}
|
|
279
283
|
return { refresh: "devices" };
|
|
280
284
|
}
|
|
@@ -283,8 +287,6 @@ class FakerokuDeviceManagement extends import_dm_utils.DeviceManagement {
|
|
|
283
287
|
0 && (module.exports = {
|
|
284
288
|
FakerokuDeviceManagement,
|
|
285
289
|
buildDeviceForm,
|
|
286
|
-
cleanDevice
|
|
287
|
-
findClash,
|
|
288
|
-
nextFreePort
|
|
290
|
+
cleanDevice
|
|
289
291
|
});
|
|
290
292
|
//# sourceMappingURL=device-management.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/device-management.ts"],
|
|
4
|
-
"sourcesContent": ["import {\n DeviceManagement,\n type ActionContext,\n type DeviceInfo,\n type DeviceLoadContext,\n type JsonFormSchema,\n} from \"@iobroker/dm-utils\";\nimport { DEFAULT_ECP_PORT, RESERVED_IDS } from \"./lib/constants\";\nimport { deriveUuid, resolveDeviceUuid } from \"./lib/device-identity\";\nimport { t } from \"./lib/i18n\";\nimport { sanitizeId } from \"./lib/pure-helpers\";\nimport type { DeviceType } from \"./ecp/state-model\";\n\n/**\n * One emulated Roku as stored in the adapter's native.devices \u2014 the manifest's own\n * element type, so the manager and the runtime (main.ts) read the same shape.\n * `uuid` is the stable SSDP identity, carried through edits so the pairing survives.\n * A row without one (the manifest default, or a hand-written config) is identified\n * by `deriveUuid(name)` at runtime, so an edit must derive from the OLD name.\n */\ntype RokuDeviceConfig = ioBroker.AdapterConfig[\"devices\"][number];\n\n/** Manager directive: reload the whole view. */\ntype InstanceResult = { refresh: boolean };\n/** Manager directive: reload the device list. */\ntype DeviceResult = { refresh: \"devices\" };\n\n/**\n * The lowest free ECP port at or above the real-Roku default, so a newly added\n * device never pre-selects a port another emulated Roku already uses.\n *\n * @param usedPorts the ports already taken by other devices\n * @returns the first free port >= 8060\n */\nexport function nextFreePort(usedPorts: readonly number[]): number {\n const taken = new Set(usedPorts);\n let port = DEFAULT_ECP_PORT;\n while (taken.has(port)) {\n port++;\n }\n return port;\n}\n\n/**\n * The add/edit form for one emulated Roku: name, ECP port and device type. The\n * name and port carry a live validator against the values already in use (the\n * OK button greys out on a clash); a hint explains that each Roku needs its own\n * port. Labels are resolved translation objects so the embedded form is\n * language-correct.\n *\n * @param usedNames names taken by OTHER devices (the edited device is excluded)\n * @param usedPorts ports taken by OTHER devices\n * @returns the jsonConfig panel schema for one device\n */\nexport function buildDeviceForm(usedNames: readonly string[], usedPorts: readonly number[]): JsonFormSchema {\n const nameList = JSON.stringify(usedNames.map(n => n.trim().toLowerCase()));\n const portList = JSON.stringify([...usedPorts]);\n return {\n type: \"panel\",\n items: {\n name: {\n type: \"text\",\n label: t(\"deviceName\"),\n validator: `!${nameList}.includes((data.name||'').trim().toLowerCase())`,\n validatorErrorText: t(\"deviceNameInUse\"),\n validatorNoSaveOnError: true,\n sm: 12,\n md: 6,\n },\n port: {\n type: \"number\",\n label: t(\"devicePort\"),\n min: 1,\n max: 65535,\n validator: `!${portList}.includes(Number(data.port))`,\n validatorErrorText: t(\"devicePortInUse\"),\n validatorNoSaveOnError: true,\n sm: 12,\n md: 3,\n },\n type: {\n type: \"select\",\n label: t(\"deviceTypeLabel\"),\n default: \"player\",\n options: [\n { label: t(\"deviceTypePlayer\"), value: \"player\" },\n { label: t(\"deviceTypeTv\"), value: \"tv\" },\n ],\n sm: 12,\n md: 3,\n },\n _portHint: { type: \"staticText\", text: t(\"devicePortHint\"), sm: 12 },\n _typeHint: { type: \"staticText\", text: t(\"deviceTypeHint\"), sm: 12 },\n },\n } as unknown as JsonFormSchema;\n}\n\n/**\n * Normalise raw form values into a clean device (trimmed name, numeric port,\n * valid type) so native.devices stays tidy. The uuid is set by the caller.\n *\n * @param raw the submitted form values\n * @returns the normalised device config (without uuid)\n */\nexport function cleanDevice(raw: Record<string, unknown>): Omit<RokuDeviceConfig, \"uuid\"> {\n const name = typeof raw.name === \"string\" ? raw.name.trim() : \"\";\n const port = Number(raw.port) || DEFAULT_ECP_PORT;\n const type: DeviceType = raw.type === \"tv\" ? \"tv\" : \"player\";\n return { name, port, type };\n}\n\n/**\n * A name/port clash against the other devices, as a ready-to-show message \u2014 the\n * backend safety net behind the form validator (the dialog validator may not\n * fire in every admin version; this never lets a duplicate through).\n *\n * @param devices the full current device list\n * @param candidate the name+port being added/edited\n * @param candidate.name the candidate device name\n * @param candidate.port the candidate ECP port\n * @param exceptIndex the list position to ignore (the device being edited), or -1\n * @returns a translated clash message, or null if free\n */\nexport function findClash(\n devices: readonly RokuDeviceConfig[],\n candidate: { name: string; port: number },\n exceptIndex: number,\n): ioBroker.StringOrTranslated | null {\n const name = candidate.name.trim().toLowerCase();\n // The object-tree path is sanitizeId(name); guard the two ways it can go wrong\n // regardless of the plain-name check: a name that sanitizes to a reserved id\n // (\"info\" would collide with the adapter's own channel), and two different\n // names that sanitize to the SAME id (\"My Roku\" and \"My*Roku\" \u2192 \"My_Roku\").\n const id = sanitizeId(candidate.name.trim());\n if (id === \"\" || RESERVED_IDS.has(id)) {\n return t(\"deviceNameInvalid\");\n }\n for (let i = 0; i < devices.length; i++) {\n if (i === exceptIndex) {\n continue;\n }\n if (devices[i].name.trim().toLowerCase() === name) {\n return t(\"deviceNameInUse\");\n }\n if (sanitizeId(devices[i].name.trim()) === id) {\n return t(\"deviceNameInvalid\");\n }\n if (Number(devices[i].port) === candidate.port) {\n return t(\"devicePortInUse\");\n }\n }\n return null;\n}\n\n/**\n * ioBroker device-manager backend: the emulated Rokus as cards with manual\n * add/edit/delete \u2014 no scan, the devices are purely user-defined. Owns no state;\n * it reads and writes the adapter's own `native.devices` config array.\n */\nexport class FakerokuDeviceManagement extends DeviceManagement {\n private get objId(): string {\n return `system.adapter.${this.adapter.namespace}`;\n }\n\n /**\n * Read the device list from the live config object.\n *\n * @returns the configured devices (normalised), or an empty list\n */\n private async readDevices(): Promise<RokuDeviceConfig[]> {\n const obj = await this.adapter.getForeignObjectAsync(this.objId);\n const devices = (obj?.native as { devices?: unknown } | undefined)?.devices;\n if (!Array.isArray(devices)) {\n return [];\n }\n // native.devices is user-editable (expert mode, CLI). Normalise every row the\n // same way the dialog does, so a hand-edited entry (numeric name, missing port)\n // shows up as a card the user can fix instead of throwing inside add/edit.\n return devices\n .filter((d): d is Record<string, unknown> => typeof d === \"object\" && d !== null)\n .map(d => {\n const clean = cleanDevice(d);\n return typeof d.uuid === \"string\" && d.uuid ? { ...clean, uuid: d.uuid } : clean;\n });\n }\n\n /**\n * Persist the device list. Writing `native.*` restarts the adapter, which\n * re-creates the object trees and servers with the new devices.\n *\n * @param devices the full device list to store\n */\n private async writeDevices(devices: RokuDeviceConfig[]): Promise<void> {\n await this.adapter.extendForeignObjectAsync(this.objId, { native: { devices } });\n }\n\n /**\n * Populate the manager with one card per configured device.\n *\n * @param context the load context\n */\n protected async loadDevices(context: DeviceLoadContext<string>): Promise<void> {\n const devices = await this.readDevices();\n devices.forEach((device, index) => context.addDevice(this.toDeviceInfo(device, index)));\n }\n\n /**\n * Build one device card. The model (Player/TV) and the ECP port each get their\n * own line \u2014 the port via `identifier` (labelled in getInstanceInfo). No\n * manufacturer line: it is always \"Roku\" and tells the user nothing for an emulator.\n * The row comes from readDevices(), so the port is already a number; only the name\n * can still be empty (a row saved without one) and gets a numbered stand-in.\n *\n * @param device the stored device\n * @param index its list position \u2014 the (per-session stable) card id\n * @returns the card descriptor\n */\n private toDeviceInfo(device: RokuDeviceConfig, index: number): DeviceInfo<string> {\n const kind = device.type === \"tv\" ? \"TV\" : \"Player\";\n return {\n id: String(index),\n name: device.name || `Roku ${index + 1}`,\n identifier: String(device.port),\n model: kind,\n actions: [\n {\n id: \"edit\",\n icon: \"edit\",\n description: t(\"dmEdit\"),\n handler: async (id: string, context: ActionContext) => this.editDevice(Number(id), context),\n },\n {\n id: \"delete\",\n icon: \"delete\",\n description: t(\"dmDelete\"),\n handler: async (id: string, context: ActionContext) => this.deleteDevice(Number(id), context),\n },\n ],\n };\n }\n\n /**\n * The \"+ add\" action above the list, plus the label for the port shown on each card.\n *\n * @returns the instance action descriptor\n */\n protected getInstanceInfo(): ReturnType<DeviceManagement[\"getInstanceInfo\"]> {\n return {\n apiVersion: \"v3\",\n identifierLabel: t(\"portLabel\"),\n actions: [{ id: \"add\", icon: \"add\", description: t(\"dmAdd\"), handler: async context => this.addDevice(context) }],\n };\n }\n\n /**\n * Manual add: pre-select a free port, show the form, and append the device with\n * a stable derived uuid.\n *\n * @param context the action context\n * @returns a directive to reload the manager\n */\n private async addDevice(context: ActionContext): Promise<InstanceResult> {\n const devices = await this.readDevices();\n const usedNames = devices.map(d => d.name);\n const usedPorts = devices.map(d => Number(d.port));\n const data = await context.showForm(buildDeviceForm(usedNames, usedPorts), {\n title: t(\"dmAdd\"),\n data: { type: \"player\", port: nextFreePort(usedPorts) },\n });\n if (data && typeof data.name === \"string\" && data.name.trim()) {\n const clean = cleanDevice(data);\n const clash = findClash(devices, clean, -1);\n if (clash) {\n await context.showMessage(clash);\n return { refresh: true };\n }\n devices.push({ ...clean, uuid: deriveUuid(clean.name) });\n await this.writeDevices(devices);\n }\n return { refresh: true };\n }\n\n /**\n * Edit a device via the pre-filled form. Its own name/port are excluded from\n * the clash check, and its uuid is preserved so the pairing survives a rename.\n *\n * A row that carries no uuid yet \u2014 the manifest's default device, or anything\n * written before 0.8.0 \u2014 gets one derived from its PREVIOUS name, because that\n * is the identity main.ts is advertising right now (it derives from the stored\n * name and never writes the value back). Deriving from the NEW name here would\n * change the SSDP identity on a plain rename and silently unpair the remote.\n *\n * @param index the device's list position\n * @param context the action context\n * @returns a directive to reload the list\n */\n private async editDevice(index: number, context: ActionContext): Promise<DeviceResult> {\n const devices = await this.readDevices();\n const current = devices[index];\n if (!current) {\n return { refresh: \"devices\" };\n }\n const usedNames = devices.filter((_, i) => i !== index).map(d => d.name);\n const usedPorts = devices.filter((_, i) => i !== index).map(d => Number(d.port));\n const data = await context.showForm(buildDeviceForm(usedNames, usedPorts), {\n title: t(\"dmEditTitle\"),\n data: { ...current },\n });\n if (data && typeof data.name === \"string\" && data.name.trim()) {\n const clean = cleanDevice(data);\n const clash = findClash(devices, clean, index);\n if (clash) {\n await context.showMessage(clash);\n return { refresh: \"devices\" };\n }\n devices[index] = { ...clean, uuid: resolveDeviceUuid(current) };\n await this.writeDevices(devices);\n }\n return { refresh: \"devices\" };\n }\n\n /**\n * Delete a device after confirmation.\n *\n * @param index the device's list position\n * @param context the action context\n * @returns a directive to reload the list\n */\n private async deleteDevice(index: number, context: ActionContext): Promise<DeviceResult> {\n const devices = await this.readDevices();\n const target = devices[index];\n if (!target) {\n return { refresh: \"devices\" };\n }\n const confirmed = await context.showConfirmation(t(\"dmDeleteConfirm\", target.name || \"\"));\n if (confirmed) {\n devices.splice(index, 1);\n await this.writeDevices(devices);\n }\n return { refresh: \"devices\" };\n }\n}\n"],
|
|
5
|
-
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA
|
|
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 findClash,\n nextFreePort,\n normalizePort,\n normalizeType,\n toDeviceRows,\n type DeviceRow,\n} from \"./lib/device-config\";\nimport { deriveUuid } 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/**\n * The add/edit form for one emulated Roku: name, ECP port and device type.\n *\n * The name validator answers all three name rules the backend knows \u2014 already taken,\n * reserved for the adapter's own tree, and \"sanitizes to the same object id as another\n * device\" \u2014 so the OK button greys out instead of the dialog accepting a name that the\n * backend then refuses after the round-trip. One field carries one error text, so it names\n * all three cases; `findClash` still returns the specific reason for anyone bypassing the\n * dialog. Labels are resolved translation objects so the embedded form is language-correct.\n *\n * @param usedNames names taken by OTHER devices (the edited device is excluded)\n * @param usedPorts ports taken by OTHER devices\n * @returns the jsonConfig panel schema for one device\n */\nexport function buildDeviceForm(usedNames: readonly string[], usedPorts: readonly number[]): JsonFormSchema {\n const nameList = JSON.stringify(usedNames.map(n => n.trim().toLowerCase()));\n // Reserved ids first, then the object ids the other devices already occupy.\n const idList = JSON.stringify([...RESERVED_IDS, ...usedNames.map(n => sanitizeId(n.trim()))]);\n const portList = JSON.stringify([...usedPorts]);\n return {\n type: \"panel\",\n items: {\n name: {\n type: \"text\",\n label: t(\"deviceName\"),\n validator:\n `${ID_EXPRESSION}.length>0` +\n ` && !${nameList}.includes((data.name||'').trim().toLowerCase())` +\n ` && !${idList}.includes(${ID_EXPRESSION})`,\n validatorErrorText: t(\"deviceNameRejected\"),\n validatorNoSaveOnError: true,\n sm: 12,\n md: 6,\n },\n port: {\n type: \"number\",\n label: t(\"devicePort\"),\n min: 1,\n max: 65535,\n validator: `!${portList}.includes(Number(data.port))`,\n validatorErrorText: t(\"devicePortInUse\"),\n validatorNoSaveOnError: true,\n sm: 12,\n md: 3,\n },\n type: {\n type: \"select\",\n label: t(\"deviceTypeLabel\"),\n default: \"player\",\n options: [\n { label: t(\"deviceTypePlayer\"), value: \"player\" },\n { label: t(\"deviceTypeTv\"), value: \"tv\" },\n ],\n sm: 12,\n md: 3,\n },\n _portHint: { type: \"staticText\", text: t(\"devicePortHint\"), sm: 12 },\n _typeHint: { type: \"staticText\", text: t(\"deviceTypeHint\"), sm: 12 },\n },\n } as unknown as JsonFormSchema;\n}\n\n/**\n * Normalise raw form values into a clean device (trimmed name, valid port, valid type) so\n * native.devices stays tidy. Same rules as reading a stored row (lib/device-config.ts), the\n * uuid is set by the caller.\n *\n * @param raw the submitted form values\n * @returns the normalised device config (without uuid)\n */\nexport function cleanDevice(raw: Record<string, unknown>): Omit<RokuDeviceConfig, \"uuid\"> {\n const name = typeof raw.name === \"string\" ? raw.name.trim() : \"\";\n 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.\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 (lib/device-config.ts).\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 return toDeviceRows(devices) ?? [];\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 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.\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 };\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 const kind = device.type === \"tv\" ? \"TV\" : \"Player\";\n return {\n id: device.identity,\n name: device.name,\n identifier: String(device.port),\n model: kind,\n actions: [\n {\n id: \"edit\",\n icon: \"edit\",\n description: t(\"dmEdit\"),\n handler: async (id: string, context: ActionContext) => this.editDevice(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 data = await context.showForm(buildDeviceForm(usedNames, usedPorts), {\n title: t(\"dmAdd\"),\n data: { type: \"player\", port: nextFreePort(usedPorts) },\n });\n if (data && typeof data.name === \"string\" && data.name.trim()) {\n const clean = cleanDevice(data);\n const clash = findClash(devices, clean, -1);\n if (clash) {\n await context.showMessage(clash);\n return { refresh: true };\n }\n const stored = devices.map(d => FakerokuDeviceManagement.toStored(d));\n stored.push({ ...clean, uuid: deriveUuid(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 const data = await context.showForm(buildDeviceForm(usedNames, usedPorts), {\n title: t(\"dmEditTitle\"),\n data: { name: current.name, port: current.port, type: current.type },\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 stored[index] = { ...clean, uuid: current.identity };\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
|
}
|