iobroker.hassemu 1.2.0 → 1.3.1

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
@@ -36,16 +36,16 @@ ioBroker adapter that emulates a [Home Assistant](https://www.home-assistant.io)
36
36
  - **Node.js >= 20**
37
37
  - **ioBroker js-controller >= 7.0.7**
38
38
  - **ioBroker Admin >= 7.7.22**
39
- - **ioBroker web >= 8.0.0** — required for the VIS/Admin URL discovery that fills the mode-dropdown
39
+ - **ioBroker web >= 8.0.0**
40
40
 
41
41
  ---
42
42
 
43
43
  ## Ports
44
44
 
45
- | Port | Protocol | Purpose | Configurable |
46
- | ---- | -------- | -------------------------------------------- | ------------ |
47
- | 8123 | TCP/HTTP | Home Assistant emulation (HA standard port) | No — fixed |
48
- | 5353 | UDP | mDNS service broadcast (only if mDNS enabled)| No |
45
+ | Port | Protocol | Purpose | Configurable |
46
+ | ---- | -------- | --------------------------------------------- | ------------ |
47
+ | 8123 | TCP/HTTP | Home Assistant emulation (HA standard port) | No — fixed |
48
+ | 5353 | UDP | mDNS service broadcast (only if mDNS enabled) | No |
49
49
 
50
50
  ---
51
51
 
@@ -53,15 +53,13 @@ ioBroker adapter that emulates a [Home Assistant](https://www.home-assistant.io)
53
53
 
54
54
  The Admin UI configures the server. Redirect URLs are set via the state tree (see below).
55
55
 
56
- | Option | Description | Default |
57
- |--------|-------------|---------|
58
- | **Bind to Interface** | Network interface to listen on | 0.0.0.0 (all) |
59
- | **Service Name** | Name broadcast via mDNS, shown as the server name on the display | `ioBroker` |
60
- | **mDNS Enabled** | Broadcast `_home-assistant._tcp` on the LAN | `true` |
61
- | **Auth Required** | Check credentials the display sends during login | `false` |
62
- | **Username / Password** | Used when *Auth Required* is on (password encrypted at rest) | `admin` / — |
63
-
64
- > URLs you set must be reachable from the display — use the LAN IP of the ioBroker host, not `localhost`.
56
+ | Option | Description | Default |
57
+ | ----------------------- | ---------------------------------------------------------------- | ------------- |
58
+ | **Bind to Interface** | Network interface to listen on | 0.0.0.0 (all) |
59
+ | **Service Name** | Name broadcast via mDNS, shown as the server name on the display | `ioBroker` |
60
+ | **mDNS Enabled** | Broadcast `_home-assistant._tcp` on the LAN | `true` |
61
+ | **Auth Required** | Check credentials the display sends during login | `false` |
62
+ | **Username / Password** | Used when _Auth Required_ is on (password encrypted at rest) | `admin` / — |
65
63
 
66
64
  ---
67
65
 
@@ -86,11 +84,11 @@ hassemu.0.
86
84
 
87
85
  The adapter reads `clients.<id>.mode` on every visit:
88
86
 
89
- | `mode` value | redirect target |
90
- |--------------|----------------|
91
- | `global` | `global.mode` / `global.manualUrl` (same rules, one level up) |
92
- | `manual` | `clients.<id>.manualUrl` |
93
- | a URL | that URL |
87
+ | `mode` value | redirect target |
88
+ | --------------- | -------------------------------------------------------------- |
89
+ | `global` | `global.mode` / `global.manualUrl` (same rules, one level up) |
90
+ | `manual` | `clients.<id>.manualUrl` |
91
+ | a URL | that URL |
94
92
  | empty / unknown | landing page (small HTML with device ID, refreshes every 15 s) |
95
93
 
96
94
  ### Master switch
@@ -107,12 +105,12 @@ The adapter broadcasts `_home-assistant._tcp` via mDNS. If the display does not
107
105
 
108
106
  1. Check the adapter log for `mDNS: Broadcasting`.
109
107
  2. Verify the service is visible from another host:
110
- ```bash
111
- # macOS
112
- dns-sd -B _home-assistant._tcp
113
- # Linux (with avahi-utils)
114
- avahi-browse _home-assistant._tcp -r -t
115
- ```
108
+ ```bash
109
+ # macOS
110
+ dns-sd -B _home-assistant._tcp
111
+ # Linux (with avahi-utils)
112
+ avahi-browse _home-assistant._tcp -r -t
113
+ ```
116
114
  3. Make sure UDP 5353 is not blocked by a firewall.
117
115
  4. If mDNS is not usable on your LAN, set the URL manually on the display: `http://<ioBroker-IP>:8123`.
118
116
 
@@ -149,37 +147,45 @@ Reverse DNS on a home LAN depends on your router/DHCP server and often fails. Th
149
147
  ---
150
148
 
151
149
  ## Changelog
150
+ ### 1.3.1 (2026-04-30)
151
+
152
+ - Hotfix for legacy v1.1.x clients: their `visUrl` channel did not have `mode` / `manualUrl` objects. The v1.2.0 migration wrote states without the matching objects, which the broker logged as `State has no existing object` and rendered the `mode` datapoint without a name or dropdown in the object browser. `ClientRegistry.restore()` now calls an idempotent `ensureObjects()` for every client, so the v1.2.0+ object shapes exist before any migration writes happen.
153
+ - Mode dropdown gains a numeric `0 = "---"` no-choice fallback (analogous to govee-smart's pattern). Existing displays keep their setting; new displays start at `0` and the resolver falls back to the landing page until a real choice is made.
154
+
155
+ ### 1.3.0 (2026-04-30)
156
+
157
+ - Security: brute-force lockout on `/auth/login_flow/:flowId` — after 5 failed credential attempts an IP is rejected with HTTP 429 for 15 min. Successful login resets the counter.
158
+ - DRY refactor: shared `parseManualUrlWrite` helper between client + global config; FIFO-cap helper in WebServer; OAuth access-token TTL + lockout window/threshold are now named constants instead of magic numbers.
159
+ - Dead-code cleanup: `resolveBindToReachable`, `coerceUuid` strict-V4 parameter, `DEFAULT_REFRESH_DEBOUNCE_MS` export, internal `getMode`/`getManualUrl` test affordances — all removed; tests rewritten to assert observable behaviour.
160
+ - New `landing-page` test suite with XSS-escape coverage and 11-language fallback verification.
161
+ - Emulated Home Assistant version bumped from 2026.3.1 to 2026.4.0.
162
+
152
163
  ### 1.2.0 (2026-04-29)
153
164
 
154
- - (krobi) Redirect target now configured via `mode` (dropdown) + `manualUrl` (free text) instead of the old `visUrl`. Migration runs automatically.
155
- - (krobi) Master switch `global.enabled` bulk-syncs every display: on → all follow the global URL, off → each display picks up its own again.
156
- - (krobi) Idle displays without auth token are auto-removed after 30 days.
157
- - (krobi) Security hardening of the auth flow.
158
- - (krobi) `web` adapter declared as dependency — needed for the URL dropdown.
165
+ - Redirect target now configured via `mode` (dropdown) + `manualUrl` (free text) instead of the old `visUrl`. Migration runs automatically.
166
+ - Master switch `global.enabled` syncs every display: on → all follow the global URL, off → each display picks up its own again.
167
+ - Idle displays without auth token are auto-removed after 30 days.
168
+ - Security hardening of the auth flow.
169
+ - `web` adapter declared as dependency.
159
170
 
160
171
  ### 1.1.6 (2026-04-28)
172
+
161
173
  - Audit cleanup against the upstream `ioBroker.example/TypeScript` full standard:
162
- - Test setup migrated: tests now live next to source as `src/lib/*.test.ts` and run directly via `ts-node/register`. Removed `tsconfig.test.json` + `build-test/`, added `test/mocharc.custom.json` + `test/mocha.setup.js` + `test/tsconfig.json` + `test/.eslintrc.json`
163
- - `@types/node` rolled back from `^25.6.0` to `^20.19.24` so type defs match `engines.node: ">=20"`
164
- - Dependabot now ignores major bumps for `@types/node`, `typescript`, `eslint`, `actions/checkout`, `actions/setup-node`
165
- - `nyc` config + `coverage` script added
166
- - Orphan `.github/auto-merge.yml` removed (active workflow is `automerge-dependabot.yml` using `gh pr merge`)
174
+ - Test setup migrated: tests now live next to source as `src/lib/*.test.ts` and run directly via `ts-node/register`. Removed `tsconfig.test.json` + `build-test/`, added `test/mocharc.custom.json` + `test/mocha.setup.js` + `test/tsconfig.json` + `test/.eslintrc.json`
175
+ - `@types/node` rolled back from `^25.6.0` to `^20.19.24` so type defs match `engines.node: ">=20"`
176
+ - Dependabot now ignores major bumps for `@types/node`, `typescript`, `eslint`, `actions/checkout`, `actions/setup-node`
177
+ - `nyc` config + `coverage` script added
178
+ - Orphan `.github/auto-merge.yml` removed (active workflow is `automerge-dependabot.yml` using `gh pr merge`)
167
179
 
168
180
  ### 1.1.5 (2026-04-26)
181
+
169
182
  - Process-level `unhandledRejection` / `uncaughtException` handlers added as last-line-of-defence against fire-and-forget rejections.
170
183
  - Stop shipping the `manual-review` release-script plugin — adapter-only consequence.
171
184
  - Audit-driven boilerplate sync with the other krobi adapters (`.vscode` json5 schemas, `tsconfig.test` looser test rules).
172
185
  - Min js-controller correction: was `>=7.0.0`, restored to repochecker-recommended `>=6.0.11` (Source: `ioBroker.repochecker/lib/M1000_IOPackageJson.js`).
173
186
  - `@types/iobroker` bumped to `^7.1.1`.
174
187
 
175
- ### 1.1.4 (2026-04-23)
176
- - Separate test-build output (`build-test/`) from production `build/` — `npm test` no longer risks leaving duplicated `build/src` + `build/test` trees in the published package. No runtime change.
177
-
178
- ### 1.1.3 (2026-04-19)
179
-
180
- - **Fix duplicate client registration on first connect** — HA displays fire several parallel cookieless requests (`GET /`, `GET /api/`, `POST /auth/login_flow`) within milliseconds of each other. Each used to create a separate client record, leaving orphans behind. The registry now locks per IP while the first client is being created, so parallel burst requests from the same display attach to the same client and cookie.
181
- - **Setup page redesigned** — big green OK banner so "everything's connected" is visible at a glance, responsive layout with dark-mode support, IP shown alongside the device ID, clearer step-by-step instructions.
182
- - **Setup page localized into all 11 adapter languages** — automatically picks the ioBroker system language (set in Admin → Main Settings), falls back to English for unknown languages.
188
+ Older entries are in [CHANGELOG_OLD.md](CHANGELOG_OLD.md).
183
189
 
184
190
  ## Support
185
191
 
@@ -1,161 +1,161 @@
1
1
  {
2
- "i18n": true,
3
- "type": "panel",
4
- "items": {
5
- "_headerServer": {
6
- "type": "header",
7
- "text": "header_server",
8
- "size": 4
9
- },
10
- "bindAddress": {
11
- "type": "ip",
12
- "label": "bindAddress",
13
- "tooltip": "bindAddressTooltip",
14
- "listenOnAllPorts": true,
15
- "onlyIp4": true,
16
- "noInternal": false,
17
- "xs": 12,
18
- "sm": 6,
19
- "md": 4,
20
- "lg": 4,
21
- "xl": 4
22
- },
23
- "serviceName": {
24
- "type": "text",
25
- "label": "serviceName",
26
- "tooltip": "serviceNameTooltip",
27
- "default": "ioBroker",
28
- "xs": 12,
29
- "sm": 6,
30
- "md": 4,
31
- "lg": 4,
32
- "xl": 4
33
- },
34
- "_redirectInfo": {
35
- "type": "staticText",
36
- "text": "redirectInfo",
37
- "xs": 12,
38
- "sm": 12,
39
- "md": 12,
40
- "lg": 12,
41
- "xl": 12
42
- },
43
- "_headerMdns": {
44
- "type": "header",
45
- "text": "header_mdns",
46
- "size": 4
47
- },
48
- "mdnsEnabled": {
49
- "type": "checkbox",
50
- "label": "mdnsEnabled",
51
- "tooltip": "mdnsEnabledTooltip",
52
- "default": true,
53
- "xs": 12,
54
- "sm": 12,
55
- "md": 12,
56
- "lg": 12,
57
- "xl": 12
58
- },
59
- "_mdnsInfo": {
60
- "type": "staticText",
61
- "text": "mdnsInfo",
62
- "xs": 12,
63
- "sm": 12,
64
- "md": 12,
65
- "lg": 12,
66
- "xl": 12
67
- },
68
- "_headerAuth": {
69
- "type": "header",
70
- "text": "header_auth",
71
- "size": 4
72
- },
73
- "authRequired": {
74
- "type": "checkbox",
75
- "label": "authRequired",
76
- "tooltip": "authRequiredTooltip",
77
- "default": false,
78
- "xs": 12,
79
- "sm": 12,
80
- "md": 12,
81
- "lg": 12,
82
- "xl": 12
83
- },
84
- "username": {
85
- "type": "text",
86
- "label": "username",
87
- "default": "admin",
88
- "hidden": "!data.authRequired",
89
- "xs": 12,
90
- "sm": 6,
91
- "md": 6,
92
- "lg": 6,
93
- "xl": 6
94
- },
95
- "password": {
96
- "type": "password",
97
- "label": "password",
98
- "visible": true,
99
- "hidden": "!data.authRequired",
100
- "xs": 12,
101
- "sm": 6,
102
- "md": 6,
103
- "lg": 6,
104
- "xl": 6
105
- },
106
- "_authInfo": {
107
- "type": "staticText",
108
- "text": "authInfo",
109
- "hidden": "data.authRequired",
110
- "xs": 12,
111
- "sm": 12,
112
- "md": 12,
113
- "lg": 12,
114
- "xl": 12
115
- },
116
- "_supportHeader": {
117
- "type": "header",
118
- "text": "supportHeader",
119
- "size": 5
120
- },
121
- "_aboutInfo": {
122
- "type": "staticText",
123
- "text": "aboutInfo",
124
- "xs": 12,
125
- "sm": 12,
126
- "md": 12,
127
- "lg": 12,
128
- "xl": 12,
129
- "style": {
130
- "fontSize": 14,
131
- "marginBottom": 16
132
- }
133
- },
134
- "_kofiLink": {
135
- "type": "staticLink",
136
- "href": "https://ko-fi.com/krobipd",
137
- "label": "donateKofi",
138
- "button": true,
139
- "variant": "outlined",
140
- "color": "primary",
141
- "xs": 12,
142
- "sm": 6,
143
- "md": 4,
144
- "lg": 4,
145
- "xl": 4
146
- },
147
- "_paypalLink": {
148
- "type": "staticLink",
149
- "href": "https://paypal.me/krobipd",
150
- "label": "donatePaypal",
151
- "button": true,
152
- "variant": "outlined",
153
- "color": "primary",
154
- "xs": 12,
155
- "sm": 6,
156
- "md": 4,
157
- "lg": 4,
158
- "xl": 4
159
- }
160
- }
2
+ "i18n": true,
3
+ "type": "panel",
4
+ "items": {
5
+ "_headerServer": {
6
+ "type": "header",
7
+ "text": "header_server",
8
+ "size": 4
9
+ },
10
+ "bindAddress": {
11
+ "type": "ip",
12
+ "label": "bindAddress",
13
+ "tooltip": "bindAddressTooltip",
14
+ "listenOnAllPorts": true,
15
+ "onlyIp4": true,
16
+ "noInternal": false,
17
+ "xs": 12,
18
+ "sm": 6,
19
+ "md": 4,
20
+ "lg": 4,
21
+ "xl": 4
22
+ },
23
+ "serviceName": {
24
+ "type": "text",
25
+ "label": "serviceName",
26
+ "tooltip": "serviceNameTooltip",
27
+ "default": "ioBroker",
28
+ "xs": 12,
29
+ "sm": 6,
30
+ "md": 4,
31
+ "lg": 4,
32
+ "xl": 4
33
+ },
34
+ "_redirectInfo": {
35
+ "type": "staticText",
36
+ "text": "redirectInfo",
37
+ "xs": 12,
38
+ "sm": 12,
39
+ "md": 12,
40
+ "lg": 12,
41
+ "xl": 12
42
+ },
43
+ "_headerMdns": {
44
+ "type": "header",
45
+ "text": "header_mdns",
46
+ "size": 4
47
+ },
48
+ "mdnsEnabled": {
49
+ "type": "checkbox",
50
+ "label": "mdnsEnabled",
51
+ "tooltip": "mdnsEnabledTooltip",
52
+ "default": true,
53
+ "xs": 12,
54
+ "sm": 12,
55
+ "md": 12,
56
+ "lg": 12,
57
+ "xl": 12
58
+ },
59
+ "_mdnsInfo": {
60
+ "type": "staticText",
61
+ "text": "mdnsInfo",
62
+ "xs": 12,
63
+ "sm": 12,
64
+ "md": 12,
65
+ "lg": 12,
66
+ "xl": 12
67
+ },
68
+ "_headerAuth": {
69
+ "type": "header",
70
+ "text": "header_auth",
71
+ "size": 4
72
+ },
73
+ "authRequired": {
74
+ "type": "checkbox",
75
+ "label": "authRequired",
76
+ "tooltip": "authRequiredTooltip",
77
+ "default": false,
78
+ "xs": 12,
79
+ "sm": 12,
80
+ "md": 12,
81
+ "lg": 12,
82
+ "xl": 12
83
+ },
84
+ "username": {
85
+ "type": "text",
86
+ "label": "username",
87
+ "default": "admin",
88
+ "hidden": "!data.authRequired",
89
+ "xs": 12,
90
+ "sm": 6,
91
+ "md": 6,
92
+ "lg": 6,
93
+ "xl": 6
94
+ },
95
+ "password": {
96
+ "type": "password",
97
+ "label": "password",
98
+ "visible": true,
99
+ "hidden": "!data.authRequired",
100
+ "xs": 12,
101
+ "sm": 6,
102
+ "md": 6,
103
+ "lg": 6,
104
+ "xl": 6
105
+ },
106
+ "_authInfo": {
107
+ "type": "staticText",
108
+ "text": "authInfo",
109
+ "hidden": "data.authRequired",
110
+ "xs": 12,
111
+ "sm": 12,
112
+ "md": 12,
113
+ "lg": 12,
114
+ "xl": 12
115
+ },
116
+ "_supportHeader": {
117
+ "type": "header",
118
+ "text": "supportHeader",
119
+ "size": 5
120
+ },
121
+ "_aboutInfo": {
122
+ "type": "staticText",
123
+ "text": "aboutInfo",
124
+ "xs": 12,
125
+ "sm": 12,
126
+ "md": 12,
127
+ "lg": 12,
128
+ "xl": 12,
129
+ "style": {
130
+ "fontSize": 14,
131
+ "marginBottom": 16
132
+ }
133
+ },
134
+ "_kofiLink": {
135
+ "type": "staticLink",
136
+ "href": "https://ko-fi.com/krobipd",
137
+ "label": "donateKofi",
138
+ "button": true,
139
+ "variant": "outlined",
140
+ "color": "primary",
141
+ "xs": 12,
142
+ "sm": 6,
143
+ "md": 4,
144
+ "lg": 4,
145
+ "xl": 4
146
+ },
147
+ "_paypalLink": {
148
+ "type": "staticLink",
149
+ "href": "https://paypal.me/krobipd",
150
+ "label": "donatePaypal",
151
+ "button": true,
152
+ "variant": "outlined",
153
+ "color": "primary",
154
+ "xs": 12,
155
+ "sm": 6,
156
+ "md": 4,
157
+ "lg": 4,
158
+ "xl": 4
159
+ }
160
+ }
161
161
  }
@@ -112,6 +112,7 @@ class ClientRegistry {
112
112
  const hostname = channelName && channelName !== ip && channelName !== id ? channelName : null;
113
113
  const record = { id, cookie, token, mode, manualUrl, ip, hostname };
114
114
  this.trackInMemory(record);
115
+ await this.ensureObjects(record);
115
116
  }
116
117
  this.adapter.log.debug(`client-registry: restored ${this.byId.size} client(s)`);
117
118
  }
@@ -214,14 +215,14 @@ class ClientRegistry {
214
215
  if (!record) {
215
216
  return;
216
217
  }
217
- if (typeof rawValue !== "string") {
218
- this.adapter.log.warn(`client-registry: rejected non-string mode for ${id}`);
219
- await this.adapter.setStateAsync(`clients.${id}.mode`, { val: record.mode, ack: true });
218
+ if (rawValue === 0 || rawValue === "0" || rawValue === "") {
219
+ record.mode = "";
220
+ await this.adapter.setStateAsync(`clients.${id}.mode`, { val: 0, ack: true });
220
221
  return;
221
222
  }
222
- if (rawValue === "") {
223
- record.mode = "";
224
- await this.adapter.setStateAsync(`clients.${id}.mode`, { val: "", ack: true });
223
+ if (typeof rawValue !== "string") {
224
+ this.adapter.log.warn(`client-registry: rejected non-string mode for ${id}`);
225
+ await this.adapter.setStateAsync(`clients.${id}.mode`, { val: record.mode || 0, ack: true });
225
226
  return;
226
227
  }
227
228
  if (rawValue === import_global_config.MODE_GLOBAL || rawValue === import_global_config.MODE_MANUAL) {
@@ -251,21 +252,20 @@ class ClientRegistry {
251
252
  * @param rawValue Value written to the state.
252
253
  */
253
254
  async handleManualUrlWrite(id, rawValue) {
254
- var _a;
255
+ var _a, _b;
255
256
  const record = this.byId.get(id);
256
257
  if (!record) {
257
258
  return;
258
259
  }
259
- const empty = rawValue === "" || rawValue === null || rawValue === void 0;
260
- const safe = empty ? null : (0, import_coerce.coerceSafeUrl)(rawValue);
261
- if (!empty && !safe) {
260
+ const result = (0, import_coerce.parseManualUrlWrite)(rawValue);
261
+ if (!result.ok) {
262
262
  this.adapter.log.warn(`client-registry: rejected unsafe manualUrl for ${id}`);
263
263
  await this.adapter.setStateAsync(`clients.${id}.manualUrl`, { val: (_a = record.manualUrl) != null ? _a : "", ack: true });
264
264
  return;
265
265
  }
266
- record.manualUrl = safe;
267
- await this.adapter.setStateAsync(`clients.${id}.manualUrl`, { val: safe != null ? safe : "", ack: true });
268
- if (record.mode === import_global_config.MODE_MANUAL && !safe) {
266
+ record.manualUrl = result.safe;
267
+ await this.adapter.setStateAsync(`clients.${id}.manualUrl`, { val: (_b = result.safe) != null ? _b : "", ack: true });
268
+ if (record.mode === import_global_config.MODE_MANUAL && !result.safe) {
269
269
  this.adapter.log.warn(
270
270
  `client-registry: ${id} manualUrl cleared while mode='manual' \u2014 display will hit the setup page`
271
271
  );
@@ -324,11 +324,7 @@ class ClientRegistry {
324
324
  */
325
325
  async syncUrlDropdown(states) {
326
326
  this.currentUrlStates = states;
327
- const merged = {
328
- [import_global_config.MODE_GLOBAL]: "Global URL",
329
- [import_global_config.MODE_MANUAL]: "Manual URL",
330
- ...states
331
- };
327
+ const merged = this.buildModeStates();
332
328
  for (const id of this.byId.keys()) {
333
329
  await this.adapter.extendObjectAsync(`clients.${id}.mode`, {
334
330
  common: { states: merged }
@@ -376,14 +372,31 @@ class ClientRegistry {
376
372
  this.lastSeenFlushedAt.set(record.id, now);
377
373
  this.adapter.extendObjectAsync(`clients.${record.id}`, { native: { lastSeen: now } }).catch((err) => this.adapter.log.debug(`touchLastSeen failed for ${record.id}: ${String(err)}`));
378
374
  }
379
- async createObjects(record) {
380
- var _a;
381
- const { id, cookie, mode, ip, hostname } = record;
382
- const mergedStates = {
375
+ /**
376
+ * Builds the dropdown-states map for `clients.<id>.mode`. Includes the
377
+ * `0='---'` no-choice fallback (analogous to the govee-smart pattern), the
378
+ * `'global'` + `'manual'` sentinels, and all currently discovered URLs.
379
+ */
380
+ buildModeStates() {
381
+ return {
382
+ 0: "---",
383
383
  [import_global_config.MODE_GLOBAL]: "Global URL",
384
384
  [import_global_config.MODE_MANUAL]: "Manual URL",
385
385
  ...this.currentUrlStates
386
386
  };
387
+ }
388
+ /**
389
+ * Idempotently creates all per-client objects (channel + states). Safe to
390
+ * call repeatedly — uses `setObjectNotExistsAsync` everywhere. Called from
391
+ * both `restore()` (so legacy v1.1.x clients gain the new mode/manualUrl
392
+ * objects before migration writes states) and `createClient()`.
393
+ *
394
+ * @param record Client to create or ensure objects for.
395
+ */
396
+ async ensureObjects(record) {
397
+ var _a;
398
+ const { id, cookie, ip, hostname } = record;
399
+ const mergedStates = this.buildModeStates();
387
400
  await Promise.all([
388
401
  this.adapter.setObjectNotExistsAsync(`clients.${id}`, {
389
402
  type: "channel",
@@ -402,7 +415,7 @@ class ClientRegistry {
402
415
  role: "value",
403
416
  read: true,
404
417
  write: true,
405
- def: "",
418
+ def: 0,
406
419
  states: mergedStates
407
420
  },
408
421
  native: {}
@@ -437,6 +450,10 @@ class ClientRegistry {
437
450
  native: {}
438
451
  })
439
452
  ]);
453
+ }
454
+ async createObjects(record) {
455
+ await this.ensureObjects(record);
456
+ const { id, mode, ip } = record;
440
457
  await Promise.all([
441
458
  this.adapter.setStateAsync(`clients.${id}.ip`, { val: ip != null ? ip : "", ack: true }),
442
459
  this.adapter.setStateAsync(`clients.${id}.mode`, { val: mode, ack: true }),