iobroker.hassemu 1.28.2 → 1.28.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 +4 -4
- package/build/lib/client-registry.js +36 -32
- package/build/lib/client-registry.js.map +2 -2
- package/build/lib/url-discovery.js +9 -5
- package/build/lib/url-discovery.js.map +2 -2
- package/build/lib/webserver.js +29 -0
- package/build/lib/webserver.js.map +2 -2
- package/build/main.js +23 -16
- package/build/main.js.map +2 -2
- package/io-package.json +14 -14
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -131,6 +131,10 @@ Got scripts that still write to `visUrl`? Update them — write to `manualUrl` i
|
|
|
131
131
|
Placeholder for the next version (at the beginning of the line):
|
|
132
132
|
### **WORK IN PROGRESS**
|
|
133
133
|
-->
|
|
134
|
+
### 1.28.3 (2026-05-10)
|
|
135
|
+
|
|
136
|
+
- Adapter starts faster on installations with many displays, and a single broken client entry no longer keeps the others from being restored. Plus a security tighten-up around the HA login flow.
|
|
137
|
+
|
|
134
138
|
### 1.28.2 (2026-05-09)
|
|
135
139
|
|
|
136
140
|
- Adapter log messages are now English only, in line with the ioBroker community standard. Localized state names, descriptions and dropdown labels (11 languages) are unchanged. The user-facing HA landing page also remains localized.
|
|
@@ -147,10 +151,6 @@ Got scripts that still write to `visUrl`? Update them — write to `manualUrl` i
|
|
|
147
151
|
|
|
148
152
|
- Docs: README rewritten in plain user voice — shorter, direct, no implementation trivia. No code changes.
|
|
149
153
|
|
|
150
|
-
### 1.27.2 (2026-05-06)
|
|
151
|
-
|
|
152
|
-
- Bugfix: dropdown `common.states` is now fully replaced on each URL-refresh instead of merged — stale URLs from earlier format versions are now removed.
|
|
153
|
-
|
|
154
154
|
Older entries are in [CHANGELOG_OLD.md](CHANGELOG_OLD.md).
|
|
155
155
|
|
|
156
156
|
## Support
|
|
@@ -94,40 +94,44 @@ class ClientRegistry {
|
|
|
94
94
|
if (!id || id.includes(".")) {
|
|
95
95
|
continue;
|
|
96
96
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const [modeRaw, manualUrlRaw, ipRaw, hostnameRaw] = await Promise.all([
|
|
103
|
-
this.readState(`${id}.mode`),
|
|
104
|
-
this.readState(`${id}.manualUrl`),
|
|
105
|
-
this.readState(`${id}.ip`),
|
|
106
|
-
this.readState(`${id}.hostname`)
|
|
107
|
-
]);
|
|
108
|
-
const mode = typeof modeRaw === "string" ? modeRaw : "";
|
|
109
|
-
const manualUrl = (0, import_coerce.coerceSafeUrl)(manualUrlRaw);
|
|
110
|
-
const ip = (0, import_coerce.coerceString)(ipRaw);
|
|
111
|
-
const token = (0, import_coerce.coerceUuid)(native.token);
|
|
112
|
-
const legacyHostname = (0, import_coerce.coerceString)(hostnameRaw);
|
|
113
|
-
let channelName = (0, import_coerce.coerceString)((_b = obj.common) == null ? void 0 : _b.name);
|
|
114
|
-
if (legacyHostname) {
|
|
115
|
-
if (legacyHostname !== channelName) {
|
|
116
|
-
await this.adapter.extendObjectAsync(`clients.${id}`, { common: { name: legacyHostname } });
|
|
117
|
-
channelName = legacyHostname;
|
|
97
|
+
try {
|
|
98
|
+
const native = (0, import_coerce.isPlainObject)(obj.native) ? obj.native : {};
|
|
99
|
+
const cookie = (0, import_coerce.coerceUuid)(native.cookie);
|
|
100
|
+
if (!cookie) {
|
|
101
|
+
continue;
|
|
118
102
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
103
|
+
const [modeRaw, manualUrlRaw, ipRaw, hostnameRaw] = await Promise.all([
|
|
104
|
+
this.readState(`${id}.mode`),
|
|
105
|
+
this.readState(`${id}.manualUrl`),
|
|
106
|
+
this.readState(`${id}.ip`),
|
|
107
|
+
this.readState(`${id}.hostname`)
|
|
108
|
+
]);
|
|
109
|
+
const mode = typeof modeRaw === "string" ? modeRaw : "";
|
|
110
|
+
const manualUrl = (0, import_coerce.coerceSafeUrl)(manualUrlRaw);
|
|
111
|
+
const ip = (0, import_coerce.coerceString)(ipRaw);
|
|
112
|
+
const token = (0, import_coerce.coerceUuid)(native.token);
|
|
113
|
+
const legacyHostname = (0, import_coerce.coerceString)(hostnameRaw);
|
|
114
|
+
let channelName = (0, import_coerce.coerceString)((_b = obj.common) == null ? void 0 : _b.name);
|
|
115
|
+
if (legacyHostname) {
|
|
116
|
+
if (legacyHostname !== channelName) {
|
|
117
|
+
await this.adapter.extendObjectAsync(`clients.${id}`, { common: { name: legacyHostname } });
|
|
118
|
+
channelName = legacyHostname;
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
await this.adapter.delObjectAsync(`clients.${id}.hostname`);
|
|
122
|
+
} catch {
|
|
123
|
+
}
|
|
122
124
|
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
125
|
+
const hostname = channelName && channelName !== ip && channelName !== id ? channelName : null;
|
|
126
|
+
const record = { id, cookie, token, mode, manualUrl, ip, hostname };
|
|
127
|
+
this.trackInMemory(record);
|
|
128
|
+
await this.ensureObjects(record);
|
|
129
|
+
const modeStateRaw = await this.readState(`${id}.mode`);
|
|
130
|
+
if (modeStateRaw === "" || modeStateRaw === null || modeStateRaw === void 0) {
|
|
131
|
+
await this.adapter.setStateAsync(`clients.${id}.mode`, { val: 0, ack: true });
|
|
132
|
+
}
|
|
133
|
+
} catch (err) {
|
|
134
|
+
this.adapter.log.debug(`client-registry: skipping ${id} during restore \u2014 ${String(err)}`);
|
|
131
135
|
}
|
|
132
136
|
}
|
|
133
137
|
this.adapter.log.debug(`client-registry: restored ${this.byId.size} client(s)`);
|
|
@@ -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 {\n buildDropdownStates,\n coerceSafeUrl,\n coerceString,\n coerceUuid,\n isPlainObject,\n parseAdapterStateId,\n parseManualUrlWrite,\n parseModeWrite,\n safeGetState,\n} from './coerce';\nimport { MODE_GLOBAL, MODE_MANUAL } from './constants';\nimport { tLabel, tName } from './i18n-states';\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 | 'getObjectAsync'\n | 'setObjectNotExistsAsync'\n | 'setObjectAsync'\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 * v1.19.0 (G5): per-IP burst tracking f\u00FCr broken-cookie-Displays.\n * Wenn eine IP > 3 neue Clients in einer Stunde erzeugt, kommt ein\n * einmaliger warn-log mit Hinweis (cookie-Persistenz auf Display kaputt).\n */\n private readonly newClientBurst = new Map<string, { count: number; since: number; warnedAt: 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 // v1.9.0 (D8): vier readState-Calls parallel statt sequenziell.\n // Mit 50 Clients waren das vorher 200 sequenzielle Round-Trips\n // bevor der WebServer up war; jetzt 50 parallele 4er-Gruppen.\n const [modeRaw, manualUrlRaw, ipRaw, hostnameRaw] = await Promise.all([\n this.readState(`${id}.mode`),\n this.readState(`${id}.manualUrl`),\n this.readState(`${id}.ip`),\n this.readState(`${id}.hostname`),\n ]);\n const mode = typeof modeRaw === 'string' ? modeRaw : '';\n const manualUrl = coerceSafeUrl(manualUrlRaw);\n const ip = coerceString(ipRaw);\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(hostnameRaw);\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 * @param userAgent Optional User-Agent header for NAT-collision-Schutz im Pending-Lock.\n */\n async identifyOrCreate(\n cookie: string | null,\n ip: string | null,\n hostname: string | null,\n userAgent: string | null = null,\n ): 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 //\n // v1.17.0 (C8): Bucket-Key kombiniert IP + User-Agent-Hash, sodass\n // zwei verschiedene Displays hinter derselben NAT-IP NICHT in denselben\n // Pending-Lock fallen (vorher: gleicher Cookie/Token/Mode \u2192 Cookie-\n // Klau-Vektor). UA-Hash truncated auf 12 Hex-Chars um Memory-Footprint\n // klein zu halten. Bei UA=null f\u00E4llt der Bucket auf reines IP zur\u00FCck.\n if (ip) {\n const bucketKey = userAgent\n ? `${ip}|${crypto.createHash('sha256').update(userAgent).digest('hex').substring(0, 12)}`\n : ip;\n const pending = this.pendingByIp.get(bucketKey);\n if (pending) {\n // v1.21.0 (D3): pending-promise kann rejecten \u2014 z.B. wenn\n // createClient async failed (broker-disconnect, object-create-\n // error). Wir k\u00F6nnen nicht recover'n (der erste Caller hat eh\n // schon gefailt), aber wir wollen den Fehler diagnostizierbar\n // machen. catch+rethrow sorgt f\u00FCr ein einzelnes log statt\n // unhandled-rejection im fastify-error-handler.\n return pending.catch(err => {\n this.adapter.log.debug(\n `client-registry: pending createClient for ${bucketKey} rejected: ${String(err)}`,\n );\n throw err;\n });\n }\n const promise = this.createClient(ip, hostname);\n this.pendingByIp.set(bucketKey, promise);\n try {\n return await promise;\n } catch (err) {\n // Tech-Diagnose mit Stack-Detail \u2014 bleibt debug (Maintainer-only).\n this.adapter.log.debug(\n `client-registry: createClient failed for IP ${ip}: ${err instanceof Error ? err.message : String(err)}`,\n );\n throw err;\n } finally {\n this.pendingByIp.delete(bucketKey);\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 // v1.23.0 (F2): zentralisierte Validierung via parseModeWrite. Vorher\n // hatten client-registry und global-config ~80% identische Logik\n // (no-choice, non-string, sentinel, URL-coerce) dupliziert.\n const result = parseModeWrite(rawValue, [MODE_GLOBAL, MODE_MANUAL]);\n switch (result.kind) {\n case 'no-choice':\n record.mode = '';\n await this.adapter.setStateAsync(`clients.${id}.mode`, { val: 0, ack: true });\n return;\n case 'rejected-non-string':\n // v1.18.0 (G7): debug statt warn \u2014 nicht-string mode-Schreibungen\n // sind UI-Echo, kein Server-Concern.\n this.adapter.log.debug(`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 case 'sentinel':\n if (result.value === MODE_MANUAL && !record.manualUrl) {\n this.adapter.log.warn(\n `Client ${id}: mode set to \"manual\" but manualUrl is empty \u2014 fill clients.${id}.manualUrl to redirect`,\n );\n }\n record.mode = result.value;\n await this.adapter.setStateAsync(`clients.${id}.mode`, { val: result.value, ack: true });\n return;\n case 'rejected-unsafe-url':\n this.adapter.log.warn(`Client ${id}: rejected unsafe mode value \"${result.raw}\"`);\n await this.adapter.setStateAsync(`clients.${id}.mode`, { val: record.mode, ack: true });\n return;\n case 'url':\n record.mode = result.value;\n await this.adapter.setStateAsync(`clients.${id}.mode`, { val: result.value, ack: true });\n return;\n // 'rejected-disallowed-sentinel' kommt hier nicht vor weil beide\n // Sentinels (global/manual) erlaubt sind. Defensive: revert.\n default:\n await this.adapter.setStateAsync(`clients.${id}.mode`, { val: record.mode || 0, ack: true });\n }\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 ${id}: rejected unsafe manualUrl value`);\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 ${id}: manualUrl cleared while mode is \"manual\" \u2014 display will see 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 // v1.8.1 (D7): parallele setStateAsync statt sequenziell. Mit 50 Displays\n // war das vorher 50 Broker-Round-Trips. setStateAsync ist Broker-internal,\n // Parallelism ist safe.\n const writes: Array<Promise<unknown>> = [];\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 writes.push(this.adapter.setStateAsync(`clients.${record.id}.mode`, { val: value, ack: true }));\n changed++;\n }\n if (writes.length > 0) {\n await Promise.all(writes);\n }\n if (changed > 0) {\n this.adapter.log.debug(`bulkSetMode applied to ${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 // v1.8.1 (D2): lastSeenFlushedAt war fr\u00FCher nicht aufger\u00E4umt \u2014 bei\n // ID-Reuse (16M-Space, m\u00F6glich nach 100+ Clients \u00FCber Jahre) h\u00E4tte\n // die alte Throttle-Entry den ersten lastSeen-Write des neuen Clients\n // inhibiert. Plus minimal Memory-Leak.\n this.lastSeenFlushedAt.delete(id);\n try {\n await this.adapter.delObjectAsync(`clients.${id}`, { recursive: true });\n } catch (err) {\n // Stack-trace level \u2014 Maintainer-Diagnose, EN bleibt.\n this.adapter.log.debug(`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 // v1.27.2: extendObjectAsync mergt `common.states` tief \u2014 alte\n // URL-Schl\u00FCssel die nicht mehr discovered werden, blieben sonst im\n // Dropdown stehen (sichtbar nach v1.26\u2192v1.27 URL-Format-Wechsel:\n // alte `vis-2.0/main/index.html`-Keys neben neuen `vis-2/index.html?main`).\n // Object lesen, common.states komplett ersetzen, dann setObjectAsync.\n const stateId = `clients.${id}.mode`;\n const existing = await this.adapter.getObjectAsync(stateId);\n if (!existing) {\n continue;\n }\n existing.common.states = merged;\n await this.adapter.setObjectAsync(stateId, existing);\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(ip ? `New client connected: ${id} (${hostname ?? ip})` : `New client connected: ${id}`);\n // v1.19.0 (G5): IP-Burst-Detection f\u00FCr broken-cookie-Displays. Wenn\n // dieselbe IP > 3 neue Clients in einer Stunde erzeugt, ist der Cookie-\n // Mechanismus auf dem Display kaputt (aggressive Privacy, refresh-bug).\n // Einmaliger warn-Hinweis pro IP pro Stunde \u2014 danach bleibt der info-\n // log normal, aber der Operator hat wenigstens einen Anker zur Diagnose.\n if (ip) {\n this.recordNewClientIp(ip);\n }\n return record;\n }\n\n /**\n * v1.19.0 (G5): tracking-only \u2014 wenn eine IP > 3 neue Clients pro Stunde\n * erzeugt, einmaliger warn-log mit Diagnose-Hinweis. Danach 1h cooldown\n * pro IP. Map-Cap 200 (FIFO).\n *\n * @param ip Remote IP that just got a new ClientRecord assigned.\n */\n private recordNewClientIp(ip: string): void {\n const now = Date.now();\n const HOUR = 60 * 60 * 1000;\n const entry = this.newClientBurst.get(ip) ?? { count: 0, since: now, warnedAt: 0 };\n if (now - entry.since > HOUR) {\n // Window expired \u2014 reset.\n entry.count = 0;\n entry.since = now;\n }\n entry.count += 1;\n if (entry.count > 3 && now - entry.warnedAt > HOUR) {\n this.adapter.log.warn(\n `IP ${ip} created ${entry.count} clients within an hour \u2014 display likely is not persisting cookies (privacy mode? refresh bug?)`,\n );\n entry.warnedAt = now;\n }\n this.newClientBurst.set(ip, entry);\n // Soft-cap to keep the map bounded (analog der anderen Caps).\n if (this.newClientBurst.size > 200) {\n const oldest = this.newClientBurst.keys().next().value;\n if (oldest !== undefined) {\n this.newClientBurst.delete(oldest);\n }\n }\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 * v1.19.0 (F11): zentraler lastSeen-Seed-Pfad. Vorher hatte main.ts\n * gcStaleClients seinen eigenen extendObjectAsync-Call mit identischem\n * native-Format \u2014 DRY-Violation und gef\u00E4hrlich wenn das Format mal \u00E4ndert.\n * Jetzt nutzen beide Pfade diese Methode. Throttle-Map wird auch upgedated,\n * damit der n\u00E4chste touchLastSeen den seed nicht direkt \u00FCberschreibt.\n *\n * @param id Client id (short segment, ohne `clients.`-Prefix).\n * @param now Optionaler Timestamp f\u00FCr tests; default Date.now().\n */\n async seedLastSeen(id: string, now: number = Date.now()): Promise<void> {\n this.lastSeenFlushedAt.set(id, now);\n try {\n await this.adapter.extendObjectAsync(`clients.${id}`, { native: { lastSeen: now } });\n } catch (err) {\n this.adapter.log.debug(`seedLastSeen failed for ${id}: ${String(err)}`);\n }\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 // v1.20.0 (F4): Helper aus coerce.ts. Vorher dupliziert mit global-config\n // (bis auf den zus\u00E4tzlichen `global`-Sentinel hier, weil clients per\n // `mode='global'` an global delegieren k\u00F6nnen \u2014 global selbst nicht).\n // v1.28.0: Sentinel-Werte sind Translation-Objects (`tLabel`) \u2014 Cast\n // via `unknown as string` weil `UrlStates` aus Test-Stabilit\u00E4ts-Gr\u00FCnden\n // string-typed bleibt; Admin v6+ rendert Translation-Object korrekt.\n return buildDropdownStates(\n {\n [MODE_GLOBAL]: tLabel('globalUrl') as unknown as string,\n [MODE_MANUAL]: tLabel('manualUrl') as unknown as string,\n },\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: tName('clientMode') as unknown as string,\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: tName('clientManualUrl') as unknown as string,\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: {\n name: tName('clientIp'),\n type: 'string',\n role: 'info.ip',\n read: true,\n write: false,\n def: '',\n },\n native: {},\n }),\n this.adapter.setObjectNotExistsAsync(`clients.${id}.remove`, {\n type: 'state',\n common: {\n name: tName('clientRemove'),\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 // v1.20.0 (F10): Helper aus coerce.ts \u2014 vorher dupliziert mit\n // global-config.safeGetState (gleicher try/catch-+-null-Fallback,\n // nur Pfad-Prefix anders).\n const s = await safeGetState(this.adapter, `clients.${subId}`);\n return s?.val ?? null;\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 // v1.20.0 (F9): generischer parseAdapterStateId-Helper. Vorher hatte\n // client-registry seine eigene Prefix-+-Tail-Validierung dupliziert mit\n // global-config.parseGlobalStateId.\n const parts = parseAdapterStateId(fullId, namespace, CLIENTS_PREFIX, 2);\n if (!parts) {\n return null;\n }\n const [id, kind] = parts;\n // v1.9.0 (E5): empty id rejection (`clients..mode` would parse to id='').\n if (!id) {\n return null;\n }\n if (kind !== 'mode' && kind !== 'manualUrl' && kind !== 'remove') {\n return null;\n }\n return { id, kind };\n}\n"],
|
|
5
|
-
"mappings": "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",
|
|
4
|
+
"sourcesContent": ["/**\n * Client Registry \u2014 persistent multi-client store.\n *\n * Each client gets a channel `clients.<id>` with native.cookie / native.token\n * and states mode / manualUrl / ip / remove. Cookie is the primary identity\n * (auto-sent by browsers on page navigation), IP is only advisory.\n *\n * Registry state is dual-homed: in-memory maps for hot lookups, ioBroker\n * objects for persistence and user-visible config.\n */\n\nimport crypto from 'node:crypto';\nimport {\n buildDropdownStates,\n coerceSafeUrl,\n coerceString,\n coerceUuid,\n isPlainObject,\n parseAdapterStateId,\n parseManualUrlWrite,\n parseModeWrite,\n safeGetState,\n} from './coerce';\nimport { MODE_GLOBAL, MODE_MANUAL } from './constants';\nimport { tLabel, tName } from './i18n-states';\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 | 'getObjectAsync'\n | 'setObjectNotExistsAsync'\n | 'setObjectAsync'\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 * v1.19.0 (G5): per-IP burst tracking f\u00FCr broken-cookie-Displays.\n * Wenn eine IP > 3 neue Clients in einer Stunde erzeugt, kommt ein\n * einmaliger warn-log mit Hinweis (cookie-Persistenz auf Display kaputt).\n */\n private readonly newClientBurst = new Map<string, { count: number; since: number; warnedAt: 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 // v1.28.3 (HE1): per-client try/catch \u2014 bei Promise.all auf 4\n // readState-Calls reicht ein einzelner Reject (z.B. corrupted state\n // bei migrating jsonl-store), und ALLE folgenden Clients werden\n // nicht restored. Pro-Client-Wrap entkoppelt: ein broken Client\n // kostet nur sich selbst.\n try {\n const native = isPlainObject(obj.native) ? obj.native : {};\n const cookie = coerceUuid(native.cookie);\n if (!cookie) {\n continue;\n }\n // v1.9.0 (D8): vier readState-Calls parallel statt sequenziell.\n // Mit 50 Clients waren das vorher 200 sequenzielle Round-Trips\n // bevor der WebServer up war; jetzt 50 parallele 4er-Gruppen.\n const [modeRaw, manualUrlRaw, ipRaw, hostnameRaw] = await Promise.all([\n this.readState(`${id}.mode`),\n this.readState(`${id}.manualUrl`),\n this.readState(`${id}.ip`),\n this.readState(`${id}.hostname`),\n ]);\n const mode = typeof modeRaw === 'string' ? modeRaw : '';\n const manualUrl = coerceSafeUrl(manualUrlRaw);\n const ip = coerceString(ipRaw);\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(hostnameRaw);\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 } catch (err) {\n this.adapter.log.debug(`client-registry: skipping ${id} during restore \u2014 ${String(err)}`);\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 * @param userAgent Optional User-Agent header for NAT-collision-Schutz im Pending-Lock.\n */\n async identifyOrCreate(\n cookie: string | null,\n ip: string | null,\n hostname: string | null,\n userAgent: string | null = null,\n ): 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 //\n // v1.17.0 (C8): Bucket-Key kombiniert IP + User-Agent-Hash, sodass\n // zwei verschiedene Displays hinter derselben NAT-IP NICHT in denselben\n // Pending-Lock fallen (vorher: gleicher Cookie/Token/Mode \u2192 Cookie-\n // Klau-Vektor). UA-Hash truncated auf 12 Hex-Chars um Memory-Footprint\n // klein zu halten. Bei UA=null f\u00E4llt der Bucket auf reines IP zur\u00FCck.\n if (ip) {\n const bucketKey = userAgent\n ? `${ip}|${crypto.createHash('sha256').update(userAgent).digest('hex').substring(0, 12)}`\n : ip;\n const pending = this.pendingByIp.get(bucketKey);\n if (pending) {\n // v1.21.0 (D3): pending-promise kann rejecten \u2014 z.B. wenn\n // createClient async failed (broker-disconnect, object-create-\n // error). Wir k\u00F6nnen nicht recover'n (der erste Caller hat eh\n // schon gefailt), aber wir wollen den Fehler diagnostizierbar\n // machen. catch+rethrow sorgt f\u00FCr ein einzelnes log statt\n // unhandled-rejection im fastify-error-handler.\n return pending.catch(err => {\n this.adapter.log.debug(\n `client-registry: pending createClient for ${bucketKey} rejected: ${String(err)}`,\n );\n throw err;\n });\n }\n const promise = this.createClient(ip, hostname);\n this.pendingByIp.set(bucketKey, promise);\n try {\n return await promise;\n } catch (err) {\n // Tech-Diagnose mit Stack-Detail \u2014 bleibt debug (Maintainer-only).\n this.adapter.log.debug(\n `client-registry: createClient failed for IP ${ip}: ${err instanceof Error ? err.message : String(err)}`,\n );\n throw err;\n } finally {\n this.pendingByIp.delete(bucketKey);\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 // v1.23.0 (F2): zentralisierte Validierung via parseModeWrite. Vorher\n // hatten client-registry und global-config ~80% identische Logik\n // (no-choice, non-string, sentinel, URL-coerce) dupliziert.\n const result = parseModeWrite(rawValue, [MODE_GLOBAL, MODE_MANUAL]);\n switch (result.kind) {\n case 'no-choice':\n record.mode = '';\n await this.adapter.setStateAsync(`clients.${id}.mode`, { val: 0, ack: true });\n return;\n case 'rejected-non-string':\n // v1.18.0 (G7): debug statt warn \u2014 nicht-string mode-Schreibungen\n // sind UI-Echo, kein Server-Concern.\n this.adapter.log.debug(`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 case 'sentinel':\n if (result.value === MODE_MANUAL && !record.manualUrl) {\n this.adapter.log.warn(\n `Client ${id}: mode set to \"manual\" but manualUrl is empty \u2014 fill clients.${id}.manualUrl to redirect`,\n );\n }\n record.mode = result.value;\n await this.adapter.setStateAsync(`clients.${id}.mode`, { val: result.value, ack: true });\n return;\n case 'rejected-unsafe-url':\n this.adapter.log.warn(`Client ${id}: rejected unsafe mode value \"${result.raw}\"`);\n await this.adapter.setStateAsync(`clients.${id}.mode`, { val: record.mode, ack: true });\n return;\n case 'url':\n record.mode = result.value;\n await this.adapter.setStateAsync(`clients.${id}.mode`, { val: result.value, ack: true });\n return;\n // 'rejected-disallowed-sentinel' kommt hier nicht vor weil beide\n // Sentinels (global/manual) erlaubt sind. Defensive: revert.\n default:\n await this.adapter.setStateAsync(`clients.${id}.mode`, { val: record.mode || 0, ack: true });\n }\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 ${id}: rejected unsafe manualUrl value`);\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 ${id}: manualUrl cleared while mode is \"manual\" \u2014 display will see 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 // v1.8.1 (D7): parallele setStateAsync statt sequenziell. Mit 50 Displays\n // war das vorher 50 Broker-Round-Trips. setStateAsync ist Broker-internal,\n // Parallelism ist safe.\n const writes: Array<Promise<unknown>> = [];\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 writes.push(this.adapter.setStateAsync(`clients.${record.id}.mode`, { val: value, ack: true }));\n changed++;\n }\n if (writes.length > 0) {\n await Promise.all(writes);\n }\n if (changed > 0) {\n this.adapter.log.debug(`bulkSetMode applied to ${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 // v1.8.1 (D2): lastSeenFlushedAt war fr\u00FCher nicht aufger\u00E4umt \u2014 bei\n // ID-Reuse (16M-Space, m\u00F6glich nach 100+ Clients \u00FCber Jahre) h\u00E4tte\n // die alte Throttle-Entry den ersten lastSeen-Write des neuen Clients\n // inhibiert. Plus minimal Memory-Leak.\n this.lastSeenFlushedAt.delete(id);\n try {\n await this.adapter.delObjectAsync(`clients.${id}`, { recursive: true });\n } catch (err) {\n // Stack-trace level \u2014 Maintainer-Diagnose, EN bleibt.\n this.adapter.log.debug(`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 // v1.27.2: extendObjectAsync mergt `common.states` tief \u2014 alte\n // URL-Schl\u00FCssel die nicht mehr discovered werden, blieben sonst im\n // Dropdown stehen (sichtbar nach v1.26\u2192v1.27 URL-Format-Wechsel:\n // alte `vis-2.0/main/index.html`-Keys neben neuen `vis-2/index.html?main`).\n // Object lesen, common.states komplett ersetzen, dann setObjectAsync.\n const stateId = `clients.${id}.mode`;\n const existing = await this.adapter.getObjectAsync(stateId);\n if (!existing) {\n continue;\n }\n existing.common.states = merged;\n await this.adapter.setObjectAsync(stateId, existing);\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(ip ? `New client connected: ${id} (${hostname ?? ip})` : `New client connected: ${id}`);\n // v1.19.0 (G5): IP-Burst-Detection f\u00FCr broken-cookie-Displays. Wenn\n // dieselbe IP > 3 neue Clients in einer Stunde erzeugt, ist der Cookie-\n // Mechanismus auf dem Display kaputt (aggressive Privacy, refresh-bug).\n // Einmaliger warn-Hinweis pro IP pro Stunde \u2014 danach bleibt der info-\n // log normal, aber der Operator hat wenigstens einen Anker zur Diagnose.\n if (ip) {\n this.recordNewClientIp(ip);\n }\n return record;\n }\n\n /**\n * v1.19.0 (G5): tracking-only \u2014 wenn eine IP > 3 neue Clients pro Stunde\n * erzeugt, einmaliger warn-log mit Diagnose-Hinweis. Danach 1h cooldown\n * pro IP. Map-Cap 200 (FIFO).\n *\n * @param ip Remote IP that just got a new ClientRecord assigned.\n */\n private recordNewClientIp(ip: string): void {\n const now = Date.now();\n const HOUR = 60 * 60 * 1000;\n const entry = this.newClientBurst.get(ip) ?? { count: 0, since: now, warnedAt: 0 };\n if (now - entry.since > HOUR) {\n // Window expired \u2014 reset.\n entry.count = 0;\n entry.since = now;\n }\n entry.count += 1;\n if (entry.count > 3 && now - entry.warnedAt > HOUR) {\n this.adapter.log.warn(\n `IP ${ip} created ${entry.count} clients within an hour \u2014 display likely is not persisting cookies (privacy mode? refresh bug?)`,\n );\n entry.warnedAt = now;\n }\n this.newClientBurst.set(ip, entry);\n // Soft-cap to keep the map bounded (analog der anderen Caps).\n if (this.newClientBurst.size > 200) {\n const oldest = this.newClientBurst.keys().next().value;\n if (oldest !== undefined) {\n this.newClientBurst.delete(oldest);\n }\n }\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 * v1.19.0 (F11): zentraler lastSeen-Seed-Pfad. Vorher hatte main.ts\n * gcStaleClients seinen eigenen extendObjectAsync-Call mit identischem\n * native-Format \u2014 DRY-Violation und gef\u00E4hrlich wenn das Format mal \u00E4ndert.\n * Jetzt nutzen beide Pfade diese Methode. Throttle-Map wird auch upgedated,\n * damit der n\u00E4chste touchLastSeen den seed nicht direkt \u00FCberschreibt.\n *\n * @param id Client id (short segment, ohne `clients.`-Prefix).\n * @param now Optionaler Timestamp f\u00FCr tests; default Date.now().\n */\n async seedLastSeen(id: string, now: number = Date.now()): Promise<void> {\n this.lastSeenFlushedAt.set(id, now);\n try {\n await this.adapter.extendObjectAsync(`clients.${id}`, { native: { lastSeen: now } });\n } catch (err) {\n this.adapter.log.debug(`seedLastSeen failed for ${id}: ${String(err)}`);\n }\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 // v1.20.0 (F4): Helper aus coerce.ts. Vorher dupliziert mit global-config\n // (bis auf den zus\u00E4tzlichen `global`-Sentinel hier, weil clients per\n // `mode='global'` an global delegieren k\u00F6nnen \u2014 global selbst nicht).\n // v1.28.0: Sentinel-Werte sind Translation-Objects (`tLabel`) \u2014 Cast\n // via `unknown as string` weil `UrlStates` aus Test-Stabilit\u00E4ts-Gr\u00FCnden\n // string-typed bleibt; Admin v6+ rendert Translation-Object korrekt.\n return buildDropdownStates(\n {\n [MODE_GLOBAL]: tLabel('globalUrl') as unknown as string,\n [MODE_MANUAL]: tLabel('manualUrl') as unknown as string,\n },\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: tName('clientMode') as unknown as string,\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: tName('clientManualUrl') as unknown as string,\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: {\n name: tName('clientIp'),\n type: 'string',\n role: 'info.ip',\n read: true,\n write: false,\n def: '',\n },\n native: {},\n }),\n this.adapter.setObjectNotExistsAsync(`clients.${id}.remove`, {\n type: 'state',\n common: {\n name: tName('clientRemove'),\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 // v1.20.0 (F10): Helper aus coerce.ts \u2014 vorher dupliziert mit\n // global-config.safeGetState (gleicher try/catch-+-null-Fallback,\n // nur Pfad-Prefix anders).\n const s = await safeGetState(this.adapter, `clients.${subId}`);\n return s?.val ?? null;\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 // v1.20.0 (F9): generischer parseAdapterStateId-Helper. Vorher hatte\n // client-registry seine eigene Prefix-+-Tail-Validierung dupliziert mit\n // global-config.parseGlobalStateId.\n const parts = parseAdapterStateId(fullId, namespace, CLIENTS_PREFIX, 2);\n if (!parts) {\n return null;\n }\n const [id, kind] = parts;\n // v1.9.0 (E5): empty id rejection (`clients..mode` would parse to id='').\n if (!id) {\n return null;\n }\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
|
}
|
|
@@ -102,11 +102,15 @@ class UrlDiscovery {
|
|
|
102
102
|
}
|
|
103
103
|
const webInstances = Array.from(crossRefs.entries()).filter(([n]) => n.startsWith("web."));
|
|
104
104
|
const showWebSuffix = webInstances.length > 1;
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
105
|
+
await Promise.all(
|
|
106
|
+
webInstances.flatMap(([shortName, native]) => {
|
|
107
|
+
const labelSuffix = showWebSuffix ? ` (${shortName})` : "";
|
|
108
|
+
return [
|
|
109
|
+
this.addVisProjects(result, native, hostIp, "vis-2.0", "vis-2", `VIS-2${labelSuffix}`),
|
|
110
|
+
this.addVisProjects(result, native, hostIp, "vis.0", "vis", `VIS${labelSuffix}`)
|
|
111
|
+
];
|
|
112
|
+
})
|
|
113
|
+
);
|
|
110
114
|
this.cached = result;
|
|
111
115
|
if (this.onChange) {
|
|
112
116
|
try {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/url-discovery.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * URL Discovery \u2014 collects VIS/VIS-2 project URLs and Admin intro-tile URLs\n * from the running ioBroker instance. Result is used as `common.states` dropdown\n * on `clients.<id>.visUrl`.\n *\n * All external object data is type-guarded via coerce helpers; invalid entries\n * are silently skipped.\n */\n\nimport { coerceFiniteNumber, coerceString, coerceSafeUrl, isPlainObject } from './coerce';\nimport { getLocalIp, isWildcardBind } from './network';\nimport type { AdapterInterface, UrlStates } from './types';\n\n/** Minimal adapter interface the discovery needs \u2014 allows easy mocking in tests. */\nexport interface DiscoveryAdapter extends AdapterInterface {\n /** Mirrors `ioBroker.Adapter.getForeignObjectsAsync`; typed loose so tests can mock it. */\n getForeignObjectsAsync: (pattern: string, type: 'instance') => Promise<Record<string, unknown>>;\n /** Mirrors `ioBroker.Adapter.readDirAsync`; returns an array of file entries. */\n readDirAsync: (adapterName: string, path: string) => Promise<unknown[]>;\n /** Mirrors `ioBroker.Adapter.readFileAsync`; tests mock this to provide vis-views.json. */\n readFileAsync: (\n adapterName: string,\n path: string,\n ) => Promise<{ file: Buffer | string; mimeType?: string } | string | Buffer>;\n}\n\ninterface ResolveContext {\n instanceId: string;\n native: Record<string, unknown>;\n crossRefs: Map<string, Record<string, unknown>>;\n hostIp: string;\n}\n\n/** Default debounce (ms) for scheduleRefresh \u2014 groups bursts of objectChange events. */\nexport const DEFAULT_REFRESH_DEBOUNCE_MS = 2000;\n\n/** Callback invoked after each successful refresh with the newly collected URL states. */\nexport type UrlStatesListener = (states: UrlStates) => void | Promise<void>;\n\n/** Collects VIS/VIS-2 project URLs and Admin intro-tile URLs into the visUrl dropdown. */\nexport class UrlDiscovery {\n private readonly adapter: DiscoveryAdapter;\n private readonly onChange?: UrlStatesListener;\n private cached: UrlStates = {};\n private debounceTimer: ioBroker.Timeout | null = null;\n\n /**\n * @param adapter Adapter instance used to read broker state.\n * @param onChange Optional callback fired after every successful refresh.\n */\n constructor(adapter: DiscoveryAdapter, onChange?: UrlStatesListener) {\n this.adapter = adapter;\n this.onChange = onChange;\n }\n\n /** Returns a copy of the last collected URL states. Does not trigger collection. */\n getCached(): UrlStates {\n return { ...this.cached };\n }\n\n /**\n * Returns the first discovered URL (insertion order), or null if the cache\n * is empty. Used by the global-config bulk-sync when the master switch is\n * flipped off \u2014 clients fall back to a sensible default URL.\n */\n getFirstDiscoveredUrl(): string | null {\n for (const url of Object.keys(this.cached)) {\n return url;\n }\n return null;\n }\n\n /**\n * Schedules a refresh after `debounceMs`. Multiple calls within the window coalesce into one.\n *\n * @param debounceMs Debounce window in milliseconds.\n */\n scheduleRefresh(debounceMs: number = DEFAULT_REFRESH_DEBOUNCE_MS): void {\n if (this.debounceTimer !== null) {\n this.adapter.clearTimeout(this.debounceTimer);\n }\n this.debounceTimer =\n this.adapter.setTimeout(() => {\n this.debounceTimer = null;\n this.collect().catch(err => {\n this.adapter.log.debug(`url-discovery: refresh failed: ${String(err)}`);\n });\n }, debounceMs) ?? null;\n }\n\n /** Cancels any pending scheduled refresh. */\n cancelRefresh(): void {\n if (this.debounceTimer !== null) {\n this.adapter.clearTimeout(this.debounceTimer);\n this.debounceTimer = null;\n }\n }\n\n /** Collects all discoverable URLs from the broker. Updates cache, returns states map. */\n async collect(): Promise<UrlStates> {\n const result: UrlStates = {};\n const hostIp = getLocalIp();\n\n let instances: Record<string, unknown> = {};\n let instancesOk = false;\n try {\n instances = (await this.adapter.getForeignObjectsAsync('system.adapter.*', 'instance')) ?? {};\n instancesOk = true;\n } catch (err) {\n this.adapter.log.debug(`url-discovery: getForeignObjectsAsync failed: ${String(err)}`);\n }\n\n // v1.8.1 (D4): bei transientem Broker-Fehler nicht den Cache mit `{}`\n // wipen. Listeners w\u00FCrden sonst leere Dropdowns sehen, mode='global'\n // resolved-via-discovered-URL fiele in null durch. Beim n\u00E4chsten Refresh\n // (debounce 2s) erholt es sich. Neue States werden NUR bei Erfolg\n // committed.\n if (!instancesOk) {\n return { ...this.cached };\n }\n\n const crossRefs = buildCrossRefs(instances);\n\n for (const [id, obj] of Object.entries(instances)) {\n collectFromInstance(id, obj, crossRefs, hostIp, result);\n }\n\n // v1.17.0 (E10): \u00FCber alle `web.*`-Instances iterieren statt nur `web.0`\n // hardcoded. User mit `web.1` (zweite Web-Instance, z.B. separat f\u00FCr VIS)\n // hatten vorher keine VIS-Projekte im Dropdown.\n //\n // v1.27.2: bei MEHREREN web-Instances die Instance-ID ins Label nehmen\n // (sonst landen die User mit 2-3 web-Instances bei identischen Labels\n // im Dropdown \u2014 drei Mal `VIS-2: main / Wohnzimmer` ohne Hinweis welche\n // welcher web-Instance geh\u00F6rt). Bei nur einer web-Instance bleibt das\n // Label minimal (`VIS-2: main / Wohnzimmer`).\n const webInstances = Array.from(crossRefs.entries()).filter(([n]) => n.startsWith('web.'));\n const showWebSuffix = webInstances.length > 1;\n for (const [shortName, native] of webInstances) {\n const labelSuffix = showWebSuffix ? ` (${shortName})` : '';\n // URL-Pfad-Komponente kommt aus `common.localLinks.Runtime.link`\n // bzw. `common.welcomeScreen.link` der jeweiligen vis-Instance:\n // vis-2: `/vis-2/index.html` (NICHT `/vis-2.0/`)\n // vis-1: `/vis/index.html` (NICHT `/vis.0/`)\n // Verifiziert in iobroker.vis-2 + iobroker.vis io-package.json.\n // adapterName (`vis-2.0`/`vis.0`) wird f\u00FCr `readDirAsync` /\n // `readFileAsync` gebraucht (Folder-Lookup), urlPath nur f\u00FCr die URL.\n await this.addVisProjects(result, native, hostIp, 'vis-2.0', 'vis-2', `VIS-2${labelSuffix}`);\n await this.addVisProjects(result, native, hostIp, 'vis.0', 'vis', `VIS${labelSuffix}`);\n }\n\n this.cached = result;\n if (this.onChange) {\n try {\n await this.onChange(result);\n } catch (err) {\n this.adapter.log.debug(`url-discovery: onChange listener failed: ${String(err)}`);\n }\n }\n return result;\n }\n\n private async addVisProjects(\n result: UrlStates,\n webInstance: Record<string, unknown> | undefined,\n hostIp: string,\n adapterName: string,\n urlPath: string,\n label: string,\n ): Promise<void> {\n if (!webInstance) {\n return;\n }\n const native = isPlainObject(webInstance.native) ? webInstance.native : null;\n if (!native) {\n return;\n }\n const port = coerceFiniteNumber(native.port);\n if (port === null) {\n return;\n }\n const bindRaw = coerceString(native.bind);\n const ip = isWildcardBind(bindRaw) ? hostIp : bindRaw!;\n const protocol = native.secure === true ? 'https' : 'http';\n\n let entries: unknown[] = [];\n try {\n entries = (await this.adapter.readDirAsync(adapterName, '/')) ?? [];\n } catch {\n return;\n }\n if (!Array.isArray(entries)) {\n return;\n }\n\n for (const e of entries) {\n if (!isPlainObject(e)) {\n continue;\n }\n if (e.isDir !== true) {\n continue;\n }\n const name = coerceString(e.file);\n if (!name || name.startsWith('_')) {\n continue;\n }\n // URL-Form (Quelle: io-package.json `localLinks.Runtime.link`\n // bzw. `welcomeScreen.link` aus iobroker.vis-2 + iobroker.vis):\n // vis-2: http(s)://<ip>:<port>/vis-2/index.html\n // vis-1: http(s)://<ip>:<port>/vis/index.html\n //\n // Project-Switch via `?<project>` Query \u2014 funktioniert in BEIDEN:\n // VIS-2: `Runtime.tsx:920-923` parst `window.location.search`\n // (`projectName = window.location.search.replace('?', '')`).\n // VIS-1: `visEdit.js:1539, 1562, 2225` setzt `?<project>` beim Switch.\n // Pro Project-Folder eine eigene Runtime-URL mit explizitem Query \u2014\n // damit ist jedes Projekt ohne localStorage-Trick direkt erreichbar.\n //\n // View ist Hash-Fragment hinter dem Query (visEngine.tsx:430-455\n // f\u00FCr VIS-2; visEdit.js:2225 f\u00FCr VIS-1).\n const runtimeUrl = `${protocol}://${ip}:${port}/${urlPath}/index.html?${encodeURIComponent(name)}`;\n const safeBase = coerceSafeUrl(runtimeUrl);\n if (!safeBase) {\n continue;\n }\n result[safeBase] = `${label}: ${name}`;\n\n // Sub-views aus <project>/vis-views.json \u2014 pro View ein\n // <base>#<viewName>-Eintrag. Sowohl VIS-2 als auch VIS-1\n // verwenden dieselbe Datei-Konvention (Top-Level-Object,\n // optionaler `views`-Wrapper). Datei fehlt/malformed \u2192 silently skip.\n await this.addVisViews(result, adapterName, name, safeBase, label);\n }\n }\n\n /**\n * Lese `<project>/vis-views.json` und f\u00FCge pro View einen Dropdown-Eintrag\n * `<runtimeUrl>#<viewName>` ein. Defensive \u2014 alle Fehler silently caught.\n *\n * VIS-2-Hash-Routing-Quelle: iobroker.vis-2 v2.13.19+\n * src-vis/src/Vis/visEngine.tsx:430-455 (`getCurrentPath` parst\n * `window.location.hash`, `buildPath` baut `#encodeURIComponent(view)`).\n *\n * @param result Output-Map (mutated)\n * @param adapterName VIS-Adapter (z.B. `vis-2.0`)\n * @param projectName Project-Folder-Name (f\u00FCr vis-views.json-Lookup, NICHT in URL)\n * @param runtimeUrl Komplette Runtime-URL inkl. `/index.html` (z.B. `http://host:8082/vis-2/index.html`)\n * @param label Sprach-Label f\u00FCr Dropdown (`VIS-2`)\n */\n private async addVisViews(\n result: UrlStates,\n adapterName: string,\n projectName: string,\n runtimeUrl: string,\n label: string,\n ): Promise<void> {\n let raw: unknown;\n try {\n raw = await this.adapter.readFileAsync(adapterName, `${projectName}/vis-views.json`);\n } catch {\n return; // file fehlt \u2014 kein VIS-2-Projekt mit Views, oder pre-VIS-2.x\n }\n\n // readFileAsync kann je nach js-controller-Version Buffer, string oder\n // { file, mimeType } returnen \u2014 alle Varianten zu utf-8-string normalisieren.\n let text: string;\n if (typeof raw === 'string') {\n text = raw;\n } else if (Buffer.isBuffer(raw)) {\n text = raw.toString('utf8');\n } else if (isPlainObject(raw) && raw.file !== undefined) {\n const f = raw.file;\n text = typeof f === 'string' ? f : Buffer.isBuffer(f) ? f.toString('utf8') : '';\n } else {\n return;\n }\n\n if (!text) {\n return;\n }\n\n let parsed: unknown;\n try {\n parsed = JSON.parse(text);\n } catch {\n return; // malformed JSON \u2014 Top-level-Projekt-Eintrag reicht\n }\n\n if (!isPlainObject(parsed)) {\n return;\n }\n\n // VIS-2 vis-views.json hat optionalen Top-Key `views` (Object). Wenn der\n // fehlt, sind die View-Keys direkt im Top-Level-Object. Beide Varianten\n // unterst\u00FCtzen \u2014 VIS-2 hat sich da \u00FCber Versionen ver\u00E4ndert.\n const viewsContainer = isPlainObject(parsed.views) ? parsed.views : parsed;\n\n for (const viewName of Object.keys(viewsContainer)) {\n // Skip Meta-Keys (typische VIS-2-Struktur: `views`, `settings`, `___settings`, `activeView`, ...)\n if (viewName.startsWith('_') || viewName === 'settings' || viewName === 'activeView') {\n continue;\n }\n // Skip wenn dazugeh\u00F6riger Wert kein View-Object ist (View-Objects haben\n // typische Felder wie `widgets` oder `name`).\n const v = viewsContainer[viewName];\n if (!isPlainObject(v)) {\n continue;\n }\n const viewUrl = `${runtimeUrl}#${encodeURIComponent(viewName)}`;\n const safe = coerceSafeUrl(viewUrl);\n if (!safe) {\n continue;\n }\n result[safe] = `${label}: ${projectName} / ${viewName}`;\n }\n }\n}\n\n// --- Pure helpers (exported for testing) ---\n\n/**\n * Indexes raw instance objects by short name (e.g. `web.0`) for placeholder cross-refs.\n *\n * @param instances Raw `system.adapter.*` objects keyed by full id.\n */\nexport function buildCrossRefs(instances: Record<string, unknown>): Map<string, Record<string, unknown>> {\n const map = new Map<string, Record<string, unknown>>();\n for (const [id, obj] of Object.entries(instances)) {\n if (!isPlainObject(obj)) {\n continue;\n }\n const short = id.startsWith('system.adapter.') ? id.substring('system.adapter.'.length) : id;\n map.set(short, obj);\n }\n return map;\n}\n\n/**\n * Scans one instance object for `localLinks`, `localLink`, `welcomeScreen`, `welcomeScreenPro`\n * and appends resolved URLs to `result`.\n *\n * @param id Full instance id (`system.adapter.<name>.<n>`).\n * @param obj Raw instance object.\n * @param crossRefs Map for placeholder cross-instance lookup.\n * @param hostIp Local host IP used for wildcard binds.\n * @param result Output map \u2014 mutated with discovered URL \u2192 label entries.\n */\nexport function collectFromInstance(\n id: string,\n obj: unknown,\n crossRefs: Map<string, Record<string, unknown>>,\n hostIp: string,\n result: UrlStates,\n): void {\n if (!isPlainObject(obj)) {\n return;\n }\n const common = isPlainObject(obj.common) ? obj.common : null;\n if (!common) {\n return;\n }\n if (common.enabled !== true) {\n return;\n }\n\n const instanceId = id.startsWith('system.adapter.') ? id.substring('system.adapter.'.length) : id;\n const native = isPlainObject(obj.native) ? obj.native : {};\n const ctx: ResolveContext = { instanceId, native, crossRefs, hostIp };\n\n // New format: common.localLinks = { key: {link, name, ...}, ... }\n if (isPlainObject(common.localLinks)) {\n for (const entry of Object.values(common.localLinks)) {\n addFromEntry(entry, ctx, instanceId, result);\n }\n }\n\n // Legacy format: common.localLink = \"template string\"\n if (typeof common.localLink === 'string' && !isPlainObject(common.localLinks)) {\n const resolved = resolvePlaceholders(common.localLink, ctx);\n if (resolved) {\n const safe = coerceSafeUrl(resolved);\n if (safe) {\n result[safe] = instanceId;\n }\n }\n }\n\n // welcomeScreen / welcomeScreenPro \u2014 array or single object\n for (const key of ['welcomeScreen', 'welcomeScreenPro'] as const) {\n const ws = common[key];\n const entries = Array.isArray(ws) ? ws : isPlainObject(ws) ? [ws] : [];\n for (const entry of entries) {\n addFromEntry(entry, ctx, instanceId, result);\n }\n }\n}\n\nfunction addFromEntry(entry: unknown, ctx: ResolveContext, instanceId: string, result: UrlStates): void {\n if (!isPlainObject(entry)) {\n return;\n }\n const linkTpl = coerceString(entry.link);\n if (!linkTpl) {\n return;\n }\n const resolved = resolvePlaceholders(linkTpl, ctx);\n if (!resolved) {\n return;\n }\n const safe = coerceSafeUrl(resolved);\n if (!safe) {\n return;\n }\n const name = coerceString(entry.name);\n result[safe] = name ? `${instanceId}: ${name}` : instanceId;\n}\n\n/**\n * Resolves `%token%` placeholders in a template string. Returns null if any token cannot be resolved.\n *\n * Supported tokens: %ip%, %bind%, %port%, %protocol%, %secure%, %instance%, %instanceNumeric%,\n * %native_<field>%, %<adapter>.<inst>_<field>% (cross-instance, e.g. %web.0_port%).\n *\n * @param template Link template containing `%...%` placeholders.\n * @param ctx Lookup context (instance native, cross-refs, host IP).\n */\nexport function resolvePlaceholders(template: string, ctx: ResolveContext): string | null {\n let failed = false;\n const out = template.replace(/%([^%]+)%/g, (match, token: string) => {\n const v = resolveOne(token, ctx);\n if (v === null) {\n failed = true;\n return match;\n }\n return v;\n });\n return failed ? null : out;\n}\n\nfunction resolveOne(token: string, ctx: ResolveContext): string | null {\n switch (token) {\n case 'ip':\n return ctx.hostIp;\n case 'bind': {\n const b = coerceString(ctx.native.bind);\n return isWildcardBind(b) ? ctx.hostIp : b;\n }\n case 'port': {\n const p = coerceFiniteNumber(ctx.native.port);\n return p !== null ? String(p) : null;\n }\n case 'protocol':\n return ctx.native.secure === true ? 'https' : 'http';\n case 'secure':\n return ctx.native.secure === true ? 'true' : 'false';\n case 'instance':\n return ctx.instanceId.split('.').pop() ?? null;\n case 'instanceNumeric': {\n const last = ctx.instanceId.split('.').pop();\n return last && /^\\d+$/.test(last) ? last : null;\n }\n }\n\n if (token.startsWith('native_')) {\n const field = token.substring('native_'.length);\n return primitiveToString(ctx.native[field]);\n }\n\n const crossMatch = token.match(/^([a-zA-Z0-9-]+\\.\\d+)_(.+)$/);\n if (crossMatch) {\n const [, refKey, field] = crossMatch;\n const refInstance = ctx.crossRefs.get(refKey);\n if (!refInstance) {\n return null;\n }\n const refNative = isPlainObject(refInstance.native) ? refInstance.native : null;\n if (!refNative) {\n return null;\n }\n if (field === 'bind') {\n const b = coerceString(refNative.bind);\n return isWildcardBind(b) ? ctx.hostIp : b;\n }\n if (field === 'port') {\n const p = coerceFiniteNumber(refNative.port);\n return p !== null ? String(p) : null;\n }\n if (field === 'protocol') {\n return refNative.secure === true ? 'https' : 'http';\n }\n return primitiveToString(refNative[field]);\n }\n\n return null;\n}\n\nfunction primitiveToString(v: unknown): string | null {\n if (typeof v === 'string') {\n return v;\n }\n if (typeof v === 'number' && Number.isFinite(v)) {\n return String(v);\n }\n if (typeof v === 'boolean') {\n return String(v);\n }\n return null;\n}\n"],
|
|
5
|
-
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AASA,oBAA+E;AAC/E,qBAA2C;AAwBpC,MAAM,8BAA8B;AAMpC,MAAM,aAAa;AAAA,EACL;AAAA,EACA;AAAA,EACT,SAAoB,CAAC;AAAA,EACrB,gBAAyC;AAAA;AAAA;AAAA;AAAA;AAAA,EAMjD,YAAY,SAA2B,UAA8B;AACjE,SAAK,UAAU;AACf,SAAK,WAAW;AAAA,EACpB;AAAA;AAAA,EAGA,YAAuB;AACnB,WAAO,EAAE,GAAG,KAAK,OAAO;AAAA,EAC5B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,wBAAuC;AACnC,eAAW,OAAO,OAAO,KAAK,KAAK,MAAM,GAAG;AACxC,aAAO;AAAA,IACX;AACA,WAAO;AAAA,EACX;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,gBAAgB,aAAqB,6BAAmC;AA7E5E;AA8EQ,QAAI,KAAK,kBAAkB,MAAM;AAC7B,WAAK,QAAQ,aAAa,KAAK,aAAa;AAAA,IAChD;AACA,SAAK,iBACD,UAAK,QAAQ,WAAW,MAAM;AAC1B,WAAK,gBAAgB;AACrB,WAAK,QAAQ,EAAE,MAAM,SAAO;AACxB,aAAK,QAAQ,IAAI,MAAM,kCAAkC,OAAO,GAAG,CAAC,EAAE;AAAA,MAC1E,CAAC;AAAA,IACL,GAAG,UAAU,MALb,YAKkB;AAAA,EAC1B;AAAA;AAAA,EAGA,gBAAsB;AAClB,QAAI,KAAK,kBAAkB,MAAM;AAC7B,WAAK,QAAQ,aAAa,KAAK,aAAa;AAC5C,WAAK,gBAAgB;AAAA,IACzB;AAAA,EACJ;AAAA;AAAA,EAGA,MAAM,UAA8B;AAnGxC;AAoGQ,UAAM,SAAoB,CAAC;AAC3B,UAAM,aAAS,2BAAW;AAE1B,QAAI,YAAqC,CAAC;AAC1C,QAAI,cAAc;AAClB,QAAI;AACA,mBAAa,WAAM,KAAK,QAAQ,uBAAuB,oBAAoB,UAAU,MAAxE,YAA8E,CAAC;AAC5F,oBAAc;AAAA,IAClB,SAAS,KAAK;AACV,WAAK,QAAQ,IAAI,MAAM,iDAAiD,OAAO,GAAG,CAAC,EAAE;AAAA,IACzF;AAOA,QAAI,CAAC,aAAa;AACd,aAAO,EAAE,GAAG,KAAK,OAAO;AAAA,IAC5B;AAEA,UAAM,YAAY,eAAe,SAAS;AAE1C,eAAW,CAAC,IAAI,GAAG,KAAK,OAAO,QAAQ,SAAS,GAAG;AAC/C,0BAAoB,IAAI,KAAK,WAAW,QAAQ,MAAM;AAAA,IAC1D;AAWA,UAAM,eAAe,MAAM,KAAK,UAAU,QAAQ,CAAC,EAAE,OAAO,CAAC,CAAC,CAAC,MAAM,EAAE,WAAW,MAAM,CAAC;AACzF,UAAM,gBAAgB,aAAa,SAAS;
|
|
4
|
+
"sourcesContent": ["/**\n * URL Discovery \u2014 collects VIS/VIS-2 project URLs and Admin intro-tile URLs\n * from the running ioBroker instance. Result is used as `common.states` dropdown\n * on `clients.<id>.visUrl`.\n *\n * All external object data is type-guarded via coerce helpers; invalid entries\n * are silently skipped.\n */\n\nimport { coerceFiniteNumber, coerceString, coerceSafeUrl, isPlainObject } from './coerce';\nimport { getLocalIp, isWildcardBind } from './network';\nimport type { AdapterInterface, UrlStates } from './types';\n\n/** Minimal adapter interface the discovery needs \u2014 allows easy mocking in tests. */\nexport interface DiscoveryAdapter extends AdapterInterface {\n /** Mirrors `ioBroker.Adapter.getForeignObjectsAsync`; typed loose so tests can mock it. */\n getForeignObjectsAsync: (pattern: string, type: 'instance') => Promise<Record<string, unknown>>;\n /** Mirrors `ioBroker.Adapter.readDirAsync`; returns an array of file entries. */\n readDirAsync: (adapterName: string, path: string) => Promise<unknown[]>;\n /** Mirrors `ioBroker.Adapter.readFileAsync`; tests mock this to provide vis-views.json. */\n readFileAsync: (\n adapterName: string,\n path: string,\n ) => Promise<{ file: Buffer | string; mimeType?: string } | string | Buffer>;\n}\n\ninterface ResolveContext {\n instanceId: string;\n native: Record<string, unknown>;\n crossRefs: Map<string, Record<string, unknown>>;\n hostIp: string;\n}\n\n/** Default debounce (ms) for scheduleRefresh \u2014 groups bursts of objectChange events. */\nexport const DEFAULT_REFRESH_DEBOUNCE_MS = 2000;\n\n/** Callback invoked after each successful refresh with the newly collected URL states. */\nexport type UrlStatesListener = (states: UrlStates) => void | Promise<void>;\n\n/** Collects VIS/VIS-2 project URLs and Admin intro-tile URLs into the visUrl dropdown. */\nexport class UrlDiscovery {\n private readonly adapter: DiscoveryAdapter;\n private readonly onChange?: UrlStatesListener;\n private cached: UrlStates = {};\n private debounceTimer: ioBroker.Timeout | null = null;\n\n /**\n * @param adapter Adapter instance used to read broker state.\n * @param onChange Optional callback fired after every successful refresh.\n */\n constructor(adapter: DiscoveryAdapter, onChange?: UrlStatesListener) {\n this.adapter = adapter;\n this.onChange = onChange;\n }\n\n /** Returns a copy of the last collected URL states. Does not trigger collection. */\n getCached(): UrlStates {\n return { ...this.cached };\n }\n\n /**\n * Returns the first discovered URL (insertion order), or null if the cache\n * is empty. Used by the global-config bulk-sync when the master switch is\n * flipped off \u2014 clients fall back to a sensible default URL.\n */\n getFirstDiscoveredUrl(): string | null {\n for (const url of Object.keys(this.cached)) {\n return url;\n }\n return null;\n }\n\n /**\n * Schedules a refresh after `debounceMs`. Multiple calls within the window coalesce into one.\n *\n * @param debounceMs Debounce window in milliseconds.\n */\n scheduleRefresh(debounceMs: number = DEFAULT_REFRESH_DEBOUNCE_MS): void {\n if (this.debounceTimer !== null) {\n this.adapter.clearTimeout(this.debounceTimer);\n }\n this.debounceTimer =\n this.adapter.setTimeout(() => {\n this.debounceTimer = null;\n this.collect().catch(err => {\n this.adapter.log.debug(`url-discovery: refresh failed: ${String(err)}`);\n });\n }, debounceMs) ?? null;\n }\n\n /** Cancels any pending scheduled refresh. */\n cancelRefresh(): void {\n if (this.debounceTimer !== null) {\n this.adapter.clearTimeout(this.debounceTimer);\n this.debounceTimer = null;\n }\n }\n\n /** Collects all discoverable URLs from the broker. Updates cache, returns states map. */\n async collect(): Promise<UrlStates> {\n const result: UrlStates = {};\n const hostIp = getLocalIp();\n\n let instances: Record<string, unknown> = {};\n let instancesOk = false;\n try {\n instances = (await this.adapter.getForeignObjectsAsync('system.adapter.*', 'instance')) ?? {};\n instancesOk = true;\n } catch (err) {\n this.adapter.log.debug(`url-discovery: getForeignObjectsAsync failed: ${String(err)}`);\n }\n\n // v1.8.1 (D4): bei transientem Broker-Fehler nicht den Cache mit `{}`\n // wipen. Listeners w\u00FCrden sonst leere Dropdowns sehen, mode='global'\n // resolved-via-discovered-URL fiele in null durch. Beim n\u00E4chsten Refresh\n // (debounce 2s) erholt es sich. Neue States werden NUR bei Erfolg\n // committed.\n if (!instancesOk) {\n return { ...this.cached };\n }\n\n const crossRefs = buildCrossRefs(instances);\n\n for (const [id, obj] of Object.entries(instances)) {\n collectFromInstance(id, obj, crossRefs, hostIp, result);\n }\n\n // v1.17.0 (E10): \u00FCber alle `web.*`-Instances iterieren statt nur `web.0`\n // hardcoded. User mit `web.1` (zweite Web-Instance, z.B. separat f\u00FCr VIS)\n // hatten vorher keine VIS-Projekte im Dropdown.\n //\n // v1.27.2: bei MEHREREN web-Instances die Instance-ID ins Label nehmen\n // (sonst landen die User mit 2-3 web-Instances bei identischen Labels\n // im Dropdown \u2014 drei Mal `VIS-2: main / Wohnzimmer` ohne Hinweis welche\n // welcher web-Instance geh\u00F6rt). Bei nur einer web-Instance bleibt das\n // Label minimal (`VIS-2: main / Wohnzimmer`).\n const webInstances = Array.from(crossRefs.entries()).filter(([n]) => n.startsWith('web.'));\n const showWebSuffix = webInstances.length > 1;\n // v1.28.3 (UD1): pro web-Instance vis-2- und vis-1-Discovery parallel.\n // addVisProjects mutiert `result` direkt \u2014 der Output bleibt deterministisch\n // weil jede Project-URL ein eigener key ist (keine Race auf identischen\n // keys m\u00F6glich, da vis-2 und vis-1 unterschiedliche urlPaths benutzen).\n // URL-Pfad-Komponente kommt aus `common.localLinks.Runtime.link`\n // bzw. `common.welcomeScreen.link` der jeweiligen vis-Instance:\n // vis-2: `/vis-2/index.html` (NICHT `/vis-2.0/`)\n // vis-1: `/vis/index.html` (NICHT `/vis.0/`)\n // Verifiziert in iobroker.vis-2 + iobroker.vis io-package.json.\n // adapterName (`vis-2.0`/`vis.0`) wird f\u00FCr `readDirAsync` /\n // `readFileAsync` gebraucht (Folder-Lookup), urlPath nur f\u00FCr die URL.\n await Promise.all(\n webInstances.flatMap(([shortName, native]) => {\n const labelSuffix = showWebSuffix ? ` (${shortName})` : '';\n return [\n this.addVisProjects(result, native, hostIp, 'vis-2.0', 'vis-2', `VIS-2${labelSuffix}`),\n this.addVisProjects(result, native, hostIp, 'vis.0', 'vis', `VIS${labelSuffix}`),\n ];\n }),\n );\n\n this.cached = result;\n if (this.onChange) {\n try {\n await this.onChange(result);\n } catch (err) {\n this.adapter.log.debug(`url-discovery: onChange listener failed: ${String(err)}`);\n }\n }\n return result;\n }\n\n private async addVisProjects(\n result: UrlStates,\n webInstance: Record<string, unknown> | undefined,\n hostIp: string,\n adapterName: string,\n urlPath: string,\n label: string,\n ): Promise<void> {\n if (!webInstance) {\n return;\n }\n const native = isPlainObject(webInstance.native) ? webInstance.native : null;\n if (!native) {\n return;\n }\n const port = coerceFiniteNumber(native.port);\n if (port === null) {\n return;\n }\n const bindRaw = coerceString(native.bind);\n const ip = isWildcardBind(bindRaw) ? hostIp : bindRaw!;\n const protocol = native.secure === true ? 'https' : 'http';\n\n let entries: unknown[] = [];\n try {\n entries = (await this.adapter.readDirAsync(adapterName, '/')) ?? [];\n } catch {\n return;\n }\n if (!Array.isArray(entries)) {\n return;\n }\n\n for (const e of entries) {\n if (!isPlainObject(e)) {\n continue;\n }\n if (e.isDir !== true) {\n continue;\n }\n const name = coerceString(e.file);\n if (!name || name.startsWith('_')) {\n continue;\n }\n // URL-Form (Quelle: io-package.json `localLinks.Runtime.link`\n // bzw. `welcomeScreen.link` aus iobroker.vis-2 + iobroker.vis):\n // vis-2: http(s)://<ip>:<port>/vis-2/index.html\n // vis-1: http(s)://<ip>:<port>/vis/index.html\n //\n // Project-Switch via `?<project>` Query \u2014 funktioniert in BEIDEN:\n // VIS-2: `Runtime.tsx:920-923` parst `window.location.search`\n // (`projectName = window.location.search.replace('?', '')`).\n // VIS-1: `visEdit.js:1539, 1562, 2225` setzt `?<project>` beim Switch.\n // Pro Project-Folder eine eigene Runtime-URL mit explizitem Query \u2014\n // damit ist jedes Projekt ohne localStorage-Trick direkt erreichbar.\n //\n // View ist Hash-Fragment hinter dem Query (visEngine.tsx:430-455\n // f\u00FCr VIS-2; visEdit.js:2225 f\u00FCr VIS-1).\n const runtimeUrl = `${protocol}://${ip}:${port}/${urlPath}/index.html?${encodeURIComponent(name)}`;\n const safeBase = coerceSafeUrl(runtimeUrl);\n if (!safeBase) {\n continue;\n }\n result[safeBase] = `${label}: ${name}`;\n\n // Sub-views aus <project>/vis-views.json \u2014 pro View ein\n // <base>#<viewName>-Eintrag. Sowohl VIS-2 als auch VIS-1\n // verwenden dieselbe Datei-Konvention (Top-Level-Object,\n // optionaler `views`-Wrapper). Datei fehlt/malformed \u2192 silently skip.\n await this.addVisViews(result, adapterName, name, safeBase, label);\n }\n }\n\n /**\n * Lese `<project>/vis-views.json` und f\u00FCge pro View einen Dropdown-Eintrag\n * `<runtimeUrl>#<viewName>` ein. Defensive \u2014 alle Fehler silently caught.\n *\n * VIS-2-Hash-Routing-Quelle: iobroker.vis-2 v2.13.19+\n * src-vis/src/Vis/visEngine.tsx:430-455 (`getCurrentPath` parst\n * `window.location.hash`, `buildPath` baut `#encodeURIComponent(view)`).\n *\n * @param result Output-Map (mutated)\n * @param adapterName VIS-Adapter (z.B. `vis-2.0`)\n * @param projectName Project-Folder-Name (f\u00FCr vis-views.json-Lookup, NICHT in URL)\n * @param runtimeUrl Komplette Runtime-URL inkl. `/index.html` (z.B. `http://host:8082/vis-2/index.html`)\n * @param label Sprach-Label f\u00FCr Dropdown (`VIS-2`)\n */\n private async addVisViews(\n result: UrlStates,\n adapterName: string,\n projectName: string,\n runtimeUrl: string,\n label: string,\n ): Promise<void> {\n let raw: unknown;\n try {\n raw = await this.adapter.readFileAsync(adapterName, `${projectName}/vis-views.json`);\n } catch {\n return; // file fehlt \u2014 kein VIS-2-Projekt mit Views, oder pre-VIS-2.x\n }\n\n // readFileAsync kann je nach js-controller-Version Buffer, string oder\n // { file, mimeType } returnen \u2014 alle Varianten zu utf-8-string normalisieren.\n let text: string;\n if (typeof raw === 'string') {\n text = raw;\n } else if (Buffer.isBuffer(raw)) {\n text = raw.toString('utf8');\n } else if (isPlainObject(raw) && raw.file !== undefined) {\n const f = raw.file;\n text = typeof f === 'string' ? f : Buffer.isBuffer(f) ? f.toString('utf8') : '';\n } else {\n return;\n }\n\n if (!text) {\n return;\n }\n\n let parsed: unknown;\n try {\n parsed = JSON.parse(text);\n } catch {\n return; // malformed JSON \u2014 Top-level-Projekt-Eintrag reicht\n }\n\n if (!isPlainObject(parsed)) {\n return;\n }\n\n // VIS-2 vis-views.json hat optionalen Top-Key `views` (Object). Wenn der\n // fehlt, sind die View-Keys direkt im Top-Level-Object. Beide Varianten\n // unterst\u00FCtzen \u2014 VIS-2 hat sich da \u00FCber Versionen ver\u00E4ndert.\n const viewsContainer = isPlainObject(parsed.views) ? parsed.views : parsed;\n\n for (const viewName of Object.keys(viewsContainer)) {\n // Skip Meta-Keys (typische VIS-2-Struktur: `views`, `settings`, `___settings`, `activeView`, ...)\n if (viewName.startsWith('_') || viewName === 'settings' || viewName === 'activeView') {\n continue;\n }\n // Skip wenn dazugeh\u00F6riger Wert kein View-Object ist (View-Objects haben\n // typische Felder wie `widgets` oder `name`).\n const v = viewsContainer[viewName];\n if (!isPlainObject(v)) {\n continue;\n }\n const viewUrl = `${runtimeUrl}#${encodeURIComponent(viewName)}`;\n const safe = coerceSafeUrl(viewUrl);\n if (!safe) {\n continue;\n }\n result[safe] = `${label}: ${projectName} / ${viewName}`;\n }\n }\n}\n\n// --- Pure helpers (exported for testing) ---\n\n/**\n * Indexes raw instance objects by short name (e.g. `web.0`) for placeholder cross-refs.\n *\n * @param instances Raw `system.adapter.*` objects keyed by full id.\n */\nexport function buildCrossRefs(instances: Record<string, unknown>): Map<string, Record<string, unknown>> {\n const map = new Map<string, Record<string, unknown>>();\n for (const [id, obj] of Object.entries(instances)) {\n if (!isPlainObject(obj)) {\n continue;\n }\n const short = id.startsWith('system.adapter.') ? id.substring('system.adapter.'.length) : id;\n map.set(short, obj);\n }\n return map;\n}\n\n/**\n * Scans one instance object for `localLinks`, `localLink`, `welcomeScreen`, `welcomeScreenPro`\n * and appends resolved URLs to `result`.\n *\n * @param id Full instance id (`system.adapter.<name>.<n>`).\n * @param obj Raw instance object.\n * @param crossRefs Map for placeholder cross-instance lookup.\n * @param hostIp Local host IP used for wildcard binds.\n * @param result Output map \u2014 mutated with discovered URL \u2192 label entries.\n */\nexport function collectFromInstance(\n id: string,\n obj: unknown,\n crossRefs: Map<string, Record<string, unknown>>,\n hostIp: string,\n result: UrlStates,\n): void {\n if (!isPlainObject(obj)) {\n return;\n }\n const common = isPlainObject(obj.common) ? obj.common : null;\n if (!common) {\n return;\n }\n if (common.enabled !== true) {\n return;\n }\n\n const instanceId = id.startsWith('system.adapter.') ? id.substring('system.adapter.'.length) : id;\n const native = isPlainObject(obj.native) ? obj.native : {};\n const ctx: ResolveContext = { instanceId, native, crossRefs, hostIp };\n\n // New format: common.localLinks = { key: {link, name, ...}, ... }\n if (isPlainObject(common.localLinks)) {\n for (const entry of Object.values(common.localLinks)) {\n addFromEntry(entry, ctx, instanceId, result);\n }\n }\n\n // Legacy format: common.localLink = \"template string\"\n if (typeof common.localLink === 'string' && !isPlainObject(common.localLinks)) {\n const resolved = resolvePlaceholders(common.localLink, ctx);\n if (resolved) {\n const safe = coerceSafeUrl(resolved);\n if (safe) {\n result[safe] = instanceId;\n }\n }\n }\n\n // welcomeScreen / welcomeScreenPro \u2014 array or single object\n for (const key of ['welcomeScreen', 'welcomeScreenPro'] as const) {\n const ws = common[key];\n const entries = Array.isArray(ws) ? ws : isPlainObject(ws) ? [ws] : [];\n for (const entry of entries) {\n addFromEntry(entry, ctx, instanceId, result);\n }\n }\n}\n\nfunction addFromEntry(entry: unknown, ctx: ResolveContext, instanceId: string, result: UrlStates): void {\n if (!isPlainObject(entry)) {\n return;\n }\n const linkTpl = coerceString(entry.link);\n if (!linkTpl) {\n return;\n }\n const resolved = resolvePlaceholders(linkTpl, ctx);\n if (!resolved) {\n return;\n }\n const safe = coerceSafeUrl(resolved);\n if (!safe) {\n return;\n }\n const name = coerceString(entry.name);\n result[safe] = name ? `${instanceId}: ${name}` : instanceId;\n}\n\n/**\n * Resolves `%token%` placeholders in a template string. Returns null if any token cannot be resolved.\n *\n * Supported tokens: %ip%, %bind%, %port%, %protocol%, %secure%, %instance%, %instanceNumeric%,\n * %native_<field>%, %<adapter>.<inst>_<field>% (cross-instance, e.g. %web.0_port%).\n *\n * @param template Link template containing `%...%` placeholders.\n * @param ctx Lookup context (instance native, cross-refs, host IP).\n */\nexport function resolvePlaceholders(template: string, ctx: ResolveContext): string | null {\n let failed = false;\n const out = template.replace(/%([^%]+)%/g, (match, token: string) => {\n const v = resolveOne(token, ctx);\n if (v === null) {\n failed = true;\n return match;\n }\n return v;\n });\n return failed ? null : out;\n}\n\nfunction resolveOne(token: string, ctx: ResolveContext): string | null {\n switch (token) {\n case 'ip':\n return ctx.hostIp;\n case 'bind': {\n const b = coerceString(ctx.native.bind);\n return isWildcardBind(b) ? ctx.hostIp : b;\n }\n case 'port': {\n const p = coerceFiniteNumber(ctx.native.port);\n return p !== null ? String(p) : null;\n }\n case 'protocol':\n return ctx.native.secure === true ? 'https' : 'http';\n case 'secure':\n return ctx.native.secure === true ? 'true' : 'false';\n case 'instance':\n return ctx.instanceId.split('.').pop() ?? null;\n case 'instanceNumeric': {\n const last = ctx.instanceId.split('.').pop();\n return last && /^\\d+$/.test(last) ? last : null;\n }\n }\n\n if (token.startsWith('native_')) {\n const field = token.substring('native_'.length);\n return primitiveToString(ctx.native[field]);\n }\n\n const crossMatch = token.match(/^([a-zA-Z0-9-]+\\.\\d+)_(.+)$/);\n if (crossMatch) {\n const [, refKey, field] = crossMatch;\n const refInstance = ctx.crossRefs.get(refKey);\n if (!refInstance) {\n return null;\n }\n const refNative = isPlainObject(refInstance.native) ? refInstance.native : null;\n if (!refNative) {\n return null;\n }\n if (field === 'bind') {\n const b = coerceString(refNative.bind);\n return isWildcardBind(b) ? ctx.hostIp : b;\n }\n if (field === 'port') {\n const p = coerceFiniteNumber(refNative.port);\n return p !== null ? String(p) : null;\n }\n if (field === 'protocol') {\n return refNative.secure === true ? 'https' : 'http';\n }\n return primitiveToString(refNative[field]);\n }\n\n return null;\n}\n\nfunction primitiveToString(v: unknown): string | null {\n if (typeof v === 'string') {\n return v;\n }\n if (typeof v === 'number' && Number.isFinite(v)) {\n return String(v);\n }\n if (typeof v === 'boolean') {\n return String(v);\n }\n return null;\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/build/lib/webserver.js
CHANGED
|
@@ -189,6 +189,7 @@ class WebServer {
|
|
|
189
189
|
} catch (err) {
|
|
190
190
|
this.adapter.log.debug(`Web server stop error: ${String(err)}`);
|
|
191
191
|
}
|
|
192
|
+
this.dnsInFlight.clear();
|
|
192
193
|
}
|
|
193
194
|
// v1.14.0 (H8): `inject` ist jetzt ein readonly Field (oben deklariert,
|
|
194
195
|
// im Constructor einmalig gebunden). Der frühere Getter allokierte bei
|
|
@@ -315,6 +316,22 @@ class WebServer {
|
|
|
315
316
|
this.loginAttempts.delete(key);
|
|
316
317
|
return false;
|
|
317
318
|
}
|
|
319
|
+
/**
|
|
320
|
+
* v1.28.3 (HW13): Seconds remaining until the lockout for `ip` expires,
|
|
321
|
+
* or 0 if the IP isn't currently locked. Used to set the HTTP `Retry-After`
|
|
322
|
+
* header on 429 responses so well-behaved clients back off correctly
|
|
323
|
+
* instead of hammering the endpoint and prolonging the lockout.
|
|
324
|
+
*
|
|
325
|
+
* @param ip Remote IP whose lockout window is being queried.
|
|
326
|
+
*/
|
|
327
|
+
retryAfterSeconds(ip) {
|
|
328
|
+
const entry = this.loginAttempts.get(WebServer.normalizeLockoutKey(ip));
|
|
329
|
+
if (!entry || entry.lockedUntil === 0) {
|
|
330
|
+
return 0;
|
|
331
|
+
}
|
|
332
|
+
const remaining = entry.lockedUntil - Date.now();
|
|
333
|
+
return remaining > 0 ? Math.max(1, Math.ceil(remaining / 1e3)) : 0;
|
|
334
|
+
}
|
|
318
335
|
/**
|
|
319
336
|
* Records a failed login attempt for `ip`. When the running count reaches
|
|
320
337
|
* {@link LOGIN_LOCKOUT_THRESHOLD}, the IP is locked for
|
|
@@ -551,6 +568,10 @@ class WebServer {
|
|
|
551
568
|
this.adapter.log.warn(
|
|
552
569
|
`Login rejected: IP ${ip} is currently locked out (too many failed attempts)`
|
|
553
570
|
);
|
|
571
|
+
const retry = this.retryAfterSeconds(ip);
|
|
572
|
+
if (retry > 0) {
|
|
573
|
+
reply.header("Retry-After", String(retry));
|
|
574
|
+
}
|
|
554
575
|
reply.status(429);
|
|
555
576
|
return { type: "abort", flow_id: flowId, reason: "too_many_failed_attempts" };
|
|
556
577
|
}
|
|
@@ -602,6 +623,10 @@ class WebServer {
|
|
|
602
623
|
const { code, grant_type, refresh_token } = (_a = req.body) != null ? _a : {};
|
|
603
624
|
const ip = WebServer.getClientIp(req);
|
|
604
625
|
if (this.isIpLocked(ip)) {
|
|
626
|
+
const retry = this.retryAfterSeconds(ip);
|
|
627
|
+
if (retry > 0) {
|
|
628
|
+
reply.header("Retry-After", String(retry));
|
|
629
|
+
}
|
|
605
630
|
reply.status(429);
|
|
606
631
|
return { error: "rate_limited", error_description: "Too many failures, try again later" };
|
|
607
632
|
}
|
|
@@ -631,11 +656,15 @@ class WebServer {
|
|
|
631
656
|
reply.status(400);
|
|
632
657
|
return { error: "invalid_grant", error_description: "Invalid refresh token" };
|
|
633
658
|
}
|
|
659
|
+
this.refreshTokens.delete(incoming);
|
|
634
660
|
const newAccess = import_node_crypto.default.randomUUID();
|
|
661
|
+
const newRefresh = import_node_crypto.default.randomUUID();
|
|
662
|
+
this.storeRefreshToken(newRefresh, ownerId);
|
|
635
663
|
await this.registry.setToken(ownerId, newAccess);
|
|
636
664
|
return {
|
|
637
665
|
access_token: newAccess,
|
|
638
666
|
token_type: "Bearer",
|
|
667
|
+
refresh_token: newRefresh,
|
|
639
668
|
expires_in: import_constants.OAUTH_ACCESS_TOKEN_TTL_S
|
|
640
669
|
};
|
|
641
670
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/webserver.ts"],
|
|
4
|
-
"sourcesContent": ["import crypto from 'node:crypto';\nimport dns from 'node:dns/promises';\nimport fastifyCookie from '@fastify/cookie';\nimport fastifyFormbody from '@fastify/formbody';\nimport Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from 'fastify';\nimport {\n HA_VERSION,\n SESSION_TTL_MS,\n CLEANUP_INTERVAL_MS,\n LOGIN_SCHEMA,\n OAUTH_ACCESS_TOKEN_TTL_S,\n LOGIN_LOCKOUT_THRESHOLD,\n LOGIN_LOCKOUT_WINDOW_MS,\n SESSIONS_CAP,\n REFRESH_TOKENS_CAP,\n LOGIN_ATTEMPTS_CAP,\n REQUEST_ERROR_COOLDOWN_MS,\n REQUEST_ERROR_COOLDOWN_CAP,\n COOKIE_MAX_AGE_S,\n} from './constants';\nimport { coerceString, coerceUuid, safeStringEqual } from './coerce';\nimport type { ClientRegistry } from './client-registry';\nimport type { GlobalConfig } from './global-config';\nimport { renderLandingPage } from './landing-page';\nimport { getLocalIp, isWildcardBind } from './network';\nimport type { AdapterConfig, AdapterInterface, ClientRecord, SessionData } from './types';\n\n// v1.22.0 (F5): `safeStringEqual` ist nach `coerce.ts` verschoben \u2014 generischer\n// crypto-Helper, kein webserver-spezifischer Belang.\n\n/** Adapter surface the WebServer depends on \u2014 adds `namespace` for the setup page. */\nexport type WebServerAdapter = AdapterInterface & Pick<ioBroker.Adapter, 'namespace'>;\n\n/**\n * HTML-Wrapper statt 302-Redirect (A3 / v1.7.0). Display l\u00E4dt das HTML einmal,\n * sieht den Target im iframe, polled `/api/redirect_check` alle 30s. Bei\n * Target-Wechsel (User edit) macht es `location.reload()` und bekommt das\n * neue iframe-Target.\n *\n * `target` muss bereits durch `coerceSafeUrl` validiert sein (Resolver garantiert\n * das). Der Wrapper escaped trotzdem \u2014 defense in depth.\n *\n * @param target Vom Resolver gelieferte Ziel-URL\n */\nfunction renderRedirectWrapper(target: string): string {\n // Conservative HTML-attribute escape: minimal set to make the URL safe in\n // the `src` attribute and the JS string literal below. Sicherheits-relevant\n // weil target letztlich aus user-konfigurierten States stammt.\n const escAttr = target.replace(/&/g, '&').replace(/\"/g, '"').replace(/</g, '<');\n const escJs = JSON.stringify(target); // safe for inline JS string literal\n return `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1, user-scalable=no\">\n<title>ioBroker HASS Emulator</title>\n<style>\nhtml,body{margin:0;padding:0;width:100%;height:100%;background:#000;overflow:hidden;}\n/* display:block kills the inline-baseline gap below the iframe; position:fixed +\n 100vw/100vh nimmt das Display sicher voll aus, auch wenn ein WebView die\n 100%-Berechnung subpixel-falsch macht (Shelly Wall Display zeigte sonst\n einen schwarzen Streifen rechts/unten). */\niframe{display:block;border:0;margin:0;padding:0;position:fixed;top:0;left:0;width:100vw;height:100vh;background:#000;}\n</style>\n</head>\n<body>\n<iframe src=\"${escAttr}\" allow=\"autoplay; fullscreen; geolocation; microphone; camera\"></iframe>\n<script>\n(function(){\n var current=${escJs};\n setInterval(function(){\n fetch('/api/redirect_check',{cache:'no-store',credentials:'same-origin'})\n .then(function(r){return r.json();})\n .then(function(j){\n if(j&&typeof j.target==='string'&&j.target&&j.target!==current){\n location.reload();\n }\n })\n .catch(function(){/* silent \u2014 broker hiccup, retry next tick */});\n },30000);\n})();\n</script>\n</body>\n</html>`;\n}\n\n/** Browser cookie name. Client identity lives here \u2014 auto-sent on every page navigation. */\nexport const CLIENT_COOKIE = 'hassemu_client';\n\n/**\n * Fastify web server emulating the HA REST API.\n *\n * Each incoming request is identified by cookie \u2192 {@link ClientRegistry} entry; new clients\n * get a channel created on first hit. Express was swapped for Fastify in 1.1.0 for first-party\n * cookie support, schema validation and a lighter runtime.\n */\nexport class WebServer {\n private readonly adapter: WebServerAdapter;\n private readonly config: AdapterConfig;\n private readonly registry: ClientRegistry;\n private readonly globalConfig: GlobalConfig;\n private readonly app: FastifyInstance;\n public readonly sessions: Map<string, SessionData> = new Map();\n /**\n * Issued refresh tokens \u2192 owning clientId. Validated on every refresh-grant \u2014\n * unknown tokens are rejected (was: any string accepted).\n */\n public readonly refreshTokens: Map<string, string> = new Map();\n /**\n * Brute-force lockout state per remote IP. Each entry tracks failed login\n * attempts and the timestamp of the last failure; once\n * {@link LOGIN_LOCKOUT_THRESHOLD} is reached, `lockedUntil` is set and\n * further attempts from that IP are rejected with HTTP 429 until the\n * window passes. The map is FIFO-capped at {@link LOGIN_ATTEMPTS_CAP}\n * (else internet-exposed instances leak slowly via stray failure counts\n * with `lockedUntil=0`); expired and stale entries are pruned in\n * {@link cleanupSessions}.\n */\n public readonly loginAttempts: Map<string, { failedCount: number; lockedUntil: number; lastSeen: number }> =\n new Map();\n private cleanupTimer: ioBroker.Interval | null = null;\n /**\n * v1.14.0 (H8): bind once im Constructor statt bei jedem Property-Access\n * via getter \u2014 vorher allokierte jeder `s.inject({...})`-Call eine neue\n * gebundene Funktion. Tests rufen das in Loops auf \u2014 unn\u00F6tiger GC-Druck.\n */\n public readonly inject!: FastifyInstance['inject'];\n public readonly instanceUuid: string;\n /** ioBroker system language for the setup page \u2014 resolved on startup. */\n public readonly systemLanguage: string;\n /** Set of IPs whose reverse DNS lookup is already in-flight \u2014 prevents duplicate work. */\n private readonly dnsInFlight = new Set<string>();\n /**\n * Per-message cooldown timestamps for 5xx error logging. First occurrence\n * of a unique message logs at warn; repeats within {@link REQUEST_ERROR_COOLDOWN_MS}\n * fall to debug to prevent log-spam under attack/probe traffic.\n */\n private readonly errorLogCooldown: Map<string, number> = new Map();\n\n /**\n * @param adapter Adapter instance used for logging, timers and namespace.\n * @param config Resolved runtime config.\n * @param registry Multi-client registry.\n * @param globalConfig Global redirect override.\n * @param instanceUuid Stable UUID shared with the mDNS advert.\n * @param systemLanguage ioBroker system language (`en`, `de`, \u2026) used for the setup page.\n */\n constructor(\n adapter: WebServerAdapter,\n config: AdapterConfig,\n registry: ClientRegistry,\n globalConfig: GlobalConfig,\n instanceUuid: string,\n systemLanguage: string = 'en',\n ) {\n this.adapter = adapter;\n this.config = config;\n this.registry = registry;\n this.globalConfig = globalConfig;\n this.instanceUuid = instanceUuid;\n this.systemLanguage = systemLanguage;\n // v1.25.0 (C11): trustProxy ist Opt-In \u00FCber config \u2014 nur aktivieren\n // wenn der Adapter HINTER einem trusted Reverse-Proxy mit TLS-\n // Termination l\u00E4uft. Mit trustProxy=true holt Fastify `req.ip` aus\n // `X-Forwarded-For` (statt aus dem Socket), `req.protocol` aus\n // `X-Forwarded-Proto` etc. \u2014 Voraussetzung: der Proxy bereinigt diese\n // Header (sonst kann jeder Client den Lockout umgehen).\n this.app = Fastify({ logger: false, trustProxy: this.config.trustProxy === true });\n // v1.14.0 (H8): inject einmal binden, nicht pro Getter-Access.\n (this as { inject: FastifyInstance['inject'] }).inject = this.app.inject.bind(this.app);\n }\n\n /** Human-readable service name advertised in responses and mDNS. */\n get serviceName(): string {\n return this.config.serviceName || 'ioBroker';\n }\n\n /** Resolved listener address once `start()` has completed, or null otherwise. */\n get boundAddress(): { address: string; port: number } | null {\n const addr = this.app.server.address();\n if (!addr || typeof addr === 'string') {\n return null;\n }\n return { address: addr.address, port: addr.port };\n }\n\n // --- lifecycle ---\n\n /** Registers plugins and starts the HTTP listener. */\n async start(): Promise<void> {\n // v1.14.0 (H9): defensive \u2014 wenn start() jemals doppelt gerufen wird\n // (Refactor, Test-Setup-Bug), Timer aus dem Vorlauf clearen statt zu\n // leaken.\n if (this.cleanupTimer) {\n this.adapter.clearInterval(this.cleanupTimer);\n this.cleanupTimer = null;\n }\n await this.app.register(fastifyCookie);\n // OAuth2-Spec verlangt `application/x-www-form-urlencoded` f\u00FCr `/auth/token`.\n // Echte HA-Reference-Clients (frontend/Wall Display SDK) folgen dem.\n // Fastify hat by-default nur einen JSON-Bodyparser \u2014 ohne diesen Plugin\n // beantwortet `/auth/token` mit form-Body 415 und der Login bleibt komplett\n // h\u00E4ngen. Tests via `app.inject({payload:{...}})` serialisieren zu JSON\n // und maskieren das.\n await this.app.register(fastifyFormbody);\n this.setupAuthGuard();\n this.setupErrorHandler();\n this.setupRoutes();\n\n const bindAddress = this.config.bindAddress || '0.0.0.0';\n try {\n await this.app.listen({ port: this.config.port, host: bindAddress });\n } catch (err) {\n const e = err as NodeJS.ErrnoException;\n const msg =\n e.code === 'EADDRINUSE'\n ? `Port ${this.config.port} is already in use \u2014 another service is bound to it`\n : `Server error during startup: ${e.message}`;\n this.adapter.log.error(msg);\n throw err;\n }\n this.adapter.log.debug(`Web server listening on ${bindAddress}:${this.config.port}`);\n\n this.cleanupTimer = this.adapter.setInterval(() => this.cleanupSessions(), CLEANUP_INTERVAL_MS) ?? null;\n }\n\n /** Stops the listener and cancels the session cleanup timer. */\n async stop(): Promise<void> {\n if (this.cleanupTimer) {\n this.adapter.clearInterval(this.cleanupTimer);\n this.cleanupTimer = null;\n }\n try {\n await this.app.close();\n this.adapter.log.debug('Web server stopped');\n } catch (err) {\n // v1.18.0 (G6+G8): debug statt error \u2014 bei intended shutdown\n // (onUnload) ist ein close-error meist ein \"already-closed\"-Race\n // ohne Konsequenz. Caller (main.ts onUnload) loggt nicht doppelt.\n this.adapter.log.debug(`Web server stop error: ${String(err)}`);\n }\n }\n\n // v1.14.0 (H8): `inject` ist jetzt ein readonly Field (oben deklariert,\n // im Constructor einmalig gebunden). Der fr\u00FChere Getter allokierte bei\n // jedem Access eine neue Funktion.\n\n /** Periodic cleanup of expired in-flight auth sessions and stale lockouts. */\n public cleanupSessions(): void {\n const now = Date.now();\n let cleanedSessions = 0;\n for (const [key, session] of this.sessions) {\n if (now - session.created > SESSION_TTL_MS) {\n this.sessions.delete(key);\n cleanedSessions++;\n }\n }\n if (cleanedSessions > 0) {\n this.adapter.log.debug(`Session cleanup: removed ${cleanedSessions} expired sessions`);\n }\n let cleanedLockouts = 0;\n for (const [ip, entry] of this.loginAttempts) {\n // Expired lockout \u2014 IP gets a fresh budget on next attempt.\n if (entry.lockedUntil > 0 && entry.lockedUntil <= now) {\n this.loginAttempts.delete(ip);\n cleanedLockouts++;\n continue;\n }\n // Stale failure-count without active lockout (failedCount<threshold,\n // lockedUntil=0). Without this prune, every distinct IP that ever\n // typed a wrong password leaves a row behind forever.\n if (entry.lockedUntil === 0 && entry.failedCount > 0 && now - entry.lastSeen > LOGIN_LOCKOUT_WINDOW_MS) {\n this.loginAttempts.delete(ip);\n cleanedLockouts++;\n }\n }\n if (cleanedLockouts > 0) {\n this.adapter.log.debug(`Lockout cleanup: cleared ${cleanedLockouts} expired/stale IP entries`);\n }\n }\n\n /**\n * Drops the oldest entry of a Map if it would exceed `cap` after the next insert.\n * Map iteration order in JS is insertion order, so `keys().next()` is the oldest.\n *\n * @param map Map to evict from.\n * @param cap Hard cap; when `map.size >= cap`, the oldest entry is removed.\n */\n /**\n * Cooldown-Decision f\u00FCr 5xx-Error-Logging. Liefert `true` f\u00FCr die erste\n * Beobachtung pro `key` innerhalb {@link REQUEST_ERROR_COOLDOWN_MS} und\n * markiert den Eintrag \u2014 Wiederholungen liefern `false` bis das Fenster\n * abgelaufen ist. Map ist FIFO-gedeckelt auf {@link REQUEST_ERROR_COOLDOWN_CAP}.\n *\n * @param key Eindeutiger Error-Identifier (\u00FCblicherweise `error.message`).\n * @param now Aktuelle Zeit in ms (testbar).\n */\n public shouldEmitRequestErrorWarn(key: string, now: number): boolean {\n const lastSeen = this.errorLogCooldown.get(key) ?? 0;\n if (lastSeen !== 0 && now - lastSeen <= REQUEST_ERROR_COOLDOWN_MS) {\n return false;\n }\n if (!this.errorLogCooldown.has(key)) {\n WebServer.evictOldest(this.errorLogCooldown, REQUEST_ERROR_COOLDOWN_CAP);\n }\n this.errorLogCooldown.set(key, now);\n return true;\n }\n\n private static evictOldest<V>(map: Map<string, V>, cap: number): void {\n // v1.9.0 (E9): while-loop statt single if. Single Eviction reicht\n // wenn der Caller VOR jedem Insert evictet (heute der Fall), aber\n // ein while ist defensiv robust falls cap mal nachtr\u00E4glich gesenkt\n // wird oder ein Caller bulk-inserts macht.\n while (map.size >= cap) {\n const oldest = map.keys().next().value;\n if (oldest === undefined) {\n return;\n }\n map.delete(oldest);\n }\n }\n\n /**\n * Inserts a session, dropping the oldest entry if {@link SESSIONS_CAP} is exceeded.\n *\n * @param key Session key (flow id or auth code).\n * @param data Session payload.\n */\n private storeSession(key: string, data: SessionData): void {\n WebServer.evictOldest(this.sessions, SESSIONS_CAP);\n this.sessions.set(key, data);\n }\n\n /**\n * Inserts a refresh token mapping, dropping the oldest if cap exceeded.\n *\n * @param token Refresh token issued in `/auth/token`.\n * @param clientId Owning client id.\n */\n private storeRefreshToken(token: string, clientId: string): void {\n WebServer.evictOldest(this.refreshTokens, REFRESH_TOKENS_CAP);\n this.refreshTokens.set(token, clientId);\n }\n\n /**\n * v1.12.0 (C5): Map-Key f\u00FCr Lockout normalisieren \u2014 IPv6-Mapped-IPv4\n * (`::ffff:1.2.3.4`) auf IPv4 strippen, damit derselbe Client nicht zwei\n * Buckets bekommt. Bei `null` (Unix-Socket-Bind, Test-Inject ohne\n * remoteAddress, Fastify IPv6-Edge) eindeutiger Sentinel-Bucket statt\n * Lockout-Bypass.\n *\n * @param ip Raw IP from `req.ip` oder null.\n */\n private static normalizeLockoutKey(ip: string | null): string {\n if (!ip) {\n return '__no-ip__';\n }\n // Strip IPv6-Mapped-IPv4 prefix\n if (ip.startsWith('::ffff:')) {\n return ip.substring(7);\n }\n return ip;\n }\n\n /**\n * Brute-force lockout: returns true if `ip` is currently in the timeout window.\n * Lazy-resets entries whose lockout already expired (caller can immediately try again).\n *\n * @param ip Remote IP, or null when unavailable.\n */\n private isIpLocked(ip: string | null): boolean {\n const key = WebServer.normalizeLockoutKey(ip);\n const entry = this.loginAttempts.get(key);\n if (!entry || entry.lockedUntil === 0) {\n return false;\n }\n if (entry.lockedUntil > Date.now()) {\n return true;\n }\n // Lockout window passed \u2014 drop the entry, IP gets a fresh budget.\n this.loginAttempts.delete(key);\n return false;\n }\n\n /**\n * Records a failed login attempt for `ip`. When the running count reaches\n * {@link LOGIN_LOCKOUT_THRESHOLD}, the IP is locked for\n * {@link LOGIN_LOCKOUT_WINDOW_MS}.\n *\n * @param ip Remote IP that failed authentication.\n */\n private recordLoginFailure(ip: string | null): void {\n const key = WebServer.normalizeLockoutKey(ip);\n const now = Date.now();\n const entry = this.loginAttempts.get(key) ?? { failedCount: 0, lockedUntil: 0, lastSeen: now };\n entry.failedCount += 1;\n entry.lastSeen = now;\n if (entry.failedCount >= LOGIN_LOCKOUT_THRESHOLD) {\n entry.lockedUntil = now + LOGIN_LOCKOUT_WINDOW_MS;\n this.adapter.log.warn(\n `Login lockout: IP ${ip} reached ${LOGIN_LOCKOUT_THRESHOLD} failed attempts \u2014 locked for ${Math.round(LOGIN_LOCKOUT_WINDOW_MS / 60000)} min`,\n );\n }\n // Cap loginAttempts before insert to avoid unbounded growth from stray\n // attempts. evictOldest is a no-op when this key already exists in map\n // (size won't change on re-set).\n if (!this.loginAttempts.has(key)) {\n WebServer.evictOldest(this.loginAttempts, LOGIN_ATTEMPTS_CAP);\n }\n this.loginAttempts.set(key, entry);\n }\n\n /**\n * Resets the failure counter and any active lockout for `ip`. Called after\n * a successful credential check so legit clients don't accumulate counts\n * across long-lived sessions.\n *\n * @param ip Remote IP that just authenticated successfully.\n */\n private clearLoginAttempts(ip: string | null): void {\n this.loginAttempts.delete(WebServer.normalizeLockoutKey(ip));\n }\n\n // --- client identification ---\n\n /**\n * v1.15.0 (F6): zentraler Extract `req.ip \u2192 coerced string|null`. Vorher\n * 3\u00D7 inline `coerceString(req.ip)` in identify/login/token-Handlern.\n *\n * @param req Fastify request (uses `req.ip`).\n */\n private static getClientIp(req: FastifyRequest): string | null {\n return coerceString(req.ip);\n }\n\n private async identify(req: FastifyRequest, reply: FastifyReply): Promise<ClientRecord> {\n const cookie = coerceUuid(req.cookies?.[CLIENT_COOKIE]);\n const ip = WebServer.getClientIp(req);\n // v1.17.0 (C8): UA durchreichen damit NAT-Co-Located Displays nicht\n // im selben Pending-Lock landen (siehe identifyOrCreate-Kommentar).\n const userAgent = coerceString(req.headers['user-agent']);\n const record = await this.registry.identifyOrCreate(cookie, ip, null, userAgent);\n if (cookie !== record.cookie) {\n // v1.25.0 (C11): Cookie `secure: true` wenn TLS \u2014 Browser sendet\n // den Cookie dann nur \u00FCber HTTPS. Bei trustProxy=true kommt\n // `req.protocol` aus `X-Forwarded-Proto`-Header. Default ohne\n // trustProxy: `req.protocol === 'http'` (Adapter ist HTTP only),\n // also Cookie nicht-secure \u2014 sonst w\u00FCrde der Browser ihn nie senden.\n const useSecure = req.protocol === 'https';\n reply.setCookie(CLIENT_COOKIE, record.cookie, {\n path: '/',\n httpOnly: true,\n sameSite: 'lax',\n secure: useSecure,\n maxAge: COOKIE_MAX_AGE_S,\n });\n }\n if (ip) {\n this.resolveHostnameAsync(record, ip);\n }\n return record;\n }\n\n private resolveHostnameAsync(record: ClientRecord, ip: string): void {\n if (record.hostname || this.dnsInFlight.has(ip)) {\n return;\n }\n this.dnsInFlight.add(ip);\n // v1.8.1 (D5): DNS-Lookup mit hartem 5s-Timeout. Default-Node-DNS hat\n // KEIN Timeout \u2014 bei broken Resolver (Captive-Portal, Misconfig) blieb\n // der Promise unendlich pending \u2192 IP f\u00FCr Adapter-Lifetime in dnsInFlight\n // blockiert, hostname auf record.ip gefroren.\n const timeout = new Promise<string[]>((_, reject) =>\n setTimeout(() => reject(new Error('dns reverse-lookup timeout')), 5_000),\n );\n Promise.race([dns.reverse(ip), timeout])\n .then(names => {\n const name = names[0];\n if (name) {\n this.registry.identifyOrCreate(record.cookie, ip, name).catch(() => {\n /* registry itself logs */\n });\n }\n })\n .catch(() => {\n // Reverse DNS often fails on LAN or times out \u2014 intentionally silent.\n })\n .finally(() => {\n this.dnsInFlight.delete(ip);\n });\n }\n\n // --- auth guard ---\n\n /**\n * Pre-handler hook der `/api/*`-Routen sch\u00FCtzt wenn `authRequired=true`.\n *\n * Vorher: `/api/states`, `/api/services`, `/api/events`, `/api/error_log`,\n * `/api/discovery_info` lieferten unauthenticated alle ihre Daten \u2014\n * pure Information-Disclosure. Echte HA verlangt `Authorization: Bearer\n * <token>` f\u00FCr alle `/api/*` au\u00DFer dem `/api/`-Heartbeat.\n *\n * Whitelist (kein Auth n\u00F6tig):\n * - `/`, `/manifest.json`, `/health`, `/api/` \u2014 public Endpoints (Heartbeat, PWA)\n * - `/api/discovery_info` \u2014 HA-Clients fragen das VOR dem Auth-Flow ab um\n * zu erkennen ob `requires_api_password` true ist (Spec-Verhalten)\n * - `/auth/*` \u2014 der Auth-Flow selbst\n *\n * Bei `authRequired=false`: Hook macht nichts (no-op), bestehender Verhalten.\n */\n private setupAuthGuard(): void {\n this.app.addHook('preHandler', async (req, reply) => {\n if (!this.config.authRequired) {\n return;\n }\n const path = (req.url ?? '/').split('?')[0];\n // Public endpoints \u2014 explicitly allowed\n if (\n path === '/' ||\n path === '/api/' ||\n path === '/api/discovery_info' ||\n path === '/manifest.json' ||\n path === '/health' ||\n path.startsWith('/auth/')\n ) {\n return;\n }\n // From here on: protected (`/api/*` apart from `/api/`)\n const authHeader = req.headers.authorization;\n if (typeof authHeader !== 'string' || !authHeader.startsWith('Bearer ')) {\n this.adapter.log.debug(`Auth required for ${path} \u2014 missing Bearer token`);\n reply.status(401).send({ error: 'unauthorized' });\n return;\n }\n const token = authHeader.substring('Bearer '.length).trim();\n const client = this.registry.getByToken(token);\n if (!client) {\n this.adapter.log.debug(`Auth required for ${path} \u2014 unknown Bearer token`);\n reply.status(401).send({ error: 'invalid_token' });\n return;\n }\n // OK \u2014 handler runs\n });\n }\n\n // --- error handling ---\n\n private setupErrorHandler(): void {\n this.app.setErrorHandler((err, _req, reply) => {\n const error = err as Error & { validation?: unknown; statusCode?: number };\n if (error.validation) {\n this.adapter.log.debug(`Validation error: ${error.message}`);\n reply.status(400).send({ error: 'Invalid request', details: error.message });\n return;\n }\n // Fastify body-parsing / client errors already set statusCode in 4xx range\n const code = typeof error.statusCode === 'number' ? error.statusCode : 500;\n if (code >= 400 && code < 500) {\n this.adapter.log.debug(`Client error ${code}: ${error.message}`);\n reply.status(code).send({ error: error.message });\n return;\n }\n // 5xx: ein attacker kann mit malformed paths/oversized bodies viele\n // 500er triggern. Per-Message-Dedup-Map mit 60s-Cooldown \u2014 das erste\n // Auftreten pro unique message kommt als warn, alle Wiederholungen\n // im 60s-Fenster auf debug. Memory `feedback_no_log_spam`.\n const key = error.message || 'unknown';\n if (this.shouldEmitRequestErrorWarn(key, Date.now())) {\n this.adapter.log.warn(`Request error: ${error.message}`);\n } else {\n this.adapter.log.debug(`Request error (repeat): ${error.message}`);\n }\n reply.status(500).send({ error: 'Internal server error' });\n });\n }\n\n // --- routes ---\n\n private setupRoutes(): void {\n this.setupApiRoutes();\n this.setupAuthRoutes();\n this.setupMiscRoutes();\n this.setupNotFound();\n }\n\n private setupApiRoutes(): void {\n // CRITICAL: trailing slash \u2014 HA clients check this endpoint for discovery\n this.app.get('/api/', () => ({ message: 'API running.' }));\n\n this.app.get('/api/config', () => ({\n components: ['http', 'api', 'frontend', 'homeassistant'],\n config_dir: '/config',\n elevation: 0,\n latitude: 0,\n longitude: 0,\n location_name: this.serviceName,\n time_zone: 'UTC',\n unit_system: { length: 'km', mass: 'g', temperature: '\u00B0C', volume: 'L' },\n version: HA_VERSION,\n whitelist_external_dirs: [],\n }));\n\n this.app.get('/api/discovery_info', () => {\n // v1.17.0 (E11): NICHT mehr `req.hostname` \u2014 der Host-Header ist\n // client-controlled und ein Angreifer k\u00F6nnte mit `Host: attacker.lan`\n // andere HA-Clients zur falschen URL umleiten. Stattdessen die\n // tats\u00E4chlich gebundene Adresse: bindAddress (ggf. wildcard) oder\n // ersten lokalen non-internal IPv4 via getLocalIp.\n const isWildcard = !this.config.bindAddress || isWildcardBind(this.config.bindAddress);\n const host = isWildcard ? getLocalIp() : this.config.bindAddress;\n const baseUrl = `http://${host}:${this.config.port}`;\n return {\n base_url: baseUrl,\n external_url: null,\n internal_url: baseUrl,\n location_name: this.serviceName,\n // Vorher hardcoded `true` unabh\u00E4ngig von authRequired \u2014 strict HA-Clients\n // versuchten Auth auch bei authRequired=false und scheiterten am leeren Login-Flow.\n requires_api_password: this.config.authRequired,\n uuid: this.instanceUuid,\n version: HA_VERSION,\n };\n });\n\n for (const path of ['/api/states', '/api/services', '/api/events']) {\n this.app.get(path, () => []);\n }\n this.app.get('/api/error_log', () => '');\n }\n\n private setupAuthRoutes(): void {\n this.app.get('/auth/providers', () => [{ name: 'Home Assistant Local', type: 'homeassistant', id: null }]);\n\n this.app.post('/auth/login_flow', async (req, reply) => {\n const client = await this.identify(req, reply);\n const flowId = crypto.randomUUID();\n this.storeSession(flowId, { created: Date.now(), clientId: client.id });\n this.adapter.log.debug(`Auth flow created: ${flowId} for client ${client.id}`);\n\n return {\n type: 'form',\n flow_id: flowId,\n handler: ['homeassistant', null],\n step_id: 'init',\n data_schema: LOGIN_SCHEMA,\n description_placeholders: null,\n errors: null,\n };\n });\n\n this.app.post<{\n Params: { flowId: string };\n Body: { username?: string; password?: string };\n }>(\n '/auth/login_flow/:flowId',\n {\n schema: {\n params: {\n type: 'object',\n properties: { flowId: { type: 'string', minLength: 1 } },\n required: ['flowId'],\n },\n },\n },\n async (req, reply) => {\n const flowId = req.params.flowId;\n const session = this.sessions.get(flowId);\n if (!session) {\n // v1.8.0: nach Session-TTL (10 min) feuert das bei jedem\n // legit returning user \u2014 nicht actionable. debug, nicht warn.\n this.adapter.log.debug(`Unknown flow_id: ${flowId}`);\n reply.status(400);\n return { type: 'abort', flow_id: flowId, reason: 'unknown_flow' };\n }\n\n if (this.config.authRequired) {\n const ip = WebServer.getClientIp(req);\n if (this.isIpLocked(ip)) {\n this.adapter.log.warn(\n `Login rejected: IP ${ip} is currently locked out (too many failed attempts)`,\n );\n reply.status(429);\n return { type: 'abort', flow_id: flowId, reason: 'too_many_failed_attempts' };\n }\n const { username, password } = req.body ?? {};\n const userOk = typeof username === 'string' && safeStringEqual(username, this.config.username);\n const passOk = typeof password === 'string' && safeStringEqual(password, this.config.password);\n if (!userOk || !passOk) {\n this.recordLoginFailure(ip);\n // v1.18.0 (G1): erste 5 Failures pro IP at warn (matcht\n // Lockout-Threshold + zeigt das echte Problem), Rest at\n // debug. Memory `feedback_no_log_spam`.\n const entry = this.loginAttempts.get(WebServer.normalizeLockoutKey(ip));\n const failCount = entry?.failedCount ?? 0;\n const ipSuffix = ip ? ` (IP ${ip})` : '';\n if (failCount <= LOGIN_LOCKOUT_THRESHOLD) {\n this.adapter.log.warn(`Invalid credentials${ipSuffix}`);\n } else {\n this.adapter.log.debug(`Invalid credentials${ipSuffix} (post-lockout-threshold)`);\n }\n reply.status(400);\n return {\n type: 'form',\n flow_id: flowId,\n handler: ['homeassistant', null],\n step_id: 'init',\n data_schema: LOGIN_SCHEMA,\n errors: { base: 'invalid_auth' },\n description_placeholders: null,\n };\n }\n this.clearLoginAttempts(ip);\n }\n\n this.sessions.delete(flowId);\n const code = crypto.randomUUID();\n this.storeSession(code, { created: Date.now(), clientId: session.clientId });\n this.adapter.log.debug('Auth flow completed \u2014 code issued');\n\n return {\n version: 1,\n type: 'create_entry',\n flow_id: flowId,\n handler: ['homeassistant', null],\n result: code,\n description: null,\n description_placeholders: null,\n };\n },\n );\n\n this.app.post<{ Body: { code?: string; grant_type?: string; refresh_token?: string } }>(\n '/auth/token',\n async (req, reply) => {\n const { code, grant_type, refresh_token } = req.body ?? {};\n const ip = WebServer.getClientIp(req);\n\n // v1.11.0 (C7): /auth/token mit refresh_token-grant war vorher\n // brute-force-frei \u2014 Login-Flow gelockt, Token-Endpoint nicht.\n // Asymmetrie ist riskant (massive Probes, Side-Channel-Risk).\n // Jetzt: dieselbe Lockout-Mechanik wie /auth/login_flow/:flowId.\n if (this.isIpLocked(ip)) {\n reply.status(429);\n return { error: 'rate_limited', error_description: 'Too many failures, try again later' };\n }\n\n if (grant_type === 'authorization_code' && code && this.sessions.has(code)) {\n const session = this.sessions.get(code)!;\n this.sessions.delete(code);\n const token = crypto.randomUUID();\n const refreshToken = crypto.randomUUID();\n if (session.clientId) {\n await this.registry.setToken(session.clientId, token);\n this.storeRefreshToken(refreshToken, session.clientId);\n this.adapter.log.debug(`Display authenticated \u2014 client ${session.clientId}`);\n }\n return {\n access_token: token,\n token_type: 'Bearer',\n refresh_token: refreshToken,\n expires_in: OAUTH_ACCESS_TOKEN_TTL_S,\n };\n }\n\n if (grant_type === 'refresh_token') {\n // Validate the refresh token against issued ones \u2014 was previously\n // accepting any string and minting a new access_token (security fix v1.2.0).\n const incoming = typeof refresh_token === 'string' ? refresh_token : '';\n const ownerId = incoming ? this.refreshTokens.get(incoming) : undefined;\n if (!ownerId) {\n // v1.8.0: internet-exposed Instance sieht Scanner hammern\n // /auth/token. Erste Occurrence pro Restart bleibt warn,\n // alle weiteren auf debug. v1.11.0 (C7): jetzt zus\u00E4tzlich\n // recordLoginFailure \u2192 triggert Lockout nach 5 invalid-grants.\n this.adapter.log.debug('Refresh token rejected \u2014 unknown or missing');\n this.recordLoginFailure(ip);\n reply.status(400);\n return { error: 'invalid_grant', error_description: 'Invalid refresh token' };\n }\n const newAccess = crypto.randomUUID();\n await this.registry.setToken(ownerId, newAccess);\n return {\n access_token: newAccess,\n token_type: 'Bearer',\n expires_in: OAUTH_ACCESS_TOKEN_TTL_S,\n };\n }\n\n // v1.8.0: \u201Ewrong grant_type\" ist ein Client-Format-Fehler,\n // nicht ein Server-Concern \u2014 debug. KEIN Lockout-Counter\n // (legitimer Client-Bug soll nicht zu IP-Sperre f\u00FChren).\n this.adapter.log.debug(`Token exchange failed: grant_type=${String(grant_type)}`);\n reply.status(400);\n return { error: 'invalid_request', error_description: 'Invalid or expired code' };\n },\n );\n }\n\n private setupMiscRoutes(): void {\n // Liveness only \u2014 no config leak. Earlier versions exposed the global\n // redirect URL via /health which is unauthenticated; removed in v1.2.0.\n // v1.5.0: auch der `config: { mdns, auth }`-Block raus \u2014 Auth-Status leakte\n // unauthenticated und lie\u00DF sich von einem Network-Attacker zur Reconnaissance\n // nutzen (auth-disabled Instances quickly mappen).\n this.app.get('/health', () => ({\n status: 'ok',\n adapter: 'hassemu',\n version: HA_VERSION,\n }));\n\n this.app.get('/manifest.json', () => ({\n name: this.serviceName,\n short_name: this.serviceName,\n start_url: '/',\n display: 'standalone',\n background_color: '#ffffff',\n theme_color: '#03a9f4',\n }));\n\n // Root \u2014 HTML-Wrapper (iframe + auto-reload), oder Landing-Page wenn keine URL.\n //\n // v1.7.0 (A3): statt 302 liefern wir ein iframe-HTML + 30s-poll auf\n // /api/redirect_check. Wenn die Mode-/URL-Config sich \u00E4ndert (User edit\n // im Adapter), pollt das Display den Wechsel und macht `location.reload()`\n // \u2014 ohne Soft-Reboot des Displays. Vorher musste der User das Display\n // manuell rebooten.\n //\n // WebViews wie Shelly Wall Display rendern iframes + JavaScript korrekt.\n // Falls ein User direkten 302-Redirect will (Browser-Test, Bookmarklet\n // etc.), kann er die Target-URL direkt eingeben \u2014 der Wrapper l\u00E4uft nur\n // beim Aufruf von `/`.\n this.app.get('/', async (req, reply) => {\n const client = await this.identify(req, reply);\n const url = this.globalConfig.resolveUrlFor(client);\n if (!url) {\n this.adapter.log.debug(`No redirect URL for client ${client.id} \u2014 serving landing page`);\n return reply\n .status(200)\n .type('text/html; charset=utf-8')\n .send(renderLandingPage(client.id, this.adapter.namespace, this.systemLanguage, client.ip));\n }\n this.adapter.log.debug(`Serving wrapper for client ${client.id} \u2192 ${url}`);\n return reply.status(200).type('text/html; charset=utf-8').send(renderRedirectWrapper(url));\n });\n\n // /api/redirect_check \u2014 Display polled das alle 30s; wenn der target\n // sich ge\u00E4ndert hat (User edit), gibt der Wrapper `location.reload()`\n // ab. Cookie-basiert \u2014 Display schickt seinen `hassemu_client`-Cookie\n // automatisch mit.\n this.app.get('/api/redirect_check', async (req, reply) => {\n const client = await this.identify(req, reply);\n const url = this.globalConfig.resolveUrlFor(client);\n return { target: url ?? null };\n });\n }\n\n private setupNotFound(): void {\n this.app.setNotFoundHandler((req, reply) => {\n this.adapter.log.debug(`404: ${req.method} ${req.url}`);\n reply.status(404).send({ error: 'Not Found', path: req.url });\n });\n }\n}\n"],
|
|
5
|
-
"mappings": "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",
|
|
4
|
+
"sourcesContent": ["import crypto from 'node:crypto';\nimport dns from 'node:dns/promises';\nimport fastifyCookie from '@fastify/cookie';\nimport fastifyFormbody from '@fastify/formbody';\nimport Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from 'fastify';\nimport {\n HA_VERSION,\n SESSION_TTL_MS,\n CLEANUP_INTERVAL_MS,\n LOGIN_SCHEMA,\n OAUTH_ACCESS_TOKEN_TTL_S,\n LOGIN_LOCKOUT_THRESHOLD,\n LOGIN_LOCKOUT_WINDOW_MS,\n SESSIONS_CAP,\n REFRESH_TOKENS_CAP,\n LOGIN_ATTEMPTS_CAP,\n REQUEST_ERROR_COOLDOWN_MS,\n REQUEST_ERROR_COOLDOWN_CAP,\n COOKIE_MAX_AGE_S,\n} from './constants';\nimport { coerceString, coerceUuid, safeStringEqual } from './coerce';\nimport type { ClientRegistry } from './client-registry';\nimport type { GlobalConfig } from './global-config';\nimport { renderLandingPage } from './landing-page';\nimport { getLocalIp, isWildcardBind } from './network';\nimport type { AdapterConfig, AdapterInterface, ClientRecord, SessionData } from './types';\n\n// v1.22.0 (F5): `safeStringEqual` ist nach `coerce.ts` verschoben \u2014 generischer\n// crypto-Helper, kein webserver-spezifischer Belang.\n\n/** Adapter surface the WebServer depends on \u2014 adds `namespace` for the setup page. */\nexport type WebServerAdapter = AdapterInterface & Pick<ioBroker.Adapter, 'namespace'>;\n\n/**\n * HTML-Wrapper statt 302-Redirect (A3 / v1.7.0). Display l\u00E4dt das HTML einmal,\n * sieht den Target im iframe, polled `/api/redirect_check` alle 30s. Bei\n * Target-Wechsel (User edit) macht es `location.reload()` und bekommt das\n * neue iframe-Target.\n *\n * `target` muss bereits durch `coerceSafeUrl` validiert sein (Resolver garantiert\n * das). Der Wrapper escaped trotzdem \u2014 defense in depth.\n *\n * @param target Vom Resolver gelieferte Ziel-URL\n */\nfunction renderRedirectWrapper(target: string): string {\n // Conservative HTML-attribute escape: minimal set to make the URL safe in\n // the `src` attribute and the JS string literal below. Sicherheits-relevant\n // weil target letztlich aus user-konfigurierten States stammt.\n const escAttr = target.replace(/&/g, '&').replace(/\"/g, '"').replace(/</g, '<');\n const escJs = JSON.stringify(target); // safe for inline JS string literal\n return `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1, user-scalable=no\">\n<title>ioBroker HASS Emulator</title>\n<style>\nhtml,body{margin:0;padding:0;width:100%;height:100%;background:#000;overflow:hidden;}\n/* display:block kills the inline-baseline gap below the iframe; position:fixed +\n 100vw/100vh nimmt das Display sicher voll aus, auch wenn ein WebView die\n 100%-Berechnung subpixel-falsch macht (Shelly Wall Display zeigte sonst\n einen schwarzen Streifen rechts/unten). */\niframe{display:block;border:0;margin:0;padding:0;position:fixed;top:0;left:0;width:100vw;height:100vh;background:#000;}\n</style>\n</head>\n<body>\n<iframe src=\"${escAttr}\" allow=\"autoplay; fullscreen; geolocation; microphone; camera\"></iframe>\n<script>\n(function(){\n var current=${escJs};\n setInterval(function(){\n fetch('/api/redirect_check',{cache:'no-store',credentials:'same-origin'})\n .then(function(r){return r.json();})\n .then(function(j){\n if(j&&typeof j.target==='string'&&j.target&&j.target!==current){\n location.reload();\n }\n })\n .catch(function(){/* silent \u2014 broker hiccup, retry next tick */});\n },30000);\n})();\n</script>\n</body>\n</html>`;\n}\n\n/** Browser cookie name. Client identity lives here \u2014 auto-sent on every page navigation. */\nexport const CLIENT_COOKIE = 'hassemu_client';\n\n/**\n * Fastify web server emulating the HA REST API.\n *\n * Each incoming request is identified by cookie \u2192 {@link ClientRegistry} entry; new clients\n * get a channel created on first hit. Express was swapped for Fastify in 1.1.0 for first-party\n * cookie support, schema validation and a lighter runtime.\n */\nexport class WebServer {\n private readonly adapter: WebServerAdapter;\n private readonly config: AdapterConfig;\n private readonly registry: ClientRegistry;\n private readonly globalConfig: GlobalConfig;\n private readonly app: FastifyInstance;\n public readonly sessions: Map<string, SessionData> = new Map();\n /**\n * Issued refresh tokens \u2192 owning clientId. Validated on every refresh-grant \u2014\n * unknown tokens are rejected (was: any string accepted).\n */\n public readonly refreshTokens: Map<string, string> = new Map();\n /**\n * Brute-force lockout state per remote IP. Each entry tracks failed login\n * attempts and the timestamp of the last failure; once\n * {@link LOGIN_LOCKOUT_THRESHOLD} is reached, `lockedUntil` is set and\n * further attempts from that IP are rejected with HTTP 429 until the\n * window passes. The map is FIFO-capped at {@link LOGIN_ATTEMPTS_CAP}\n * (else internet-exposed instances leak slowly via stray failure counts\n * with `lockedUntil=0`); expired and stale entries are pruned in\n * {@link cleanupSessions}.\n */\n public readonly loginAttempts: Map<string, { failedCount: number; lockedUntil: number; lastSeen: number }> =\n new Map();\n private cleanupTimer: ioBroker.Interval | null = null;\n /**\n * v1.14.0 (H8): bind once im Constructor statt bei jedem Property-Access\n * via getter \u2014 vorher allokierte jeder `s.inject({...})`-Call eine neue\n * gebundene Funktion. Tests rufen das in Loops auf \u2014 unn\u00F6tiger GC-Druck.\n */\n public readonly inject!: FastifyInstance['inject'];\n public readonly instanceUuid: string;\n /** ioBroker system language for the setup page \u2014 resolved on startup. */\n public readonly systemLanguage: string;\n /** Set of IPs whose reverse DNS lookup is already in-flight \u2014 prevents duplicate work. */\n private readonly dnsInFlight = new Set<string>();\n /**\n * Per-message cooldown timestamps for 5xx error logging. First occurrence\n * of a unique message logs at warn; repeats within {@link REQUEST_ERROR_COOLDOWN_MS}\n * fall to debug to prevent log-spam under attack/probe traffic.\n */\n private readonly errorLogCooldown: Map<string, number> = new Map();\n\n /**\n * @param adapter Adapter instance used for logging, timers and namespace.\n * @param config Resolved runtime config.\n * @param registry Multi-client registry.\n * @param globalConfig Global redirect override.\n * @param instanceUuid Stable UUID shared with the mDNS advert.\n * @param systemLanguage ioBroker system language (`en`, `de`, \u2026) used for the setup page.\n */\n constructor(\n adapter: WebServerAdapter,\n config: AdapterConfig,\n registry: ClientRegistry,\n globalConfig: GlobalConfig,\n instanceUuid: string,\n systemLanguage: string = 'en',\n ) {\n this.adapter = adapter;\n this.config = config;\n this.registry = registry;\n this.globalConfig = globalConfig;\n this.instanceUuid = instanceUuid;\n this.systemLanguage = systemLanguage;\n // v1.25.0 (C11): trustProxy ist Opt-In \u00FCber config \u2014 nur aktivieren\n // wenn der Adapter HINTER einem trusted Reverse-Proxy mit TLS-\n // Termination l\u00E4uft. Mit trustProxy=true holt Fastify `req.ip` aus\n // `X-Forwarded-For` (statt aus dem Socket), `req.protocol` aus\n // `X-Forwarded-Proto` etc. \u2014 Voraussetzung: der Proxy bereinigt diese\n // Header (sonst kann jeder Client den Lockout umgehen).\n this.app = Fastify({ logger: false, trustProxy: this.config.trustProxy === true });\n // v1.14.0 (H8): inject einmal binden, nicht pro Getter-Access.\n (this as { inject: FastifyInstance['inject'] }).inject = this.app.inject.bind(this.app);\n }\n\n /** Human-readable service name advertised in responses and mDNS. */\n get serviceName(): string {\n return this.config.serviceName || 'ioBroker';\n }\n\n /** Resolved listener address once `start()` has completed, or null otherwise. */\n get boundAddress(): { address: string; port: number } | null {\n const addr = this.app.server.address();\n if (!addr || typeof addr === 'string') {\n return null;\n }\n return { address: addr.address, port: addr.port };\n }\n\n // --- lifecycle ---\n\n /** Registers plugins and starts the HTTP listener. */\n async start(): Promise<void> {\n // v1.14.0 (H9): defensive \u2014 wenn start() jemals doppelt gerufen wird\n // (Refactor, Test-Setup-Bug), Timer aus dem Vorlauf clearen statt zu\n // leaken.\n if (this.cleanupTimer) {\n this.adapter.clearInterval(this.cleanupTimer);\n this.cleanupTimer = null;\n }\n await this.app.register(fastifyCookie);\n // OAuth2-Spec verlangt `application/x-www-form-urlencoded` f\u00FCr `/auth/token`.\n // Echte HA-Reference-Clients (frontend/Wall Display SDK) folgen dem.\n // Fastify hat by-default nur einen JSON-Bodyparser \u2014 ohne diesen Plugin\n // beantwortet `/auth/token` mit form-Body 415 und der Login bleibt komplett\n // h\u00E4ngen. Tests via `app.inject({payload:{...}})` serialisieren zu JSON\n // und maskieren das.\n await this.app.register(fastifyFormbody);\n this.setupAuthGuard();\n this.setupErrorHandler();\n this.setupRoutes();\n\n const bindAddress = this.config.bindAddress || '0.0.0.0';\n try {\n await this.app.listen({ port: this.config.port, host: bindAddress });\n } catch (err) {\n const e = err as NodeJS.ErrnoException;\n const msg =\n e.code === 'EADDRINUSE'\n ? `Port ${this.config.port} is already in use \u2014 another service is bound to it`\n : `Server error during startup: ${e.message}`;\n this.adapter.log.error(msg);\n throw err;\n }\n this.adapter.log.debug(`Web server listening on ${bindAddress}:${this.config.port}`);\n\n this.cleanupTimer = this.adapter.setInterval(() => this.cleanupSessions(), CLEANUP_INTERVAL_MS) ?? null;\n }\n\n /** Stops the listener and cancels the session cleanup timer. */\n async stop(): Promise<void> {\n if (this.cleanupTimer) {\n this.adapter.clearInterval(this.cleanupTimer);\n this.cleanupTimer = null;\n }\n try {\n await this.app.close();\n this.adapter.log.debug('Web server stopped');\n } catch (err) {\n // v1.18.0 (G6+G8): debug statt error \u2014 bei intended shutdown\n // (onUnload) ist ein close-error meist ein \"already-closed\"-Race\n // ohne Konsequenz. Caller (main.ts onUnload) loggt nicht doppelt.\n this.adapter.log.debug(`Web server stop error: ${String(err)}`);\n }\n // v1.28.3 (HW1): drop in-flight DNS markers so a slow reverse-lookup\n // started just before stop() doesn't keep an IP entry pinned for the\n // whole process lifetime. The Promise.race(timeout) finally-handler\n // would do that eventually, but only after up to 5s \u2014 racy if the\n // adapter is restarted during that window.\n this.dnsInFlight.clear();\n }\n\n // v1.14.0 (H8): `inject` ist jetzt ein readonly Field (oben deklariert,\n // im Constructor einmalig gebunden). Der fr\u00FChere Getter allokierte bei\n // jedem Access eine neue Funktion.\n\n /** Periodic cleanup of expired in-flight auth sessions and stale lockouts. */\n public cleanupSessions(): void {\n const now = Date.now();\n let cleanedSessions = 0;\n for (const [key, session] of this.sessions) {\n if (now - session.created > SESSION_TTL_MS) {\n this.sessions.delete(key);\n cleanedSessions++;\n }\n }\n if (cleanedSessions > 0) {\n this.adapter.log.debug(`Session cleanup: removed ${cleanedSessions} expired sessions`);\n }\n let cleanedLockouts = 0;\n for (const [ip, entry] of this.loginAttempts) {\n // Expired lockout \u2014 IP gets a fresh budget on next attempt.\n if (entry.lockedUntil > 0 && entry.lockedUntil <= now) {\n this.loginAttempts.delete(ip);\n cleanedLockouts++;\n continue;\n }\n // Stale failure-count without active lockout (failedCount<threshold,\n // lockedUntil=0). Without this prune, every distinct IP that ever\n // typed a wrong password leaves a row behind forever.\n if (entry.lockedUntil === 0 && entry.failedCount > 0 && now - entry.lastSeen > LOGIN_LOCKOUT_WINDOW_MS) {\n this.loginAttempts.delete(ip);\n cleanedLockouts++;\n }\n }\n if (cleanedLockouts > 0) {\n this.adapter.log.debug(`Lockout cleanup: cleared ${cleanedLockouts} expired/stale IP entries`);\n }\n }\n\n /**\n * Drops the oldest entry of a Map if it would exceed `cap` after the next insert.\n * Map iteration order in JS is insertion order, so `keys().next()` is the oldest.\n *\n * @param map Map to evict from.\n * @param cap Hard cap; when `map.size >= cap`, the oldest entry is removed.\n */\n /**\n * Cooldown-Decision f\u00FCr 5xx-Error-Logging. Liefert `true` f\u00FCr die erste\n * Beobachtung pro `key` innerhalb {@link REQUEST_ERROR_COOLDOWN_MS} und\n * markiert den Eintrag \u2014 Wiederholungen liefern `false` bis das Fenster\n * abgelaufen ist. Map ist FIFO-gedeckelt auf {@link REQUEST_ERROR_COOLDOWN_CAP}.\n *\n * @param key Eindeutiger Error-Identifier (\u00FCblicherweise `error.message`).\n * @param now Aktuelle Zeit in ms (testbar).\n */\n public shouldEmitRequestErrorWarn(key: string, now: number): boolean {\n const lastSeen = this.errorLogCooldown.get(key) ?? 0;\n if (lastSeen !== 0 && now - lastSeen <= REQUEST_ERROR_COOLDOWN_MS) {\n return false;\n }\n if (!this.errorLogCooldown.has(key)) {\n WebServer.evictOldest(this.errorLogCooldown, REQUEST_ERROR_COOLDOWN_CAP);\n }\n this.errorLogCooldown.set(key, now);\n return true;\n }\n\n private static evictOldest<V>(map: Map<string, V>, cap: number): void {\n // v1.9.0 (E9): while-loop statt single if. Single Eviction reicht\n // wenn der Caller VOR jedem Insert evictet (heute der Fall), aber\n // ein while ist defensiv robust falls cap mal nachtr\u00E4glich gesenkt\n // wird oder ein Caller bulk-inserts macht.\n while (map.size >= cap) {\n const oldest = map.keys().next().value;\n if (oldest === undefined) {\n return;\n }\n map.delete(oldest);\n }\n }\n\n /**\n * Inserts a session, dropping the oldest entry if {@link SESSIONS_CAP} is exceeded.\n *\n * @param key Session key (flow id or auth code).\n * @param data Session payload.\n */\n private storeSession(key: string, data: SessionData): void {\n WebServer.evictOldest(this.sessions, SESSIONS_CAP);\n this.sessions.set(key, data);\n }\n\n /**\n * Inserts a refresh token mapping, dropping the oldest if cap exceeded.\n *\n * @param token Refresh token issued in `/auth/token`.\n * @param clientId Owning client id.\n */\n private storeRefreshToken(token: string, clientId: string): void {\n WebServer.evictOldest(this.refreshTokens, REFRESH_TOKENS_CAP);\n this.refreshTokens.set(token, clientId);\n }\n\n /**\n * v1.12.0 (C5): Map-Key f\u00FCr Lockout normalisieren \u2014 IPv6-Mapped-IPv4\n * (`::ffff:1.2.3.4`) auf IPv4 strippen, damit derselbe Client nicht zwei\n * Buckets bekommt. Bei `null` (Unix-Socket-Bind, Test-Inject ohne\n * remoteAddress, Fastify IPv6-Edge) eindeutiger Sentinel-Bucket statt\n * Lockout-Bypass.\n *\n * @param ip Raw IP from `req.ip` oder null.\n */\n private static normalizeLockoutKey(ip: string | null): string {\n if (!ip) {\n return '__no-ip__';\n }\n // Strip IPv6-Mapped-IPv4 prefix\n if (ip.startsWith('::ffff:')) {\n return ip.substring(7);\n }\n return ip;\n }\n\n /**\n * Brute-force lockout: returns true if `ip` is currently in the timeout window.\n * Lazy-resets entries whose lockout already expired (caller can immediately try again).\n *\n * @param ip Remote IP, or null when unavailable.\n */\n private isIpLocked(ip: string | null): boolean {\n const key = WebServer.normalizeLockoutKey(ip);\n const entry = this.loginAttempts.get(key);\n if (!entry || entry.lockedUntil === 0) {\n return false;\n }\n if (entry.lockedUntil > Date.now()) {\n return true;\n }\n // Lockout window passed \u2014 drop the entry, IP gets a fresh budget.\n this.loginAttempts.delete(key);\n return false;\n }\n\n /**\n * v1.28.3 (HW13): Seconds remaining until the lockout for `ip` expires,\n * or 0 if the IP isn't currently locked. Used to set the HTTP `Retry-After`\n * header on 429 responses so well-behaved clients back off correctly\n * instead of hammering the endpoint and prolonging the lockout.\n *\n * @param ip Remote IP whose lockout window is being queried.\n */\n private retryAfterSeconds(ip: string | null): number {\n const entry = this.loginAttempts.get(WebServer.normalizeLockoutKey(ip));\n if (!entry || entry.lockedUntil === 0) {\n return 0;\n }\n const remaining = entry.lockedUntil - Date.now();\n return remaining > 0 ? Math.max(1, Math.ceil(remaining / 1000)) : 0;\n }\n\n /**\n * Records a failed login attempt for `ip`. When the running count reaches\n * {@link LOGIN_LOCKOUT_THRESHOLD}, the IP is locked for\n * {@link LOGIN_LOCKOUT_WINDOW_MS}.\n *\n * @param ip Remote IP that failed authentication.\n */\n private recordLoginFailure(ip: string | null): void {\n const key = WebServer.normalizeLockoutKey(ip);\n const now = Date.now();\n const entry = this.loginAttempts.get(key) ?? { failedCount: 0, lockedUntil: 0, lastSeen: now };\n entry.failedCount += 1;\n entry.lastSeen = now;\n if (entry.failedCount >= LOGIN_LOCKOUT_THRESHOLD) {\n entry.lockedUntil = now + LOGIN_LOCKOUT_WINDOW_MS;\n this.adapter.log.warn(\n `Login lockout: IP ${ip} reached ${LOGIN_LOCKOUT_THRESHOLD} failed attempts \u2014 locked for ${Math.round(LOGIN_LOCKOUT_WINDOW_MS / 60000)} min`,\n );\n }\n // Cap loginAttempts before insert to avoid unbounded growth from stray\n // attempts. evictOldest is a no-op when this key already exists in map\n // (size won't change on re-set).\n if (!this.loginAttempts.has(key)) {\n WebServer.evictOldest(this.loginAttempts, LOGIN_ATTEMPTS_CAP);\n }\n this.loginAttempts.set(key, entry);\n }\n\n /**\n * Resets the failure counter and any active lockout for `ip`. Called after\n * a successful credential check so legit clients don't accumulate counts\n * across long-lived sessions.\n *\n * @param ip Remote IP that just authenticated successfully.\n */\n private clearLoginAttempts(ip: string | null): void {\n this.loginAttempts.delete(WebServer.normalizeLockoutKey(ip));\n }\n\n // --- client identification ---\n\n /**\n * v1.15.0 (F6): zentraler Extract `req.ip \u2192 coerced string|null`. Vorher\n * 3\u00D7 inline `coerceString(req.ip)` in identify/login/token-Handlern.\n *\n * @param req Fastify request (uses `req.ip`).\n */\n private static getClientIp(req: FastifyRequest): string | null {\n return coerceString(req.ip);\n }\n\n private async identify(req: FastifyRequest, reply: FastifyReply): Promise<ClientRecord> {\n const cookie = coerceUuid(req.cookies?.[CLIENT_COOKIE]);\n const ip = WebServer.getClientIp(req);\n // v1.17.0 (C8): UA durchreichen damit NAT-Co-Located Displays nicht\n // im selben Pending-Lock landen (siehe identifyOrCreate-Kommentar).\n const userAgent = coerceString(req.headers['user-agent']);\n const record = await this.registry.identifyOrCreate(cookie, ip, null, userAgent);\n if (cookie !== record.cookie) {\n // v1.25.0 (C11): Cookie `secure: true` wenn TLS \u2014 Browser sendet\n // den Cookie dann nur \u00FCber HTTPS. Bei trustProxy=true kommt\n // `req.protocol` aus `X-Forwarded-Proto`-Header. Default ohne\n // trustProxy: `req.protocol === 'http'` (Adapter ist HTTP only),\n // also Cookie nicht-secure \u2014 sonst w\u00FCrde der Browser ihn nie senden.\n const useSecure = req.protocol === 'https';\n reply.setCookie(CLIENT_COOKIE, record.cookie, {\n path: '/',\n httpOnly: true,\n sameSite: 'lax',\n secure: useSecure,\n maxAge: COOKIE_MAX_AGE_S,\n });\n }\n if (ip) {\n this.resolveHostnameAsync(record, ip);\n }\n return record;\n }\n\n private resolveHostnameAsync(record: ClientRecord, ip: string): void {\n if (record.hostname || this.dnsInFlight.has(ip)) {\n return;\n }\n this.dnsInFlight.add(ip);\n // v1.8.1 (D5): DNS-Lookup mit hartem 5s-Timeout. Default-Node-DNS hat\n // KEIN Timeout \u2014 bei broken Resolver (Captive-Portal, Misconfig) blieb\n // der Promise unendlich pending \u2192 IP f\u00FCr Adapter-Lifetime in dnsInFlight\n // blockiert, hostname auf record.ip gefroren.\n const timeout = new Promise<string[]>((_, reject) =>\n setTimeout(() => reject(new Error('dns reverse-lookup timeout')), 5_000),\n );\n Promise.race([dns.reverse(ip), timeout])\n .then(names => {\n const name = names[0];\n if (name) {\n this.registry.identifyOrCreate(record.cookie, ip, name).catch(() => {\n /* registry itself logs */\n });\n }\n })\n .catch(() => {\n // Reverse DNS often fails on LAN or times out \u2014 intentionally silent.\n })\n .finally(() => {\n this.dnsInFlight.delete(ip);\n });\n }\n\n // --- auth guard ---\n\n /**\n * Pre-handler hook der `/api/*`-Routen sch\u00FCtzt wenn `authRequired=true`.\n *\n * Vorher: `/api/states`, `/api/services`, `/api/events`, `/api/error_log`,\n * `/api/discovery_info` lieferten unauthenticated alle ihre Daten \u2014\n * pure Information-Disclosure. Echte HA verlangt `Authorization: Bearer\n * <token>` f\u00FCr alle `/api/*` au\u00DFer dem `/api/`-Heartbeat.\n *\n * Whitelist (kein Auth n\u00F6tig):\n * - `/`, `/manifest.json`, `/health`, `/api/` \u2014 public Endpoints (Heartbeat, PWA)\n * - `/api/discovery_info` \u2014 HA-Clients fragen das VOR dem Auth-Flow ab um\n * zu erkennen ob `requires_api_password` true ist (Spec-Verhalten)\n * - `/auth/*` \u2014 der Auth-Flow selbst\n *\n * Bei `authRequired=false`: Hook macht nichts (no-op), bestehender Verhalten.\n */\n private setupAuthGuard(): void {\n this.app.addHook('preHandler', async (req, reply) => {\n if (!this.config.authRequired) {\n return;\n }\n const path = (req.url ?? '/').split('?')[0];\n // Public endpoints \u2014 explicitly allowed\n if (\n path === '/' ||\n path === '/api/' ||\n path === '/api/discovery_info' ||\n path === '/manifest.json' ||\n path === '/health' ||\n path.startsWith('/auth/')\n ) {\n return;\n }\n // From here on: protected (`/api/*` apart from `/api/`)\n const authHeader = req.headers.authorization;\n if (typeof authHeader !== 'string' || !authHeader.startsWith('Bearer ')) {\n this.adapter.log.debug(`Auth required for ${path} \u2014 missing Bearer token`);\n reply.status(401).send({ error: 'unauthorized' });\n return;\n }\n const token = authHeader.substring('Bearer '.length).trim();\n const client = this.registry.getByToken(token);\n if (!client) {\n this.adapter.log.debug(`Auth required for ${path} \u2014 unknown Bearer token`);\n reply.status(401).send({ error: 'invalid_token' });\n return;\n }\n // OK \u2014 handler runs\n });\n }\n\n // --- error handling ---\n\n private setupErrorHandler(): void {\n this.app.setErrorHandler((err, _req, reply) => {\n const error = err as Error & { validation?: unknown; statusCode?: number };\n if (error.validation) {\n this.adapter.log.debug(`Validation error: ${error.message}`);\n reply.status(400).send({ error: 'Invalid request', details: error.message });\n return;\n }\n // Fastify body-parsing / client errors already set statusCode in 4xx range\n const code = typeof error.statusCode === 'number' ? error.statusCode : 500;\n if (code >= 400 && code < 500) {\n this.adapter.log.debug(`Client error ${code}: ${error.message}`);\n reply.status(code).send({ error: error.message });\n return;\n }\n // 5xx: ein attacker kann mit malformed paths/oversized bodies viele\n // 500er triggern. Per-Message-Dedup-Map mit 60s-Cooldown \u2014 das erste\n // Auftreten pro unique message kommt als warn, alle Wiederholungen\n // im 60s-Fenster auf debug. Memory `feedback_no_log_spam`.\n const key = error.message || 'unknown';\n if (this.shouldEmitRequestErrorWarn(key, Date.now())) {\n this.adapter.log.warn(`Request error: ${error.message}`);\n } else {\n this.adapter.log.debug(`Request error (repeat): ${error.message}`);\n }\n reply.status(500).send({ error: 'Internal server error' });\n });\n }\n\n // --- routes ---\n\n private setupRoutes(): void {\n this.setupApiRoutes();\n this.setupAuthRoutes();\n this.setupMiscRoutes();\n this.setupNotFound();\n }\n\n private setupApiRoutes(): void {\n // CRITICAL: trailing slash \u2014 HA clients check this endpoint for discovery\n this.app.get('/api/', () => ({ message: 'API running.' }));\n\n this.app.get('/api/config', () => ({\n components: ['http', 'api', 'frontend', 'homeassistant'],\n config_dir: '/config',\n elevation: 0,\n latitude: 0,\n longitude: 0,\n location_name: this.serviceName,\n time_zone: 'UTC',\n unit_system: { length: 'km', mass: 'g', temperature: '\u00B0C', volume: 'L' },\n version: HA_VERSION,\n whitelist_external_dirs: [],\n }));\n\n this.app.get('/api/discovery_info', () => {\n // v1.17.0 (E11): NICHT mehr `req.hostname` \u2014 der Host-Header ist\n // client-controlled und ein Angreifer k\u00F6nnte mit `Host: attacker.lan`\n // andere HA-Clients zur falschen URL umleiten. Stattdessen die\n // tats\u00E4chlich gebundene Adresse: bindAddress (ggf. wildcard) oder\n // ersten lokalen non-internal IPv4 via getLocalIp.\n const isWildcard = !this.config.bindAddress || isWildcardBind(this.config.bindAddress);\n const host = isWildcard ? getLocalIp() : this.config.bindAddress;\n const baseUrl = `http://${host}:${this.config.port}`;\n return {\n base_url: baseUrl,\n external_url: null,\n internal_url: baseUrl,\n location_name: this.serviceName,\n // Vorher hardcoded `true` unabh\u00E4ngig von authRequired \u2014 strict HA-Clients\n // versuchten Auth auch bei authRequired=false und scheiterten am leeren Login-Flow.\n requires_api_password: this.config.authRequired,\n uuid: this.instanceUuid,\n version: HA_VERSION,\n };\n });\n\n for (const path of ['/api/states', '/api/services', '/api/events']) {\n this.app.get(path, () => []);\n }\n this.app.get('/api/error_log', () => '');\n }\n\n private setupAuthRoutes(): void {\n this.app.get('/auth/providers', () => [{ name: 'Home Assistant Local', type: 'homeassistant', id: null }]);\n\n this.app.post('/auth/login_flow', async (req, reply) => {\n const client = await this.identify(req, reply);\n const flowId = crypto.randomUUID();\n this.storeSession(flowId, { created: Date.now(), clientId: client.id });\n this.adapter.log.debug(`Auth flow created: ${flowId} for client ${client.id}`);\n\n return {\n type: 'form',\n flow_id: flowId,\n handler: ['homeassistant', null],\n step_id: 'init',\n data_schema: LOGIN_SCHEMA,\n description_placeholders: null,\n errors: null,\n };\n });\n\n this.app.post<{\n Params: { flowId: string };\n Body: { username?: string; password?: string };\n }>(\n '/auth/login_flow/:flowId',\n {\n schema: {\n params: {\n type: 'object',\n properties: { flowId: { type: 'string', minLength: 1 } },\n required: ['flowId'],\n },\n },\n },\n async (req, reply) => {\n const flowId = req.params.flowId;\n const session = this.sessions.get(flowId);\n if (!session) {\n // v1.8.0: nach Session-TTL (10 min) feuert das bei jedem\n // legit returning user \u2014 nicht actionable. debug, nicht warn.\n this.adapter.log.debug(`Unknown flow_id: ${flowId}`);\n reply.status(400);\n return { type: 'abort', flow_id: flowId, reason: 'unknown_flow' };\n }\n\n if (this.config.authRequired) {\n const ip = WebServer.getClientIp(req);\n if (this.isIpLocked(ip)) {\n this.adapter.log.warn(\n `Login rejected: IP ${ip} is currently locked out (too many failed attempts)`,\n );\n // v1.28.3 (HW13): RFC 7231 Retry-After header \u2014 well-behaved\n // clients back off until the window passes instead of\n // re-trying the same lockout-IP every second.\n const retry = this.retryAfterSeconds(ip);\n if (retry > 0) {\n reply.header('Retry-After', String(retry));\n }\n reply.status(429);\n return { type: 'abort', flow_id: flowId, reason: 'too_many_failed_attempts' };\n }\n const { username, password } = req.body ?? {};\n const userOk = typeof username === 'string' && safeStringEqual(username, this.config.username);\n const passOk = typeof password === 'string' && safeStringEqual(password, this.config.password);\n if (!userOk || !passOk) {\n this.recordLoginFailure(ip);\n // v1.18.0 (G1): erste 5 Failures pro IP at warn (matcht\n // Lockout-Threshold + zeigt das echte Problem), Rest at\n // debug. Memory `feedback_no_log_spam`.\n const entry = this.loginAttempts.get(WebServer.normalizeLockoutKey(ip));\n const failCount = entry?.failedCount ?? 0;\n const ipSuffix = ip ? ` (IP ${ip})` : '';\n if (failCount <= LOGIN_LOCKOUT_THRESHOLD) {\n this.adapter.log.warn(`Invalid credentials${ipSuffix}`);\n } else {\n this.adapter.log.debug(`Invalid credentials${ipSuffix} (post-lockout-threshold)`);\n }\n reply.status(400);\n return {\n type: 'form',\n flow_id: flowId,\n handler: ['homeassistant', null],\n step_id: 'init',\n data_schema: LOGIN_SCHEMA,\n errors: { base: 'invalid_auth' },\n description_placeholders: null,\n };\n }\n this.clearLoginAttempts(ip);\n }\n\n this.sessions.delete(flowId);\n const code = crypto.randomUUID();\n this.storeSession(code, { created: Date.now(), clientId: session.clientId });\n this.adapter.log.debug('Auth flow completed \u2014 code issued');\n\n return {\n version: 1,\n type: 'create_entry',\n flow_id: flowId,\n handler: ['homeassistant', null],\n result: code,\n description: null,\n description_placeholders: null,\n };\n },\n );\n\n this.app.post<{ Body: { code?: string; grant_type?: string; refresh_token?: string } }>(\n '/auth/token',\n async (req, reply) => {\n const { code, grant_type, refresh_token } = req.body ?? {};\n const ip = WebServer.getClientIp(req);\n\n // v1.11.0 (C7): /auth/token mit refresh_token-grant war vorher\n // brute-force-frei \u2014 Login-Flow gelockt, Token-Endpoint nicht.\n // Asymmetrie ist riskant (massive Probes, Side-Channel-Risk).\n // Jetzt: dieselbe Lockout-Mechanik wie /auth/login_flow/:flowId.\n if (this.isIpLocked(ip)) {\n // v1.28.3 (HW13): see /auth/login_flow/:flowId \u2014 same Retry-After\n // signal so /auth/token brute-force probes back off identically.\n const retry = this.retryAfterSeconds(ip);\n if (retry > 0) {\n reply.header('Retry-After', String(retry));\n }\n reply.status(429);\n return { error: 'rate_limited', error_description: 'Too many failures, try again later' };\n }\n\n if (grant_type === 'authorization_code' && code && this.sessions.has(code)) {\n const session = this.sessions.get(code)!;\n this.sessions.delete(code);\n const token = crypto.randomUUID();\n const refreshToken = crypto.randomUUID();\n if (session.clientId) {\n await this.registry.setToken(session.clientId, token);\n this.storeRefreshToken(refreshToken, session.clientId);\n this.adapter.log.debug(`Display authenticated \u2014 client ${session.clientId}`);\n }\n return {\n access_token: token,\n token_type: 'Bearer',\n refresh_token: refreshToken,\n expires_in: OAUTH_ACCESS_TOKEN_TTL_S,\n };\n }\n\n if (grant_type === 'refresh_token') {\n // Validate the refresh token against issued ones \u2014 was previously\n // accepting any string and minting a new access_token (security fix v1.2.0).\n const incoming = typeof refresh_token === 'string' ? refresh_token : '';\n const ownerId = incoming ? this.refreshTokens.get(incoming) : undefined;\n if (!ownerId) {\n // v1.8.0: internet-exposed Instance sieht Scanner hammern\n // /auth/token. Erste Occurrence pro Restart bleibt warn,\n // alle weiteren auf debug. v1.11.0 (C7): jetzt zus\u00E4tzlich\n // recordLoginFailure \u2192 triggert Lockout nach 5 invalid-grants.\n this.adapter.log.debug('Refresh token rejected \u2014 unknown or missing');\n this.recordLoginFailure(ip);\n reply.status(400);\n return { error: 'invalid_grant', error_description: 'Invalid refresh token' };\n }\n // v1.28.3 (HW5): rotate refresh_token on every refresh-grant\n // (OAuth 2.0 BCP \u2014 RFC 6819 \u00A75.2.2.3 / draft-ietf-oauth-security-topics).\n // Each refresh_token is single-use: on success the old token is\n // invalidated and a fresh one returned alongside the new\n // access_token. Limits the window of a leaked refresh_token to\n // exactly one use; replay of the old token after rotation is\n // rejected as `invalid_grant`.\n this.refreshTokens.delete(incoming);\n const newAccess = crypto.randomUUID();\n const newRefresh = crypto.randomUUID();\n this.storeRefreshToken(newRefresh, ownerId);\n await this.registry.setToken(ownerId, newAccess);\n return {\n access_token: newAccess,\n token_type: 'Bearer',\n refresh_token: newRefresh,\n expires_in: OAUTH_ACCESS_TOKEN_TTL_S,\n };\n }\n\n // v1.8.0: \u201Ewrong grant_type\" ist ein Client-Format-Fehler,\n // nicht ein Server-Concern \u2014 debug. KEIN Lockout-Counter\n // (legitimer Client-Bug soll nicht zu IP-Sperre f\u00FChren).\n this.adapter.log.debug(`Token exchange failed: grant_type=${String(grant_type)}`);\n reply.status(400);\n return { error: 'invalid_request', error_description: 'Invalid or expired code' };\n },\n );\n }\n\n private setupMiscRoutes(): void {\n // Liveness only \u2014 no config leak. Earlier versions exposed the global\n // redirect URL via /health which is unauthenticated; removed in v1.2.0.\n // v1.5.0: auch der `config: { mdns, auth }`-Block raus \u2014 Auth-Status leakte\n // unauthenticated und lie\u00DF sich von einem Network-Attacker zur Reconnaissance\n // nutzen (auth-disabled Instances quickly mappen).\n this.app.get('/health', () => ({\n status: 'ok',\n adapter: 'hassemu',\n version: HA_VERSION,\n }));\n\n this.app.get('/manifest.json', () => ({\n name: this.serviceName,\n short_name: this.serviceName,\n start_url: '/',\n display: 'standalone',\n background_color: '#ffffff',\n theme_color: '#03a9f4',\n }));\n\n // Root \u2014 HTML-Wrapper (iframe + auto-reload), oder Landing-Page wenn keine URL.\n //\n // v1.7.0 (A3): statt 302 liefern wir ein iframe-HTML + 30s-poll auf\n // /api/redirect_check. Wenn die Mode-/URL-Config sich \u00E4ndert (User edit\n // im Adapter), pollt das Display den Wechsel und macht `location.reload()`\n // \u2014 ohne Soft-Reboot des Displays. Vorher musste der User das Display\n // manuell rebooten.\n //\n // WebViews wie Shelly Wall Display rendern iframes + JavaScript korrekt.\n // Falls ein User direkten 302-Redirect will (Browser-Test, Bookmarklet\n // etc.), kann er die Target-URL direkt eingeben \u2014 der Wrapper l\u00E4uft nur\n // beim Aufruf von `/`.\n this.app.get('/', async (req, reply) => {\n const client = await this.identify(req, reply);\n const url = this.globalConfig.resolveUrlFor(client);\n if (!url) {\n this.adapter.log.debug(`No redirect URL for client ${client.id} \u2014 serving landing page`);\n return reply\n .status(200)\n .type('text/html; charset=utf-8')\n .send(renderLandingPage(client.id, this.adapter.namespace, this.systemLanguage, client.ip));\n }\n this.adapter.log.debug(`Serving wrapper for client ${client.id} \u2192 ${url}`);\n return reply.status(200).type('text/html; charset=utf-8').send(renderRedirectWrapper(url));\n });\n\n // /api/redirect_check \u2014 Display polled das alle 30s; wenn der target\n // sich ge\u00E4ndert hat (User edit), gibt der Wrapper `location.reload()`\n // ab. Cookie-basiert \u2014 Display schickt seinen `hassemu_client`-Cookie\n // automatisch mit.\n this.app.get('/api/redirect_check', async (req, reply) => {\n const client = await this.identify(req, reply);\n const url = this.globalConfig.resolveUrlFor(client);\n return { target: url ?? null };\n });\n }\n\n private setupNotFound(): void {\n this.app.setNotFoundHandler((req, reply) => {\n this.adapter.log.debug(`404: ${req.method} ${req.url}`);\n reply.status(404).send({ error: 'Not Found', path: req.url });\n });\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["Fastify", "fastifyCookie", "fastifyFormbody", "dns", "crypto"]
|
|
7
7
|
}
|
package/build/main.js
CHANGED
|
@@ -376,25 +376,32 @@ class HassEmu extends utils.Adapter {
|
|
|
376
376
|
* lastSeen 30 Tage zurückliegt, ist der Token längst abgelaufen.
|
|
377
377
|
*/
|
|
378
378
|
async gcStaleClients() {
|
|
379
|
-
var _a2, _b
|
|
379
|
+
var _a2, _b;
|
|
380
380
|
const now = Date.now();
|
|
381
381
|
const records = (_b = (_a2 = this.registry) == null ? void 0 : _a2.listAll()) != null ? _b : [];
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
382
|
+
const results = await Promise.all(
|
|
383
|
+
records.map(async (record) => {
|
|
384
|
+
var _a3;
|
|
385
|
+
try {
|
|
386
|
+
const obj = await this.getObjectAsync(`clients.${record.id}`);
|
|
387
|
+
const native = (_a3 = obj == null ? void 0 : obj.native) != null ? _a3 : {};
|
|
388
|
+
const action = (0, import_coerce.decideGcAction)(native.lastSeen, now, import_constants.STALE_CLIENT_TTL_MS);
|
|
389
|
+
if (action === "seed") {
|
|
390
|
+
await this.registry.seedLastSeen(record.id, now);
|
|
391
|
+
return 0;
|
|
392
|
+
}
|
|
393
|
+
if (action === "stale") {
|
|
394
|
+
await this.registry.remove(record.id);
|
|
395
|
+
return 1;
|
|
396
|
+
}
|
|
397
|
+
return 0;
|
|
398
|
+
} catch (err) {
|
|
399
|
+
this.log.debug(`Stale-GC: failed for ${record.id}: ${String(err)}`);
|
|
400
|
+
return 0;
|
|
393
401
|
}
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
}
|
|
402
|
+
})
|
|
403
|
+
);
|
|
404
|
+
const removed = results.reduce((acc, n) => acc + n, 0);
|
|
398
405
|
if (removed > 0) {
|
|
399
406
|
this.log.info(`Removed ${removed} inactive client(s) (idle longer than 30 days)`);
|
|
400
407
|
}
|
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, decideGcAction, decideLegacyVisMigration } from './lib/coerce';\nimport { MODE_GLOBAL, MODE_MANUAL, STALE_CLIENT_TTL_MS } from './lib/constants';\nimport { GlobalConfig, 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// v1.25.0 (F3): instanceObjects als single source of truth \u2014 repairGlobalSchemas\n// liest die Object-Schemas aus dem io-package.json statt sie zu duplizieren.\n// resolveJsonModule ist im tsconfig aktiv.\nimport iobrokerPackage from '../io-package.json';\nconst instanceObjectsList = (iobrokerPackage as { instanceObjects: unknown[] }).instanceObjects ?? [];\n\n// v1.10.0 (B3): in compact-mode teilen alle hassemu-Instances einen Node-Prozess.\n// W\u00FCrde jeder Konstruktor `process.on('unhandledRejection'/'uncaughtException')`\n// adden, w\u00E4re jeder Error N\u00D7 im Log und die Listener stacken bei host-restarts.\n// Module-Level-Flag \u2192 es wird genau ein Paar Listeners installiert, egal wieviele\n// Instances. Logging via console.error weil zum Zeitpunkt eines Last-Defence-\n// Errors keine spezifische Adapter-Instance \u201Edie richtige\" ist.\nlet processHandlersInstalled = false;\nlet installedUnhandledHandler: ((reason: unknown) => void) | null = null;\nlet installedUncaughtHandler: ((err: Error) => void) | null = null;\n\nclass HassEmu extends utils.Adapter {\n /**\n * ioBroker system language used to render the user-facing landing page\n * (HTML) in the user's language. Adapter logs themselves stay English by\n * ioBroker convention. Read in `onReady` from `system.config.language`,\n * EN-Fallback. Public so library modules can access it via the\n * `AdapterInterface` they receive.\n */\n public systemLanguage: string = 'en';\n\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\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', (id, obj) => {\n // v1.13.0 (H4): Narrow filter \u2014 vorher feuerte JEDER objectChange\n // im `system.adapter.*`-Namespace ein scheduleRefresh, auch wenn\n // ein anderer Adapter (mit Discovery-irrelevanten Properties) eine\n // Konfiguration \u00E4nderte. Jetzt nur Trigger bei:\n // - Instance-Add/-Remove (obj=null bei delete, oder fresh _id ohne obj)\n // - native.intro / native.welcomeScreen / native.welcomeScreenPro\n // (Quellen f\u00FCr discovered URLs)\n // - admin/web/vis/vis-2 generell (deren Available-Status entscheidet)\n if (!id?.startsWith('system.adapter.')) {\n return;\n }\n const isUrlSourceAdapter =\n id.startsWith('system.adapter.admin.') ||\n id.startsWith('system.adapter.web.') ||\n id.startsWith('system.adapter.vis.') ||\n id.startsWith('system.adapter.vis-2.');\n const isAddOrRemove = !obj || (obj.type === 'instance' && !obj.common?.host);\n if (isUrlSourceAdapter || isAddOrRemove) {\n this.urlDiscovery?.scheduleRefresh();\n }\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. Module-Level-Flag \u2014 siehe Kommentar oben.\n if (!processHandlersInstalled) {\n installedUnhandledHandler = (reason: unknown): void => {\n console.error(\n `[hassemu] Unhandled rejection: ${reason instanceof Error ? reason.message : String(reason)}`,\n );\n };\n installedUncaughtHandler = (err: Error): void => {\n console.error(`[hassemu] Uncaught exception: ${err.message}`);\n };\n process.on('unhandledRejection', installedUnhandledHandler);\n process.on('uncaughtException', installedUncaughtHandler);\n processHandlersInstalled = true;\n }\n }\n\n private async onReady(): Promise<void> {\n // v1.14.0 (H7): defensive bei onReady-Re-Run ohne unload (sollte nicht\n // passieren, aber js-controller-Edge-Cases). Vorhandene Refs sauber\n // entsorgen, sonst orphaned Server + Listeners.\n if (this.webServer) {\n await this.webServer.stop().catch(() => {});\n this.webServer = null;\n }\n if (this.mdnsService) {\n this.mdnsService.stop();\n this.mdnsService = null;\n }\n this.urlDiscovery?.cancelRefresh();\n this.urlDiscovery = null;\n\n await this.setState('info.connection', { val: false, ack: true });\n\n // System-Sprache lesen \u2014 wird an WebServer durchgereicht f\u00FCr die\n // user-facing Landing-Seite (HTML). Adapter-Logs sind Englisch.\n this.systemLanguage = await this.readSystemLanguage();\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 // HA-Server-UUID stabil \u00FCber Restarts halten \u2014 sonst behandeln HA-Clients\n // (Companion-App, Wall-Display, ...) jeden Adapter-Restart als \u201Eneuer Server\"\n // \u2192 Re-Onboarding, Token-Invalidation, History-Verlust. Persistierung in\n // einem normalen State (NICHT via extendForeignObjectAsync auf\n // system.adapter.X.native \u2014 das triggert Restart-Loops, govee-smart-Lesson\n // v2.1.3, Memory `feedback_unhandled_rejection_crash_loop` / `reference_iobroker_partial_object_repair`).\n const instanceUuid = await this.getOrCreateServerUuid();\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 // v1.13.0 (H5): Provider VOR collect() setzen \u2014 sonst l\u00E4uft das\n // erste collect() mit dem Default-Provider (`() => MODE_GLOBAL`),\n // der nicht den Resolver-Output f\u00FCr neue Clients widerspiegelt.\n this.registry.setNewClientModeProvider(() => this.computeNewClientMode());\n await this.urlDiscovery.collect();\n\n try {\n this.webServer = new WebServer(\n this,\n this.config,\n this.registry,\n this.globalConfig,\n instanceUuid,\n this.systemLanguage,\n );\n await this.webServer.start();\n } catch (err) {\n this.log.error(`Web server failed to start: ${String(err)}`);\n // v1.10.0 (B4): nicht stumm zur\u00FCckkehren \u2014 der Adapter w\u00E4re sonst\n // zombie (info.connection=false, kein Server, keine Subscriptions,\n // kein Restart-Signal an js-controller). terminate() signalisiert\n // explizit Failure mit code 11 \u2192 js-controller restartet nach\n // Backoff. Bei EADDRINUSE (Port belegt) ist das die einzig sinnvolle\n // Reaktion: warten + retry, statt unsichtbar idle zu sitzen.\n // v1.13.0 (H6): subscriptions waren noch nicht angelegt (jetzt nach\n // diesem Block) \u2014 daher kein cleanup n\u00F6tig. Falls ein Refactor\n // subscriptions VORZIEHT: hier explizit unsubscribe.\n this.terminate?.(11) ?? process.exit(11);\n return;\n }\n\n // v1.13.0 (D11+H6): Subscriptions NACH webServer.start() \u2014 vorher\n // h\u00E4tte ein State-Write zwischen subscribe und start einen Handler\n // ausgel\u00F6st der auf einen noch-nicht-laufenden Server zugriff. Plus:\n // wenn webServer.start() throwt, sind Subscriptions noch nicht angelegt\n // (kein Cleanup-Pfad n\u00F6tig im catch-Block oben).\n await this.subscribeForeignObjectsAsync('system.adapter.*');\n await this.subscribeStatesAsync('clients.*');\n await this.subscribeStatesAsync('global.*');\n await this.subscribeStatesAsync('info.refresh_urls');\n\n let mdnsActive = false;\n if (this.config.mdnsEnabled) {\n this.mdnsService = new MDNSService(this, this.config, instanceUuid);\n this.mdnsService.start();\n // v1.10.0 (H1): mdns.start() catched intern und setzt active=false\n // bei Fehler \u2014 vorher wurde info.connection=true unabh\u00E4ngig gesetzt\n // und der User hatte den Eindruck Discovery funktioniert. Jetzt\n // f\u00FChren wir die Information sichtbar im Log + im Suffix der\n // running-Meldung.\n mdnsActive = this.mdnsService.isActive();\n if (!mdnsActive) {\n // Use mdnsStartFailed (User-Hint) \u2014 `error` slot uses generic phrase since\n // the underlying cause was already warn'd by MDNSService itself.\n this.log.warn(`mDNS failed to start: ${'see preceding mDNS warning'}`);\n }\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 const mdnsSuffix = this.config.mdnsEnabled ? (mdnsActive ? ', mDNS active' : ', mDNS FAILED') : '';\n this.log.info(`HA emulation running on ${bindAddr}:${this.config.port}${mdnsSuffix}`);\n }\n\n /**\n * Liefert die persistente Server-UUID. Beim ersten Start wird sie generiert und in\n * `info.serverUuid` geschrieben; bei sp\u00E4teren Starts kommt der gleiche Wert raus.\n *\n * Warum nicht `extendForeignObjectAsync(system.adapter.X, native: { serverUuid })`?\n * Schreibt man auf den eigenen `system.adapter.X`-Objekt, triggert js-controller\n * einen Adapter-Restart \u2014 bei jedem Start ein Restart-Loop. govee-smart hatte das\n * in v2.1.3 (`extendForeignObjectAsync` f\u00FCr `mqttCredentials`-native) und musste\n * auf state-based persistence migrieren.\n */\n private async getOrCreateServerUuid(): Promise<string> {\n try {\n const existing = await this.getStateAsync('info.serverUuid');\n const val = existing?.val;\n if (\n typeof val === 'string' &&\n /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(val)\n ) {\n return val;\n }\n } catch {\n /* state didn't exist yet \u2014 fresh install */\n }\n const fresh = crypto.randomUUID();\n await this.setStateAsync('info.serverUuid', { val: fresh, ack: true }).catch(err => {\n // info.serverUuid is an instanceObject \u2014 should always exist. Falls\n // doch nicht: log + fortfahren mit der frischen UUID, sie wird beim\n // n\u00E4chsten Start erneut generiert (kein bleibender Schaden).\n this.log.warn(`Could not save server UUID: ${String(err)}`);\n });\n this.log.info(`Server UUID generated and saved: ${fresh}`);\n return fresh;\n }\n\n /**\n * Default mode for newly registered clients. Respects the master switch:\n * - `global.enabled=true` \u2192 `'global'` (follow master)\n * - sonst \u2192 `'0'` (no-choice) \u2192 Resolver returnt null \u2192\n * Landing-Page bis der User im Mode-Dropdown explizit eine URL w\u00E4hlt.\n * Pre-v1.26.0 fiel der Default auf die erste discovered URL \u2014 das hat\n * die Landing-Page f\u00FCr neue Displays praktisch unsichtbar gemacht und\n * den User mit einer ungewollten Auto-Wahl \u00FCberrascht.\n */\n private computeNewClientMode(): string {\n if (this.globalConfig?.isEnabled()) {\n return MODE_GLOBAL;\n }\n return '0';\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 // Defensive: validiere die legacy-URL bevor wir sie nach `global.visUrl`\n // schreiben. Malicious-Werte (`javascript:`, `data:`) sollen nicht durch\n // die Migration durchrutschen \u2014 `migrateVisUrlToMode` validiert zwar\n // nochmal, aber zwischen den Migrations-Schritten w\u00FCrde unsafe-Wert\n // sichtbar sein, und die native-Cleanup ist unbedingt.\n const safe = coerceSafeUrl(url);\n if (!safe) {\n this.log.warn(`Migration: legacy global URL rejected as unsafe \u2014 please set global.manualUrl manually`);\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 return;\n }\n\n this.log.info(`Migrating legacy URL configuration to the new model`);\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 // Wichtig: wenn der State-Write FEHLSCHL\u00C4GT (z.B. weil global.visUrl-Object\n // in v1.2.0+ schon weg ist), d\u00FCrfen wir die native-Werte NICHT l\u00F6schen \u2014\n // sonst ist die User-URL silent verloren. Stattdessen direkt nach\n // global.mode/manualUrl schreiben (das Ziel wo migrateVisUrlToMode\n // sie sonst hingeschrieben h\u00E4tte).\n let stateWritten = false;\n try {\n await this.setStateAsync('global.visUrl', { val: safe, ack: true });\n stateWritten = true;\n } catch {\n // global.visUrl-Object existiert nicht mehr \u2192 direkt ins Ziel schreiben\n try {\n if (this.globalConfig) {\n await this.globalConfig.migrationSet(MODE_MANUAL, safe);\n // Tech-Internal-Pfad: shortcut wenn global.visUrl-state fehlt \u2014 debug-only.\n this.log.debug(\n `Migration shortcut: global.visUrl-state missing \u2014 wrote directly to manualUrl=${safe}`,\n );\n stateWritten = true;\n }\n } catch (err) {\n this.log.debug(`Legacy URL migration fallback failed: ${String(err)}`);\n }\n }\n\n if (!stateWritten) {\n // Both paths failed \u2014 keep native values as a recovery anchor for the user.\n this.log.warn(`Legacy URL preserved in instance config \u2014 neither global URL write succeeded`);\n return;\n }\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 // v1.25.0 (J2): Decision-Logik in pure helper coerce.decideLegacyVisMigration\n // (testbar). Hier nur das I/O zum Broker.\n // 1) Global visUrl \u2192 mode + manualUrl\n try {\n const legacyGlobal = await this.getStateAsync('global.visUrl');\n const decision = decideLegacyVisMigration(legacyGlobal?.val);\n if (decision.kind === 'safe-url') {\n await this.globalConfig!.migrationSet(MODE_MANUAL, decision.safe);\n this.log.info(`Migration: global URL \"${decision.safe}\" moved to global.manualUrl`);\n } else if (decision.kind === 'unsafe-rejected') {\n await this.globalConfig!.migrationSet(MODE_MANUAL, null);\n this.log.warn(`Migration: legacy global URL rejected as unsafe \u2014 please set global.manualUrl manually`);\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 const decision = decideLegacyVisMigration(legacy?.val);\n if (decision.kind === 'safe-url') {\n record.mode = MODE_MANUAL;\n record.manualUrl = decision.safe;\n await this.setStateAsync(`clients.${record.id}.mode`, { val: MODE_MANUAL, ack: true });\n await this.setStateAsync(`clients.${record.id}.manualUrl`, { val: decision.safe, ack: true });\n this.log.info(`Migration: client ${record.id} URL \"${decision.safe}\" moved to manualUrl`);\n } else if (decision.kind === 'unsafe-rejected') {\n this.log.warn(\n `Migration: client ${record.id} legacy URL rejected as unsafe \u2014 please set the URL manually`,\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 // v1.14.0 (H3): needs-repair-Check vor unconditional extendObjectAsync\n // \u2014 spart 2 Round-Trips bei jedem Start f\u00FCr ~99% der Installationen.\n // v1.25.0 (F3): schemas kommen jetzt aus io-package.json:instanceObjects\n // statt hardgecoded \u2014 vorher waren die schemas in zwei Pl\u00E4tzen (hier\n // und in der instanceObjects-Section), Drift m\u00F6glich. Jetzt: single\n // source of truth.\n const repair = async (id: string, expectedCommonType: string): Promise<void> => {\n try {\n const obj = await this.getObjectAsync(id);\n if (obj && obj.type === 'state' && obj.common?.type === expectedCommonType) {\n return; // bereits korrekt\n }\n } catch {\n /* fall through to repair */\n }\n const fullId = `${this.namespace}.${id}`;\n const schema = (\n instanceObjectsList as Array<{ _id: string; type: string; common?: unknown; native?: unknown }>\n ).find(o => o._id === id || o._id === fullId);\n if (!schema) {\n this.log.debug(`repair ${id}: no instanceObjects-schema found, skipping`);\n return;\n }\n try {\n // extendObjectAsync hat overloads je nach `type`. Wir sind hier\n // ausschlie\u00DFlich bei `type:'state'`-Items aus instanceObjects \u2014\n // ein `as never` erlaubt der TS-Type-Narrowing den Aufruf ohne\n // dass wir die genaue State-Common-Shape statisch beweisen m\u00FCssen.\n // Die Schema-Quelle (io-package.json) ist build-time-validiert.\n await this.extendObjectAsync(id, {\n type: schema.type,\n common: schema.common,\n native: schema.native ?? {},\n } as never);\n } catch (err) {\n this.log.debug(`repair ${id} failed: ${String(err)}`);\n }\n };\n\n await repair('global.mode', 'mixed');\n await repair('global.manualUrl', 'string');\n }\n\n /**\n * Removes clients that are clearly stale: `native.lastSeen` older than\n * {@link STALE_CLIENT_TTL_MS}.\n *\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 * v1.11.0 (C9): vorher \u00FCbersprang GC alle token-haltenden Clients (`if record.token`).\n * Effekt: \u00FCber Jahre wuchs die Liste mit \u201Eauthenticated, but never seen again\"-\n * Clients (Display weg/refurbished/Bridge-Reset etc.). Jetzt: lastSeen-basiert\n * unabh\u00E4ngig vom Token. Access-Token sind ohnehin nur 30min g\u00FCltig \u2014 wenn\n * lastSeen 30 Tage zur\u00FCckliegt, ist der Token l\u00E4ngst abgelaufen.\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 try {\n const obj = await this.getObjectAsync(`clients.${record.id}`);\n const native = (obj?.native as { lastSeen?: number } | undefined) ?? {};\n // v1.25.0 (J1): Decision-Logik in pure helper coerce.decideGcAction\n // (testbar). Hier nur das I/O zum Broker.\n const action = decideGcAction(native.lastSeen, now, STALE_CLIENT_TTL_MS);\n if (action === 'seed') {\n await this.registry!.seedLastSeen(record.id, now);\n } else if (action === 'stale') {\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(`Removed ${removed} inactive client(s) (idle longer than 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 // Master aus \u2192 alle Clients auf no-choice. Ohne explizite User-Wahl\n // zeigt jedes Display die Landing-Page (statt automatisch auf irgendeine\n // discovered URL umzuswitchen, die der User vielleicht gar nicht meinte).\n await this.registry.bulkSetMode('0');\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 is \"global\" but global has no resolvable URL \u2014 fill global.mode/manualUrl, or pick a different 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 return;\n }\n\n // info.refresh_urls \u2014 User-Trigger f\u00FCr manuelles Dropdown-Refresh ohne\n // Adapter-Neustart. Re-scan'd den Broker nach VIS/VIS-2-Projekten und\n // Admin-Tiles, schreibt die neuen states-Maps in alle Mode-Dropdowns.\n if (id === `${this.namespace}.info.refresh_urls` && state.val === true) {\n await this.handleRefreshUrlsWrite();\n }\n }\n\n /**\n * Handler for the `info.refresh_urls` button.\n * Triggert eine sofortige `urlDiscovery.collect()` (statt Debounce-Schedule),\n * damit der User nicht 2s warten muss. Schreibt anschlie\u00DFend `false ack` damit\n * der Button in der Admin-UI wieder \u201Eklickbar\" wird.\n */\n private async handleRefreshUrlsWrite(): Promise<void> {\n if (!this.urlDiscovery) {\n return;\n }\n try {\n await this.urlDiscovery.collect();\n this.log.info(`URL list refreshed on user request`);\n } catch (err) {\n this.log.warn(`URL refresh failed: ${err instanceof Error ? err.message : String(err)}`);\n } finally {\n await this.setStateAsync('info.refresh_urls', { val: false, ack: true }).catch(() => {});\n }\n }\n\n private onUnload(callback: () => void): void {\n try {\n // v1.13.0 (H10): info.connection=false zuerst, vor jedem cleanup \u2014\n // wenn ein cleanup-Step throws, bleibt der State mindestens als\n // false ack'd statt als true h\u00E4ngen.\n void this.setState('info.connection', { val: false, ack: true });\n\n // v1.10.0 (H2): subscriptions explizit l\u00F6sen bevor Refs nullen.\n // js-controller cleant das normalerweise \u2014 aber im compact-mode mit\n // hot-remove + re-add kann Residual entstehen, das dann auf eine\n // bereits genullte Adapter-Instance feuert. Sync-call (void) weil\n // onUnload synchron sein MUSS (sonst SIGKILL).\n void this.unsubscribeStatesAsync('clients.*');\n void this.unsubscribeStatesAsync('global.*');\n void this.unsubscribeStatesAsync('info.refresh_urls');\n void this.unsubscribeForeignObjectsAsync('system.adapter.*');\n\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 // v1.18.0 (G6): kein doppeltes log \u2014 webServer.stop() loggt\n // intern bereits auf debug. Hier nur silent-catch.\n this.webServer.stop().catch(() => {});\n this.webServer = null;\n }\n\n this.registry = null;\n this.globalConfig = null;\n\n // Process-level last-line-of-defence handlers werden NICHT detached:\n // im compact-mode k\u00F6nnen andere hassemu-Instances noch laufen und\n // brauchen die Handler weiterhin. Wenn der Prozess am Ende ist, r\u00E4umt\n // Node die Listeners eh auf. (B3 v1.10.0)\n } catch (error) {\n const err = error as Error;\n this.log.error(`Shutdown error: ${err.message}`);\n } finally {\n callback();\n }\n }\n}\n\nif (require.main !== module) {\n module.exports = (options: Partial<utils.AdapterOptions> | undefined) => new HassEmu(options);\n} else {\n (() => new HassEmu())();\n}\n"],
|
|
5
|
-
"mappings": "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",
|
|
4
|
+
"sourcesContent": ["import crypto from 'node:crypto';\nimport * as utils from '@iobroker/adapter-core';\nimport { ClientRegistry, parseClientStateId } from './lib/client-registry';\nimport { coerceSafeUrl, decideGcAction, decideLegacyVisMigration } from './lib/coerce';\nimport { MODE_GLOBAL, MODE_MANUAL, STALE_CLIENT_TTL_MS } from './lib/constants';\nimport { GlobalConfig, 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// v1.25.0 (F3): instanceObjects als single source of truth \u2014 repairGlobalSchemas\n// liest die Object-Schemas aus dem io-package.json statt sie zu duplizieren.\n// resolveJsonModule ist im tsconfig aktiv.\nimport iobrokerPackage from '../io-package.json';\nconst instanceObjectsList = (iobrokerPackage as { instanceObjects: unknown[] }).instanceObjects ?? [];\n\n// v1.10.0 (B3): in compact-mode teilen alle hassemu-Instances einen Node-Prozess.\n// W\u00FCrde jeder Konstruktor `process.on('unhandledRejection'/'uncaughtException')`\n// adden, w\u00E4re jeder Error N\u00D7 im Log und die Listener stacken bei host-restarts.\n// Module-Level-Flag \u2192 es wird genau ein Paar Listeners installiert, egal wieviele\n// Instances. Logging via console.error weil zum Zeitpunkt eines Last-Defence-\n// Errors keine spezifische Adapter-Instance \u201Edie richtige\" ist.\nlet processHandlersInstalled = false;\nlet installedUnhandledHandler: ((reason: unknown) => void) | null = null;\nlet installedUncaughtHandler: ((err: Error) => void) | null = null;\n\nclass HassEmu extends utils.Adapter {\n /**\n * ioBroker system language used to render the user-facing landing page\n * (HTML) in the user's language. Adapter logs themselves stay English by\n * ioBroker convention. Read in `onReady` from `system.config.language`,\n * EN-Fallback. Public so library modules can access it via the\n * `AdapterInterface` they receive.\n */\n public systemLanguage: string = 'en';\n\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\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', (id, obj) => {\n // v1.13.0 (H4): Narrow filter \u2014 vorher feuerte JEDER objectChange\n // im `system.adapter.*`-Namespace ein scheduleRefresh, auch wenn\n // ein anderer Adapter (mit Discovery-irrelevanten Properties) eine\n // Konfiguration \u00E4nderte. Jetzt nur Trigger bei:\n // - Instance-Add/-Remove (obj=null bei delete, oder fresh _id ohne obj)\n // - native.intro / native.welcomeScreen / native.welcomeScreenPro\n // (Quellen f\u00FCr discovered URLs)\n // - admin/web/vis/vis-2 generell (deren Available-Status entscheidet)\n if (!id?.startsWith('system.adapter.')) {\n return;\n }\n const isUrlSourceAdapter =\n id.startsWith('system.adapter.admin.') ||\n id.startsWith('system.adapter.web.') ||\n id.startsWith('system.adapter.vis.') ||\n id.startsWith('system.adapter.vis-2.');\n const isAddOrRemove = !obj || (obj.type === 'instance' && !obj.common?.host);\n if (isUrlSourceAdapter || isAddOrRemove) {\n this.urlDiscovery?.scheduleRefresh();\n }\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. Module-Level-Flag \u2014 siehe Kommentar oben.\n if (!processHandlersInstalled) {\n installedUnhandledHandler = (reason: unknown): void => {\n console.error(\n `[hassemu] Unhandled rejection: ${reason instanceof Error ? reason.message : String(reason)}`,\n );\n };\n installedUncaughtHandler = (err: Error): void => {\n console.error(`[hassemu] Uncaught exception: ${err.message}`);\n };\n process.on('unhandledRejection', installedUnhandledHandler);\n process.on('uncaughtException', installedUncaughtHandler);\n processHandlersInstalled = true;\n }\n }\n\n private async onReady(): Promise<void> {\n // v1.14.0 (H7): defensive bei onReady-Re-Run ohne unload (sollte nicht\n // passieren, aber js-controller-Edge-Cases). Vorhandene Refs sauber\n // entsorgen, sonst orphaned Server + Listeners.\n if (this.webServer) {\n await this.webServer.stop().catch(() => {});\n this.webServer = null;\n }\n if (this.mdnsService) {\n this.mdnsService.stop();\n this.mdnsService = null;\n }\n this.urlDiscovery?.cancelRefresh();\n this.urlDiscovery = null;\n\n await this.setState('info.connection', { val: false, ack: true });\n\n // System-Sprache lesen \u2014 wird an WebServer durchgereicht f\u00FCr die\n // user-facing Landing-Seite (HTML). Adapter-Logs sind Englisch.\n this.systemLanguage = await this.readSystemLanguage();\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 // HA-Server-UUID stabil \u00FCber Restarts halten \u2014 sonst behandeln HA-Clients\n // (Companion-App, Wall-Display, ...) jeden Adapter-Restart als \u201Eneuer Server\"\n // \u2192 Re-Onboarding, Token-Invalidation, History-Verlust. Persistierung in\n // einem normalen State (NICHT via extendForeignObjectAsync auf\n // system.adapter.X.native \u2014 das triggert Restart-Loops, govee-smart-Lesson\n // v2.1.3, Memory `feedback_unhandled_rejection_crash_loop` / `reference_iobroker_partial_object_repair`).\n const instanceUuid = await this.getOrCreateServerUuid();\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 // v1.13.0 (H5): Provider VOR collect() setzen \u2014 sonst l\u00E4uft das\n // erste collect() mit dem Default-Provider (`() => MODE_GLOBAL`),\n // der nicht den Resolver-Output f\u00FCr neue Clients widerspiegelt.\n this.registry.setNewClientModeProvider(() => this.computeNewClientMode());\n await this.urlDiscovery.collect();\n\n try {\n this.webServer = new WebServer(\n this,\n this.config,\n this.registry,\n this.globalConfig,\n instanceUuid,\n this.systemLanguage,\n );\n await this.webServer.start();\n } catch (err) {\n this.log.error(`Web server failed to start: ${String(err)}`);\n // v1.10.0 (B4): nicht stumm zur\u00FCckkehren \u2014 der Adapter w\u00E4re sonst\n // zombie (info.connection=false, kein Server, keine Subscriptions,\n // kein Restart-Signal an js-controller). terminate() signalisiert\n // explizit Failure mit code 11 \u2192 js-controller restartet nach\n // Backoff. Bei EADDRINUSE (Port belegt) ist das die einzig sinnvolle\n // Reaktion: warten + retry, statt unsichtbar idle zu sitzen.\n // v1.13.0 (H6): subscriptions waren noch nicht angelegt (jetzt nach\n // diesem Block) \u2014 daher kein cleanup n\u00F6tig. Falls ein Refactor\n // subscriptions VORZIEHT: hier explizit unsubscribe.\n this.terminate?.(11) ?? process.exit(11);\n return;\n }\n\n // v1.13.0 (D11+H6): Subscriptions NACH webServer.start() \u2014 vorher\n // h\u00E4tte ein State-Write zwischen subscribe und start einen Handler\n // ausgel\u00F6st der auf einen noch-nicht-laufenden Server zugriff. Plus:\n // wenn webServer.start() throwt, sind Subscriptions noch nicht angelegt\n // (kein Cleanup-Pfad n\u00F6tig im catch-Block oben).\n await this.subscribeForeignObjectsAsync('system.adapter.*');\n await this.subscribeStatesAsync('clients.*');\n await this.subscribeStatesAsync('global.*');\n await this.subscribeStatesAsync('info.refresh_urls');\n\n let mdnsActive = false;\n if (this.config.mdnsEnabled) {\n this.mdnsService = new MDNSService(this, this.config, instanceUuid);\n this.mdnsService.start();\n // v1.10.0 (H1): mdns.start() catched intern und setzt active=false\n // bei Fehler \u2014 vorher wurde info.connection=true unabh\u00E4ngig gesetzt\n // und der User hatte den Eindruck Discovery funktioniert. Jetzt\n // f\u00FChren wir die Information sichtbar im Log + im Suffix der\n // running-Meldung.\n mdnsActive = this.mdnsService.isActive();\n if (!mdnsActive) {\n // Use mdnsStartFailed (User-Hint) \u2014 `error` slot uses generic phrase since\n // the underlying cause was already warn'd by MDNSService itself.\n this.log.warn(`mDNS failed to start: ${'see preceding mDNS warning'}`);\n }\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 const mdnsSuffix = this.config.mdnsEnabled ? (mdnsActive ? ', mDNS active' : ', mDNS FAILED') : '';\n this.log.info(`HA emulation running on ${bindAddr}:${this.config.port}${mdnsSuffix}`);\n }\n\n /**\n * Liefert die persistente Server-UUID. Beim ersten Start wird sie generiert und in\n * `info.serverUuid` geschrieben; bei sp\u00E4teren Starts kommt der gleiche Wert raus.\n *\n * Warum nicht `extendForeignObjectAsync(system.adapter.X, native: { serverUuid })`?\n * Schreibt man auf den eigenen `system.adapter.X`-Objekt, triggert js-controller\n * einen Adapter-Restart \u2014 bei jedem Start ein Restart-Loop. govee-smart hatte das\n * in v2.1.3 (`extendForeignObjectAsync` f\u00FCr `mqttCredentials`-native) und musste\n * auf state-based persistence migrieren.\n */\n private async getOrCreateServerUuid(): Promise<string> {\n try {\n const existing = await this.getStateAsync('info.serverUuid');\n const val = existing?.val;\n if (\n typeof val === 'string' &&\n /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(val)\n ) {\n return val;\n }\n } catch {\n /* state didn't exist yet \u2014 fresh install */\n }\n const fresh = crypto.randomUUID();\n await this.setStateAsync('info.serverUuid', { val: fresh, ack: true }).catch(err => {\n // info.serverUuid is an instanceObject \u2014 should always exist. Falls\n // doch nicht: log + fortfahren mit der frischen UUID, sie wird beim\n // n\u00E4chsten Start erneut generiert (kein bleibender Schaden).\n this.log.warn(`Could not save server UUID: ${String(err)}`);\n });\n this.log.info(`Server UUID generated and saved: ${fresh}`);\n return fresh;\n }\n\n /**\n * Default mode for newly registered clients. Respects the master switch:\n * - `global.enabled=true` \u2192 `'global'` (follow master)\n * - sonst \u2192 `'0'` (no-choice) \u2192 Resolver returnt null \u2192\n * Landing-Page bis der User im Mode-Dropdown explizit eine URL w\u00E4hlt.\n * Pre-v1.26.0 fiel der Default auf die erste discovered URL \u2014 das hat\n * die Landing-Page f\u00FCr neue Displays praktisch unsichtbar gemacht und\n * den User mit einer ungewollten Auto-Wahl \u00FCberrascht.\n */\n private computeNewClientMode(): string {\n if (this.globalConfig?.isEnabled()) {\n return MODE_GLOBAL;\n }\n return '0';\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 // Defensive: validiere die legacy-URL bevor wir sie nach `global.visUrl`\n // schreiben. Malicious-Werte (`javascript:`, `data:`) sollen nicht durch\n // die Migration durchrutschen \u2014 `migrateVisUrlToMode` validiert zwar\n // nochmal, aber zwischen den Migrations-Schritten w\u00FCrde unsafe-Wert\n // sichtbar sein, und die native-Cleanup ist unbedingt.\n const safe = coerceSafeUrl(url);\n if (!safe) {\n this.log.warn(`Migration: legacy global URL rejected as unsafe \u2014 please set global.manualUrl manually`);\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 return;\n }\n\n this.log.info(`Migrating legacy URL configuration to the new model`);\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 // Wichtig: wenn der State-Write FEHLSCHL\u00C4GT (z.B. weil global.visUrl-Object\n // in v1.2.0+ schon weg ist), d\u00FCrfen wir die native-Werte NICHT l\u00F6schen \u2014\n // sonst ist die User-URL silent verloren. Stattdessen direkt nach\n // global.mode/manualUrl schreiben (das Ziel wo migrateVisUrlToMode\n // sie sonst hingeschrieben h\u00E4tte).\n let stateWritten = false;\n try {\n await this.setStateAsync('global.visUrl', { val: safe, ack: true });\n stateWritten = true;\n } catch {\n // global.visUrl-Object existiert nicht mehr \u2192 direkt ins Ziel schreiben\n try {\n if (this.globalConfig) {\n await this.globalConfig.migrationSet(MODE_MANUAL, safe);\n // Tech-Internal-Pfad: shortcut wenn global.visUrl-state fehlt \u2014 debug-only.\n this.log.debug(\n `Migration shortcut: global.visUrl-state missing \u2014 wrote directly to manualUrl=${safe}`,\n );\n stateWritten = true;\n }\n } catch (err) {\n this.log.debug(`Legacy URL migration fallback failed: ${String(err)}`);\n }\n }\n\n if (!stateWritten) {\n // Both paths failed \u2014 keep native values as a recovery anchor for the user.\n this.log.warn(`Legacy URL preserved in instance config \u2014 neither global URL write succeeded`);\n return;\n }\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 // v1.25.0 (J2): Decision-Logik in pure helper coerce.decideLegacyVisMigration\n // (testbar). Hier nur das I/O zum Broker.\n // 1) Global visUrl \u2192 mode + manualUrl\n try {\n const legacyGlobal = await this.getStateAsync('global.visUrl');\n const decision = decideLegacyVisMigration(legacyGlobal?.val);\n if (decision.kind === 'safe-url') {\n await this.globalConfig!.migrationSet(MODE_MANUAL, decision.safe);\n this.log.info(`Migration: global URL \"${decision.safe}\" moved to global.manualUrl`);\n } else if (decision.kind === 'unsafe-rejected') {\n await this.globalConfig!.migrationSet(MODE_MANUAL, null);\n this.log.warn(`Migration: legacy global URL rejected as unsafe \u2014 please set global.manualUrl manually`);\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 const decision = decideLegacyVisMigration(legacy?.val);\n if (decision.kind === 'safe-url') {\n record.mode = MODE_MANUAL;\n record.manualUrl = decision.safe;\n await this.setStateAsync(`clients.${record.id}.mode`, { val: MODE_MANUAL, ack: true });\n await this.setStateAsync(`clients.${record.id}.manualUrl`, { val: decision.safe, ack: true });\n this.log.info(`Migration: client ${record.id} URL \"${decision.safe}\" moved to manualUrl`);\n } else if (decision.kind === 'unsafe-rejected') {\n this.log.warn(\n `Migration: client ${record.id} legacy URL rejected as unsafe \u2014 please set the URL manually`,\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 // v1.14.0 (H3): needs-repair-Check vor unconditional extendObjectAsync\n // \u2014 spart 2 Round-Trips bei jedem Start f\u00FCr ~99% der Installationen.\n // v1.25.0 (F3): schemas kommen jetzt aus io-package.json:instanceObjects\n // statt hardgecoded \u2014 vorher waren die schemas in zwei Pl\u00E4tzen (hier\n // und in der instanceObjects-Section), Drift m\u00F6glich. Jetzt: single\n // source of truth.\n const repair = async (id: string, expectedCommonType: string): Promise<void> => {\n try {\n const obj = await this.getObjectAsync(id);\n if (obj && obj.type === 'state' && obj.common?.type === expectedCommonType) {\n return; // bereits korrekt\n }\n } catch {\n /* fall through to repair */\n }\n const fullId = `${this.namespace}.${id}`;\n const schema = (\n instanceObjectsList as Array<{ _id: string; type: string; common?: unknown; native?: unknown }>\n ).find(o => o._id === id || o._id === fullId);\n if (!schema) {\n this.log.debug(`repair ${id}: no instanceObjects-schema found, skipping`);\n return;\n }\n try {\n // extendObjectAsync hat overloads je nach `type`. Wir sind hier\n // ausschlie\u00DFlich bei `type:'state'`-Items aus instanceObjects \u2014\n // ein `as never` erlaubt der TS-Type-Narrowing den Aufruf ohne\n // dass wir die genaue State-Common-Shape statisch beweisen m\u00FCssen.\n // Die Schema-Quelle (io-package.json) ist build-time-validiert.\n await this.extendObjectAsync(id, {\n type: schema.type,\n common: schema.common,\n native: schema.native ?? {},\n } as never);\n } catch (err) {\n this.log.debug(`repair ${id} failed: ${String(err)}`);\n }\n };\n\n await repair('global.mode', 'mixed');\n await repair('global.manualUrl', 'string');\n }\n\n /**\n * Removes clients that are clearly stale: `native.lastSeen` older than\n * {@link STALE_CLIENT_TTL_MS}.\n *\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 * v1.11.0 (C9): vorher \u00FCbersprang GC alle token-haltenden Clients (`if record.token`).\n * Effekt: \u00FCber Jahre wuchs die Liste mit \u201Eauthenticated, but never seen again\"-\n * Clients (Display weg/refurbished/Bridge-Reset etc.). Jetzt: lastSeen-basiert\n * unabh\u00E4ngig vom Token. Access-Token sind ohnehin nur 30min g\u00FCltig \u2014 wenn\n * lastSeen 30 Tage zur\u00FCckliegt, ist der Token l\u00E4ngst abgelaufen.\n */\n private async gcStaleClients(): Promise<void> {\n const now = Date.now();\n const records = this.registry?.listAll() ?? [];\n // v1.28.3 (M5): GC-Pass parallel statt sequentiell. Bei vielen Clients\n // (Display-Farm) summierten sich die Broker-Round-Trips beim Adapter-\n // Start zur sp\u00FCrbaren Pause vor `webServer.start()`. Pro-Client-try-catch\n // bleibt \u2014 ein einzelner getObject-Fehler darf den GC-Pass nicht\n // abbrechen. Counter ist ein primitive number unter Promise.all sicher.\n const results: number[] = await Promise.all(\n records.map(async (record): Promise<number> => {\n try {\n const obj = await this.getObjectAsync(`clients.${record.id}`);\n const native = (obj?.native as { lastSeen?: number } | undefined) ?? {};\n // v1.25.0 (J1): Decision-Logik in pure helper coerce.decideGcAction\n // (testbar). Hier nur das I/O zum Broker.\n const action = decideGcAction(native.lastSeen, now, STALE_CLIENT_TTL_MS);\n if (action === 'seed') {\n await this.registry!.seedLastSeen(record.id, now);\n return 0;\n }\n if (action === 'stale') {\n await this.registry!.remove(record.id);\n return 1;\n }\n return 0;\n } catch (err) {\n this.log.debug(`Stale-GC: failed for ${record.id}: ${String(err)}`);\n return 0;\n }\n }),\n );\n const removed = results.reduce((acc, n) => acc + n, 0);\n if (removed > 0) {\n this.log.info(`Removed ${removed} inactive client(s) (idle longer than 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 // Master aus \u2192 alle Clients auf no-choice. Ohne explizite User-Wahl\n // zeigt jedes Display die Landing-Page (statt automatisch auf irgendeine\n // discovered URL umzuswitchen, die der User vielleicht gar nicht meinte).\n await this.registry.bulkSetMode('0');\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 is \"global\" but global has no resolvable URL \u2014 fill global.mode/manualUrl, or pick a different 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 return;\n }\n\n // info.refresh_urls \u2014 User-Trigger f\u00FCr manuelles Dropdown-Refresh ohne\n // Adapter-Neustart. Re-scan'd den Broker nach VIS/VIS-2-Projekten und\n // Admin-Tiles, schreibt die neuen states-Maps in alle Mode-Dropdowns.\n if (id === `${this.namespace}.info.refresh_urls` && state.val === true) {\n await this.handleRefreshUrlsWrite();\n }\n }\n\n /**\n * Handler for the `info.refresh_urls` button.\n * Triggert eine sofortige `urlDiscovery.collect()` (statt Debounce-Schedule),\n * damit der User nicht 2s warten muss. Schreibt anschlie\u00DFend `false ack` damit\n * der Button in der Admin-UI wieder \u201Eklickbar\" wird.\n */\n private async handleRefreshUrlsWrite(): Promise<void> {\n if (!this.urlDiscovery) {\n return;\n }\n try {\n await this.urlDiscovery.collect();\n this.log.info(`URL list refreshed on user request`);\n } catch (err) {\n this.log.warn(`URL refresh failed: ${err instanceof Error ? err.message : String(err)}`);\n } finally {\n await this.setStateAsync('info.refresh_urls', { val: false, ack: true }).catch(() => {});\n }\n }\n\n private onUnload(callback: () => void): void {\n try {\n // v1.13.0 (H10): info.connection=false zuerst, vor jedem cleanup \u2014\n // wenn ein cleanup-Step throws, bleibt der State mindestens als\n // false ack'd statt als true h\u00E4ngen.\n void this.setState('info.connection', { val: false, ack: true });\n\n // v1.10.0 (H2): subscriptions explizit l\u00F6sen bevor Refs nullen.\n // js-controller cleant das normalerweise \u2014 aber im compact-mode mit\n // hot-remove + re-add kann Residual entstehen, das dann auf eine\n // bereits genullte Adapter-Instance feuert. Sync-call (void) weil\n // onUnload synchron sein MUSS (sonst SIGKILL).\n void this.unsubscribeStatesAsync('clients.*');\n void this.unsubscribeStatesAsync('global.*');\n void this.unsubscribeStatesAsync('info.refresh_urls');\n void this.unsubscribeForeignObjectsAsync('system.adapter.*');\n\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 // v1.18.0 (G6): kein doppeltes log \u2014 webServer.stop() loggt\n // intern bereits auf debug. Hier nur silent-catch.\n this.webServer.stop().catch(() => {});\n this.webServer = null;\n }\n\n this.registry = null;\n this.globalConfig = null;\n\n // Process-level last-line-of-defence handlers werden NICHT detached:\n // im compact-mode k\u00F6nnen andere hassemu-Instances noch laufen und\n // brauchen die Handler weiterhin. Wenn der Prozess am Ende ist, r\u00E4umt\n // Node die Listeners eh auf. (B3 v1.10.0)\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": ["iobrokerPackage", "_a", "_b", "crypto"]
|
|
7
7
|
}
|
package/io-package.json
CHANGED
|
@@ -1,8 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"common": {
|
|
3
3
|
"name": "hassemu",
|
|
4
|
-
"version": "1.28.
|
|
4
|
+
"version": "1.28.3",
|
|
5
5
|
"news": {
|
|
6
|
+
"1.28.3": {
|
|
7
|
+
"en": "Adapter starts faster on installations with many displays, and a single broken client entry no longer keeps the others from being restored. Plus a security tighten-up on the HA login flow.",
|
|
8
|
+
"de": "Adapter-Start mit vielen Displays läuft schneller, und ein einzelnes defektes Client-Objekt blockiert nicht mehr den Rest. Dazu eine Sicherheits-Härtung im HA-Login.",
|
|
9
|
+
"ru": "Адаптер быстрее запускается при многих дисплеях, а одна повреждённая запись клиента больше не мешает восстановлению остальных. Плюс усиление безопасности входа HA.",
|
|
10
|
+
"pt": "O adaptador inicia mais rápido com muitos visores e um único registo de cliente corrompido já não impede a restauração dos restantes. Acréscimo: reforço de segurança no login HA.",
|
|
11
|
+
"nl": "De adapter start sneller bij veel displays en een enkele beschadigde client blokkeert niet langer het herstel van de andere. Daarnaast een beveiligingsverbetering bij de HA-login.",
|
|
12
|
+
"fr": "L'adaptateur démarre plus vite avec beaucoup d'écrans et une fiche client corrompue n'empêche plus la restauration des autres. Plus une amélioration de sécurité côté login HA.",
|
|
13
|
+
"it": "L'adapter si avvia più rapidamente con molti display e una singola voce client danneggiata non blocca più il ripristino delle altre. In più un miglioramento di sicurezza sul login HA.",
|
|
14
|
+
"es": "El adaptador arranca más rápido con muchas pantallas y un único cliente dañado ya no impide restaurar los demás. Además, un refuerzo de seguridad en el login HA.",
|
|
15
|
+
"pl": "Adapter uruchamia się szybciej przy wielu wyświetlaczach, a pojedynczy uszkodzony wpis klienta nie blokuje już przywracania pozostałych. Plus wzmocnienie bezpieczeństwa logowania HA.",
|
|
16
|
+
"uk": "Адаптер швидше стартує з багатьма дисплеями, а один пошкоджений запис клієнта більше не заважає відновленню інших. Плюс посилення безпеки входу HA.",
|
|
17
|
+
"zh-cn": "在拥有多个显示设备的安装中,适配器启动更快,单个损坏的客户端条目不再阻止其他客户端被恢复。此外还包含 HA 登录流程的安全加固,让密码限流响应更规范。"
|
|
18
|
+
},
|
|
6
19
|
"1.28.2": {
|
|
7
20
|
"en": "Adapter log messages are now English only, in line with the ioBroker community standard. Localized state names, descriptions and dropdown labels (11 languages) and the user-facing landing page remain unchanged.",
|
|
8
21
|
"de": "Adapter-Logs sind jetzt nur noch auf Englisch, gemäß ioBroker-Community-Standard. Lokalisierte Datenpunkt-Namen, Beschreibungen und Dropdown-Labels (11 Sprachen) sowie die User-facing Landing-Seite bleiben erhalten.",
|
|
@@ -80,19 +93,6 @@
|
|
|
80
93
|
"pl": "Bugfix: dropdown VIS-2 wyświetla teraz każdy projekt osobno przez ?<project> (Runtime.tsx:920-923 parsuje location.search). Przed v1.27.1 tylko aktywny projekt był dostępny.",
|
|
81
94
|
"uk": "Bugfix: dropdown VIS-2 тепер перелічує кожен проект окремо через ?<project> (Runtime.tsx:920-923 парсить location.search). До v1.27.1 був доступний лише активний проект.",
|
|
82
95
|
"zh-cn": "Bug 修复:VIS-2 下拉框现在通过 ?<project> 单独列出每个项目(Runtime.tsx:920-923 解析 location.search)。v1.27.1 之前仅能访问活动项目。"
|
|
83
|
-
},
|
|
84
|
-
"1.27.0": {
|
|
85
|
-
"en": "Bugfixes: VIS-2 URLs in dropdown now correct (vis-2/index.html#view). VIS-1 fully supported (?<project> + #view). info/warn/error logs in system language, debug stays English.",
|
|
86
|
-
"de": "Bugfixes: VIS-2-URLs im Dropdown jetzt korrekt (vis-2/index.html#view). VIS-1 voll unterstützt (?<projekt> + #view). info/warn/error-Logs in Systemsprache, debug bleibt englisch.",
|
|
87
|
-
"ru": "Bugfixes: URL VIS-2 в dropdown теперь корректные (vis-2/index.html#view). VIS-1 поддержан полностью (?<project> + #view). info/warn/error-логи на языке системы, debug остаётся английским.",
|
|
88
|
-
"pt": "Bugfixes: URLs VIS-2 no dropdown agora corretos (vis-2/index.html#view). VIS-1 totalmente suportado (?<project> + #view). Logs info/warn/error no idioma do sistema, debug permanece em inglês.",
|
|
89
|
-
"nl": "Bugfixes: VIS-2-URLs in dropdown nu correct (vis-2/index.html#view). VIS-1 volledig ondersteund (?<project> + #view). info/warn/error-logs in systeemtaal, debug blijft Engels.",
|
|
90
|
-
"fr": "Bugfixes : URL VIS-2 du dropdown correctes (vis-2/index.html#view). VIS-1 entièrement pris en charge (?<projet> + #view). Logs info/warn/error dans la langue système, debug reste en anglais.",
|
|
91
|
-
"it": "Bugfixes: URL VIS-2 nel dropdown ora corretti (vis-2/index.html#view). VIS-1 completamente supportato (?<progetto> + #view). Log info/warn/error nella lingua di sistema, debug resta in inglese.",
|
|
92
|
-
"es": "Bugfixes: URLs VIS-2 en el dropdown ahora correctas (vis-2/index.html#view). VIS-1 totalmente soportado (?<proyecto> + #view). Logs info/warn/error en el idioma del sistema, debug en inglés.",
|
|
93
|
-
"pl": "Bugfixy: URL-e VIS-2 w dropdown teraz poprawne (vis-2/index.html#view). VIS-1 w pełni obsługiwany (?<projekt> + #view). Logi info/warn/error w języku systemu, debug pozostaje angielski.",
|
|
94
|
-
"uk": "Bugfixes: URL VIS-2 у dropdown тепер коректні (vis-2/index.html#view). VIS-1 повністю підтримано (?<project> + #view). Логи info/warn/error мовою системи, debug залишається англійським.",
|
|
95
|
-
"zh-cn": "Bug 修复:VIS-2 下拉框 URL 现在正确(vis-2/index.html#view)。完整支持 VIS-1(?<project> + #view)。info/warn/error 日志使用系统语言,debug 保留英文。"
|
|
96
96
|
}
|
|
97
97
|
},
|
|
98
98
|
"titleLang": {
|
package/package.json
CHANGED