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 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
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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
- function nextFreePort(usedPorts) {
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: `!${nameList}.includes((data.name||'').trim().toLowerCase())`,
51
- validatorErrorText: (0, import_i18n.t)("deviceNameInUse"),
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
- const port = Number(raw.port) || import_constants.DEFAULT_ECP_PORT;
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
- if (!Array.isArray(devices)) {
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. Writing `native.*` restarts the adapter, which
134
- * re-creates the object trees and servers with the new devices.
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
- devices.forEach((device, index) => context.addDevice(this.toDeviceInfo(device, index)));
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
- * The row comes from readDevices(), so the port is already a number; only the name
155
- * can still be empty (a row saved without one) and gets a numbered stand-in.
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, index) {
161
+ toDeviceInfo(device) {
162
162
  const kind = device.type === "tv" ? "TV" : "Player";
163
163
  return {
164
- id: String(index),
165
- name: device.name || `Roku ${index + 1}`,
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(Number(id), context)
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(Number(id), context)
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) => Number(d.port));
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.push({ ...clean, uuid: (0, import_device_identity.deriveUuid)(clean.name) });
219
- await this.writeDevices(devices);
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 uuid is preserved so the pairing survives a rename.
226
+ * the clash check, and its identity is preserved so the pairing survives a rename.
226
227
  *
227
- * A row that carries no uuid yet — the manifest's default device, or anything
228
- * written before 0.8.0 — gets one derived from its PREVIOUS name, because that
229
- * is the identity main.ts is advertising right now (it derives from the stored
230
- * name and never writes the value back). Deriving from the NEW name here would
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 index the device's list position
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(index, context) {
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) => Number(d.port));
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: { ...current }
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
- devices[index] = { ...clean, uuid: (0, import_device_identity.resolveDeviceUuid)(current) };
257
- await this.writeDevices(devices);
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 index the device's list position
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(index, context) {
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.splice(index, 1);
277
- await this.writeDevices(devices);
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": "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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 { 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
  }