iobroker.hassemu 1.3.1 → 1.3.3
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 +12 -29
- package/build/lib/client-registry.js +12 -10
- package/build/lib/client-registry.js.map +2 -2
- package/build/lib/global-config.js +4 -0
- package/build/lib/global-config.js.map +2 -2
- package/build/main.js +39 -2
- package/build/main.js.map +2 -2
- package/io-package.json +82 -82
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -147,44 +147,27 @@ 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.
|
|
150
|
+
### 1.3.3 (2026-05-01)
|
|
151
|
+
- Documentation: rewrote release notes for v1.1.4–v1.3.2 in user-friendly style across all languages.
|
|
151
152
|
|
|
152
|
-
|
|
153
|
-
-
|
|
153
|
+
### 1.3.2 (2026-04-30)
|
|
154
|
+
- Fix: dropdown default `---` now applied correctly on upgrades from older v1.1.x clients (was empty after migration).
|
|
154
155
|
|
|
155
|
-
### 1.3.
|
|
156
|
+
### 1.3.1 (2026-04-30)
|
|
157
|
+
- Fix: legacy v1.1.x clients without `mode`/`manualUrl` objects now get migrated correctly on first start.
|
|
158
|
+
- Mode dropdown gains a `0 = "---"` no-choice fallback — new displays start without a target until a real choice is made.
|
|
156
159
|
|
|
157
|
-
|
|
158
|
-
-
|
|
159
|
-
-
|
|
160
|
-
- New `landing-page` test suite with XSS-escape coverage and 11-language fallback verification.
|
|
161
|
-
- Emulated Home Assistant version bumped from 2026.3.1 to 2026.4.0.
|
|
160
|
+
### 1.3.0 (2026-04-30)
|
|
161
|
+
- Security: brute-force lockout on login (5 failed attempts → IP blocked for 15 min, successful login resets the counter).
|
|
162
|
+
- Emulated Home Assistant version bumped to 2026.4.0.
|
|
162
163
|
|
|
163
164
|
### 1.2.0 (2026-04-29)
|
|
164
|
-
|
|
165
|
-
-
|
|
166
|
-
- Master switch `global.enabled` syncs every display: on → all follow the global URL, off → each display picks up its own again.
|
|
165
|
+
- **Breaking:** Redirect target now configured via `mode` (dropdown) + `manualUrl` (free text) instead of the old `visUrl`. Existing setups auto-migrated.
|
|
166
|
+
- Master switch `global.enabled` syncs every display (on → all follow global URL, off → each display picks up its own).
|
|
167
167
|
- Idle displays without auth token are auto-removed after 30 days.
|
|
168
168
|
- Security hardening of the auth flow.
|
|
169
169
|
- `web` adapter declared as dependency.
|
|
170
170
|
|
|
171
|
-
### 1.1.6 (2026-04-28)
|
|
172
|
-
|
|
173
|
-
- Audit cleanup against the upstream `ioBroker.example/TypeScript` full standard:
|
|
174
|
-
- Test setup migrated: tests now live next to source as `src/lib/*.test.ts` and run directly via `ts-node/register`. Removed `tsconfig.test.json` + `build-test/`, added `test/mocharc.custom.json` + `test/mocha.setup.js` + `test/tsconfig.json` + `test/.eslintrc.json`
|
|
175
|
-
- `@types/node` rolled back from `^25.6.0` to `^20.19.24` so type defs match `engines.node: ">=20"`
|
|
176
|
-
- Dependabot now ignores major bumps for `@types/node`, `typescript`, `eslint`, `actions/checkout`, `actions/setup-node`
|
|
177
|
-
- `nyc` config + `coverage` script added
|
|
178
|
-
- Orphan `.github/auto-merge.yml` removed (active workflow is `automerge-dependabot.yml` using `gh pr merge`)
|
|
179
|
-
|
|
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
171
|
Older entries are in [CHANGELOG_OLD.md](CHANGELOG_OLD.md).
|
|
189
172
|
|
|
190
173
|
## 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.
|
|
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).
|
|
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.
|
|
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": ";;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAWA,yBAAmB;AACnB,oBAA4F;AAC5F,2BAAyC;AACzC,qBAAiC;AAgBjC,MAAM,iBAAiB;AAMhB,MAAM,eAAe;AAAA,EACP;AAAA,EACA,WAAW,oBAAI,IAA0B;AAAA,EACzC,OAAO,oBAAI,IAA0B;AAAA,EACrC,UAAU,oBAAI,IAA0B;AAAA,EACjD,mBAA8B,CAAC;AAAA,EAC/B,wBAA+C,MAAM;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAS5C,cAAc,oBAAI,IAAmC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAMrD,oBAAoB,oBAAI,IAAoB;AAAA;AAAA,EAG7D,YAAY,SAA0B;AAClC,SAAK,UAAU;AAAA,EACnB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,yBAAyB,UAAuC;AAC5D,SAAK,wBAAwB;AAAA,EACjC;AAAA;AAAA,EAGA,MAAM,UAAyB;AA3EnC;AA4EQ,QAAI,WAAmD,CAAC;AACxD,QAAI;AACA,kBACK,WAAM,KAAK,QAAQ,uBAAuB,GAAG,KAAK,QAAQ,SAAS,cAAc,SAAS,MAA1F,YAAgG,CAAC;AAAA,IAC1G,SAAS,KAAK;AACV,WAAK,QAAQ,IAAI,MAAM,oCAAoC,OAAO,GAAG,CAAC,EAAE;AACxE;AAAA,IACJ;AAEA,eAAW,CAAC,QAAQ,GAAG,KAAK,OAAO,QAAQ,QAAQ,GAAG;AAClD,YAAM,KAAK,OAAO,UAAU,GAAG,KAAK,QAAQ,SAAS,YAAY,MAAM;AACvE,UAAI,CAAC,MAAM,GAAG,SAAS,GAAG,GAAG;AACzB;AAAA,MACJ;AACA,YAAM,aAAS,6BAAc,IAAI,MAAM,IAAI,IAAI,SAAS,CAAC;AACzD,YAAM,aAAS,0BAAW,OAAO,MAAM;AACvC,UAAI,CAAC,QAAQ;AACT;AAAA,MACJ;AACA,YAAM,UAAU,MAAM,KAAK,UAAU,GAAG,EAAE,OAAO;AACjD,YAAM,OAAO,OAAO,YAAY,WAAW,UAAU;AACrD,YAAM,gBAAY,6BAAc,MAAM,KAAK,UAAU,GAAG,EAAE,YAAY,CAAC;AACvE,YAAM,SAAK,4BAAa,MAAM,KAAK,UAAU,GAAG,EAAE,KAAK,CAAC;AACxD,YAAM,YAAQ,0BAAW,OAAO,KAAK;AAIrC,YAAM,qBAAiB,4BAAa,MAAM,KAAK,UAAU,GAAG,EAAE,WAAW,CAAC;AAC1E,UAAI,kBAAc,6BAAa,SAAI,WAAJ,mBAAY,IAAI;AAC/C,UAAI,gBAAgB;AAChB,YAAI,mBAAmB,aAAa;AAChC,gBAAM,KAAK,QAAQ,kBAAkB,WAAW,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,eAAe,EAAE,CAAC;AAC1F,wBAAc;AAAA,QAClB;AACA,YAAI;AACA,gBAAM,KAAK,QAAQ,eAAe,WAAW,EAAE,WAAW;AAAA,QAC9D,QAAQ;AAAA,QAER;AAAA,MACJ;AACA,YAAM,WAAW,eAAe,gBAAgB,MAAM,gBAAgB,KAAK,cAAc;AAEzF,YAAM,SAAuB,EAAE,IAAI,QAAQ,OAAO,MAAM,WAAW,IAAI,SAAS;AAChF,WAAK,cAAc,MAAM;AAKzB,YAAM,KAAK,cAAc,MAAM;AAAA,
|
|
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": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAaA,oBAAkE;AAW3D,MAAM,cAAc;AAEpB,MAAM,cAAc;AAGpB,MAAM,aAAa;AAAA,EACL;AAAA,EACT,OAAe;AAAA,EACf,YAA2B;AAAA,EAC3B,UAAU;AAAA;AAAA,EAGlB,YAAY,SAA8B;AACtC,SAAK,UAAU;AAAA,EACnB;AAAA;AAAA,EAGA,MAAM,UAAyB;AAC3B,UAAM,YAAY,MAAM,KAAK,aAAa,aAAa;AACvD,UAAM,cAAc,MAAM,KAAK,aAAa,kBAAkB;AAC9D,UAAM,eAAe,MAAM,KAAK,aAAa,gBAAgB;AAC7D,SAAK,OAAO,QAAO,uCAAW,SAAQ,WAAW,UAAU,MAAM;AACjE,SAAK,gBAAY,6BAAc,2CAAa,GAAG;AAC/C,SAAK,cAAU,6BAAc,6CAAc,GAAG,MAAM;AAAA,
|
|
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: {
|
|
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(`
|
|
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": ";;;;;;;;;;;;;;;;;;;;;;;AAAA,yBAAmB;AACnB,YAAuB;AACvB,6BAAmD;AACnD,oBAA8B;AAC9B,2BAA2E;AAC3E,kBAA4B;AAC5B,2BAA6B;AAC7B,uBAA0B;AAI1B,MAAM,sBAAsB,KAAK,KAAK,KAAK,KAAK;AAEhD,MAAM,gBAAgB,MAAM,QAAQ;AAAA,EACxB,cAAkC;AAAA,EAClC,YAA8B;AAAA,EAC9B,WAAkC;AAAA,EAClC,eAAoC;AAAA,EACpC,eAAoC;AAAA,EACpC,4BAAgE;AAAA,EAChE,2BAA0D;AAAA,EAI3D,YAAY,UAAyC,CAAC,GAAG;AAC5D,UAAM,EAAE,GAAG,SAAS,MAAM,UAAU,CAAC;AAErC,SAAK,GAAG,SAAS,MAAM;AACnB,WAAK,QAAQ,EAAE,MAAM,SAAO,KAAK,IAAI,MAAM,sBAAsB,OAAO,GAAG,CAAC,EAAE,CAAC;AAAA,IACnF,CAAC;AACD,SAAK,GAAG,eAAe,CAAC,IAAI,UAAU;AAClC,WAAK,cAAc,IAAI,KAAK,EAAE,MAAM,SAAO,KAAK,IAAI,MAAM,0BAA0B,OAAO,GAAG,CAAC,EAAE,CAAC;AAAA,IACtG,CAAC;AACD,SAAK,GAAG,gBAAgB,MAAM;AAjCtC;AAmCY,iBAAK,iBAAL,mBAAmB;AAAA,IACvB,CAAC;AACD,SAAK,GAAG,UAAU,KAAK,SAAS,KAAK,IAAI,CAAC;AAK1C,SAAK,4BAA4B,CAAC,WAAoB;AAClD,WAAK,IAAI,MAAM,wBAAwB,kBAAkB,QAAQ,OAAO,UAAU,OAAO,MAAM,CAAC,EAAE;AAAA,IACtG;AACA,SAAK,2BAA2B,CAAC,QAAe;AAC5C,WAAK,IAAI,MAAM,uBAAuB,IAAI,OAAO,EAAE;AAAA,IACvD;AACA,YAAQ,GAAG,sBAAsB,KAAK,yBAAyB;AAC/D,YAAQ,GAAG,qBAAqB,KAAK,wBAAwB;AAAA,EACjE;AAAA,EAEA,MAAc,UAAyB;AACnC,UAAM,KAAK,SAAS,mBAAmB,EAAE,KAAK,OAAO,KAAK,KAAK,CAAC;AAEhE,SAAK,eAAe,IAAI,kCAAa,IAAI;AACzC,UAAM,KAAK,aAAa,QAAQ;AAEhC,SAAK,WAAW,IAAI,sCAAe,IAAI;AACvC,UAAM,KAAK,SAAS,QAAQ;
|
|
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,98 +1,98 @@
|
|
|
1
1
|
{
|
|
2
2
|
"common": {
|
|
3
3
|
"name": "hassemu",
|
|
4
|
-
"version": "1.3.
|
|
4
|
+
"version": "1.3.3",
|
|
5
5
|
"news": {
|
|
6
|
+
"1.3.3": {
|
|
7
|
+
"en": "Documentation: rewrote release notes in user-friendly style across all languages.",
|
|
8
|
+
"de": "Dokumentation: Release-Notes in alle Sprachen User-orientiert neu geschrieben.",
|
|
9
|
+
"ru": "Документация: примечания к релизу переписаны в дружественном к пользователю стиле на всех языках.",
|
|
10
|
+
"pt": "Documentação: notas de release reescritas em estilo amigável ao usuário em todos os idiomas.",
|
|
11
|
+
"nl": "Documentatie: release-notes herschreven in gebruikersvriendelijke stijl in alle talen.",
|
|
12
|
+
"fr": "Documentation : notes de version réécrites dans un style adapté à l'utilisateur dans toutes les langues.",
|
|
13
|
+
"it": "Documentazione: note di release riscritte in stile user-friendly in tutte le lingue.",
|
|
14
|
+
"es": "Documentación: notas de versión reescritas en estilo amigable para el usuario en todos los idiomas.",
|
|
15
|
+
"pl": "Dokumentacja: notatki wydania przepisane w przyjaznym dla użytkownika stylu we wszystkich językach.",
|
|
16
|
+
"uk": "Документація: нотатки до релізу переписано у дружньому до користувача стилі усіма мовами.",
|
|
17
|
+
"zh-cn": "文档:所有语言的发布说明已重写为用户友好的风格。"
|
|
18
|
+
},
|
|
19
|
+
"1.3.2": {
|
|
20
|
+
"en": "Fix: dropdown default `---` now applied correctly on upgrades from older v1.1.x clients (was empty after migration).",
|
|
21
|
+
"de": "Fix: Dropdown-Standard `---` wird bei Upgrades von älteren v1.1.x-Clients jetzt korrekt gesetzt (war nach der Migration leer).",
|
|
22
|
+
"ru": "Fix: значение по умолчанию `---` в dropdown теперь правильно применяется при обновлении со старых клиентов v1.1.x (было пустым после миграции).",
|
|
23
|
+
"pt": "Fix: valor padrão `---` do dropdown agora é aplicado corretamente em atualizações de clientes v1.1.x antigos (estava vazio após a migração).",
|
|
24
|
+
"nl": "Fix: dropdown-standaard `---` wordt nu correct toegepast bij upgrades van oudere v1.1.x-clients (was leeg na de migratie).",
|
|
25
|
+
"fr": "Fix : la valeur par défaut `---` du dropdown est désormais correctement appliquée lors des mises à niveau depuis les anciens clients v1.1.x (était vide après la migration).",
|
|
26
|
+
"it": "Fix: il valore predefinito `---` del dropdown ora viene applicato correttamente sugli aggiornamenti da client v1.1.x più vecchi (era vuoto dopo la migrazione).",
|
|
27
|
+
"es": "Fix: el valor predeterminado `---` del dropdown ahora se aplica correctamente en actualizaciones desde clientes v1.1.x antiguos (estaba vacío tras la migración).",
|
|
28
|
+
"pl": "Fix: wartość domyślna `---` w dropdown jest teraz poprawnie ustawiana przy aktualizacjach ze starszych klientów v1.1.x (była pusta po migracji).",
|
|
29
|
+
"uk": "Fix: значення за замовчуванням `---` у dropdown тепер коректно застосовується при оновленнях зі старих клієнтів v1.1.x (було порожнім після міграції).",
|
|
30
|
+
"zh-cn": "修复:从旧的 v1.1.x 客户端升级时,下拉框默认值 `---` 现在能正确显示(迁移后曾为空)。"
|
|
31
|
+
},
|
|
6
32
|
"1.3.1": {
|
|
7
|
-
"en": "
|
|
8
|
-
"de": "
|
|
9
|
-
"ru": "
|
|
10
|
-
"pt": "
|
|
11
|
-
"nl": "
|
|
12
|
-
"fr": "
|
|
13
|
-
"it": "
|
|
14
|
-
"es": "
|
|
15
|
-
"pl": "
|
|
16
|
-
"uk": "
|
|
17
|
-
"zh-cn": "
|
|
33
|
+
"en": "Fix: legacy v1.1.x clients without mode/manualUrl objects now get migrated correctly on first start.",
|
|
34
|
+
"de": "Fix: Legacy-v1.1.x-Clients ohne mode/manualUrl-Objekte werden beim ersten Start jetzt korrekt migriert.",
|
|
35
|
+
"ru": "Fix: устаревшие клиенты v1.1.x без объектов mode/manualUrl теперь корректно мигрируются при первом запуске.",
|
|
36
|
+
"pt": "Fix: clientes v1.1.x antigos sem objetos mode/manualUrl agora são migrados corretamente na primeira inicialização.",
|
|
37
|
+
"nl": "Fix: oude v1.1.x-clients zonder mode/manualUrl-objecten worden nu correct gemigreerd bij de eerste start.",
|
|
38
|
+
"fr": "Fix : les anciens clients v1.1.x sans objets mode/manualUrl sont désormais correctement migrés au premier démarrage.",
|
|
39
|
+
"it": "Fix: i client v1.1.x legacy senza oggetti mode/manualUrl ora vengono migrati correttamente al primo avvio.",
|
|
40
|
+
"es": "Fix: los clientes v1.1.x antiguos sin objetos mode/manualUrl ahora se migran correctamente en el primer inicio.",
|
|
41
|
+
"pl": "Fix: starsze klienty v1.1.x bez obiektów mode/manualUrl są teraz poprawnie migrowane przy pierwszym uruchomieniu.",
|
|
42
|
+
"uk": "Fix: застарілі клієнти v1.1.x без об'єктів mode/manualUrl тепер коректно мігрують при першому запуску.",
|
|
43
|
+
"zh-cn": "修复:缺少 mode/manualUrl 对象的旧版 v1.1.x 客户端现在能在首次启动时正确迁移。"
|
|
18
44
|
},
|
|
19
45
|
"1.3.0": {
|
|
20
|
-
"en": "Security: brute-force lockout on
|
|
21
|
-
"de": "Sicherheit:
|
|
22
|
-
"ru": "Безопасность: блокировка
|
|
23
|
-
"pt": "Segurança: bloqueio
|
|
24
|
-
"nl": "Beveiliging: brute-force
|
|
25
|
-
"fr": "
|
|
26
|
-
"it": "Sicurezza: blocco
|
|
27
|
-
"es": "Seguridad: bloqueo
|
|
28
|
-
"pl": "Bezpieczeństwo:
|
|
29
|
-
"uk": "Безпека:
|
|
30
|
-
"zh-cn": "
|
|
46
|
+
"en": "Security: brute-force lockout on login (5 failed attempts → IP blocked for 15 min). Emulated Home Assistant version bumped to 2026.4.0.",
|
|
47
|
+
"de": "Sicherheit: Brute-Force-Sperre beim Login (5 Fehlversuche → IP für 15 Min gesperrt). Emulierte Home-Assistant-Version auf 2026.4.0 erhöht.",
|
|
48
|
+
"ru": "Безопасность: блокировка от brute-force при входе (5 неудачных попыток → IP блокируется на 15 мин). Версия эмулируемого Home Assistant повышена до 2026.4.0.",
|
|
49
|
+
"pt": "Segurança: bloqueio brute-force no login (5 tentativas falhadas → IP bloqueado por 15 min). Versão emulada do Home Assistant atualizada para 2026.4.0.",
|
|
50
|
+
"nl": "Beveiliging: brute-force-blokkering bij login (5 mislukte pogingen → IP 15 min geblokkeerd). Geëmuleerde Home Assistant-versie naar 2026.4.0.",
|
|
51
|
+
"fr": "Sécurité : verrouillage brute-force sur la connexion (5 tentatives échouées → IP bloquée 15 min). Version Home Assistant émulée portée à 2026.4.0.",
|
|
52
|
+
"it": "Sicurezza: blocco brute-force al login (5 tentativi falliti → IP bloccato per 15 min). Versione Home Assistant emulata aggiornata a 2026.4.0.",
|
|
53
|
+
"es": "Seguridad: bloqueo brute-force al iniciar sesión (5 intentos fallidos → IP bloqueada 15 min). Versión emulada de Home Assistant actualizada a 2026.4.0.",
|
|
54
|
+
"pl": "Bezpieczeństwo: blokada brute-force przy logowaniu (5 nieudanych prób → IP zablokowane na 15 min). Wersja emulowanego Home Assistant podniesiona do 2026.4.0.",
|
|
55
|
+
"uk": "Безпека: блокування brute-force при вході (5 невдалих спроб → IP заблоковано на 15 хв). Версія емульованого Home Assistant оновлена до 2026.4.0.",
|
|
56
|
+
"zh-cn": "安全:登录暴力破解防护(5 次失败 → IP 阻断 15 分钟)。模拟的 Home Assistant 版本提升至 2026.4.0。"
|
|
31
57
|
},
|
|
32
58
|
"1.2.0": {
|
|
33
|
-
"en": "
|
|
34
|
-
"de": "
|
|
35
|
-
"ru": "
|
|
36
|
-
"pt": "
|
|
37
|
-
"nl": "
|
|
38
|
-
"fr": "
|
|
39
|
-
"it": "
|
|
40
|
-
"es": "
|
|
41
|
-
"pl": "
|
|
42
|
-
"uk": "
|
|
43
|
-
"zh-cn": "
|
|
59
|
+
"en": "Breaking: redirect target now configured via mode dropdown + manualUrl free text instead of the old visUrl. Existing setups auto-migrated.",
|
|
60
|
+
"de": "Breaking: Weiterleitungs-Ziel jetzt über mode-Dropdown + manualUrl-Freitext konfiguriert statt altem visUrl. Bestehende Setups werden automatisch migriert.",
|
|
61
|
+
"ru": "Breaking: цель перенаправления теперь настраивается через dropdown mode + свободный текст manualUrl вместо старого visUrl. Существующие настройки мигрируют автоматически.",
|
|
62
|
+
"pt": "Breaking: alvo de redirecionamento agora configurado via dropdown mode + texto livre manualUrl em vez do antigo visUrl. Configurações existentes são migradas automaticamente.",
|
|
63
|
+
"nl": "Breaking: redirect-doel nu via mode-dropdown + manualUrl-vrije tekst geconfigureerd in plaats van het oude visUrl. Bestaande setups worden automatisch gemigreerd.",
|
|
64
|
+
"fr": "Breaking : la cible de redirection se configure désormais via le dropdown mode + le texte libre manualUrl au lieu de l'ancien visUrl. Les configurations existantes sont migrées automatiquement.",
|
|
65
|
+
"it": "Breaking: obiettivo di redirect ora configurato tramite dropdown mode + testo libero manualUrl invece del vecchio visUrl. Le configurazioni esistenti vengono migrate automaticamente.",
|
|
66
|
+
"es": "Breaking: el destino de redirección ahora se configura mediante el dropdown mode + el texto libre manualUrl en lugar del antiguo visUrl. Las configuraciones existentes se migran automáticamente.",
|
|
67
|
+
"pl": "Breaking: cel przekierowania jest teraz konfigurowany przez dropdown mode + wolny tekst manualUrl zamiast starego visUrl. Istniejące konfiguracje są migrowane automatycznie.",
|
|
68
|
+
"uk": "Breaking: ціль перенаправлення тепер налаштовується через dropdown mode + вільний текст manualUrl замість старого visUrl. Існуючі налаштування мігрують автоматично.",
|
|
69
|
+
"zh-cn": "Breaking:重定向目标现在通过 mode 下拉框 + manualUrl 自由文本配置,取代旧的 visUrl。现有配置会自动迁移。"
|
|
44
70
|
},
|
|
45
71
|
"1.1.6": {
|
|
46
|
-
"en": "
|
|
47
|
-
"de": "
|
|
48
|
-
"ru": "
|
|
49
|
-
"pt": "Limpeza
|
|
50
|
-
"nl": "
|
|
51
|
-
"fr": "Nettoyage
|
|
52
|
-
"it": "
|
|
53
|
-
"es": "Limpieza
|
|
54
|
-
"pl": "
|
|
55
|
-
"uk": "
|
|
56
|
-
"zh-cn": "
|
|
72
|
+
"en": "Internal cleanup. No user-facing changes.",
|
|
73
|
+
"de": "Interne Bereinigung. Keine User-sichtbaren Änderungen.",
|
|
74
|
+
"ru": "Внутренняя очистка. Без видимых изменений для пользователя.",
|
|
75
|
+
"pt": "Limpeza interna. Sem alterações visíveis para o usuário.",
|
|
76
|
+
"nl": "Interne opschoning. Geen wijzigingen voor de gebruiker.",
|
|
77
|
+
"fr": "Nettoyage interne. Aucune modification visible pour l'utilisateur.",
|
|
78
|
+
"it": "Pulizia interna. Nessuna modifica visibile all'utente.",
|
|
79
|
+
"es": "Limpieza interna. Sin cambios visibles para el usuario.",
|
|
80
|
+
"pl": "Wewnętrzne porządki. Brak zmian widocznych dla użytkownika.",
|
|
81
|
+
"uk": "Внутрішнє прибирання. Без помітних для користувача змін.",
|
|
82
|
+
"zh-cn": "内部清理。对用户无可见变化。"
|
|
57
83
|
},
|
|
58
84
|
"1.1.5": {
|
|
59
|
-
"en": "
|
|
60
|
-
"de": "
|
|
61
|
-
"ru": "
|
|
62
|
-
"pt": "
|
|
63
|
-
"nl": "
|
|
64
|
-
"fr": "
|
|
65
|
-
"it": "
|
|
66
|
-
"es": "
|
|
67
|
-
"pl": "
|
|
68
|
-
"uk": "
|
|
69
|
-
"zh-cn": "
|
|
70
|
-
},
|
|
71
|
-
"1.1.4": {
|
|
72
|
-
"en": "Separate test-build output (`build-test/`) from production `build/` — `npm test` no longer risks leaving duplicated `build/src` + `build/test` trees in the published package. No runtime change.",
|
|
73
|
-
"de": "Separate Test-Building-Ausgang (`Building-Test/`) von der Produktion `Building/` — `npm-Test` nicht mehr Risiken verlassen duplizierte `Building/Src` + `Building/test` Bäume im veröffentlichten Paket. Keine Laufzeitänderung.",
|
|
74
|
-
"ru": "Отдельная продукция испытательной постройки (‘build-test/’) от производства ‘build/’ — ‘npm test’ больше не рискует оставить дублированные деревья ‘build/src’ + ‘build/test’ в опубликованном пакете. Никаких изменений.",
|
|
75
|
-
"pt": "Saída de construção de teste separada (`build-test/`) da produção `build/` — `npm test` já não corre o risco de deixar árvores duplicadas `build/src` + `build/test` no pacote publicado. Nenhuma mudança de tempo de execução.",
|
|
76
|
-
"nl": "Aparte test-build output van de productie Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build- Geen verandering in de looptijd.",
|
|
77
|
-
"fr": "La sortie séparée de la construction d'essai (`build-test/`) de la production `build/` — `npm test` ne risque plus de laisser des arbres dupliqués `build/src` + `build/test` dans le paquet publié. Pas de changement d'exécution.",
|
|
78
|
-
"it": "Uscita separata di test-build (`build-test/`) dalla produzione `build/` — `npm test` non rischia più di lasciare duplicati `build/src` + `build/test` alberi nel pacchetto pubblicato. Nessun cambiamento di runtime.",
|
|
79
|
-
"es": "La producción separada de la producción de pruebas ( \" pruebas impresas \" ) de la producción \" , \" prueba npm \" ya no corre el riesgo de dejar árboles duplicados \" construidos/src \" + \" construidos/test \" en el paquete publicado. No hay cambio de horario.",
|
|
80
|
-
"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
|
-
"uk": "Окремий тестово-будівельний вихід (`build-test/``) від виробництва `build/`` — `npm test’ не більше ризиків, що залишають дублікатом `build/src` + `build/test` дерев у опублікованому пакеті. Немає змін робочого часу.",
|
|
82
|
-
"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。"
|
|
85
|
+
"en": "Crash defense: process-level error handlers. Min js-controller restored to >=6.0.11.",
|
|
86
|
+
"de": "Crash-Schutz: process-level Error-Handler. Min js-controller zurück auf >=6.0.11.",
|
|
87
|
+
"ru": "Защита от сбоев: process-level обработчики ошибок. Мин. js-controller восстановлен на >=6.0.11.",
|
|
88
|
+
"pt": "Proteção contra falhas: handlers de erro process-level. Mín. js-controller restaurado para >=6.0.11.",
|
|
89
|
+
"nl": "Crashbescherming: process-level error-handlers. Min. js-controller hersteld op >=6.0.11.",
|
|
90
|
+
"fr": "Défense contre les crashs : handlers d'erreur process-level. Min js-controller rétabli à >=6.0.11.",
|
|
91
|
+
"it": "Difesa anti-crash: handler di errore process-level. Min js-controller ripristinato a >=6.0.11.",
|
|
92
|
+
"es": "Defensa contra fallos: handlers de error process-level. Mín. js-controller restaurado a >=6.0.11.",
|
|
93
|
+
"pl": "Ochrona przed crashem: handlery błędów process-level. Min. js-controller przywrócony do >=6.0.11.",
|
|
94
|
+
"uk": "Захист від збоїв: process-level обробники помилок. Мін. js-controller повернено на >=6.0.11.",
|
|
95
|
+
"zh-cn": "崩溃防护:process-level 错误处理器。最低 js-controller 恢复为 >=6.0.11。"
|
|
96
96
|
}
|
|
97
97
|
},
|
|
98
98
|
"titleLang": {
|
package/package.json
CHANGED