iobroker.hassemu 1.3.1 → 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -147,6 +147,12 @@ Reverse DNS on a home LAN depends on your router/DHCP server and often fails. Th
147
147
  ---
148
148
 
149
149
  ## Changelog
150
+ ### 1.3.2 (2026-04-30)
151
+
152
+ - Hotfix for v1.3.1: `setObjectNotExistsAsync` is a no-op on objects that already exist as partial-formed leftovers from the v1.2.0 migration bug. v1.3.2 uses `extendObjectAsync` for `clients.<id>.mode` + `clients.<id>.manualUrl` so the missing properties (top-level `type`, name, role, read, write, def) are merged into the existing partial object — js-controller's "obj.type has to exist" warning goes away and the dropdown renders the labels.
153
+ - New `repairGlobalSchemas()` in main.ts does the same defensive merge for `global.mode` + `global.manualUrl`. Runs unconditionally on every start so users upgrading from v1.2.0/v1.3.0/v1.3.1 (where the legacy `visUrl` is already gone) also get the schema repaired.
154
+ - Restore step now promotes a blank state-value (`''` left over from v1.2.0) to numeric `0`, so the dropdown actually shows the `0='---'` option as selected on first start after the upgrade.
155
+
150
156
  ### 1.3.1 (2026-04-30)
151
157
 
152
158
  - 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.
@@ -177,14 +183,6 @@ Reverse DNS on a home LAN depends on your router/DHCP server and often fails. Th
177
183
  - `nyc` config + `coverage` script added
178
184
  - Orphan `.github/auto-merge.yml` removed (active workflow is `automerge-dependabot.yml` using `gh pr merge`)
179
185
 
180
- ### 1.1.5 (2026-04-26)
181
-
182
- - Process-level `unhandledRejection` / `uncaughtException` handlers added as last-line-of-defence against fire-and-forget rejections.
183
- - Stop shipping the `manual-review` release-script plugin — adapter-only consequence.
184
- - Audit-driven boilerplate sync with the other krobi adapters (`.vscode` json5 schemas, `tsconfig.test` looser test rules).
185
- - Min js-controller correction: was `>=7.0.0`, restored to repochecker-recommended `>=6.0.11` (Source: `ioBroker.repochecker/lib/M1000_IOPackageJson.js`).
186
- - `@types/iobroker` bumped to `^7.1.1`.
187
-
188
186
  Older entries are in [CHANGELOG_OLD.md](CHANGELOG_OLD.md).
189
187
 
190
188
  ## Support
@@ -113,6 +113,10 @@ class ClientRegistry {
113
113
  const record = { id, cookie, token, mode, manualUrl, ip, hostname };
114
114
  this.trackInMemory(record);
115
115
  await this.ensureObjects(record);
116
+ const modeStateRaw = await this.readState(`${id}.mode`);
117
+ if (modeStateRaw === "" || modeStateRaw === null || modeStateRaw === void 0) {
118
+ await this.adapter.setStateAsync(`clients.${id}.mode`, { val: 0, ack: true });
119
+ }
116
120
  }
117
121
  this.adapter.log.debug(`client-registry: restored ${this.byId.size} client(s)`);
118
122
  }
@@ -397,20 +401,18 @@ class ClientRegistry {
397
401
  var _a;
398
402
  const { id, cookie, ip, hostname } = record;
399
403
  const mergedStates = this.buildModeStates();
404
+ await this.adapter.setObjectNotExistsAsync(`clients.${id}`, {
405
+ type: "channel",
406
+ common: { name: (_a = hostname != null ? hostname : ip) != null ? _a : id },
407
+ native: { cookie, token: null }
408
+ });
400
409
  await Promise.all([
401
- this.adapter.setObjectNotExistsAsync(`clients.${id}`, {
402
- type: "channel",
403
- common: { name: (_a = hostname != null ? hostname : ip) != null ? _a : id },
404
- native: { cookie, token: null }
405
- }),
406
- this.adapter.setObjectNotExistsAsync(`clients.${id}.mode`, {
410
+ this.adapter.extendObjectAsync(`clients.${id}.mode`, {
407
411
  type: "state",
408
412
  common: {
409
413
  name: "Redirect mode",
410
414
  // 'mixed' future-proofs against the upcoming js-controller
411
- // strict-type cast (see govee-smart v1.11.0 pattern). User
412
- // can write Number/String/Sentinel from Blockly/scripts
413
- // without "expects type X but received Y" warnings.
415
+ // strict-type cast (see govee-smart v1.11.0 pattern).
414
416
  type: "mixed",
415
417
  role: "value",
416
418
  read: true,
@@ -420,7 +422,7 @@ class ClientRegistry {
420
422
  },
421
423
  native: {}
422
424
  }),
423
- this.adapter.setObjectNotExistsAsync(`clients.${id}.manualUrl`, {
425
+ this.adapter.extendObjectAsync(`clients.${id}.manualUrl`, {
424
426
  type: "state",
425
427
  common: {
426
428
  name: "Manual URL",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/lib/client-registry.ts"],
4
- "sourcesContent": ["/**\n * Client Registry \u2014 persistent multi-client store.\n *\n * Each client gets a channel `clients.<id>` with native.cookie / native.token\n * and states mode / manualUrl / ip / remove. Cookie is the primary identity\n * (auto-sent by browsers on page navigation), IP is only advisory.\n *\n * Registry state is dual-homed: in-memory maps for hot lookups, ioBroker\n * objects for persistence and user-visible config.\n */\n\nimport crypto from 'node:crypto';\nimport { coerceString, coerceUuid, coerceSafeUrl, isPlainObject, parseManualUrlWrite } from './coerce';\nimport { MODE_GLOBAL, MODE_MANUAL } from './global-config';\nimport { generateClientId } from './network';\nimport type { AdapterInterface, ClientRecord, UrlStates } from './types';\n\n/** Extended adapter interface for registry \u2014 needs object and state operations. */\nexport type RegistryAdapter = AdapterInterface &\n Pick<\n ioBroker.Adapter,\n | 'namespace'\n | 'getForeignObjectsAsync'\n | 'getStateAsync'\n | 'setObjectNotExistsAsync'\n | 'extendObjectAsync'\n | 'setStateAsync'\n | 'delObjectAsync'\n >;\n\nconst CLIENTS_PREFIX = 'clients.';\n\n/** Provides the default mode value for a freshly created client. */\nexport type NewClientModeProvider = () => string;\n\n/** Persistent multi-client store: cookie \u2192 channel, with in-memory lookup maps. */\nexport class ClientRegistry {\n private readonly adapter: RegistryAdapter;\n private readonly byCookie = new Map<string, ClientRecord>();\n private readonly byId = new Map<string, ClientRecord>();\n private readonly byToken = new Map<string, ClientRecord>();\n private currentUrlStates: UrlStates = {};\n private newClientModeProvider: NewClientModeProvider = () => MODE_GLOBAL;\n /**\n * In-flight client creations keyed by remote IP. Keeps parallel cookieless\n * requests from the same display (typical on first connect: HA clients fire\n * `GET /`, `GET /api/`, `POST /auth/login_flow` almost simultaneously) from\n * each creating a separate client record. The first request starts the\n * create; parallel requests await the same Promise and receive the same\n * client + cookie.\n */\n private readonly pendingByIp = new Map<string, Promise<ClientRecord>>();\n /**\n * Throttle for lastSeen-updates per client. Keyed by client id, value is the\n * last `Date.now()` we wrote `native.lastSeen` to ioBroker. Throttle window\n * is one hour \u2014 saves us extendObject roundtrips on every request.\n */\n private readonly lastSeenFlushedAt = new Map<string, number>();\n\n /** @param adapter Adapter instance used for object/state I/O. */\n constructor(adapter: RegistryAdapter) {\n this.adapter = adapter;\n }\n\n /**\n * Wires the default-mode provider used when a new client is registered.\n * Called from main.ts once registry, globalConfig and urlDiscovery exist.\n *\n * @param provider Function returning the desired default mode for a new client.\n */\n setNewClientModeProvider(provider: NewClientModeProvider): void {\n this.newClientModeProvider = provider;\n }\n\n /** Loads existing clients from ioBroker objects into memory. Call once on adapter start. */\n async restore(): Promise<void> {\n let channels: Record<string, ioBroker.ChannelObject> = {};\n try {\n channels =\n (await this.adapter.getForeignObjectsAsync(`${this.adapter.namespace}.clients.*`, 'channel')) ?? {};\n } catch (err) {\n this.adapter.log.debug(`client-registry: restore failed: ${String(err)}`);\n return;\n }\n\n for (const [fullId, obj] of Object.entries(channels)) {\n const id = fullId.substring(`${this.adapter.namespace}.clients.`.length);\n if (!id || id.includes('.')) {\n continue;\n }\n const native = isPlainObject(obj.native) ? obj.native : {};\n const cookie = coerceUuid(native.cookie);\n if (!cookie) {\n continue;\n }\n const modeRaw = await this.readState(`${id}.mode`);\n const mode = typeof modeRaw === 'string' ? modeRaw : '';\n const manualUrl = coerceSafeUrl(await this.readState(`${id}.manualUrl`));\n const ip = coerceString(await this.readState(`${id}.ip`));\n const token = coerceUuid(native.token);\n\n // Legacy migration (<=1.1.1): hostname lived in its own state. If present,\n // move the value into common.name and drop the state.\n const legacyHostname = coerceString(await this.readState(`${id}.hostname`));\n let channelName = coerceString(obj.common?.name);\n if (legacyHostname) {\n if (legacyHostname !== channelName) {\n await this.adapter.extendObjectAsync(`clients.${id}`, { common: { name: legacyHostname } });\n channelName = legacyHostname;\n }\n try {\n await this.adapter.delObjectAsync(`clients.${id}.hostname`);\n } catch {\n /* best effort \u2014 ignore */\n }\n }\n const hostname = channelName && channelName !== ip && channelName !== id ? channelName : null;\n\n const record: ClientRecord = { id, cookie, token, mode, manualUrl, ip, hostname };\n this.trackInMemory(record);\n // Legacy clients (v1.1.x) only had `visUrl` + `ip` + `remove` objects;\n // ensure the v1.2.0+ objects (`mode`, `manualUrl`) exist before any\n // state writes from migration land \u2014 otherwise js-controller logs\n // \"State has no existing object\" warnings.\n await this.ensureObjects(record);\n }\n this.adapter.log.debug(`client-registry: restored ${this.byId.size} client(s)`);\n }\n\n /**\n * Find the client for this cookie or create a new one.\n * Creates channel + states on first call and updates IP/hostname if changed.\n *\n * @param cookie Incoming cookie value (may be null/invalid).\n * @param ip Remote IP observed by the server.\n * @param hostname Optional hostname (from reverse DNS), stored for the admin UI.\n */\n async identifyOrCreate(cookie: string | null, ip: string | null, hostname: string | null): Promise<ClientRecord> {\n const validCookie = coerceUuid(cookie);\n if (validCookie) {\n const existing = this.byCookie.get(validCookie);\n if (existing) {\n await this.updateIpHostname(existing, ip, hostname);\n this.touchLastSeen(existing);\n return existing;\n }\n }\n // No valid cookie: before spinning up a new client, check whether this\n // IP already has a create in flight. If so, await that Promise \u2014 the\n // parallel request of the same display's initial burst will get the\n // same cookie + client, no more duplicate \"New client\" log entries.\n if (ip) {\n const pending = this.pendingByIp.get(ip);\n if (pending) {\n return pending;\n }\n const promise = this.createClient(ip, hostname);\n this.pendingByIp.set(ip, promise);\n try {\n return await promise;\n } finally {\n this.pendingByIp.delete(ip);\n }\n }\n return this.createClient(ip, hostname);\n }\n\n /**\n * Lookup by short client id (channel segment).\n *\n * @param id Client id.\n */\n getById(id: string): ClientRecord | null {\n return this.byId.get(id) ?? null;\n }\n\n /**\n * Lookup by cookie value. Invalid UUIDs return null.\n *\n * @param cookie Raw cookie string.\n */\n getByCookie(cookie: string): ClientRecord | null {\n const v = coerceUuid(cookie);\n return v ? (this.byCookie.get(v) ?? null) : null;\n }\n\n /**\n * Lookup by access token issued during the auth flow.\n *\n * @param token Bearer token.\n */\n getByToken(token: string): ClientRecord | null {\n return this.byToken.get(token) ?? null;\n }\n\n /** Returns a snapshot array of all registered clients. */\n listAll(): ClientRecord[] {\n return [...this.byId.values()];\n }\n\n /**\n * Updates in-memory token and persists to channel.native. Old token is freed.\n *\n * @param id Client id.\n * @param token New bearer token, or null to clear.\n */\n async setToken(id: string, token: string | null): Promise<void> {\n const record = this.byId.get(id);\n if (!record) {\n return;\n }\n if (record.token) {\n this.byToken.delete(record.token);\n }\n record.token = token;\n if (token) {\n this.byToken.set(token, record);\n }\n await this.adapter.extendObjectAsync(`clients.${id}`, { native: { token } });\n }\n\n /**\n * Accept an external mode write on `clients.<id>.mode`.\n *\n * Allowed values: `'global'`, `'manual'`, or any URL that passes\n * {@link coerceSafeUrl}. Empty string clears the choice \u2192 setup page.\n *\n * @param id Client id.\n * @param rawValue Value written to the state.\n */\n async handleModeWrite(id: string, rawValue: unknown): Promise<void> {\n const record = this.byId.get(id);\n if (!record) {\n return;\n }\n // No-choice marker: numeric 0 (default), string '0', or empty string \u2014\n // all clear the choice and trigger the landing page.\n if (rawValue === 0 || rawValue === '0' || rawValue === '') {\n record.mode = '';\n await this.adapter.setStateAsync(`clients.${id}.mode`, { val: 0, ack: true });\n return;\n }\n if (typeof rawValue !== 'string') {\n this.adapter.log.warn(`client-registry: rejected non-string mode for ${id}`);\n await this.adapter.setStateAsync(`clients.${id}.mode`, { val: record.mode || 0, ack: true });\n return;\n }\n if (rawValue === MODE_GLOBAL || rawValue === MODE_MANUAL) {\n if (rawValue === MODE_MANUAL && !record.manualUrl) {\n this.adapter.log.warn(\n `client-registry: ${id} mode set to 'manual' but manualUrl is empty \u2014 fill clients.${id}.manualUrl to redirect`,\n );\n }\n record.mode = rawValue;\n await this.adapter.setStateAsync(`clients.${id}.mode`, { val: rawValue, ack: true });\n return;\n }\n const safe = coerceSafeUrl(rawValue);\n if (!safe) {\n this.adapter.log.warn(`client-registry: rejected unsafe mode value for ${id}: '${rawValue}'`);\n await this.adapter.setStateAsync(`clients.${id}.mode`, { val: record.mode, ack: true });\n return;\n }\n record.mode = safe;\n await this.adapter.setStateAsync(`clients.${id}.mode`, { val: safe, ack: true });\n }\n\n /**\n * Accept an external manualUrl write on `clients.<id>.manualUrl`.\n * Free-text \u2014 must pass {@link coerceSafeUrl} or be empty (clears).\n *\n * @param id Client id.\n * @param rawValue Value written to the state.\n */\n async handleManualUrlWrite(id: string, rawValue: unknown): Promise<void> {\n const record = this.byId.get(id);\n if (!record) {\n return;\n }\n const result = parseManualUrlWrite(rawValue);\n if (!result.ok) {\n this.adapter.log.warn(`client-registry: rejected unsafe manualUrl for ${id}`);\n await this.adapter.setStateAsync(`clients.${id}.manualUrl`, { val: record.manualUrl ?? '', ack: true });\n return;\n }\n record.manualUrl = result.safe;\n await this.adapter.setStateAsync(`clients.${id}.manualUrl`, { val: result.safe ?? '', ack: true });\n if (record.mode === MODE_MANUAL && !result.safe) {\n this.adapter.log.warn(\n `client-registry: ${id} manualUrl cleared while mode='manual' \u2014 display will hit the setup page`,\n );\n }\n }\n\n /**\n * Set every client's `mode` to the same value. Used by the master switch\n * (`global.enabled`) to bulk-sync all displays \u2014 `'global'` when on,\n * the first discovered URL when off.\n *\n * Skips clients whose mode already matches (no spurious state writes).\n *\n * @param value New mode value (sentinel or URL).\n */\n async bulkSetMode(value: string): Promise<void> {\n let changed = 0;\n for (const record of this.byId.values()) {\n if (record.mode === value) {\n continue;\n }\n record.mode = value;\n await this.adapter.setStateAsync(`clients.${record.id}.mode`, { val: value, ack: true });\n changed++;\n }\n if (changed > 0) {\n this.adapter.log.info(`client-registry: bulk-set mode='${value}' on ${changed} client(s)`);\n }\n }\n\n /**\n * Removes the client entirely \u2014 channel + states deleted, next visit creates a new entry.\n *\n * @param id Client id to forget.\n */\n async remove(id: string): Promise<void> {\n const record = this.byId.get(id);\n if (!record) {\n return;\n }\n this.byId.delete(id);\n this.byCookie.delete(record.cookie);\n if (record.token) {\n this.byToken.delete(record.token);\n }\n try {\n await this.adapter.delObjectAsync(`clients.${id}`, { recursive: true });\n } catch (err) {\n this.adapter.log.warn(`client-registry: delObject failed for ${id}: ${String(err)}`);\n }\n this.adapter.log.info(`Client forgotten: ${id}`);\n }\n\n /**\n * Updates the mode dropdown states (`common.states`) on every client's mode datapoint.\n * Adds the `'global'` and `'manual'` sentinels on top of the discovered URLs.\n *\n * @param states Discovered URL \u2192 label map.\n */\n async syncUrlDropdown(states: UrlStates): Promise<void> {\n this.currentUrlStates = states;\n const merged = this.buildModeStates();\n for (const id of this.byId.keys()) {\n await this.adapter.extendObjectAsync(`clients.${id}.mode`, {\n common: { states: merged },\n });\n }\n }\n\n // --- internal ---\n\n private trackInMemory(record: ClientRecord): void {\n this.byId.set(record.id, record);\n this.byCookie.set(record.cookie, record);\n if (record.token) {\n this.byToken.set(record.token, record);\n }\n }\n\n private async createClient(ip: string | null, hostname: string | null): Promise<ClientRecord> {\n let id = generateClientId();\n while (this.byId.has(id)) {\n id = generateClientId();\n }\n const cookie = crypto.randomUUID();\n const mode = this.newClientModeProvider();\n const record: ClientRecord = { id, cookie, token: null, mode, manualUrl: null, ip, hostname };\n this.trackInMemory(record);\n await this.createObjects(record);\n this.touchLastSeen(record);\n this.adapter.log.info(`New client registered: ${id}${ip ? ` (${hostname ?? ip})` : ''}, mode='${mode}'`);\n return record;\n }\n\n /**\n * Updates `native.lastSeen` on the channel, throttled to once per hour per\n * client. Used for the stale-client-GC: clients without token + lastSeen\n * older than 30 days get auto-removed on adapter start.\n *\n * Fire-and-forget \u2014 failures only debug-logged.\n *\n * @param record Client whose lastSeen-timestamp should be refreshed.\n */\n private touchLastSeen(record: ClientRecord): void {\n const now = Date.now();\n const last = this.lastSeenFlushedAt.get(record.id) ?? 0;\n if (now - last < 60 * 60 * 1000) {\n return; // throttle: 1\u00D7 per hour\n }\n this.lastSeenFlushedAt.set(record.id, now);\n this.adapter\n .extendObjectAsync(`clients.${record.id}`, { native: { lastSeen: now } })\n .catch(err => this.adapter.log.debug(`touchLastSeen failed for ${record.id}: ${String(err)}`));\n }\n\n /**\n * Builds the dropdown-states map for `clients.<id>.mode`. Includes the\n * `0='---'` no-choice fallback (analogous to the govee-smart pattern), the\n * `'global'` + `'manual'` sentinels, and all currently discovered URLs.\n */\n private buildModeStates(): UrlStates {\n return {\n 0: '---',\n [MODE_GLOBAL]: 'Global URL',\n [MODE_MANUAL]: 'Manual URL',\n ...this.currentUrlStates,\n };\n }\n\n /**\n * Idempotently creates all per-client objects (channel + states). Safe to\n * call repeatedly \u2014 uses `setObjectNotExistsAsync` everywhere. Called from\n * both `restore()` (so legacy v1.1.x clients gain the new mode/manualUrl\n * objects before migration writes states) and `createClient()`.\n *\n * @param record Client to create or ensure objects for.\n */\n private async ensureObjects(record: ClientRecord): Promise<void> {\n const { id, cookie, ip, hostname } = record;\n const mergedStates = this.buildModeStates();\n\n await Promise.all([\n this.adapter.setObjectNotExistsAsync(`clients.${id}`, {\n type: 'channel',\n common: { name: hostname ?? ip ?? id },\n native: { cookie, token: null },\n }),\n this.adapter.setObjectNotExistsAsync(`clients.${id}.mode`, {\n type: 'state',\n common: {\n name: 'Redirect mode',\n // 'mixed' future-proofs against the upcoming js-controller\n // strict-type cast (see govee-smart v1.11.0 pattern). User\n // can write Number/String/Sentinel from Blockly/scripts\n // without \"expects type X but received Y\" warnings.\n type: 'mixed',\n role: 'value',\n read: true,\n write: true,\n def: 0,\n states: mergedStates,\n },\n native: {},\n }),\n this.adapter.setObjectNotExistsAsync(`clients.${id}.manualUrl`, {\n type: 'state',\n common: {\n name: 'Manual URL',\n type: 'string',\n role: 'url',\n read: true,\n write: true,\n def: '',\n },\n native: {},\n }),\n this.adapter.setObjectNotExistsAsync(`clients.${id}.ip`, {\n type: 'state',\n common: { name: 'Client IP', type: 'string', role: 'info.ip', read: true, write: false, def: '' },\n native: {},\n }),\n this.adapter.setObjectNotExistsAsync(`clients.${id}.remove`, {\n type: 'state',\n common: {\n name: 'Forget this client',\n type: 'boolean',\n role: 'button',\n read: false,\n write: true,\n def: false,\n },\n native: {},\n }),\n ]);\n }\n\n private async createObjects(record: ClientRecord): Promise<void> {\n await this.ensureObjects(record);\n const { id, mode, ip } = record;\n await Promise.all([\n this.adapter.setStateAsync(`clients.${id}.ip`, { val: ip ?? '', ack: true }),\n this.adapter.setStateAsync(`clients.${id}.mode`, { val: mode, ack: true }),\n this.adapter.setStateAsync(`clients.${id}.manualUrl`, { val: '', ack: true }),\n ]);\n }\n\n private async updateIpHostname(record: ClientRecord, ip: string | null, hostname: string | null): Promise<void> {\n if (ip && ip !== record.ip) {\n record.ip = ip;\n await this.adapter.setStateAsync(`clients.${record.id}.ip`, { val: ip, ack: true });\n // If no hostname known yet, common.name falls back to the IP \u2014 keep it current.\n if (!record.hostname) {\n await this.adapter.extendObjectAsync(`clients.${record.id}`, { common: { name: ip } });\n }\n }\n if (hostname && hostname !== record.hostname) {\n record.hostname = hostname;\n await this.adapter.extendObjectAsync(`clients.${record.id}`, { common: { name: hostname } });\n }\n }\n\n private async readState(subId: string): Promise<unknown> {\n try {\n const s = await this.adapter.getStateAsync(`clients.${subId}`);\n return s?.val ?? null;\n } catch {\n return null;\n }\n }\n}\n\n/**\n * Check whether a full state ID matches a client control datapoint and extract id + kind.\n *\n * @param fullId The full state id from a state change event.\n * @param namespace The adapter namespace (e.g. `hassemu.0`).\n */\nexport function parseClientStateId(\n fullId: string,\n namespace: string,\n): { id: string; kind: 'mode' | 'manualUrl' | 'remove' } | null {\n const prefix = `${namespace}.${CLIENTS_PREFIX}`;\n if (!fullId.startsWith(prefix)) {\n return null;\n }\n const tail = fullId.substring(prefix.length);\n const parts = tail.split('.');\n if (parts.length !== 2) {\n return null;\n }\n const [id, kind] = parts;\n if (kind !== 'mode' && kind !== 'manualUrl' && kind !== 'remove') {\n return null;\n }\n return { id, kind };\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["/**\n * Client Registry \u2014 persistent multi-client store.\n *\n * Each client gets a channel `clients.<id>` with native.cookie / native.token\n * and states mode / manualUrl / ip / remove. Cookie is the primary identity\n * (auto-sent by browsers on page navigation), IP is only advisory.\n *\n * Registry state is dual-homed: in-memory maps for hot lookups, ioBroker\n * objects for persistence and user-visible config.\n */\n\nimport crypto from 'node:crypto';\nimport { coerceString, coerceUuid, coerceSafeUrl, isPlainObject, parseManualUrlWrite } from './coerce';\nimport { MODE_GLOBAL, MODE_MANUAL } from './global-config';\nimport { generateClientId } from './network';\nimport type { AdapterInterface, ClientRecord, UrlStates } from './types';\n\n/** Extended adapter interface for registry \u2014 needs object and state operations. */\nexport type RegistryAdapter = AdapterInterface &\n Pick<\n ioBroker.Adapter,\n | 'namespace'\n | 'getForeignObjectsAsync'\n | 'getStateAsync'\n | 'setObjectNotExistsAsync'\n | 'extendObjectAsync'\n | 'setStateAsync'\n | 'delObjectAsync'\n >;\n\nconst CLIENTS_PREFIX = 'clients.';\n\n/** Provides the default mode value for a freshly created client. */\nexport type NewClientModeProvider = () => string;\n\n/** Persistent multi-client store: cookie \u2192 channel, with in-memory lookup maps. */\nexport class ClientRegistry {\n private readonly adapter: RegistryAdapter;\n private readonly byCookie = new Map<string, ClientRecord>();\n private readonly byId = new Map<string, ClientRecord>();\n private readonly byToken = new Map<string, ClientRecord>();\n private currentUrlStates: UrlStates = {};\n private newClientModeProvider: NewClientModeProvider = () => MODE_GLOBAL;\n /**\n * In-flight client creations keyed by remote IP. Keeps parallel cookieless\n * requests from the same display (typical on first connect: HA clients fire\n * `GET /`, `GET /api/`, `POST /auth/login_flow` almost simultaneously) from\n * each creating a separate client record. The first request starts the\n * create; parallel requests await the same Promise and receive the same\n * client + cookie.\n */\n private readonly pendingByIp = new Map<string, Promise<ClientRecord>>();\n /**\n * Throttle for lastSeen-updates per client. Keyed by client id, value is the\n * last `Date.now()` we wrote `native.lastSeen` to ioBroker. Throttle window\n * is one hour \u2014 saves us extendObject roundtrips on every request.\n */\n private readonly lastSeenFlushedAt = new Map<string, number>();\n\n /** @param adapter Adapter instance used for object/state I/O. */\n constructor(adapter: RegistryAdapter) {\n this.adapter = adapter;\n }\n\n /**\n * Wires the default-mode provider used when a new client is registered.\n * Called from main.ts once registry, globalConfig and urlDiscovery exist.\n *\n * @param provider Function returning the desired default mode for a new client.\n */\n setNewClientModeProvider(provider: NewClientModeProvider): void {\n this.newClientModeProvider = provider;\n }\n\n /** Loads existing clients from ioBroker objects into memory. Call once on adapter start. */\n async restore(): Promise<void> {\n let channels: Record<string, ioBroker.ChannelObject> = {};\n try {\n channels =\n (await this.adapter.getForeignObjectsAsync(`${this.adapter.namespace}.clients.*`, 'channel')) ?? {};\n } catch (err) {\n this.adapter.log.debug(`client-registry: restore failed: ${String(err)}`);\n return;\n }\n\n for (const [fullId, obj] of Object.entries(channels)) {\n const id = fullId.substring(`${this.adapter.namespace}.clients.`.length);\n if (!id || id.includes('.')) {\n continue;\n }\n const native = isPlainObject(obj.native) ? obj.native : {};\n const cookie = coerceUuid(native.cookie);\n if (!cookie) {\n continue;\n }\n const modeRaw = await this.readState(`${id}.mode`);\n const mode = typeof modeRaw === 'string' ? modeRaw : '';\n const manualUrl = coerceSafeUrl(await this.readState(`${id}.manualUrl`));\n const ip = coerceString(await this.readState(`${id}.ip`));\n const token = coerceUuid(native.token);\n\n // Legacy migration (<=1.1.1): hostname lived in its own state. If present,\n // move the value into common.name and drop the state.\n const legacyHostname = coerceString(await this.readState(`${id}.hostname`));\n let channelName = coerceString(obj.common?.name);\n if (legacyHostname) {\n if (legacyHostname !== channelName) {\n await this.adapter.extendObjectAsync(`clients.${id}`, { common: { name: legacyHostname } });\n channelName = legacyHostname;\n }\n try {\n await this.adapter.delObjectAsync(`clients.${id}.hostname`);\n } catch {\n /* best effort \u2014 ignore */\n }\n }\n const hostname = channelName && channelName !== ip && channelName !== id ? channelName : null;\n\n const record: ClientRecord = { id, cookie, token, mode, manualUrl, ip, hostname };\n this.trackInMemory(record);\n // Legacy clients (v1.1.x) only had `visUrl` + `ip` + `remove` objects;\n // ensure the v1.2.0+ objects (`mode`, `manualUrl`) exist before any\n // state writes from migration land \u2014 otherwise js-controller logs\n // \"State has no existing object\" warnings.\n await this.ensureObjects(record);\n // Promote blank state-value to numeric 0 so the dropdown renders\n // the `0='---'` option as selected. v1.2.0 installs left the value\n // as `''` which doesn't match any common.states entry.\n const modeStateRaw = await this.readState(`${id}.mode`);\n if (modeStateRaw === '' || modeStateRaw === null || modeStateRaw === undefined) {\n await this.adapter.setStateAsync(`clients.${id}.mode`, { val: 0, ack: true });\n }\n }\n this.adapter.log.debug(`client-registry: restored ${this.byId.size} client(s)`);\n }\n\n /**\n * Find the client for this cookie or create a new one.\n * Creates channel + states on first call and updates IP/hostname if changed.\n *\n * @param cookie Incoming cookie value (may be null/invalid).\n * @param ip Remote IP observed by the server.\n * @param hostname Optional hostname (from reverse DNS), stored for the admin UI.\n */\n async identifyOrCreate(cookie: string | null, ip: string | null, hostname: string | null): Promise<ClientRecord> {\n const validCookie = coerceUuid(cookie);\n if (validCookie) {\n const existing = this.byCookie.get(validCookie);\n if (existing) {\n await this.updateIpHostname(existing, ip, hostname);\n this.touchLastSeen(existing);\n return existing;\n }\n }\n // No valid cookie: before spinning up a new client, check whether this\n // IP already has a create in flight. If so, await that Promise \u2014 the\n // parallel request of the same display's initial burst will get the\n // same cookie + client, no more duplicate \"New client\" log entries.\n if (ip) {\n const pending = this.pendingByIp.get(ip);\n if (pending) {\n return pending;\n }\n const promise = this.createClient(ip, hostname);\n this.pendingByIp.set(ip, promise);\n try {\n return await promise;\n } finally {\n this.pendingByIp.delete(ip);\n }\n }\n return this.createClient(ip, hostname);\n }\n\n /**\n * Lookup by short client id (channel segment).\n *\n * @param id Client id.\n */\n getById(id: string): ClientRecord | null {\n return this.byId.get(id) ?? null;\n }\n\n /**\n * Lookup by cookie value. Invalid UUIDs return null.\n *\n * @param cookie Raw cookie string.\n */\n getByCookie(cookie: string): ClientRecord | null {\n const v = coerceUuid(cookie);\n return v ? (this.byCookie.get(v) ?? null) : null;\n }\n\n /**\n * Lookup by access token issued during the auth flow.\n *\n * @param token Bearer token.\n */\n getByToken(token: string): ClientRecord | null {\n return this.byToken.get(token) ?? null;\n }\n\n /** Returns a snapshot array of all registered clients. */\n listAll(): ClientRecord[] {\n return [...this.byId.values()];\n }\n\n /**\n * Updates in-memory token and persists to channel.native. Old token is freed.\n *\n * @param id Client id.\n * @param token New bearer token, or null to clear.\n */\n async setToken(id: string, token: string | null): Promise<void> {\n const record = this.byId.get(id);\n if (!record) {\n return;\n }\n if (record.token) {\n this.byToken.delete(record.token);\n }\n record.token = token;\n if (token) {\n this.byToken.set(token, record);\n }\n await this.adapter.extendObjectAsync(`clients.${id}`, { native: { token } });\n }\n\n /**\n * Accept an external mode write on `clients.<id>.mode`.\n *\n * Allowed values: `'global'`, `'manual'`, or any URL that passes\n * {@link coerceSafeUrl}. Empty string clears the choice \u2192 setup page.\n *\n * @param id Client id.\n * @param rawValue Value written to the state.\n */\n async handleModeWrite(id: string, rawValue: unknown): Promise<void> {\n const record = this.byId.get(id);\n if (!record) {\n return;\n }\n // No-choice marker: numeric 0 (default), string '0', or empty string \u2014\n // all clear the choice and trigger the landing page.\n if (rawValue === 0 || rawValue === '0' || rawValue === '') {\n record.mode = '';\n await this.adapter.setStateAsync(`clients.${id}.mode`, { val: 0, ack: true });\n return;\n }\n if (typeof rawValue !== 'string') {\n this.adapter.log.warn(`client-registry: rejected non-string mode for ${id}`);\n await this.adapter.setStateAsync(`clients.${id}.mode`, { val: record.mode || 0, ack: true });\n return;\n }\n if (rawValue === MODE_GLOBAL || rawValue === MODE_MANUAL) {\n if (rawValue === MODE_MANUAL && !record.manualUrl) {\n this.adapter.log.warn(\n `client-registry: ${id} mode set to 'manual' but manualUrl is empty \u2014 fill clients.${id}.manualUrl to redirect`,\n );\n }\n record.mode = rawValue;\n await this.adapter.setStateAsync(`clients.${id}.mode`, { val: rawValue, ack: true });\n return;\n }\n const safe = coerceSafeUrl(rawValue);\n if (!safe) {\n this.adapter.log.warn(`client-registry: rejected unsafe mode value for ${id}: '${rawValue}'`);\n await this.adapter.setStateAsync(`clients.${id}.mode`, { val: record.mode, ack: true });\n return;\n }\n record.mode = safe;\n await this.adapter.setStateAsync(`clients.${id}.mode`, { val: safe, ack: true });\n }\n\n /**\n * Accept an external manualUrl write on `clients.<id>.manualUrl`.\n * Free-text \u2014 must pass {@link coerceSafeUrl} or be empty (clears).\n *\n * @param id Client id.\n * @param rawValue Value written to the state.\n */\n async handleManualUrlWrite(id: string, rawValue: unknown): Promise<void> {\n const record = this.byId.get(id);\n if (!record) {\n return;\n }\n const result = parseManualUrlWrite(rawValue);\n if (!result.ok) {\n this.adapter.log.warn(`client-registry: rejected unsafe manualUrl for ${id}`);\n await this.adapter.setStateAsync(`clients.${id}.manualUrl`, { val: record.manualUrl ?? '', ack: true });\n return;\n }\n record.manualUrl = result.safe;\n await this.adapter.setStateAsync(`clients.${id}.manualUrl`, { val: result.safe ?? '', ack: true });\n if (record.mode === MODE_MANUAL && !result.safe) {\n this.adapter.log.warn(\n `client-registry: ${id} manualUrl cleared while mode='manual' \u2014 display will hit the setup page`,\n );\n }\n }\n\n /**\n * Set every client's `mode` to the same value. Used by the master switch\n * (`global.enabled`) to bulk-sync all displays \u2014 `'global'` when on,\n * the first discovered URL when off.\n *\n * Skips clients whose mode already matches (no spurious state writes).\n *\n * @param value New mode value (sentinel or URL).\n */\n async bulkSetMode(value: string): Promise<void> {\n let changed = 0;\n for (const record of this.byId.values()) {\n if (record.mode === value) {\n continue;\n }\n record.mode = value;\n await this.adapter.setStateAsync(`clients.${record.id}.mode`, { val: value, ack: true });\n changed++;\n }\n if (changed > 0) {\n this.adapter.log.info(`client-registry: bulk-set mode='${value}' on ${changed} client(s)`);\n }\n }\n\n /**\n * Removes the client entirely \u2014 channel + states deleted, next visit creates a new entry.\n *\n * @param id Client id to forget.\n */\n async remove(id: string): Promise<void> {\n const record = this.byId.get(id);\n if (!record) {\n return;\n }\n this.byId.delete(id);\n this.byCookie.delete(record.cookie);\n if (record.token) {\n this.byToken.delete(record.token);\n }\n try {\n await this.adapter.delObjectAsync(`clients.${id}`, { recursive: true });\n } catch (err) {\n this.adapter.log.warn(`client-registry: delObject failed for ${id}: ${String(err)}`);\n }\n this.adapter.log.info(`Client forgotten: ${id}`);\n }\n\n /**\n * Updates the mode dropdown states (`common.states`) on every client's mode datapoint.\n * Adds the `'global'` and `'manual'` sentinels on top of the discovered URLs.\n *\n * @param states Discovered URL \u2192 label map.\n */\n async syncUrlDropdown(states: UrlStates): Promise<void> {\n this.currentUrlStates = states;\n const merged = this.buildModeStates();\n for (const id of this.byId.keys()) {\n await this.adapter.extendObjectAsync(`clients.${id}.mode`, {\n common: { states: merged },\n });\n }\n }\n\n // --- internal ---\n\n private trackInMemory(record: ClientRecord): void {\n this.byId.set(record.id, record);\n this.byCookie.set(record.cookie, record);\n if (record.token) {\n this.byToken.set(record.token, record);\n }\n }\n\n private async createClient(ip: string | null, hostname: string | null): Promise<ClientRecord> {\n let id = generateClientId();\n while (this.byId.has(id)) {\n id = generateClientId();\n }\n const cookie = crypto.randomUUID();\n const mode = this.newClientModeProvider();\n const record: ClientRecord = { id, cookie, token: null, mode, manualUrl: null, ip, hostname };\n this.trackInMemory(record);\n await this.createObjects(record);\n this.touchLastSeen(record);\n this.adapter.log.info(`New client registered: ${id}${ip ? ` (${hostname ?? ip})` : ''}, mode='${mode}'`);\n return record;\n }\n\n /**\n * Updates `native.lastSeen` on the channel, throttled to once per hour per\n * client. Used for the stale-client-GC: clients without token + lastSeen\n * older than 30 days get auto-removed on adapter start.\n *\n * Fire-and-forget \u2014 failures only debug-logged.\n *\n * @param record Client whose lastSeen-timestamp should be refreshed.\n */\n private touchLastSeen(record: ClientRecord): void {\n const now = Date.now();\n const last = this.lastSeenFlushedAt.get(record.id) ?? 0;\n if (now - last < 60 * 60 * 1000) {\n return; // throttle: 1\u00D7 per hour\n }\n this.lastSeenFlushedAt.set(record.id, now);\n this.adapter\n .extendObjectAsync(`clients.${record.id}`, { native: { lastSeen: now } })\n .catch(err => this.adapter.log.debug(`touchLastSeen failed for ${record.id}: ${String(err)}`));\n }\n\n /**\n * Builds the dropdown-states map for `clients.<id>.mode`. Includes the\n * `0='---'` no-choice fallback (analogous to the govee-smart pattern), the\n * `'global'` + `'manual'` sentinels, and all currently discovered URLs.\n */\n private buildModeStates(): UrlStates {\n return {\n 0: '---',\n [MODE_GLOBAL]: 'Global URL',\n [MODE_MANUAL]: 'Manual URL',\n ...this.currentUrlStates,\n };\n }\n\n /**\n * Idempotently creates all per-client objects (channel + states). Safe to\n * call repeatedly \u2014 uses `setObjectNotExistsAsync` everywhere. Called from\n * both `restore()` (so legacy v1.1.x clients gain the new mode/manualUrl\n * objects before migration writes states) and `createClient()`.\n *\n * @param record Client to create or ensure objects for.\n */\n private async ensureObjects(record: ClientRecord): Promise<void> {\n const { id, cookie, ip, hostname } = record;\n const mergedStates = this.buildModeStates();\n\n // Channel: setObjectNotExistsAsync \u2014 common.name is updated dynamically\n // by updateIpHostname() when reverse-DNS resolves; we must not clobber it.\n await this.adapter.setObjectNotExistsAsync(`clients.${id}`, {\n type: 'channel',\n common: { name: hostname ?? ip ?? id },\n native: { cookie, token: null },\n });\n\n // States: extendObjectAsync \u2014 REPAIRS partial objects from the v1.2.0\n // migration bug (extendObjectAsync was called with only common.type:'mixed',\n // creating an object without top-level type/name/role/read/write/def).\n // Using extend instead of setObjectNotExists merges missing properties\n // onto the existing partial object so js-controller stops warning\n // \"obj.type has to exist\" and the dropdown renders correctly.\n await Promise.all([\n this.adapter.extendObjectAsync(`clients.${id}.mode`, {\n type: 'state',\n common: {\n name: 'Redirect mode',\n // 'mixed' future-proofs against the upcoming js-controller\n // strict-type cast (see govee-smart v1.11.0 pattern).\n type: 'mixed',\n role: 'value',\n read: true,\n write: true,\n def: 0,\n states: mergedStates,\n },\n native: {},\n }),\n this.adapter.extendObjectAsync(`clients.${id}.manualUrl`, {\n type: 'state',\n common: {\n name: 'Manual URL',\n type: 'string',\n role: 'url',\n read: true,\n write: true,\n def: '',\n },\n native: {},\n }),\n this.adapter.setObjectNotExistsAsync(`clients.${id}.ip`, {\n type: 'state',\n common: { name: 'Client IP', type: 'string', role: 'info.ip', read: true, write: false, def: '' },\n native: {},\n }),\n this.adapter.setObjectNotExistsAsync(`clients.${id}.remove`, {\n type: 'state',\n common: {\n name: 'Forget this client',\n type: 'boolean',\n role: 'button',\n read: false,\n write: true,\n def: false,\n },\n native: {},\n }),\n ]);\n }\n\n private async createObjects(record: ClientRecord): Promise<void> {\n await this.ensureObjects(record);\n const { id, mode, ip } = record;\n await Promise.all([\n this.adapter.setStateAsync(`clients.${id}.ip`, { val: ip ?? '', ack: true }),\n this.adapter.setStateAsync(`clients.${id}.mode`, { val: mode, ack: true }),\n this.adapter.setStateAsync(`clients.${id}.manualUrl`, { val: '', ack: true }),\n ]);\n }\n\n private async updateIpHostname(record: ClientRecord, ip: string | null, hostname: string | null): Promise<void> {\n if (ip && ip !== record.ip) {\n record.ip = ip;\n await this.adapter.setStateAsync(`clients.${record.id}.ip`, { val: ip, ack: true });\n // If no hostname known yet, common.name falls back to the IP \u2014 keep it current.\n if (!record.hostname) {\n await this.adapter.extendObjectAsync(`clients.${record.id}`, { common: { name: ip } });\n }\n }\n if (hostname && hostname !== record.hostname) {\n record.hostname = hostname;\n await this.adapter.extendObjectAsync(`clients.${record.id}`, { common: { name: hostname } });\n }\n }\n\n private async readState(subId: string): Promise<unknown> {\n try {\n const s = await this.adapter.getStateAsync(`clients.${subId}`);\n return s?.val ?? null;\n } catch {\n return null;\n }\n }\n}\n\n/**\n * Check whether a full state ID matches a client control datapoint and extract id + kind.\n *\n * @param fullId The full state id from a state change event.\n * @param namespace The adapter namespace (e.g. `hassemu.0`).\n */\nexport function parseClientStateId(\n fullId: string,\n namespace: string,\n): { id: string; kind: 'mode' | 'manualUrl' | 'remove' } | null {\n const prefix = `${namespace}.${CLIENTS_PREFIX}`;\n if (!fullId.startsWith(prefix)) {\n return null;\n }\n const tail = fullId.substring(prefix.length);\n const parts = tail.split('.');\n if (parts.length !== 2) {\n return null;\n }\n const [id, kind] = parts;\n if (kind !== 'mode' && kind !== 'manualUrl' && kind !== 'remove') {\n return null;\n }\n return { id, kind };\n}\n"],
5
+ "mappings": "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",
6
6
  "names": ["crypto"]
7
7
  }
@@ -44,6 +44,10 @@ class GlobalConfig {
44
44
  this.mode = typeof (modeState == null ? void 0 : modeState.val) === "string" ? modeState.val : "";
45
45
  this.manualUrl = (0, import_coerce.coerceSafeUrl)(manualState == null ? void 0 : manualState.val);
46
46
  this.enabled = (0, import_coerce.coerceBoolean)(enabledState == null ? void 0 : enabledState.val) === true;
47
+ const v = modeState == null ? void 0 : modeState.val;
48
+ if (v === "" || v === null || v === void 0) {
49
+ await this.adapter.setStateAsync("global.mode", { val: 0, ack: true });
50
+ }
47
51
  }
48
52
  /**
49
53
  * Resolves the redirect URL for `record`.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/lib/global-config.ts"],
4
- "sourcesContent": ["/**\n * Global redirect override.\n *\n * Holds three datapoints:\n * - `global.enabled` \u2014 master switch. Toggling triggers a bulk-update of all\n * `clients.<id>.mode` (driven from {@link main.ts}, not from this class).\n * - `global.mode` \u2014 dropdown with discovered URLs plus the `'manual'` sentinel.\n * `'global'` is intentionally not allowed here (would be self-referential).\n * - `global.manualUrl` \u2014 free-text URL used when `global.mode === 'manual'`.\n *\n * The resolver delegates: a client whose `mode === 'global'` ends up here.\n */\n\nimport { coerceBoolean, coerceSafeUrl, parseManualUrlWrite } from './coerce';\nimport type { AdapterInterface, ClientRecord, UrlStates } from './types';\n\n/** Extended adapter interface \u2014 needs state I/O and object extend. */\nexport type GlobalConfigAdapter = AdapterInterface &\n Pick<ioBroker.Adapter, 'getStateAsync' | 'setStateAsync' | 'extendObjectAsync'>;\n\n/** Kinds of state IDs the GlobalConfig reacts to. */\nexport type GlobalStateKind = 'mode' | 'manualUrl' | 'enabled';\n\n/** Sentinel value: client delegates to global. Not legal as `global.mode`. */\nexport const MODE_GLOBAL = 'global';\n/** Sentinel value: use the matching manualUrl datapoint. */\nexport const MODE_MANUAL = 'manual';\n\n/** Holds the runtime state of the global redirect override. */\nexport class GlobalConfig {\n private readonly adapter: GlobalConfigAdapter;\n private mode: string = '';\n private manualUrl: string | null = null;\n private enabled = false;\n\n /** @param adapter Adapter instance used for state and object I/O. */\n constructor(adapter: GlobalConfigAdapter) {\n this.adapter = adapter;\n }\n\n /** Loads the current global.* values from the broker. Call once on adapter start. */\n async restore(): Promise<void> {\n const modeState = await this.safeGetState('global.mode');\n const manualState = await this.safeGetState('global.manualUrl');\n const enabledState = await this.safeGetState('global.enabled');\n this.mode = typeof modeState?.val === 'string' ? modeState.val : '';\n this.manualUrl = coerceSafeUrl(manualState?.val);\n this.enabled = coerceBoolean(enabledState?.val) === true;\n }\n\n /**\n * Resolves the redirect URL for `record`.\n *\n * Delegates via the client's `mode`:\n * - `'global'` \u2192 resolve global mode/manualUrl\n * - `'manual'` \u2192 client's manualUrl\n * - URL string \u2192 that URL\n * - empty / unknown \u2192 null (setup page)\n *\n * @param record Client to resolve for.\n */\n resolveUrlFor(record: ClientRecord): string | null {\n return this.resolveClientMode(record);\n }\n\n private resolveClientMode(record: ClientRecord): string | null {\n const m: unknown = record.mode;\n // No-choice markers: numeric 0, string '0', empty string \u2014 all \u2192 null\n if (m === 0 || m === '0' || m === '') {\n return null;\n }\n if (m === MODE_GLOBAL) {\n return this.resolveGlobalMode();\n }\n if (m === MODE_MANUAL) {\n return record.manualUrl ?? null;\n }\n return coerceSafeUrl(m);\n }\n\n private resolveGlobalMode(): string | null {\n const m: unknown = this.mode;\n if (m === 0 || m === '0' || m === '') {\n return null;\n }\n if (this.mode === MODE_MANUAL) {\n return this.manualUrl;\n }\n return coerceSafeUrl(this.mode);\n }\n\n /** Returns whether the master switch is currently active. */\n isEnabled(): boolean {\n return this.enabled;\n }\n\n /**\n * Accept a write on `global.mode`. Allowed values: `'manual'` or a URL that\n * passes {@link coerceSafeUrl}. `'global'` is rejected (would be\n * self-referential). Empty string clears the choice.\n *\n * @param rawValue Value written to the state.\n */\n async handleModeWrite(rawValue: unknown): Promise<void> {\n // No-choice markers: numeric 0, string '0', empty string \u2014 all clear the choice\n if (rawValue === 0 || rawValue === '0' || rawValue === '') {\n this.mode = '';\n await this.adapter.setStateAsync('global.mode', { val: 0, ack: true });\n return;\n }\n if (typeof rawValue !== 'string') {\n this.adapter.log.warn('global-config: rejected non-string global.mode');\n await this.adapter.setStateAsync('global.mode', { val: this.mode || 0, ack: true });\n return;\n }\n if (rawValue === MODE_GLOBAL) {\n this.adapter.log.warn(\"global-config: 'global' is not allowed as global.mode (self-referential)\");\n await this.adapter.setStateAsync('global.mode', { val: this.mode, ack: true });\n return;\n }\n if (rawValue === MODE_MANUAL) {\n if (!this.manualUrl) {\n this.adapter.log.warn(\n \"global-config: global.mode set to 'manual' but global.manualUrl is empty \u2014 fill it to redirect\",\n );\n }\n this.mode = MODE_MANUAL;\n await this.adapter.setStateAsync('global.mode', { val: MODE_MANUAL, ack: true });\n return;\n }\n const safe = coerceSafeUrl(rawValue);\n if (!safe) {\n this.adapter.log.warn(`global-config: rejected unsafe global.mode value '${rawValue}'`);\n await this.adapter.setStateAsync('global.mode', { val: this.mode, ack: true });\n return;\n }\n this.mode = safe;\n await this.adapter.setStateAsync('global.mode', { val: safe, ack: true });\n }\n\n /**\n * Accept a write on `global.manualUrl`. Free-text \u2014 must pass\n * {@link coerceSafeUrl} (or be empty to clear).\n *\n * @param rawValue Value written to the state.\n */\n async handleManualUrlWrite(rawValue: unknown): Promise<void> {\n const result = parseManualUrlWrite(rawValue);\n if (!result.ok) {\n this.adapter.log.warn('global-config: rejected unsafe global.manualUrl');\n await this.adapter.setStateAsync('global.manualUrl', { val: this.manualUrl ?? '', ack: true });\n return;\n }\n this.manualUrl = result.safe;\n await this.adapter.setStateAsync('global.manualUrl', { val: result.safe ?? '', ack: true });\n if (this.mode === MODE_MANUAL && !result.safe) {\n this.adapter.log.warn(\n \"global-config: global.manualUrl cleared while global.mode='manual' \u2014 clients delegating to global will hit the setup page\",\n );\n }\n }\n\n /**\n * Accept a write on `global.enabled`. Persists the value but does NOT trigger\n * the bulk-sync of client modes \u2014 the caller (main.ts) does that, because it\n * holds the registry + url-discovery references needed for the sync.\n *\n * @param rawValue Value written to the state.\n */\n async handleEnabledWrite(rawValue: unknown): Promise<void> {\n const enabled = coerceBoolean(rawValue) === true;\n this.enabled = enabled;\n await this.adapter.setStateAsync('global.enabled', { val: enabled, ack: true });\n }\n\n /**\n * Updates the dropdown states (`common.states`) on `global.mode`.\n * The `'manual'` sentinel is added; `'global'` is NOT (would be self-referential).\n *\n * @param states Discovered URL \u2192 label map.\n */\n async syncUrlDropdown(states: UrlStates): Promise<void> {\n // 0='---' is the no-choice fallback (analogous to govee-smart pattern).\n // 'global' is intentionally NOT in this map \u2014 it would be self-referential.\n const merged: UrlStates = { 0: '---', [MODE_MANUAL]: 'Manual URL', ...states };\n await this.adapter.extendObjectAsync('global.mode', {\n common: { states: merged },\n });\n }\n\n /**\n * Convenience for migration: set mode + manualUrl together. Skips the\n * write-side validation that {@link handleModeWrite} / {@link handleManualUrlWrite}\n * apply, because migration trusts the legacy values it carries forward.\n *\n * @param mode New mode value.\n * @param manualUrl New manualUrl, or null to clear.\n */\n async migrationSet(mode: string, manualUrl: string | null): Promise<void> {\n this.mode = mode;\n this.manualUrl = manualUrl;\n await this.adapter.setStateAsync('global.mode', { val: mode, ack: true });\n await this.adapter.setStateAsync('global.manualUrl', { val: manualUrl ?? '', ack: true });\n }\n\n private async safeGetState(id: string): Promise<ioBroker.State | null> {\n try {\n return (await this.adapter.getStateAsync(id)) ?? null;\n } catch {\n return null;\n }\n }\n}\n\n/**\n * Check whether a full state ID matches a global control datapoint.\n *\n * @param fullId The full state id from a state change event.\n * @param namespace The adapter namespace (e.g. `hassemu.0`).\n */\nexport function parseGlobalStateId(fullId: string, namespace: string): GlobalStateKind | null {\n const prefix = `${namespace}.global.`;\n if (!fullId.startsWith(prefix)) {\n return null;\n }\n const tail = fullId.substring(prefix.length);\n if (tail === 'mode' || tail === 'manualUrl' || tail === 'enabled') {\n return tail;\n }\n return null;\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["/**\n * Global redirect override.\n *\n * Holds three datapoints:\n * - `global.enabled` \u2014 master switch. Toggling triggers a bulk-update of all\n * `clients.<id>.mode` (driven from {@link main.ts}, not from this class).\n * - `global.mode` \u2014 dropdown with discovered URLs plus the `'manual'` sentinel.\n * `'global'` is intentionally not allowed here (would be self-referential).\n * - `global.manualUrl` \u2014 free-text URL used when `global.mode === 'manual'`.\n *\n * The resolver delegates: a client whose `mode === 'global'` ends up here.\n */\n\nimport { coerceBoolean, coerceSafeUrl, parseManualUrlWrite } from './coerce';\nimport type { AdapterInterface, ClientRecord, UrlStates } from './types';\n\n/** Extended adapter interface \u2014 needs state I/O and object extend. */\nexport type GlobalConfigAdapter = AdapterInterface &\n Pick<ioBroker.Adapter, 'getStateAsync' | 'setStateAsync' | 'extendObjectAsync'>;\n\n/** Kinds of state IDs the GlobalConfig reacts to. */\nexport type GlobalStateKind = 'mode' | 'manualUrl' | 'enabled';\n\n/** Sentinel value: client delegates to global. Not legal as `global.mode`. */\nexport const MODE_GLOBAL = 'global';\n/** Sentinel value: use the matching manualUrl datapoint. */\nexport const MODE_MANUAL = 'manual';\n\n/** Holds the runtime state of the global redirect override. */\nexport class GlobalConfig {\n private readonly adapter: GlobalConfigAdapter;\n private mode: string = '';\n private manualUrl: string | null = null;\n private enabled = false;\n\n /** @param adapter Adapter instance used for state and object I/O. */\n constructor(adapter: GlobalConfigAdapter) {\n this.adapter = adapter;\n }\n\n /** Loads the current global.* values from the broker. Call once on adapter start. */\n async restore(): Promise<void> {\n const modeState = await this.safeGetState('global.mode');\n const manualState = await this.safeGetState('global.manualUrl');\n const enabledState = await this.safeGetState('global.enabled');\n this.mode = typeof modeState?.val === 'string' ? modeState.val : '';\n this.manualUrl = coerceSafeUrl(manualState?.val);\n this.enabled = coerceBoolean(enabledState?.val) === true;\n\n // Promote a blank state-value (`''`/null/undefined) to numeric `0` so\n // the admin dropdown renders the `0='---'` option as selected. v1.2.0\n // installs left the value as `''` which doesn't match any common.states\n // entry, so the dropdown showed an empty selection.\n const v = modeState?.val;\n if (v === '' || v === null || v === undefined) {\n await this.adapter.setStateAsync('global.mode', { val: 0, ack: true });\n }\n }\n\n /**\n * Resolves the redirect URL for `record`.\n *\n * Delegates via the client's `mode`:\n * - `'global'` \u2192 resolve global mode/manualUrl\n * - `'manual'` \u2192 client's manualUrl\n * - URL string \u2192 that URL\n * - empty / unknown \u2192 null (setup page)\n *\n * @param record Client to resolve for.\n */\n resolveUrlFor(record: ClientRecord): string | null {\n return this.resolveClientMode(record);\n }\n\n private resolveClientMode(record: ClientRecord): string | null {\n const m: unknown = record.mode;\n // No-choice markers: numeric 0, string '0', empty string \u2014 all \u2192 null\n if (m === 0 || m === '0' || m === '') {\n return null;\n }\n if (m === MODE_GLOBAL) {\n return this.resolveGlobalMode();\n }\n if (m === MODE_MANUAL) {\n return record.manualUrl ?? null;\n }\n return coerceSafeUrl(m);\n }\n\n private resolveGlobalMode(): string | null {\n const m: unknown = this.mode;\n if (m === 0 || m === '0' || m === '') {\n return null;\n }\n if (this.mode === MODE_MANUAL) {\n return this.manualUrl;\n }\n return coerceSafeUrl(this.mode);\n }\n\n /** Returns whether the master switch is currently active. */\n isEnabled(): boolean {\n return this.enabled;\n }\n\n /**\n * Accept a write on `global.mode`. Allowed values: `'manual'` or a URL that\n * passes {@link coerceSafeUrl}. `'global'` is rejected (would be\n * self-referential). Empty string clears the choice.\n *\n * @param rawValue Value written to the state.\n */\n async handleModeWrite(rawValue: unknown): Promise<void> {\n // No-choice markers: numeric 0, string '0', empty string \u2014 all clear the choice\n if (rawValue === 0 || rawValue === '0' || rawValue === '') {\n this.mode = '';\n await this.adapter.setStateAsync('global.mode', { val: 0, ack: true });\n return;\n }\n if (typeof rawValue !== 'string') {\n this.adapter.log.warn('global-config: rejected non-string global.mode');\n await this.adapter.setStateAsync('global.mode', { val: this.mode || 0, ack: true });\n return;\n }\n if (rawValue === MODE_GLOBAL) {\n this.adapter.log.warn(\"global-config: 'global' is not allowed as global.mode (self-referential)\");\n await this.adapter.setStateAsync('global.mode', { val: this.mode, ack: true });\n return;\n }\n if (rawValue === MODE_MANUAL) {\n if (!this.manualUrl) {\n this.adapter.log.warn(\n \"global-config: global.mode set to 'manual' but global.manualUrl is empty \u2014 fill it to redirect\",\n );\n }\n this.mode = MODE_MANUAL;\n await this.adapter.setStateAsync('global.mode', { val: MODE_MANUAL, ack: true });\n return;\n }\n const safe = coerceSafeUrl(rawValue);\n if (!safe) {\n this.adapter.log.warn(`global-config: rejected unsafe global.mode value '${rawValue}'`);\n await this.adapter.setStateAsync('global.mode', { val: this.mode, ack: true });\n return;\n }\n this.mode = safe;\n await this.adapter.setStateAsync('global.mode', { val: safe, ack: true });\n }\n\n /**\n * Accept a write on `global.manualUrl`. Free-text \u2014 must pass\n * {@link coerceSafeUrl} (or be empty to clear).\n *\n * @param rawValue Value written to the state.\n */\n async handleManualUrlWrite(rawValue: unknown): Promise<void> {\n const result = parseManualUrlWrite(rawValue);\n if (!result.ok) {\n this.adapter.log.warn('global-config: rejected unsafe global.manualUrl');\n await this.adapter.setStateAsync('global.manualUrl', { val: this.manualUrl ?? '', ack: true });\n return;\n }\n this.manualUrl = result.safe;\n await this.adapter.setStateAsync('global.manualUrl', { val: result.safe ?? '', ack: true });\n if (this.mode === MODE_MANUAL && !result.safe) {\n this.adapter.log.warn(\n \"global-config: global.manualUrl cleared while global.mode='manual' \u2014 clients delegating to global will hit the setup page\",\n );\n }\n }\n\n /**\n * Accept a write on `global.enabled`. Persists the value but does NOT trigger\n * the bulk-sync of client modes \u2014 the caller (main.ts) does that, because it\n * holds the registry + url-discovery references needed for the sync.\n *\n * @param rawValue Value written to the state.\n */\n async handleEnabledWrite(rawValue: unknown): Promise<void> {\n const enabled = coerceBoolean(rawValue) === true;\n this.enabled = enabled;\n await this.adapter.setStateAsync('global.enabled', { val: enabled, ack: true });\n }\n\n /**\n * Updates the dropdown states (`common.states`) on `global.mode`.\n * The `'manual'` sentinel is added; `'global'` is NOT (would be self-referential).\n *\n * @param states Discovered URL \u2192 label map.\n */\n async syncUrlDropdown(states: UrlStates): Promise<void> {\n // 0='---' is the no-choice fallback (analogous to govee-smart pattern).\n // 'global' is intentionally NOT in this map \u2014 it would be self-referential.\n const merged: UrlStates = { 0: '---', [MODE_MANUAL]: 'Manual URL', ...states };\n await this.adapter.extendObjectAsync('global.mode', {\n common: { states: merged },\n });\n }\n\n /**\n * Convenience for migration: set mode + manualUrl together. Skips the\n * write-side validation that {@link handleModeWrite} / {@link handleManualUrlWrite}\n * apply, because migration trusts the legacy values it carries forward.\n *\n * @param mode New mode value.\n * @param manualUrl New manualUrl, or null to clear.\n */\n async migrationSet(mode: string, manualUrl: string | null): Promise<void> {\n this.mode = mode;\n this.manualUrl = manualUrl;\n await this.adapter.setStateAsync('global.mode', { val: mode, ack: true });\n await this.adapter.setStateAsync('global.manualUrl', { val: manualUrl ?? '', ack: true });\n }\n\n private async safeGetState(id: string): Promise<ioBroker.State | null> {\n try {\n return (await this.adapter.getStateAsync(id)) ?? null;\n } catch {\n return null;\n }\n }\n}\n\n/**\n * Check whether a full state ID matches a global control datapoint.\n *\n * @param fullId The full state id from a state change event.\n * @param namespace The adapter namespace (e.g. `hassemu.0`).\n */\nexport function parseGlobalStateId(fullId: string, namespace: string): GlobalStateKind | null {\n const prefix = `${namespace}.global.`;\n if (!fullId.startsWith(prefix)) {\n return null;\n }\n const tail = fullId.substring(prefix.length);\n if (tail === 'mode' || tail === 'manualUrl' || tail === 'enabled') {\n return tail;\n }\n return null;\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
package/build/main.js CHANGED
@@ -68,6 +68,7 @@ class HassEmu extends utils.Adapter {
68
68
  await this.registry.restore();
69
69
  await this.migrateLegacyDefaultVisUrl();
70
70
  await this.migrateVisUrlToMode();
71
+ await this.repairGlobalSchemas();
71
72
  await this.gcStaleClients();
72
73
  const instanceUuid = import_node_crypto.default.randomUUID();
73
74
  this.log.debug(
@@ -221,13 +222,49 @@ class HassEmu extends utils.Adapter {
221
222
  } catch {
222
223
  }
223
224
  }
225
+ }
226
+ /**
227
+ * Repairs partial-formed `global.mode` / `global.manualUrl` objects from
228
+ * the v1.2.0 migration bug (extendObjectAsync was called with only
229
+ * `common.type:'mixed'` — leaving the object without top-level `type`,
230
+ * name, role, read, write, def). `extendObjectAsync` here merges the full
231
+ * instanceObjects schema onto the existing partial object so js-controller
232
+ * stops warning "obj.type has to exist" and the dropdown renders correctly.
233
+ *
234
+ * Idempotent — extending an already-complete object is a no-op write.
235
+ */
236
+ async repairGlobalSchemas() {
224
237
  try {
225
238
  await this.extendObjectAsync("global.mode", {
226
239
  type: "state",
227
- common: { type: "mixed" }
240
+ common: {
241
+ name: "Global redirect mode",
242
+ type: "mixed",
243
+ role: "value",
244
+ read: true,
245
+ write: true,
246
+ def: 0
247
+ },
248
+ native: {}
249
+ });
250
+ } catch (err) {
251
+ this.log.debug(`repair global.mode failed: ${String(err)}`);
252
+ }
253
+ try {
254
+ await this.extendObjectAsync("global.manualUrl", {
255
+ type: "state",
256
+ common: {
257
+ name: "Global manual URL (used when mode='manual')",
258
+ type: "string",
259
+ role: "url",
260
+ read: true,
261
+ write: true,
262
+ def: ""
263
+ },
264
+ native: {}
228
265
  });
229
266
  } catch (err) {
230
- this.log.debug(`Migration: extend global.mode failed: ${String(err)}`);
267
+ this.log.debug(`repair global.manualUrl failed: ${String(err)}`);
231
268
  }
232
269
  }
233
270
  /**
package/build/main.js.map CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/main.ts"],
4
- "sourcesContent": ["import crypto from 'node:crypto';\nimport * as utils from '@iobroker/adapter-core';\nimport { ClientRegistry, parseClientStateId } from './lib/client-registry';\nimport { coerceSafeUrl } from './lib/coerce';\nimport { GlobalConfig, MODE_GLOBAL, MODE_MANUAL, parseGlobalStateId } from './lib/global-config';\nimport { MDNSService } from './lib/mdns';\nimport { UrlDiscovery } from './lib/url-discovery';\nimport { WebServer } from './lib/webserver';\nimport type { AdapterConfig } from './lib/types';\n\n/** Stale-Client-GC threshold: clients without token + lastSeen older are auto-removed. */\nconst STALE_CLIENT_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days\n\nclass HassEmu extends utils.Adapter {\n private mdnsService: MDNSService | null = null;\n private webServer: WebServer | null = null;\n private registry: ClientRegistry | null = null;\n private globalConfig: GlobalConfig | null = null;\n private urlDiscovery: UrlDiscovery | null = null;\n private unhandledRejectionHandler: ((reason: unknown) => void) | null = null;\n private uncaughtExceptionHandler: ((err: Error) => void) | null = null;\n\n declare config: AdapterConfig;\n\n public constructor(options: Partial<utils.AdapterOptions> = {}) {\n super({ ...options, name: 'hassemu' });\n\n this.on('ready', () => {\n this.onReady().catch(err => this.log.error(`onReady unhandled: ${String(err)}`));\n });\n this.on('stateChange', (id, state) => {\n this.onStateChange(id, state).catch(err => this.log.error(`stateChange unhandled: ${String(err)}`));\n });\n this.on('objectChange', () => {\n // Foreign object changed \u2014 refresh URL dropdown (debounced inside discovery)\n this.urlDiscovery?.scheduleRefresh();\n });\n this.on('unload', this.onUnload.bind(this));\n\n // Last-line-of-defence against unhandled rejections / sync throws from\n // fire-and-forget paths. The per-handler wrappers cover documented async\n // paths; this catches anything that slips past during refactors.\n this.unhandledRejectionHandler = (reason: unknown) => {\n this.log.error(`Unhandled rejection: ${reason instanceof Error ? reason.message : String(reason)}`);\n };\n this.uncaughtExceptionHandler = (err: Error) => {\n this.log.error(`Uncaught exception: ${err.message}`);\n };\n process.on('unhandledRejection', this.unhandledRejectionHandler);\n process.on('uncaughtException', this.uncaughtExceptionHandler);\n }\n\n private async onReady(): Promise<void> {\n await this.setState('info.connection', { val: false, ack: true });\n\n this.globalConfig = new GlobalConfig(this);\n await this.globalConfig.restore();\n\n this.registry = new ClientRegistry(this);\n await this.registry.restore();\n\n // Migrations run before subscriptions / webserver \u2014 first the legacy\n // 1.0.x-style native config, then the visUrl \u2192 mode/manualUrl move.\n await this.migrateLegacyDefaultVisUrl();\n await this.migrateVisUrlToMode();\n\n // Garbage-collect stale clients (no token + lastSeen older than 30 days).\n await this.gcStaleClients();\n\n const instanceUuid = crypto.randomUUID();\n this.log.debug(\n `Config: port=${this.config.port}, auth=${this.config.authRequired}, mdns=${this.config.mdnsEnabled}`,\n );\n\n this.urlDiscovery = new UrlDiscovery(this, async states => {\n await this.globalConfig?.syncUrlDropdown(states);\n await this.registry?.syncUrlDropdown(states);\n });\n await this.urlDiscovery.collect();\n\n // After discovery: wire the default-mode provider for new clients.\n // - global.enabled=true \u2192 new clients default to 'global' (follow master)\n // - global.enabled=false \u2192 first discovered URL, fallback 'manual'\n this.registry.setNewClientModeProvider(() => this.computeNewClientMode());\n\n // Watch broker state for new/removed instances, VIS projects and client/global writes\n await this.subscribeForeignObjectsAsync('system.adapter.*');\n await this.subscribeStatesAsync('clients.*');\n await this.subscribeStatesAsync('global.*');\n\n const systemLanguage = await this.readSystemLanguage();\n\n try {\n this.webServer = new WebServer(\n this,\n this.config,\n this.registry,\n this.globalConfig,\n instanceUuid,\n systemLanguage,\n );\n await this.webServer.start();\n } catch (err) {\n this.log.error(`Web server failed to start: ${String(err)}`);\n return;\n }\n\n if (this.config.mdnsEnabled) {\n this.mdnsService = new MDNSService(this, this.config, instanceUuid);\n this.mdnsService.start();\n } else {\n this.log.debug('mDNS disabled \u2014 clients must enter the URL manually.');\n }\n\n await this.setState('info.connection', { val: true, ack: true });\n const bindAddr = this.config.bindAddress || '0.0.0.0';\n this.log.info(\n `HA emulation running on ${bindAddr}:${this.config.port}${this.config.mdnsEnabled ? ', mDNS active' : ''}`,\n );\n }\n\n /**\n * Default mode for newly registered clients. Respects the master switch:\n * - `global.enabled=true` \u2192 `'global'` (follow master)\n * - `global.enabled=false` \u2192 first discovered URL, fallback `'manual'`\n */\n private computeNewClientMode(): string {\n if (this.globalConfig?.isEnabled()) {\n return MODE_GLOBAL;\n }\n const first = this.urlDiscovery?.getFirstDiscoveredUrl();\n return first ?? MODE_MANUAL;\n }\n\n /**\n * Read the ioBroker system language (set in Admin \u2192 Main Settings).\n * Used for the landing page so the end-user sees the same language as\n * their admin UI. Falls back to `en` when `system.config` can't be read\n * or holds a language we don't translate. Read once on startup \u2014 a\n * language switch at runtime only takes effect after an adapter restart,\n * which is fine for a setup-hint page that most users see once.\n */\n private async readSystemLanguage(): Promise<string> {\n try {\n const cfg = await this.getForeignObjectAsync('system.config');\n const lang = (cfg?.common as { language?: string } | undefined)?.language;\n return typeof lang === 'string' && lang.length > 0 ? lang : 'en';\n } catch {\n return 'en';\n }\n }\n\n /**\n * 1.0.x / 1.1.0 \u2192 1.1.1 migration \u2014 move the legacy `defaultVisUrl` from\n * instance native into `global.visUrl` + `global.enabled=true` and drop it\n * from native. Subsequent migrations (`migrateVisUrlToMode`) then move\n * `global.visUrl` into the mode/manualUrl model.\n */\n private async migrateLegacyDefaultVisUrl(): Promise<void> {\n const legacy = this.config as AdapterConfig & { defaultVisUrl?: string; visUrl?: string };\n const url = legacy.defaultVisUrl || legacy.visUrl;\n if (!url) {\n return;\n }\n this.log.info('Migrating legacy native.defaultVisUrl/visUrl \u2192 global.visUrl');\n // We cannot call globalConfig.handleVisUrlWrite \u2014 that method is gone in\n // v1.2.0. Write the legacy state directly so migrateVisUrlToMode picks it up.\n await this.setStateAsync('global.visUrl', { val: url, ack: true }).catch(() => {\n // global.visUrl object may not exist anymore (v1.2.0 instanceObjects);\n // create it transparently for the migration step.\n });\n try {\n const id = `system.adapter.${this.namespace}`;\n const obj = await this.getForeignObjectAsync(id);\n if (obj?.native) {\n delete obj.native.defaultVisUrl;\n delete obj.native.visUrl;\n await this.setForeignObjectAsync(id, obj);\n }\n } catch (err) {\n this.log.warn(`Legacy config cleanup failed: ${String(err)}`);\n }\n }\n\n /**\n * 1.x \u2192 1.2.0 migration \u2014 move legacy per-client `visUrl`-states to the\n * `mode`/`manualUrl` model, plus the global `visUrl` to `global.mode` +\n * `global.manualUrl`. Old datapoints are removed, type of mode-states\n * upgraded to 'mixed'. Idempotent \u2014 does nothing on subsequent starts.\n */\n private async migrateVisUrlToMode(): Promise<void> {\n // 1) Global visUrl \u2192 mode + manualUrl\n try {\n const legacyGlobal = await this.getStateAsync('global.visUrl');\n if (\n legacyGlobal &&\n legacyGlobal.val !== undefined &&\n legacyGlobal.val !== null &&\n legacyGlobal.val !== ''\n ) {\n const safe = coerceSafeUrl(legacyGlobal.val);\n if (safe) {\n await this.globalConfig!.migrationSet(MODE_MANUAL, safe);\n this.log.info(`Migration: global.visUrl \u2192 mode='manual', manualUrl='${safe}'`);\n } else {\n await this.globalConfig!.migrationSet(MODE_MANUAL, null);\n this.log.warn(`Migration: legacy global.visUrl rejected as unsafe \u2014 set global.manualUrl manually`);\n }\n }\n } catch {\n /* state didn't exist \u2014 fresh install or already migrated */\n }\n try {\n await this.delObjectAsync('global.visUrl');\n } catch {\n /* didn't exist */\n }\n\n // 2) Per-client visUrl \u2192 mode='manual' + manualUrl\n const records = this.registry?.listAll() ?? [];\n for (const record of records) {\n try {\n const legacy = await this.getStateAsync(`clients.${record.id}.visUrl`);\n if (legacy && legacy.val !== undefined && legacy.val !== null && legacy.val !== '') {\n const safe = coerceSafeUrl(legacy.val);\n if (safe) {\n record.mode = MODE_MANUAL;\n record.manualUrl = safe;\n await this.setStateAsync(`clients.${record.id}.mode`, { val: MODE_MANUAL, ack: true });\n await this.setStateAsync(`clients.${record.id}.manualUrl`, { val: safe, ack: true });\n this.log.info(\n `Migration: client ${record.id} visUrl='${safe}' \u2192 mode='manual', manualUrl='${safe}'`,\n );\n } else {\n this.log.warn(\n `Migration: client ${record.id} legacy visUrl rejected as unsafe \u2014 set clients.${record.id}.manualUrl manually`,\n );\n }\n }\n } catch {\n /* state didn't exist for this client */\n }\n try {\n await this.delObjectAsync(`clients.${record.id}.visUrl`);\n } catch {\n /* didn't exist */\n }\n }\n\n // 3) Upgrade existing global.mode object to type:'mixed' + ensure\n // top-level type:'state' (instanceObjects only initialise on first\n // install \u2014 older adapters may have a partial object).\n try {\n await this.extendObjectAsync('global.mode', {\n type: 'state',\n common: { type: 'mixed' as ioBroker.CommonType },\n });\n } catch (err) {\n this.log.debug(`Migration: extend global.mode failed: ${String(err)}`);\n }\n // Per-client mode objects are already created with the right shape via\n // ClientRegistry.ensureObjects() during restore() \u2014 no extend needed here.\n }\n\n /**\n * Removes clients that are clearly stale: no auth token (= never authenticated\n * or revoked) AND `native.lastSeen` older than {@link STALE_CLIENT_TTL_MS}.\n * Clients without `lastSeen` (pre-1.2.0) get the timestamp seeded on this run\n * \u2014 GC kicks in only on subsequent restarts.\n */\n private async gcStaleClients(): Promise<void> {\n const now = Date.now();\n const records = this.registry?.listAll() ?? [];\n let removed = 0;\n for (const record of records) {\n if (record.token) {\n continue;\n }\n try {\n const obj = await this.getObjectAsync(`clients.${record.id}`);\n const native = (obj?.native as { lastSeen?: number } | undefined) ?? {};\n const lastSeen = typeof native.lastSeen === 'number' ? native.lastSeen : 0;\n if (lastSeen === 0) {\n // Pre-v1.2.0 client \u2014 seed timestamp, GC waits one cycle.\n await this.extendObjectAsync(`clients.${record.id}`, { native: { lastSeen: now } });\n continue;\n }\n if (now - lastSeen > STALE_CLIENT_TTL_MS) {\n await this.registry!.remove(record.id);\n removed++;\n }\n } catch (err) {\n this.log.debug(`Stale-GC: failed for ${record.id}: ${String(err)}`);\n }\n }\n if (removed > 0) {\n this.log.info(`Stale-Client-GC: removed ${removed} client(s) (no token + idle >30 days)`);\n }\n }\n\n /**\n * Master-switch action: when `global.enabled` flips, propagate to every\n * client's `mode`. true \u2192 all clients follow `'global'`. false \u2192 fall back\n * to the first discovered URL, or `'manual'` if discovery is empty.\n *\n * @param enabled New value of `global.enabled`.\n */\n private async applyMasterSwitch(enabled: boolean): Promise<void> {\n if (!this.registry) {\n return;\n }\n if (enabled) {\n await this.registry.bulkSetMode(MODE_GLOBAL);\n return;\n }\n const first = this.urlDiscovery?.getFirstDiscoveredUrl();\n if (first) {\n await this.registry.bulkSetMode(first);\n } else {\n await this.registry.bulkSetMode(MODE_MANUAL);\n this.log.warn(\n \"global.enabled=false but no discovered VIS URL \u2014 clients set to 'manual'; \" +\n 'fill clients.<id>.manualUrl per client',\n );\n }\n }\n\n private async onStateChange(id: string, state: ioBroker.State | null | undefined): Promise<void> {\n if (!state || state.ack) {\n return;\n }\n const clientParsed = this.registry ? parseClientStateId(id, this.namespace) : null;\n if (clientParsed) {\n if (clientParsed.kind === 'mode') {\n await this.registry!.handleModeWrite(clientParsed.id, state.val);\n // B4: if the user picked 'global' but global resolves to nothing,\n // give them a one-shot heads-up so the cause of the empty redirect\n // is obvious without digging through the resolver code.\n const record = this.registry!.getById(clientParsed.id);\n if (record?.mode === MODE_GLOBAL && this.globalConfig!.resolveUrlFor(record) === null) {\n this.log.warn(\n `Client ${record.id}: mode='global' but global has no resolvable URL \u2014 ` +\n 'fill global.mode/manualUrl, or pick a different client mode',\n );\n }\n } else if (clientParsed.kind === 'manualUrl') {\n await this.registry!.handleManualUrlWrite(clientParsed.id, state.val);\n } else if (clientParsed.kind === 'remove' && state.val === true) {\n await this.registry!.remove(clientParsed.id);\n }\n return;\n }\n const globalParsed = this.globalConfig ? parseGlobalStateId(id, this.namespace) : null;\n if (globalParsed === 'mode') {\n await this.globalConfig!.handleModeWrite(state.val);\n } else if (globalParsed === 'manualUrl') {\n await this.globalConfig!.handleManualUrlWrite(state.val);\n } else if (globalParsed === 'enabled') {\n await this.globalConfig!.handleEnabledWrite(state.val);\n await this.applyMasterSwitch(this.globalConfig!.isEnabled());\n }\n }\n\n private onUnload(callback: () => void): void {\n try {\n this.urlDiscovery?.cancelRefresh();\n this.urlDiscovery = null;\n\n if (this.mdnsService) {\n this.mdnsService.stop();\n this.mdnsService = null;\n }\n\n if (this.webServer) {\n this.webServer.stop().catch((err: Error) => this.log.error(`Server stop error: ${err.message}`));\n this.webServer = null;\n }\n\n this.registry = null;\n this.globalConfig = null;\n\n // Detach process-level last-line-of-defence handlers\n if (this.unhandledRejectionHandler) {\n process.off('unhandledRejection', this.unhandledRejectionHandler);\n this.unhandledRejectionHandler = null;\n }\n if (this.uncaughtExceptionHandler) {\n process.off('uncaughtException', this.uncaughtExceptionHandler);\n this.uncaughtExceptionHandler = null;\n }\n\n void this.setState('info.connection', { val: false, ack: true });\n } catch (error) {\n const err = error as Error;\n this.log.error(`Shutdown error: ${err.message}`);\n } finally {\n callback();\n }\n }\n}\n\nif (require.main !== module) {\n module.exports = (options: Partial<utils.AdapterOptions> | undefined) => new HassEmu(options);\n} else {\n (() => new HassEmu())();\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import crypto from 'node:crypto';\nimport * as utils from '@iobroker/adapter-core';\nimport { ClientRegistry, parseClientStateId } from './lib/client-registry';\nimport { coerceSafeUrl } from './lib/coerce';\nimport { GlobalConfig, MODE_GLOBAL, MODE_MANUAL, parseGlobalStateId } from './lib/global-config';\nimport { MDNSService } from './lib/mdns';\nimport { UrlDiscovery } from './lib/url-discovery';\nimport { WebServer } from './lib/webserver';\nimport type { AdapterConfig } from './lib/types';\n\n/** Stale-Client-GC threshold: clients without token + lastSeen older are auto-removed. */\nconst STALE_CLIENT_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days\n\nclass HassEmu extends utils.Adapter {\n private mdnsService: MDNSService | null = null;\n private webServer: WebServer | null = null;\n private registry: ClientRegistry | null = null;\n private globalConfig: GlobalConfig | null = null;\n private urlDiscovery: UrlDiscovery | null = null;\n private unhandledRejectionHandler: ((reason: unknown) => void) | null = null;\n private uncaughtExceptionHandler: ((err: Error) => void) | null = null;\n\n declare config: AdapterConfig;\n\n public constructor(options: Partial<utils.AdapterOptions> = {}) {\n super({ ...options, name: 'hassemu' });\n\n this.on('ready', () => {\n this.onReady().catch(err => this.log.error(`onReady unhandled: ${String(err)}`));\n });\n this.on('stateChange', (id, state) => {\n this.onStateChange(id, state).catch(err => this.log.error(`stateChange unhandled: ${String(err)}`));\n });\n this.on('objectChange', () => {\n // Foreign object changed \u2014 refresh URL dropdown (debounced inside discovery)\n this.urlDiscovery?.scheduleRefresh();\n });\n this.on('unload', this.onUnload.bind(this));\n\n // Last-line-of-defence against unhandled rejections / sync throws from\n // fire-and-forget paths. The per-handler wrappers cover documented async\n // paths; this catches anything that slips past during refactors.\n this.unhandledRejectionHandler = (reason: unknown) => {\n this.log.error(`Unhandled rejection: ${reason instanceof Error ? reason.message : String(reason)}`);\n };\n this.uncaughtExceptionHandler = (err: Error) => {\n this.log.error(`Uncaught exception: ${err.message}`);\n };\n process.on('unhandledRejection', this.unhandledRejectionHandler);\n process.on('uncaughtException', this.uncaughtExceptionHandler);\n }\n\n private async onReady(): Promise<void> {\n await this.setState('info.connection', { val: false, ack: true });\n\n this.globalConfig = new GlobalConfig(this);\n await this.globalConfig.restore();\n\n this.registry = new ClientRegistry(this);\n await this.registry.restore();\n\n // Migrations run before subscriptions / webserver \u2014 first the legacy\n // 1.0.x-style native config, then the visUrl \u2192 mode/manualUrl move,\n // then a defensive schema repair for users upgrading from v1.2.0+\n // (where the partial-formed mode-object from the v1.2.0 extend-bug\n // persists since `legacy.visUrl` is already gone and migrate doesn't trigger).\n await this.migrateLegacyDefaultVisUrl();\n await this.migrateVisUrlToMode();\n await this.repairGlobalSchemas();\n\n // Garbage-collect stale clients (no token + lastSeen older than 30 days).\n await this.gcStaleClients();\n\n const instanceUuid = crypto.randomUUID();\n this.log.debug(\n `Config: port=${this.config.port}, auth=${this.config.authRequired}, mdns=${this.config.mdnsEnabled}`,\n );\n\n this.urlDiscovery = new UrlDiscovery(this, async states => {\n await this.globalConfig?.syncUrlDropdown(states);\n await this.registry?.syncUrlDropdown(states);\n });\n await this.urlDiscovery.collect();\n\n // After discovery: wire the default-mode provider for new clients.\n // - global.enabled=true \u2192 new clients default to 'global' (follow master)\n // - global.enabled=false \u2192 first discovered URL, fallback 'manual'\n this.registry.setNewClientModeProvider(() => this.computeNewClientMode());\n\n // Watch broker state for new/removed instances, VIS projects and client/global writes\n await this.subscribeForeignObjectsAsync('system.adapter.*');\n await this.subscribeStatesAsync('clients.*');\n await this.subscribeStatesAsync('global.*');\n\n const systemLanguage = await this.readSystemLanguage();\n\n try {\n this.webServer = new WebServer(\n this,\n this.config,\n this.registry,\n this.globalConfig,\n instanceUuid,\n systemLanguage,\n );\n await this.webServer.start();\n } catch (err) {\n this.log.error(`Web server failed to start: ${String(err)}`);\n return;\n }\n\n if (this.config.mdnsEnabled) {\n this.mdnsService = new MDNSService(this, this.config, instanceUuid);\n this.mdnsService.start();\n } else {\n this.log.debug('mDNS disabled \u2014 clients must enter the URL manually.');\n }\n\n await this.setState('info.connection', { val: true, ack: true });\n const bindAddr = this.config.bindAddress || '0.0.0.0';\n this.log.info(\n `HA emulation running on ${bindAddr}:${this.config.port}${this.config.mdnsEnabled ? ', mDNS active' : ''}`,\n );\n }\n\n /**\n * Default mode for newly registered clients. Respects the master switch:\n * - `global.enabled=true` \u2192 `'global'` (follow master)\n * - `global.enabled=false` \u2192 first discovered URL, fallback `'manual'`\n */\n private computeNewClientMode(): string {\n if (this.globalConfig?.isEnabled()) {\n return MODE_GLOBAL;\n }\n const first = this.urlDiscovery?.getFirstDiscoveredUrl();\n return first ?? MODE_MANUAL;\n }\n\n /**\n * Read the ioBroker system language (set in Admin \u2192 Main Settings).\n * Used for the landing page so the end-user sees the same language as\n * their admin UI. Falls back to `en` when `system.config` can't be read\n * or holds a language we don't translate. Read once on startup \u2014 a\n * language switch at runtime only takes effect after an adapter restart,\n * which is fine for a setup-hint page that most users see once.\n */\n private async readSystemLanguage(): Promise<string> {\n try {\n const cfg = await this.getForeignObjectAsync('system.config');\n const lang = (cfg?.common as { language?: string } | undefined)?.language;\n return typeof lang === 'string' && lang.length > 0 ? lang : 'en';\n } catch {\n return 'en';\n }\n }\n\n /**\n * 1.0.x / 1.1.0 \u2192 1.1.1 migration \u2014 move the legacy `defaultVisUrl` from\n * instance native into `global.visUrl` + `global.enabled=true` and drop it\n * from native. Subsequent migrations (`migrateVisUrlToMode`) then move\n * `global.visUrl` into the mode/manualUrl model.\n */\n private async migrateLegacyDefaultVisUrl(): Promise<void> {\n const legacy = this.config as AdapterConfig & { defaultVisUrl?: string; visUrl?: string };\n const url = legacy.defaultVisUrl || legacy.visUrl;\n if (!url) {\n return;\n }\n this.log.info('Migrating legacy native.defaultVisUrl/visUrl \u2192 global.visUrl');\n // We cannot call globalConfig.handleVisUrlWrite \u2014 that method is gone in\n // v1.2.0. Write the legacy state directly so migrateVisUrlToMode picks it up.\n await this.setStateAsync('global.visUrl', { val: url, ack: true }).catch(() => {\n // global.visUrl object may not exist anymore (v1.2.0 instanceObjects);\n // create it transparently for the migration step.\n });\n try {\n const id = `system.adapter.${this.namespace}`;\n const obj = await this.getForeignObjectAsync(id);\n if (obj?.native) {\n delete obj.native.defaultVisUrl;\n delete obj.native.visUrl;\n await this.setForeignObjectAsync(id, obj);\n }\n } catch (err) {\n this.log.warn(`Legacy config cleanup failed: ${String(err)}`);\n }\n }\n\n /**\n * 1.x \u2192 1.2.0 migration \u2014 move legacy per-client `visUrl`-states to the\n * `mode`/`manualUrl` model, plus the global `visUrl` to `global.mode` +\n * `global.manualUrl`. Old datapoints are removed, type of mode-states\n * upgraded to 'mixed'. Idempotent \u2014 does nothing on subsequent starts.\n */\n private async migrateVisUrlToMode(): Promise<void> {\n // 1) Global visUrl \u2192 mode + manualUrl\n try {\n const legacyGlobal = await this.getStateAsync('global.visUrl');\n if (\n legacyGlobal &&\n legacyGlobal.val !== undefined &&\n legacyGlobal.val !== null &&\n legacyGlobal.val !== ''\n ) {\n const safe = coerceSafeUrl(legacyGlobal.val);\n if (safe) {\n await this.globalConfig!.migrationSet(MODE_MANUAL, safe);\n this.log.info(`Migration: global.visUrl \u2192 mode='manual', manualUrl='${safe}'`);\n } else {\n await this.globalConfig!.migrationSet(MODE_MANUAL, null);\n this.log.warn(`Migration: legacy global.visUrl rejected as unsafe \u2014 set global.manualUrl manually`);\n }\n }\n } catch {\n /* state didn't exist \u2014 fresh install or already migrated */\n }\n try {\n await this.delObjectAsync('global.visUrl');\n } catch {\n /* didn't exist */\n }\n\n // 2) Per-client visUrl \u2192 mode='manual' + manualUrl\n const records = this.registry?.listAll() ?? [];\n for (const record of records) {\n try {\n const legacy = await this.getStateAsync(`clients.${record.id}.visUrl`);\n if (legacy && legacy.val !== undefined && legacy.val !== null && legacy.val !== '') {\n const safe = coerceSafeUrl(legacy.val);\n if (safe) {\n record.mode = MODE_MANUAL;\n record.manualUrl = safe;\n await this.setStateAsync(`clients.${record.id}.mode`, { val: MODE_MANUAL, ack: true });\n await this.setStateAsync(`clients.${record.id}.manualUrl`, { val: safe, ack: true });\n this.log.info(\n `Migration: client ${record.id} visUrl='${safe}' \u2192 mode='manual', manualUrl='${safe}'`,\n );\n } else {\n this.log.warn(\n `Migration: client ${record.id} legacy visUrl rejected as unsafe \u2014 set clients.${record.id}.manualUrl manually`,\n );\n }\n }\n } catch {\n /* state didn't exist for this client */\n }\n try {\n await this.delObjectAsync(`clients.${record.id}.visUrl`);\n } catch {\n /* didn't exist */\n }\n }\n\n // 3) global.mode + global.manualUrl repair handled by repairGlobalSchemas()\n // (called separately in onReady so it ALSO runs for users upgrading from\n // v1.2.0/v1.3.0/v1.3.1 where the legacy visUrl is already gone but the\n // partial-formed mode-object from the v1.2.0 extendObject-bug persists).\n }\n\n /**\n * Repairs partial-formed `global.mode` / `global.manualUrl` objects from\n * the v1.2.0 migration bug (extendObjectAsync was called with only\n * `common.type:'mixed'` \u2014 leaving the object without top-level `type`,\n * name, role, read, write, def). `extendObjectAsync` here merges the full\n * instanceObjects schema onto the existing partial object so js-controller\n * stops warning \"obj.type has to exist\" and the dropdown renders correctly.\n *\n * Idempotent \u2014 extending an already-complete object is a no-op write.\n */\n private async repairGlobalSchemas(): Promise<void> {\n try {\n await this.extendObjectAsync('global.mode', {\n type: 'state',\n common: {\n name: 'Global redirect mode',\n type: 'mixed',\n role: 'value',\n read: true,\n write: true,\n def: 0,\n },\n native: {},\n });\n } catch (err) {\n this.log.debug(`repair global.mode failed: ${String(err)}`);\n }\n try {\n await this.extendObjectAsync('global.manualUrl', {\n type: 'state',\n common: {\n name: \"Global manual URL (used when mode='manual')\",\n type: 'string',\n role: 'url',\n read: true,\n write: true,\n def: '',\n },\n native: {},\n });\n } catch (err) {\n this.log.debug(`repair global.manualUrl failed: ${String(err)}`);\n }\n }\n\n /**\n * Removes clients that are clearly stale: no auth token (= never authenticated\n * or revoked) AND `native.lastSeen` older than {@link STALE_CLIENT_TTL_MS}.\n * Clients without `lastSeen` (pre-1.2.0) get the timestamp seeded on this run\n * \u2014 GC kicks in only on subsequent restarts.\n */\n private async gcStaleClients(): Promise<void> {\n const now = Date.now();\n const records = this.registry?.listAll() ?? [];\n let removed = 0;\n for (const record of records) {\n if (record.token) {\n continue;\n }\n try {\n const obj = await this.getObjectAsync(`clients.${record.id}`);\n const native = (obj?.native as { lastSeen?: number } | undefined) ?? {};\n const lastSeen = typeof native.lastSeen === 'number' ? native.lastSeen : 0;\n if (lastSeen === 0) {\n // Pre-v1.2.0 client \u2014 seed timestamp, GC waits one cycle.\n await this.extendObjectAsync(`clients.${record.id}`, { native: { lastSeen: now } });\n continue;\n }\n if (now - lastSeen > STALE_CLIENT_TTL_MS) {\n await this.registry!.remove(record.id);\n removed++;\n }\n } catch (err) {\n this.log.debug(`Stale-GC: failed for ${record.id}: ${String(err)}`);\n }\n }\n if (removed > 0) {\n this.log.info(`Stale-Client-GC: removed ${removed} client(s) (no token + idle >30 days)`);\n }\n }\n\n /**\n * Master-switch action: when `global.enabled` flips, propagate to every\n * client's `mode`. true \u2192 all clients follow `'global'`. false \u2192 fall back\n * to the first discovered URL, or `'manual'` if discovery is empty.\n *\n * @param enabled New value of `global.enabled`.\n */\n private async applyMasterSwitch(enabled: boolean): Promise<void> {\n if (!this.registry) {\n return;\n }\n if (enabled) {\n await this.registry.bulkSetMode(MODE_GLOBAL);\n return;\n }\n const first = this.urlDiscovery?.getFirstDiscoveredUrl();\n if (first) {\n await this.registry.bulkSetMode(first);\n } else {\n await this.registry.bulkSetMode(MODE_MANUAL);\n this.log.warn(\n \"global.enabled=false but no discovered VIS URL \u2014 clients set to 'manual'; \" +\n 'fill clients.<id>.manualUrl per client',\n );\n }\n }\n\n private async onStateChange(id: string, state: ioBroker.State | null | undefined): Promise<void> {\n if (!state || state.ack) {\n return;\n }\n const clientParsed = this.registry ? parseClientStateId(id, this.namespace) : null;\n if (clientParsed) {\n if (clientParsed.kind === 'mode') {\n await this.registry!.handleModeWrite(clientParsed.id, state.val);\n // B4: if the user picked 'global' but global resolves to nothing,\n // give them a one-shot heads-up so the cause of the empty redirect\n // is obvious without digging through the resolver code.\n const record = this.registry!.getById(clientParsed.id);\n if (record?.mode === MODE_GLOBAL && this.globalConfig!.resolveUrlFor(record) === null) {\n this.log.warn(\n `Client ${record.id}: mode='global' but global has no resolvable URL \u2014 ` +\n 'fill global.mode/manualUrl, or pick a different client mode',\n );\n }\n } else if (clientParsed.kind === 'manualUrl') {\n await this.registry!.handleManualUrlWrite(clientParsed.id, state.val);\n } else if (clientParsed.kind === 'remove' && state.val === true) {\n await this.registry!.remove(clientParsed.id);\n }\n return;\n }\n const globalParsed = this.globalConfig ? parseGlobalStateId(id, this.namespace) : null;\n if (globalParsed === 'mode') {\n await this.globalConfig!.handleModeWrite(state.val);\n } else if (globalParsed === 'manualUrl') {\n await this.globalConfig!.handleManualUrlWrite(state.val);\n } else if (globalParsed === 'enabled') {\n await this.globalConfig!.handleEnabledWrite(state.val);\n await this.applyMasterSwitch(this.globalConfig!.isEnabled());\n }\n }\n\n private onUnload(callback: () => void): void {\n try {\n this.urlDiscovery?.cancelRefresh();\n this.urlDiscovery = null;\n\n if (this.mdnsService) {\n this.mdnsService.stop();\n this.mdnsService = null;\n }\n\n if (this.webServer) {\n this.webServer.stop().catch((err: Error) => this.log.error(`Server stop error: ${err.message}`));\n this.webServer = null;\n }\n\n this.registry = null;\n this.globalConfig = null;\n\n // Detach process-level last-line-of-defence handlers\n if (this.unhandledRejectionHandler) {\n process.off('unhandledRejection', this.unhandledRejectionHandler);\n this.unhandledRejectionHandler = null;\n }\n if (this.uncaughtExceptionHandler) {\n process.off('uncaughtException', this.uncaughtExceptionHandler);\n this.uncaughtExceptionHandler = null;\n }\n\n void this.setState('info.connection', { val: false, ack: true });\n } catch (error) {\n const err = error as Error;\n this.log.error(`Shutdown error: ${err.message}`);\n } finally {\n callback();\n }\n }\n}\n\nif (require.main !== module) {\n module.exports = (options: Partial<utils.AdapterOptions> | undefined) => new HassEmu(options);\n} else {\n (() => new HassEmu())();\n}\n"],
5
+ "mappings": "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",
6
6
  "names": ["crypto"]
7
7
  }
package/io-package.json CHANGED
@@ -1,8 +1,21 @@
1
1
  {
2
2
  "common": {
3
3
  "name": "hassemu",
4
- "version": "1.3.1",
4
+ "version": "1.3.2",
5
5
  "news": {
6
+ "1.3.2": {
7
+ "en": "Hotfix for v1.3.1: `setObjectNotExistsAsync` is a no-op on objects that already exist as partial-formed leftovers from the v1.2.0 migration bug. v1.3.2 uses `extendObjectAsync` for `clients.<id>.mode` + `clients.<id>.manualUrl` so the missing properties (top-level `type`, name, role, read, write, def) are merged into the existing partial object — js-controller's \"obj.type has to exist\" warning goes away and the dropdown renders the labels.\nNew `repairGlobalSchemas()` in main.ts does the same defensive merge for `global.mode` + `global.manualUrl`. Runs unconditionally on every start so users upgrading from v1.2.0/v1.3.0/v1.3.1 (where the legacy `visUrl` is already gone) also get the schema repaired.\nRestore step now promotes a blank state-value (`''` left over from v1.2.0) to numeric `0`, so the dropdown actually shows the `0='---'` option as selected on first start after the upgrade.",
8
+ "de": "Hotfix für v1.3.1: `setObjectNotExistsAsync` ist ein No-op auf Objekten, die bereits als partielle Linksover aus dem v1.2.0 Migrationsfehler existieren. v1.3.2 verwendet `extendObjectAsync` für `clients.<id>.mode` + `clients.<id>.manualUrl`, so dass die fehlenden Eigenschaften (top-level `type`type`, Name, Rolle, Lesen, Schreiben, def) in das bestehende Teilobjekt zusammengefasst werden - js-controllers \"obj.type muss existieren\" warn und die Warnung geht weg und.\nNew `repairGlobalSchemas()` in main.ts tut die gleiche defensive Zusammenführung für `global.mode` + `global.manualUrl`. Lauft bedingungslos an jedem Start, so dass die Benutzer von v1.2.0/v1.3.0/v1.3.1 (wo das Vermächtnis `visUrl` bereits weg ist) auch das Schema reparieren.\nDer Wiederherstellungsschritt fördert nun einen leeren Zustandswert (`''' links von v1.2.0) auf numerisch `0`, so dass der Dropdown tatsächlich die `0='--'-Option zeigt, wie am ersten Start nach dem Upgrade ausgewählt.",
9
+ "ru": "Hotfix для v1.3.1: 'setObjectNotExistsAsync' - это no-op на объектах, которые уже существуют как частично сформированные остатки от ошибки миграции v1.2.0. v1.3.2 использует «extendObjectAsync» для «клиентов.<id>.mode» + «клиентов.<id>.manualUrl», поэтому недостающие свойства (тип верхнего уровня, имя, роль, чтение, запись, def) сливаются в существующий частичный объект — предупреждение js-контроллера «obj.type должен существовать» уходит, и выпадающий отображает ярлыки.\nНовый «repairGlobalSchemas()» в main.ts делает то же защитное слияние для «global.mode» + «global.manualUrl». Работает без каких-либо условий при каждом запуске, поэтому пользователи, обновляющиеся с v1.2.0/v1.3.0/v1.3.1 (где устаревший «visUrl» уже исчез), также получают схему исправления.\nШаг восстановления теперь способствует пустому значению состояния (оставленному от v1.2.0) до числового «0», поэтому выпадающее значение фактически показывает опцию «0=---», выбранную при первом запуске после обновления.",
10
+ "pt": "Hotfix para v1.3: `setObjectNotExistsAsync` é um no-op em objetos que já existem como restos de forma parcial do bug de migração v1.2.2. v1.3.2 usa `extendendObjectAsync` para `clients.<id>.mode` + `clients.<id>.manualUrl` para que as propriedades em falta (tipo, nome, papel, leitura, escrita, def) sejam fundidas no objeto parcial existente – o aviso \"obj.type tem que existir\" do js-controller vai embora e o dropdown renderiza os rótulos.\nNovo `repairGlobalSchemas()` no main.ts faz a mesma mesclagem defensiva para `global.mode` + `global.manualUrl`. Executa incondicionalmente em cada início para que os usuários atualizem de v1.20/v1.3.0/v1.3.1.3.1 (onde o legado 'visUrl' já se foi) também obter o esquema reparado.\nO passo de restauração agora promove um valor de estado em branco (`''`` sobra de v1.2.2) para numérico `0`, então o dropdown mostra a opção `0='---'` como selecionada no primeiro início após a atualização.",
11
+ "nl": "Hotfix voor v1.3.1: v1.3.2 maakt gebruik van .\nDe nieuwe GlobalSchemas in main.ts doet dezelfde defensieve merge voor Loopt onvoorwaardelijk op elke start, zodat gebruikers upgraden van v1.2.0/v1.3.0/v1.3.1 (waar de legacy .\nHerstel stap nu bevordert een lege staat-waarde (...'\" overgebleven van v1.2.0) tot numerieke .",
12
+ "fr": "Hotfix pour v1.3.1: `setObjectNotExistsAsync` est un non-op sur les objets qui existent déjà en tant que restes en formation partielle du bug de migration v1.2.0. v1.3.2 utilise `extendObjectAsync` pour `clients.<id>.mode` + `clients.<id>.manualUrl` de sorte que les propriétés manquantes (de haut niveau `type`, nom, rôle, lecture, écriture, def) sont fusionnées dans l'objet partiel existant — l'avertissement \"obj.type\" de js-controller disparaît et le menu déroulant rend les étiquettes.\nLa nouvelle `reparationGlobalSchemas()` dans main.ts fait la même fusion défensive pour `global.mode` + `global.manualUrl`. Exécute inconditionnellement sur chaque démarrage de sorte que les utilisateurs de mise à jour de v1.2.0/v1.3.0/v1.3.1 (où l'héritage `visUrl` est déjà parti) obtiennent également le schéma réparé.\nRestaurer l'étape favorise maintenant une valeur d'état vide (`''' gauche de v1.2.0) à la valeur numérique `0`, de sorte que le menu déroulant affiche en fait l'option `0='--'` comme sélectionné au premier départ après la mise à niveau.",
13
+ "it": "Hotfix per v1.3.1: `setObjectNotExistsAsync` è un no-op su oggetti che già esistono come avanzi di forma parziale dal bug di migrazione v1.2.0. v1.3.2 utilizza `extendObjectAsync` per `clients. <id>.mode` + `clients.\nIl nuovo `repairGlobalSchemas()` in main.ts fa la stessa fusione difensiva per `global.mode` + `global.manualUrl`. Esegue incondizionatamente su ogni inizio così gli utenti che si aggiornano da v1.2.0/v1.3.0/v1.3.1 (dove l'eredità `visUrl` è già andato) anche ottenere lo schema riparato.\nRipristino passo ora promuove uno stato-valore vuoto (`'`'` lasciato oltre da v1.2.0) a numerico `0`, quindi la discesa mostra effettivamente l'opzione `0='--'` come selezionato al primo avvio dopo l'aggiornamento.",
14
+ "es": "Hotfix for v1.3.1: `setObjectNotExistsAsync` es un no-op en objetos que ya existen como sobras de forma parcial del fallo de migración v1.2.0. v1.3.2 utiliza `extendObjectAsync` para 'clientes. interpretadoid ratio.mode` + `clientes. empleados.manualUrl` por lo que las propiedades desaparecidas (tipo 'de alto nivel, nombre, papel, lectura, escritura, def) se fusionan en el objeto parcial existente — el \"obj.tipo de js-controller tiene que existir\" la advertencia desaparece y la etiqueta des.\nNew `repairGlobalSchemas()` in main.ts hace la misma fusión defensiva para `global.mode` + `global.manualUrl`. Corre incondicionalmente en cada inicio por lo que los usuarios que se actualizan desde v1.2.0/v1.3.0/v1.3.1 (donde el legado `visUrl` ya se ha ido) también consigue el esquema reparado.\nRestore step now promotes a blank state-value (`'''' left over from v1.2.0) to numeric `0`, so the dropdown actually shows the `0='--'` opción as selected on first start after the upgrade.",
15
+ "pl": "Hotfix dla v1.3.1: 'settObjectNotExistsAsync' to nie-op dla obiektów, które już istnieją jako częściowo utworzone pozostałości z błędu migracji v1.2.0. v1.3.2 używa 'extendObjectAsync' dla 'klientów. < id > .mode' + 'klientów. < id > .manualUrl' tak więc brakujące właściwości (typ \"top- level\", nazwa, rola, czytanie, pisanie, def) są połączone w istniejący obiekt częściowy - \"obj.type\" kontrolera js- istnieje \"ostrzeżenie znika i kropla w dół powoduje, że etykiety.\nNowe \"naprawa GlobalSchemas ()\" w main.ts dokonuje tego samego połączenia obronnego dla 'global.mode' + 'global.manualUrl'. Działa bezwarunkowo na każdym starcie, więc użytkownicy aktualizacji z v1.2.0 / v1.3.0 / v1.3.1 (gdzie spuścizna 'visUrl' już zniknęła) również uzyskać schemat naprawiony.\nPrzywróć krok teraz promuje pustą wartość stanu (\"\" left over from v1.2.0) do numerycznego '0', tak więc kropla w dół faktycznie pokazuje opcję '0 =' --- \", wybraną na pierwszym starcie po aktualizacji.",
16
+ "uk": "Hotfix для v1.3.1: `setObjectNotExistsAsync` є іменем на об'єктах, які вже існують як часткові реле з v1.2.0. v1.3.2 використовує `extendObjectAsync` для `clients.<id>.mode` + `clients.<id>.manualUrl` так відсутні властивості (на рівні `тип`, ім'я, роль, читання, запис, def) об'єднуються в існуючий частковий об'єкт — js-controller's \"obj.type повинен існувати\" попередження йде і відкидає етикетки.\nНовий `repairGlobalSchemas()` в main.ts є однаковою оборонною зливою для `global.mode` + `global.manualUrl`. Запуски безумовно на кожному старті, тому користувачі, що модернізують від v1.2.0/v1.3.0/v1.3.1 (де вже йде спадщина `visUrl`) і отримують ремонт схеми.\nВідновити крок тепер пропагує порожній стан-значення (```````` пішов з v1.2.0) до н.е.н. `0`, тому попадання фактично показує варіант `0='-'''', як вибраний на першому етапі після оновлення.",
17
+ "zh-cn": "V1.3.1 的 Hotfix: “ set ObjectNotExists Async” 是对 v1. 2.0 迁移错误中已作为部分形式遗留物存在的对象的禁用。 v1.3.2 使用“ extend ObjectAsync ” 表示“ 客户”。 <id>.mode` + `客户.<id>.manualUrl' 从而将缺失属性(顶级“类型”、名称、角色、读写、写写写、def)合并到现有的部分对象——js控制器的“obj.type必须存在”警告消失,下拉使标签失效.\n新的“修复全球计划()”主要用于“Global.mode”+“Global.manualUrl”的防御性合并。 用户从v1.2.0/v1.3.0/v1.3.1(遗留的`visUrl'已经消失)升级.\n恢复步骤现在将从v1.2.0中遗留的空白状态值(`'')推广到数字`0',因此降级实际上显示升级后第一个开始时所选择的`0=-'`选项."
18
+ },
6
19
  "1.3.1": {
7
20
  "en": "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.\nMode 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.",
8
21
  "de": "Hotfix für ältere v1.1.x-Clients: ihr `visUrl`-Kanal hatte keine `mode` / `manualUrl`-Objekte. Die v1.2.0 Migration schrieb Zustände ohne die passenden Objekte, die der als `State eingeloggte Broker hat kein bestehendes Objekt` und den `mode`-Datenpunkt ohne Namen oder Dropdown im Objektbrowser. `ClientRegistry.restore()` ruft nun für jeden Client ein idempotent `ensureObjects()` auf, so dass die v1.2.0+ Objektformen existieren, bevor Migrationsschreiben passieren.\nModus Dropdown gewinnt einen numerischen `0 = \"--\"` no-choice fallback (analog zu govee-smart's Muster). Vorhandene Displays halten ihre Einstellung; neue Displays starten bei `0` und der Resolver fällt auf die Landingpage zurück, bis eine echte Wahl getroffen wird.",
@@ -80,19 +93,6 @@
80
93
  "pl": "Oddzielna produkcja testowa (\"build-test /\") z produkcji \"build /\" - \"npm test\" nie może już pozostawiać powielonych \"build / src\" + \"build / test\" drzew w opublikowanym pakiecie. Bez zmian.",
81
94
  "uk": "Окремий тестово-будівельний вихід (`build-test/``) від виробництва `build/`` — `npm test’ не більше ризиків, що залишають дублікатом `build/src` + `build/test` дерев у опублікованому пакеті. Немає змін робочого часу.",
82
95
  "zh-cn": "将试验-建设产出(`建设-测试/')与生产`建设/'——`npm测试 ' 分开,不再有将`建设/src ' +`建设/测试 ' 树木复制到已公布的成套材料的风险。 无运行时间变化 ."
83
- },
84
- "1.1.3": {
85
- "en": "Fix duplicate client registration on initial connect — HA displays fire several parallel cookieless requests, each used to create a separate client record. Now locked per IP so the first create wins and the rest of the burst attach to the same client. Setup page redesigned: big green OK banner, localized into all 11 adapter languages (auto-picked from the ioBroker system language), IP shown alongside the device ID, responsive layout with dark-mode support.",
86
- "de": "Doppelte Client-Registrierung beim ersten Verbinden behoben — HA-Displays feuern mehrere parallele Requests ohne Cookie, jeder legte bisher einen eigenen Client an. Jetzt pro IP gesperrt: der erste Create gewinnt, die restlichen Parallel-Requests werden dem gleichen Client zugeordnet. Setup-Seite überarbeitet: großes grünes OK-Banner, Lokalisierung in alle 11 Adapter-Sprachen (automatisch nach ioBroker-Systemsprache), IP wird neben der Device-ID angezeigt, responsive Layout mit Dark-Mode.",
87
- "ru": "Исправлено дублирование регистрации клиента при первом подключении — HA-дисплеи шлют несколько параллельных запросов без cookie. Теперь блокировка по IP. Страница настройки переработана, локализована, показана IP.",
88
- "pt": "Corrigido registro duplicado de cliente na conexão inicial — displays HA enviam várias requisições paralelas sem cookie. Agora bloqueadas por IP. Página de configuração redesenhada, traduzida, com IP.",
89
- "nl": "Dubbele client-registratie bij eerste verbinding opgelost — HA-displays sturen meerdere parallelle requests zonder cookie. Nu vergrendeld per IP. Setup-pagina vernieuwd, gelokaliseerd, met IP.",
90
- "fr": "Correction de l'enregistrement client dupliqué à la première connexion — les écrans HA envoient plusieurs requêtes parallèles sans cookie. Verrouillé par IP désormais. Page de configuration redessinée, traduite, IP affichée.",
91
- "it": "Corretta la registrazione duplicata del client alla prima connessione — i display HA inviano più richieste parallele senza cookie. Ora bloccato per IP. Pagina di setup ridisegnata, localizzata, con IP.",
92
- "es": "Corregido el registro duplicado de cliente en la conexión inicial — los displays HA envían varias solicitudes paralelas sin cookie. Ahora bloqueado por IP. Página de configuración rediseñada, traducida, con IP.",
93
- "pl": "Naprawiono zduplikowaną rejestrację klienta przy pierwszym połączeniu — wyświetlacze HA wysyłają kilka równoległych żądań bez cookie. Teraz blokada per IP. Strona konfiguracji przeprojektowana, przetłumaczona, z IP.",
94
- "uk": "Виправлено дублювання реєстрації клієнта при першому підключенні — HA-дисплеї надсилають кілька паралельних запитів без cookie. Тепер блокування за IP. Сторінка налаштування перероблена, перекладена, з IP.",
95
- "zh-cn": "修复首次连接时客户端重复注册 — HA 显示器并行发送多个无 cookie 请求,之前每个都会创建独立客户端记录。现在按 IP 加锁。设置页面重新设计,已本地化,显示 IP。"
96
96
  }
97
97
  },
98
98
  "titleLang": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iobroker.hassemu",
3
- "version": "1.3.1",
3
+ "version": "1.3.2",
4
4
  "description": "Emulates a minimal Home Assistant server so devices expecting a Home Assistant dashboard can display any custom web URL.",
5
5
  "author": {
6
6
  "name": "krobi",