iobroker.hassemu 1.35.1 → 1.35.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -4
- package/build/lib/webserver.js +22 -13
- package/build/lib/webserver.js.map +2 -2
- package/build/main.js +49 -34
- package/build/main.js.map +2 -2
- package/io-package.json +14 -14
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -160,6 +160,11 @@ Got scripts that still write to `visUrl`? Update them — write to `manualUrl` i
|
|
|
160
160
|
Placeholder for the next version (at the beginning of the line):
|
|
161
161
|
### **WORK IN PROGRESS**
|
|
162
162
|
-->
|
|
163
|
+
### 1.35.2 (2026-06-12)
|
|
164
|
+
|
|
165
|
+
- Displays whose registration became stale after an adapter restart now re-register automatically — the server previously answered in a way the companion app did not recognize as "please register again"
|
|
166
|
+
- Removing a display now also clears its leftover app registration, so a re-added display starts with a fresh one
|
|
167
|
+
|
|
163
168
|
### 1.35.1 (2026-06-09)
|
|
164
169
|
|
|
165
170
|
- Internal cleanup. No user-facing changes.
|
|
@@ -176,10 +181,6 @@ Got scripts that still write to `visUrl`? Update them — write to `manualUrl` i
|
|
|
176
181
|
|
|
177
182
|
- Changelog rewritten in user-centric style across all versions.
|
|
178
183
|
|
|
179
|
-
### 1.33.1 (2026-05-23)
|
|
180
|
-
|
|
181
|
-
- Fixed incorrect role assignment on global mode selector.
|
|
182
|
-
|
|
183
184
|
[Older changelogs can be found there](CHANGELOG_OLD.md)
|
|
184
185
|
|
|
185
186
|
## Support Development
|
package/build/lib/webserver.js
CHANGED
|
@@ -59,7 +59,8 @@ class WebServer {
|
|
|
59
59
|
* Mobile-App webhook registrations from `POST /api/mobile_app/registrations`
|
|
60
60
|
* (v1.29.1). Key = webhookId (URL secret), Value = owning client cookie id.
|
|
61
61
|
* Subsequent `POST /api/webhook/<id>` requests are validated against this
|
|
62
|
-
* map. FIFO-capped at {@link WEBHOOK_REGISTRATIONS_CAP}
|
|
62
|
+
* map. FIFO-capped at {@link WEBHOOK_REGISTRATIONS_CAP}; entries whose
|
|
63
|
+
* owning client was removed are pruned in {@link cleanupSessions} (v1.35.2).
|
|
63
64
|
*
|
|
64
65
|
* Reused for Shelly Wall Display FW 2.6.0+ onboarding — the on-device HA
|
|
65
66
|
* Companion App requires this endpoint to complete device registration
|
|
@@ -68,16 +69,17 @@ class WebServer {
|
|
|
68
69
|
*
|
|
69
70
|
* **Design — in-memory only, by intent.** The map is NOT persisted across
|
|
70
71
|
* adapter restarts. Restart-recovery relies on the
|
|
71
|
-
* `POST /api/webhook/<unknown-id>` branch returning HTTP 200 with
|
|
72
|
-
*
|
|
73
|
-
* re-runs `
|
|
74
|
-
* webhookId. (Source: home-assistant/android
|
|
75
|
-
* IntegrationRepositoryImpl.kt:
|
|
76
|
-
*
|
|
72
|
+
* `POST /api/webhook/<unknown-id>` branch returning HTTP 200 with a
|
|
73
|
+
* truly EMPTY body — the HA Companion App reads that as a stale webhook
|
|
74
|
+
* and re-runs `registerDevice`, which on hassemu issues a fresh
|
|
75
|
+
* webhookId. (Source, verified at tag 2026.4.4: home-assistant/android
|
|
76
|
+
* IntegrationRepositoryImpl.kt:167-171 — the trigger is
|
|
77
|
+
* `response.code() == 200 && response.body()?.contentLength() == 0L`.)
|
|
77
78
|
*
|
|
78
79
|
* If a future refactor changes the unknown-webhookId response from
|
|
79
|
-
* `200 empty` to `404
|
|
80
|
-
*
|
|
80
|
+
* `200 empty` to `404` or to any non-empty body (even JSON `null`),
|
|
81
|
+
* displays will silently break across adapter restarts. Keep that
|
|
82
|
+
* response shape OR add real persistence here.
|
|
81
83
|
*/
|
|
82
84
|
webhookRegistrations = /* @__PURE__ */ new Map();
|
|
83
85
|
/**
|
|
@@ -202,6 +204,16 @@ class WebServer {
|
|
|
202
204
|
if (prunedTargets > 0) {
|
|
203
205
|
this.adapter.log.debug(`Cleanup: pruned ${prunedTargets} stale redirect-target entries`);
|
|
204
206
|
}
|
|
207
|
+
let prunedWebhooks = 0;
|
|
208
|
+
for (const [webhookId, ownerId] of this.webhookRegistrations) {
|
|
209
|
+
if (ownerId !== "" && !activeClients.has(ownerId)) {
|
|
210
|
+
this.webhookRegistrations.delete(webhookId);
|
|
211
|
+
prunedWebhooks++;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (prunedWebhooks > 0) {
|
|
215
|
+
this.adapter.log.debug(`Cleanup: pruned ${prunedWebhooks} webhook registrations of removed clients`);
|
|
216
|
+
}
|
|
205
217
|
}
|
|
206
218
|
/**
|
|
207
219
|
* Cooldown-Decision für 5xx-Error-Logging. Liefert `true` für die erste
|
|
@@ -255,8 +267,6 @@ class WebServer {
|
|
|
255
267
|
} else {
|
|
256
268
|
const reason = cookie ? "cookie-stale (unknown)" : "no-cookie";
|
|
257
269
|
this.adapter.log.debug(`identify: ${reason}, new client=${record.id} ip=${ip != null ? ip : "?"}`);
|
|
258
|
-
}
|
|
259
|
-
if (cookie !== record.cookie) {
|
|
260
270
|
const useSecure = req.protocol === "https";
|
|
261
271
|
this.adapter.log.debug(`identify: setting cookie secure=${useSecure} (req.protocol=${req.protocol})`);
|
|
262
272
|
reply.setCookie(CLIENT_COOKIE, record.cookie, {
|
|
@@ -467,8 +477,7 @@ class WebServer {
|
|
|
467
477
|
this.adapter.log.debug(
|
|
468
478
|
`Webhook fallthrough: stale id=${id.substring(0, 8)}\u2026 \u2014 App will trigger re-registration`
|
|
469
479
|
);
|
|
470
|
-
reply.status(200);
|
|
471
|
-
return null;
|
|
480
|
+
return reply.status(200).send();
|
|
472
481
|
}
|
|
473
482
|
const body = (_a = req.body) != null ? _a : {};
|
|
474
483
|
const type = typeof body.type === "string" ? body.type : "";
|
|
@@ -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 fastifyWebsocket from \"@fastify/websocket\";\nimport Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from \"fastify\";\nimport type { WebSocket } from \"ws\";\nimport {\n HA_VERSION,\n SESSION_TTL_MS,\n CLEANUP_INTERVAL_MS,\n LOGIN_SCHEMA,\n OAUTH_ACCESS_TOKEN_TTL_S,\n SESSIONS_CAP,\n WEBHOOK_REGISTRATIONS_CAP,\n REQUEST_ERROR_COOLDOWN_MS,\n REQUEST_ERROR_COOLDOWN_CAP,\n COOKIE_MAX_AGE_S,\n WS_AUTH_TIMEOUT_MS,\n} from \"./constants\";\nimport { coerceString, coerceUuid, evictOldest, isValidRedirectUri, safeStringEqual } from \"./coerce\";\nimport { buildRedirectUrl, renderAuthorizeError, renderAuthorizeForm, renderAuthorizeRedirect } from \"./auth-page\";\nimport type { ClientRegistry } from \"./client-registry\";\nimport type { GlobalConfig } from \"./global-config\";\nimport { renderLandingPage } from \"./landing-page\";\nimport { getLocalIp, isWildcardBind } from \"./network\";\nimport { renderRedirectWrapper } from \"./redirect-wrapper\";\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// v1.32.0: `renderRedirectWrapper` ist nach `lib/redirect-wrapper.ts` ausgelagert\n// f\u00FCr Symmetrie zu `landing-page.ts` / `auth-page.ts`. `evictOldest` ist shared\n// helper aus `coerce.ts`.\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 * Light-my-request injection surface \u2014 exposed read-only as a TEST-ONLY seam so\n * unit tests can drive routes without opening a real socket. Not used in production.\n */\nexport type WebserverInject = FastifyInstance[\"inject\"];\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 * HA mobile_app registration response shape (home-assistant/android\n * RegisterDeviceResponse.kt): `webhookId` required, the cloud/remote/secret\n * fields null (no Nabu Casa cloud, the webhookId itself is the secret). Used\n * by the registration POST, the PUT update and the webhook `update_registration`.\n *\n * @param webhookId The issued webhook id (URL secret) to echo back to the App.\n */\nfunction mobileRegResponse(webhookId: string): {\n webhook_id: string;\n cloudhook_url: null;\n remote_ui_url: null;\n secret: null;\n} {\n return { webhook_id: webhookId, cloudhook_url: null, remote_ui_url: null, secret: null };\n}\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 * Mobile-App webhook registrations from `POST /api/mobile_app/registrations`\n * (v1.29.1). Key = webhookId (URL secret), Value = owning client cookie id.\n * Subsequent `POST /api/webhook/<id>` requests are validated against this\n * map. FIFO-capped at {@link WEBHOOK_REGISTRATIONS_CAP}.\n *\n * Reused for Shelly Wall Display FW 2.6.0+ onboarding \u2014 the on-device HA\n * Companion App requires this endpoint to complete device registration\n * after the OAuth2 sign-in. Without it the App refuses to proceed with a\n * \"Mobile-App-Integration nicht verf\u00FCgbar\" error.\n *\n * **Design \u2014 in-memory only, by intent.** The map is NOT persisted across\n * adapter restarts. Restart-recovery relies on the\n * `POST /api/webhook/<unknown-id>` branch returning HTTP 200 with an\n * empty body \u2014 the HA Companion App reads that as a stale webhook and\n * re-runs `update_registration`, which on hassemu issues a fresh\n * webhookId. (Source: home-assistant/android\n * IntegrationRepositoryImpl.kt:170 \u2014 `200 with empty body triggers\n * maybeReregisterDeviceOnFailedUpdate`.)\n *\n * If a future refactor changes the unknown-webhookId response from\n * `200 empty` to `404`, displays will silently break across adapter\n * restarts. Keep that response shape OR add real persistence here.\n */\n public readonly webhookRegistrations: Map<string, string> = new Map();\n /**\n * v1.32.0 F1: last redirect-target seen per client by `/api/redirect_check`.\n * Used to log only-on-change (instead of every 30s poll). Pruned in\n * {@link cleanupSessions} against `registry.listAll()` \u2014 stale entries from\n * removed clients are dropped within max 5 min.\n */\n private readonly lastRedirectTargetByClient: Map<string, string | null> = new Map();\n private cleanupTimer: ioBroker.Interval | null = null;\n /**\n * Test-only injection surface ({@link WebserverInject}). v1.14.0 (H8): bound\n * once in the constructor instead of via a getter \u2014 a getter allocated a new\n * bound function on every `s.inject({...})` call, and tests call it in loops.\n */\n public readonly inject!: WebserverInject;\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 seine sichtbare IP f\u00E4lschen \u2192 verf\u00E4lscht\n // Logs + die per-IP-Burst-Erkennung defekter Cookies).\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: WebserverInject }).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 // v1.34.0: minimal read-only WebSocket for the HA Companion App. Its\n // `registerDevice` makes a best-effort `auth/current_user` WS call after the\n // REST registration; without a WS endpoint that fails (and the username is\n // not stored). Registered before the routes so `{ websocket: true }` works.\n await this.app.register(fastifyWebsocket);\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 redirect-target entries. */\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\n // v1.32.0 F1: prune lastRedirectTargetByClient against currently known\n // clients. A removed client leaves a stale entry that would never get\n // cleared otherwise \u2014 bounded growth over months.\n const activeClients = new Set(this.registry.listAll().map(r => r.id));\n let prunedTargets = 0;\n for (const clientId of this.lastRedirectTargetByClient.keys()) {\n if (!activeClients.has(clientId)) {\n this.lastRedirectTargetByClient.delete(clientId);\n prunedTargets++;\n }\n }\n if (prunedTargets > 0) {\n this.adapter.log.debug(`Cleanup: pruned ${prunedTargets} stale redirect-target entries`);\n }\n }\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 evictOldest(this.errorLogCooldown, REQUEST_ERROR_COOLDOWN_CAP);\n }\n this.errorLogCooldown.set(key, now);\n return true;\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 evictOldest(this.sessions, SESSIONS_CAP);\n this.sessions.set(key, data);\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 // v1.32.0 A1: cookie-state explizit traced. Drei Branches:\n // hit \u2014 cookie matched a known client, no setCookie needed\n // stale/new \u2014 cookie present but unknown, OR no cookie at all \u2192 new client created\n if (cookie === record.cookie) {\n this.adapter.log.debug(`identify: cookie-hit client=${record.id} ip=${ip ?? \"?\"}`);\n } else {\n const reason = cookie ? \"cookie-stale (unknown)\" : \"no-cookie\";\n this.adapter.log.debug(`identify: ${reason}, new client=${record.id} ip=${ip ?? \"?\"}`);\n }\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 // v1.32.0 A2: Cookie-Secure-Decision tracen \u2014 wenn trustProxy-config\n // falsch ist, kriegt Display den Cookie evtl. nie zur\u00FCck.\n this.adapter.log.debug(`identify: setting cookie secure=${useSecure} (req.protocol=${req.protocol})`);\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 // v1.34.0: adapter-managed Timer (cancelt automatisch bei onUnload) + clear\n // sobald `dns.reverse` das Race gewinnt \u2014 sonst dangelt der Timer bis 5s\n // \u00FCber einen Restart hinaus (W5005).\n let timeoutHandle: ioBroker.Timeout | undefined;\n const timeout = new Promise<string[]>((_, reject) => {\n timeoutHandle = this.adapter.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 // v1.32.0 A4: Success-Trace \u2014 bei Diagnose \u201Ewarum hat Display\n // X den hostname Y?\" ist die IP\u2192hostname-Aufl\u00F6sung der Anker.\n this.adapter.log.debug(`resolveHostname: ip=${ip} \u2192 hostname=${name}`);\n this.registry.identifyOrCreate(record.cookie, ip, name).catch(() => {\n /* registry itself logs */\n });\n }\n })\n .catch(err => {\n // v1.32.0 A3: vorher silent. Reverse DNS scheitert auf LAN oft\n // legitim \u2014 daher debug-only (kein warn-Spam), aber jetzt mit\n // Diagnose-Anker f\u00FCr \u201Ehostname fehlt\"-Reports.\n this.adapter.log.debug(\n `resolveHostname: ip=${ip} failed \u2014 ${err instanceof Error ? err.message : String(err)}`,\n );\n })\n .finally(() => {\n if (timeoutHandle) {\n this.adapter.clearTimeout(timeoutHandle);\n }\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 // v1.34.0: the WebSocket does its own auth in the handshake\n // (`auth_required` \u2192 `auth` frame), not via a Bearer header \u2014 so the\n // HTTP upgrade itself must pass the guard.\n path === \"/api/websocket\" ||\n // v1.29.1: Mobile-App webhooks carry the secret in the URL\n // (`webhookId`) \u2014 HA core also serves these unauthenticated.\n // Source: home-assistant/core/.../mobile_app/webhook.py.\n path.startsWith(\"/api/webhook/\")\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.setupWebSocket();\n this.setupMiscRoutes();\n this.setupNotFound();\n }\n\n /**\n * HA `/api/config`-shaped object. Single source for REST `/api/config`, the\n * Companion webhook `get_config` and the WebSocket `get_config` command.\n * `mobile_app` in `components` advertises the integration the HA Companion App\n * probes during onboarding (v1.29.1, Shelly FW 2.6.0+).\n */\n private buildHaConfig(): Record<string, unknown> {\n return {\n components: [\"http\", \"api\", \"frontend\", \"homeassistant\", \"mobile_app\"],\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\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\", () => this.buildHaConfig());\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 // ---- Mobile-App integration (HA Companion + Shelly FW 2.6.0+) ----\n //\n // Source: home-assistant/android IntegrationRepositoryImpl.kt:120-159\n // calls POST /api/mobile_app/registrations after the OAuth2 sign-in.\n // A 404 here surfaces as \u201EMobile-App-Integration nicht verf\u00FCgbar\" in\n // the App's onboarding screen and blocks the display from finishing\n // setup. Detail in Ressourcen/hassemu/oauth2-browser-flow-shelly-fw26.md.\n //\n // The Bearer-token check is already done by the existing auth\n // pre-handler \u2014 `/api/mobile_app/registrations` is protected by\n // default, so by the time the handler runs we know the caller has\n // a valid access_token from /auth/token.\n this.app.post<{\n Body: {\n app_id?: string;\n app_name?: string;\n device_name?: string;\n device_id?: string;\n manufacturer?: string;\n model?: string;\n os_name?: string;\n os_version?: string;\n };\n }>(\"/api/mobile_app/registrations\", async (req, reply) => {\n const body = req.body ?? {};\n // Identify by Bearer token \u2014 the pre-handler already validated it.\n const authHeader = (req.headers.authorization as string) ?? \"\";\n const token = authHeader.startsWith(\"Bearer \") ? authHeader.substring(7).trim() : \"\";\n const client = this.registry.getByToken(token);\n const ownerId = client?.id ?? \"\";\n\n const webhookId = crypto.randomUUID().replace(/-/g, \"\");\n evictOldest(this.webhookRegistrations, WEBHOOK_REGISTRATIONS_CAP);\n this.webhookRegistrations.set(webhookId, ownerId);\n\n this.adapter.log.debug(\n `Mobile-App registration \u2014 client=${ownerId} app_id=${body.app_id ?? \"?\"} device_name=${body.device_name ?? \"?\"} \u2192 webhook=${webhookId}`,\n );\n\n reply.status(201);\n return mobileRegResponse(webhookId);\n });\n\n // PUT and DELETE on /api/mobile_app/registrations/:webhookId \u2014 the App\n // calls PUT to update its registration on token refresh or sensor\n // re-register. PUT echoes the registration for a KNOWN webhookId (200), but\n // returns 404 for an unknown one so a stale Pre-Restart token re-registers;\n // DELETE drops the registration and returns 204.\n this.app.put<{ Params: { webhookId: string } }>(\"/api/mobile_app/registrations/:webhookId\", async (req, reply) => {\n const id = req.params.webhookId;\n if (!this.webhookRegistrations.has(id)) {\n // v1.32.0 E1: stale-id signaliert dass Companion einen Token\n // aus Pre-Restart-Era hat \u2014 diagnostisch wertvoll f\u00FCr\n // re-registration-loop-Bugs.\n this.adapter.log.debug(`Mobile-App PUT registration: unknown webhookId=${id.substring(0, 8)}\u2026 \u2014 returning 404`);\n reply.status(404);\n return { error: \"unknown_registration\" };\n }\n return mobileRegResponse(id);\n });\n\n this.app.delete<{ Params: { webhookId: string } }>(\n \"/api/mobile_app/registrations/:webhookId\",\n async (req, reply) => {\n const id = req.params.webhookId;\n const wasPresent = this.webhookRegistrations.has(id);\n this.webhookRegistrations.delete(id);\n // v1.32.0 E2: Companion-Maintenance-Trace.\n this.adapter.log.debug(\n `Mobile-App DELETE registration: webhookId=${id.substring(0, 8)}\u2026 removed (was-present=${wasPresent})`,\n );\n reply.status(204);\n return null;\n },\n );\n\n // POST /api/webhook/:webhookId \u2014 Companion-App sensor updates,\n // location pings, registration updates etc. Public by design (URL\n // contains the webhookId secret). HA core dispatches on `type` field\n // in the JSON body and returns shape per type. For hassemu we accept\n // any payload and respond with the minimal-correct success per type;\n // the display use-case doesn't need actual state propagation, but\n // returning 200 prevents the App from re-trying in a loop and\n // surfacing onboarding-failure banners.\n this.app.post<{\n Params: { webhookId: string };\n Body: { type?: string; data?: unknown };\n }>(\"/api/webhook/:webhookId\", async (req, reply) => {\n const id = req.params.webhookId;\n if (!this.webhookRegistrations.has(id)) {\n // Unknown webhookId \u2014 match HA's 200-empty for stale webhooks\n // so the App falls back to `update_registration` (which on\n // hassemu re-issues a new registration). Source:\n // home-assistant/android IntegrationRepositoryImpl.kt:170 \u2014\n // 200 with empty body triggers `maybeReregisterDeviceOnFailedUpdate`.\n // v1.32.0 E3: stale-id ist DAS Symptom f\u00FCr re-registration-loop \u2014\n // Companion macht webhook-call mit Token aus Pre-Restart-Era.\n this.adapter.log.debug(\n `Webhook fallthrough: stale id=${id.substring(0, 8)}\u2026 \u2014 App will trigger re-registration`,\n );\n reply.status(200);\n return null;\n }\n const body = req.body ?? {};\n const type = typeof body.type === \"string\" ? body.type : \"\";\n this.adapter.log.debug(`Webhook ${id.substring(0, 8)}\u2026 type=${type || \"(no type)\"}`);\n\n switch (type) {\n case \"get_config\":\n return this.buildHaConfig();\n case \"get_zones\":\n return [];\n case \"render_template\":\n return {};\n case \"update_registration\":\n return mobileRegResponse(id);\n case \"register_sensor\":\n return { success: true };\n case \"update_sensor_states\":\n return {};\n default:\n // Generic success for unknown types \u2014 fire_event,\n // call_service, conversation_process, update_location,\n // get_zones-with-data, etc. The display doesn't need\n // their semantics, just an HTTP 200 acknowledgement.\n return {};\n }\n });\n }\n\n /**\n * Issue a fresh authorization code and persist it in the sessions map.\n *\n * Single source for both the JSON login flow (`/auth/login_flow/<flowId>`\n * \u2192 `create_entry`) and the browser OAuth2 flow (`/auth/authorize` \u2192\n * 302). The code is exchanged for tokens at `/auth/token` (`grant_type =\n * authorization_code`); the existing token-view consumes the same map.\n *\n * @param clientId Identity cookie value of the requesting display, or\n * undefined for headless OAuth2-only flows.\n */\n private issueAuthorizationCode(clientId: string | null): string {\n const code = crypto.randomUUID();\n this.storeSession(code, { created: Date.now(), clientId });\n return code;\n }\n\n /**\n * Shared validation for GET and POST `/auth/authorize`. On failure it sets the\n * `400 text/html` reply and returns the rendered error page; on success it\n * returns the validated (string-typed) `client_id` / `redirect_uri`. Never\n * redirects on failure \u2014 the endpoint must not become an open redirector.\n *\n * @param reply Fastify reply (status + content-type set on failure).\n * @param method `\"GET\"` or `\"POST\"` \u2014 only used to label the debug log.\n * @param responseType The OAuth2 `response_type` (must be `\"code\"`).\n * @param clientId The OAuth2 `client_id` (must be a string).\n * @param redirectUri The OAuth2 `redirect_uri` (must be a string + allowlisted).\n */\n private validateAuthorizeRequest(\n reply: FastifyReply,\n method: \"GET\" | \"POST\",\n responseType: unknown,\n clientId: unknown,\n redirectUri: unknown,\n ): { ok: true; clientId: string; redirectUri: string } | { ok: false; html: string } {\n if (responseType !== \"code\") {\n this.adapter.log.debug(`Authorize ${method} rejected: response_type=${String(responseType)} (expected 'code')`);\n reply.status(400).type(\"text/html\");\n return {\n ok: false,\n html: renderAuthorizeError(\n \"unsupported_response_type\",\n \"This authorization server supports `response_type=code` only.\",\n ),\n };\n }\n if (typeof clientId !== \"string\" || typeof redirectUri !== \"string\") {\n this.adapter.log.debug(\n `Authorize ${method} rejected: missing client_id or redirect_uri (cid=${typeof clientId}, ru=${typeof redirectUri})`,\n );\n reply.status(400).type(\"text/html\");\n return {\n ok: false,\n html: renderAuthorizeError(\"invalid_request\", \"Missing or invalid `client_id` or `redirect_uri` parameter.\"),\n };\n }\n if (!isValidRedirectUri(clientId, redirectUri)) {\n this.adapter.log.debug(\n `Authorize ${method} rejected: redirect_uri \"${redirectUri}\" not allowed for client_id \"${clientId}\"`,\n );\n reply.status(400).type(\"text/html\");\n return {\n ok: false,\n html: renderAuthorizeError(\n \"invalid_redirect_uri\",\n \"The `redirect_uri` parameter is not on the allowlist for this client.\",\n ),\n };\n }\n return { ok: true, clientId, redirectUri };\n }\n\n /**\n * Issue an auth code, build the redirect target and render the auto-submit redirect page.\n *\n * @param reply Fastify reply (content-type set to text/html).\n * @param clientId Identity of the requesting display, or null for headless flows.\n * @param redirectUri Already-validated `redirect_uri` to append the code to.\n * @param state Optional OAuth2 `state` round-tripped verbatim.\n */\n private issueAuthorizeRedirect(\n reply: FastifyReply,\n clientId: string | null,\n redirectUri: string,\n state: string | undefined,\n ): string {\n const code = this.issueAuthorizationCode(clientId);\n const target = buildRedirectUrl(redirectUri, code, state);\n reply.type(\"text/html\");\n return renderAuthorizeRedirect(target);\n }\n\n /**\n * Best-effort token revocation, shared by `POST /auth/revoke` (HA \u22652022.9)\n * and the legacy `POST /auth/token` with `action=revoke`. The HA Companion\n * sends the refresh token; we look it up and clear both the refresh and the\n * access token of the owning client. Always succeeds from the caller's view \u2014\n * an unknown/missing token still yields 200 (matches HA, which never leaks\n * whether a token existed). Source: AuthenticationRepositoryImpl.revokeSession.\n *\n * @param token Refresh token to revoke (from the `token` form field).\n */\n private async revokeToken(token: string | undefined): Promise<void> {\n const refresh = typeof token === \"string\" ? token : \"\";\n const owner = refresh ? this.registry.getByRefreshToken(refresh) : null;\n if (owner) {\n await this.registry.setRefreshToken(owner.id, null);\n await this.registry.setToken(owner.id, null);\n this.adapter.log.debug(`Token revoked \u2014 client ${owner.id}`);\n } else {\n this.adapter.log.debug(\"Revoke: unknown/missing token \u2014 returning 200 (HA behavior)\");\n }\n }\n\n private setupAuthRoutes(): void {\n this.app.get(\"/auth/providers\", () => [{ name: \"Home Assistant Local\", type: \"homeassistant\", id: null }]);\n\n // Browser-OAuth2 flow at GET/POST /auth/authorize. Needed by the\n // HA Companion Android App (Shelly Wall Display FW 2.6.0+ embeds\n // the Companion App). Source-verified flow:\n // home-assistant/android UrlUtil.kt:buildAuthenticationUrl\n // home-assistant/core indieauth.py:verify_redirect_uri\n // home-assistant/frontend src/data/auth.ts:redirectWithAuthCode\n // Detail: Ressourcen/hassemu/oauth2-browser-flow-shelly-fw26.md\n this.app.get<{\n Querystring: { response_type?: string; client_id?: string; redirect_uri?: string; state?: string };\n }>(\"/auth/authorize\", async (req, reply) => {\n const { response_type, client_id, redirect_uri, state } = req.query ?? {};\n\n // v1.32.0 D2: rejection-Pfade traced \u2014 Triage \u201Ewarum bricht OAuth ab\"\n const v = this.validateAuthorizeRequest(reply, \"GET\", response_type, client_id, redirect_uri);\n if (!v.ok) {\n return v.html;\n }\n\n const client = await this.identify(req, reply);\n\n // No auth required \u2192 issue the code right away and redirect.\n if (!this.config.authRequired) {\n this.adapter.log.debug(`Authorize auto-grant \u2014 client ${client.id}`);\n return this.issueAuthorizeRedirect(reply, client.id, v.redirectUri, state);\n }\n\n // v1.32.0 D1: Form-render Trace \u2014 wenn Companion die Form nie absendet,\n // sieht User hier dass sie \u00FCberhaupt gerendert wurde.\n let redirectHost = \"?\";\n try {\n redirectHost = new URL(v.redirectUri).host || v.redirectUri;\n } catch {\n redirectHost = v.redirectUri;\n }\n this.adapter.log.debug(`Authorize form rendered \u2014 client_id=${v.clientId} redirect_uri-host=${redirectHost}`);\n reply.type(\"text/html\");\n return renderAuthorizeForm({ clientId: v.clientId, redirectUri: v.redirectUri, state });\n });\n\n this.app.post<{\n Body: {\n response_type?: string;\n client_id?: string;\n redirect_uri?: string;\n state?: string;\n username?: string;\n password?: string;\n };\n }>(\"/auth/authorize\", async (req, reply) => {\n const { response_type, client_id, redirect_uri, state, username, password } = req.body ?? {};\n\n const v = this.validateAuthorizeRequest(reply, \"POST\", response_type, client_id, redirect_uri);\n if (!v.ok) {\n return v.html;\n }\n\n const client = await this.identify(req, reply);\n\n // No auth required \u2192 straight to redirect even on POST.\n if (!this.config.authRequired) {\n return this.issueAuthorizeRedirect(reply, client.id, v.redirectUri, state);\n }\n\n const ip = WebServer.getClientIp(req);\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 const ipSuffix = ip ? ` (IP ${ip})` : \"\";\n this.adapter.log.warn(`Invalid credentials${ipSuffix}`);\n reply.status(401).type(\"text/html\");\n return renderAuthorizeForm(\n { clientId: v.clientId, redirectUri: v.redirectUri, state },\n \"Invalid username or password.\",\n );\n }\n\n this.adapter.log.debug(`Authorize grant \u2014 client ${client.id}`);\n return this.issueAuthorizeRedirect(reply, client.id, v.redirectUri, state);\n });\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 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 const ipSuffix = ip ? ` (IP ${ip})` : \"\";\n this.adapter.log.warn(`Invalid credentials${ipSuffix}`);\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 }\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 // HA \u22652022.9 logout: POST /auth/revoke with form field `token` (the refresh\n // token). Always 200 with empty body. Whitelisted by the `/auth/` prefix in\n // the auth guard. Source: AuthenticationRepositoryImpl.revokeSession.\n this.app.post<{ Body: { token?: string } }>(\"/auth/revoke\", async req => {\n await this.revokeToken(req.body?.token);\n return {};\n });\n\n this.app.post<{\n Body: { code?: string; grant_type?: string; refresh_token?: string; action?: string; token?: string };\n }>(\"/auth/token\", async (req, reply) => {\n const { code, grant_type, refresh_token, action } = req.body ?? {};\n\n // Legacy logout (HA <2022.9): POST /auth/token with action=revoke + token.\n // Newer apps use /auth/revoke; we accept both so a 400 never surfaces.\n if (action === \"revoke\") {\n await this.revokeToken(req.body?.token ?? refresh_token);\n return {};\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 // Persist VOR Response-Build: ein Crash zwischen Issue + Persist\n // w\u00FCrde sonst dem Client einen Token in der Hand lassen, den der\n // Server nicht kennt \u2014 beim ersten Refresh dann invalid_grant.\n await this.registry.setToken(session.clientId, token);\n await this.registry.setRefreshToken(session.clientId, refreshToken);\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 ownerRecord = incoming ? this.registry.getByRefreshToken(incoming) : null;\n if (!ownerRecord) {\n this.adapter.log.debug(\"Refresh token rejected \u2014 unknown or missing\");\n reply.status(400);\n return { error: \"invalid_grant\", error_description: \"Invalid refresh token\" };\n }\n // v1.31.0: refresh_token bleibt valid (NICHT mehr rotated). HA Core\n // selbst (homeassistant/components/auth/__init__.py:334-348) liefert\n // beim refresh-grant nie einen neuen refresh_token, nur access_token\n // + token_type + expires_in. HA Android Companion\n // (AuthenticationRepositoryImpl.kt:147) speichert beim Refresh den\n // GESENDETEN refresh_token (Function-Parameter), ignoriert den in der\n // Response zur\u00FCckgegebenen \u2014 Companion beh\u00E4lt daher immer ihren\n // initialen refresh_token. v1.28.3 (HW5) Rotation war RFC 6819\n // \u00A75.2.2.3-konform aber inkompatibel mit dem Companion-Datenmodell:\n // Server-Rotation killte den Companion-Token beim ersten Refresh.\n const newAccess = crypto.randomUUID();\n await this.registry.setToken(ownerRecord.id, newAccess);\n this.adapter.log.debug(`Refresh-token-grant \u2014 client=${ownerRecord.id} new access_token issued`);\n return {\n access_token: newAccess,\n token_type: \"Bearer\",\n refresh_token: incoming,\n expires_in: OAUTH_ACCESS_TOKEN_TTL_S,\n };\n }\n\n // \u201Ewrong grant_type\" ist ein Client-Format-Fehler, kein Server-Concern\n // \u2014 daher nur debug (legitime Client-Bugs sollen das Log nicht fluten).\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 * Minimal read-only HA WebSocket at `/api/websocket`. The HA Companion App's\n * `registerDevice` makes a best-effort `auth/current_user` WS call after the\n * REST registration to store the username (home-assistant/android\n * IntegrationRepositoryImpl.kt at tag 2026.4.4, line 154). Without a WS\n * endpoint that throws and the registration logs \"Unable to save device registration\".\n *\n * Auth happens in-band: server sends `auth_required`, client replies with an\n * `auth` frame, we validate the access token against the registry. FAIL-FAST:\n * a missing/invalid token or a missing `auth` frame within\n * {@link WS_AUTH_TIMEOUT_MS} closes the socket \u2014 so the WS never hangs the\n * App's call (which previously failed fast against a clean 404).\n */\n private setupWebSocket(): void {\n this.app.get(\"/api/websocket\", { websocket: true }, (socket: WebSocket) => {\n let authed = false;\n let authTimer: ioBroker.Timeout | undefined = this.adapter.setTimeout(() => {\n if (!authed) {\n this.adapter.log.debug(\"WS: no auth frame within timeout \u2014 closing\");\n this.wsSend(socket, { type: \"auth_invalid\", message: \"Authentication timed out\" });\n socket.close();\n }\n }, WS_AUTH_TIMEOUT_MS);\n\n this.wsSend(socket, { type: \"auth_required\", ha_version: HA_VERSION });\n\n socket.on(\"message\", raw => {\n // ws delivers text frames as Buffer by default; normalize every RawData\n // variant to a UTF-8 string (avoids Object's default stringification).\n const text = Buffer.isBuffer(raw)\n ? raw.toString(\"utf8\")\n : Array.isArray(raw)\n ? Buffer.concat(raw).toString(\"utf8\")\n : Buffer.from(raw).toString(\"utf8\");\n let msg: Record<string, unknown>;\n try {\n msg = JSON.parse(text) as Record<string, unknown>;\n } catch {\n return; // ignore non-JSON frames\n }\n if (!authed) {\n const token = typeof msg.access_token === \"string\" ? msg.access_token : \"\";\n if (msg.type === \"auth\" && token && this.registry.getByToken(token)) {\n authed = true;\n if (authTimer) {\n this.adapter.clearTimeout(authTimer);\n authTimer = undefined;\n }\n this.wsSend(socket, { type: \"auth_ok\", ha_version: HA_VERSION });\n } else {\n this.adapter.log.debug(\"WS: auth_invalid \u2014 unknown or missing access token\");\n this.wsSend(socket, { type: \"auth_invalid\", message: \"Invalid access token\" });\n socket.close();\n }\n return;\n }\n this.handleWsCommand(socket, msg);\n });\n\n socket.on(\"error\", () => {\n // Client vanished mid-stream \u2014 the socket is gone; the auth timer is\n // cleared by the close handler below.\n });\n\n socket.on(\"close\", () => {\n // Clear the auth timer if the client disconnects before authenticating,\n // so it never fires against an already-closed socket.\n if (authTimer) {\n this.adapter.clearTimeout(authTimer);\n authTimer = undefined;\n }\n });\n });\n }\n\n /**\n * Safely serialize + send a WS frame; swallows errors from an already-closed socket.\n *\n * @param socket The client WebSocket to write to.\n * @param payload Plain object serialized to a JSON text frame.\n */\n private wsSend(socket: WebSocket, payload: Record<string, unknown>): void {\n try {\n socket.send(JSON.stringify(payload));\n } catch {\n /* socket closing/closed \u2014 drop the frame */\n }\n }\n\n /**\n * Handle one authenticated WS command. hassemu emulates an empty-but-valid HA\n * server with only the components it advertises (http/api/frontend/\n * homeassistant/mobile_app). Responses use only shapes that are either\n * source-verified or trivially correct for an empty server:\n * - data queries \u2192 correct empty shape ([] / {}),\n * - subscriptions \u2192 ack that never emits (no entities/events on a shim),\n * - everything hassemu does NOT implement (call_service on a service-less\n * server, conversation, Matter/Thread, assist_pipeline, \u2026) \u2192 `unknown_command`,\n * which is exactly what real HA returns for an unregistered command type.\n *\n * The command SET is verified against home-assistant/android\n * WebSocketRepositoryImpl at tag 2026.4.4; the error code against\n * home-assistant/core websocket_api/const.py at tag 2026.4.0 (ERR_UNKNOWN_COMMAND).\n * No speculative response shapes are emitted.\n *\n * @param socket The authenticated client WebSocket.\n * @param msg The parsed incoming command frame (`{ id, type, ... }`).\n */\n private handleWsCommand(socket: WebSocket, msg: Record<string, unknown>): void {\n const id = msg.id;\n const type = typeof msg.type === \"string\" ? msg.type : \"\";\n const result = (r: unknown): void => this.wsSend(socket, { id, type: \"result\", success: true, result: r });\n switch (type) {\n case \"ping\":\n this.wsSend(socket, { id, type: \"pong\" });\n return;\n case \"auth/current_user\":\n // CurrentUserResponse.kt @2026.4.4: { id, name, isOwner, isAdmin } \u2014\n // the HA wire format is snake_case (is_owner / is_admin).\n result({\n id: this.instanceUuid,\n name: this.config.username || this.serviceName,\n is_owner: true,\n is_admin: true,\n });\n return;\n case \"get_config\":\n result(this.buildHaConfig());\n return;\n case \"get_states\":\n result([]);\n return;\n case \"get_services\":\n result({});\n return;\n // Registries on an entity-less emulated server \u2192 empty lists.\n case \"config/area_registry/list\":\n case \"config/device_registry/list\":\n case \"config/entity_registry/list\":\n result([]);\n return;\n // Valid subscriptions on an empty server \u2014 they ack but never emit.\n // mobile_app/* is an advertised component, so both its WS commands ack\n // consistently (the channel subscribe + the confirm).\n case \"subscribe_events\":\n case \"subscribe_entities\":\n case \"supported_features\":\n case \"mobile_app/push_notification_channel\":\n case \"mobile_app/push_notification_confirm\":\n result(null);\n return;\n default:\n // hassemu doesn't implement this command (call_service has no services;\n // conversation / matter / thread / assist_pipeline are integrations it\n // doesn't advertise). Real HA returns ERR_UNKNOWN_COMMAND for an\n // unregistered command type \u2014 a reply (no hang), honest (no fake success),\n // and grounded (no guessed response shape).\n this.wsSend(socket, {\n id,\n type: \"result\",\n success: false,\n error: { code: \"unknown_command\", message: `Command \"${type}\" is not supported by this server` },\n });\n return;\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` MUST be \"Home Assistant\" exactly \u2014 the HA Companion App\n // verifies the server identity by parsing this field. Source:\n // home-assistant/android DefaultConnectivityChecker.kt:isHomeAssistant\n // checks `name === \"Home Assistant\"`. Anything else (e.g. `serviceName`\n // = \"ioBroker\") fails the onboarding probe with \"Server ist nicht\n // Home Assistant\". Detail in Ressourcen/hassemu/oauth2-browser-flow-shelly-fw26.md.\n name: \"Home Assistant\",\n short_name: \"Home Assistant\",\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 // v1.32.0 B1: Resolver-Chain als Triage-Anker. Ohne Chain musste der\n // Maintainer den Resolver-Code lesen um zu verstehen warum genau\n // diese URL f\u00FCr diesen Client gew\u00E4hlt wurde.\n const { url, chain } = this.globalConfig.resolveUrlForWithChain(client);\n if (!url) {\n this.adapter.log.debug(`GET / client=${client.id} \u2192 landing (chain=${chain})`);\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(`GET / client=${client.id} \u2192 URL (chain=${chain})`);\n return reply\n .status(200)\n .type(\"text/html; charset=utf-8\")\n .send(renderRedirectWrapper(url, client.id, this.systemLanguage, client.ip));\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 // v1.32.0 F1: only-on-change-Trace. Jeder Poll (alle 30s \u00D7 N Displays)\n // w\u00E4re Flood \u2014 diagnostisch wertvoll ist nur der Target-Wechsel.\n // First-time-poll-pro-restart wird auch geloggt weil Map leer ist.\n const prev = this.lastRedirectTargetByClient.get(client.id);\n const next = url ?? null;\n if (prev !== next) {\n this.adapter.log.debug(\n `redirect_check client=${client.id}: ${prev === undefined ? \"first-poll\" : (prev ?? \"none\")} \u2192 ${next ?? \"none\"}`,\n );\n this.lastRedirectTargetByClient.set(client.id, next);\n }\n return { target: next };\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 fastifyWebsocket from \"@fastify/websocket\";\nimport Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from \"fastify\";\nimport type { WebSocket } from \"ws\";\nimport {\n HA_VERSION,\n SESSION_TTL_MS,\n CLEANUP_INTERVAL_MS,\n LOGIN_SCHEMA,\n OAUTH_ACCESS_TOKEN_TTL_S,\n SESSIONS_CAP,\n WEBHOOK_REGISTRATIONS_CAP,\n REQUEST_ERROR_COOLDOWN_MS,\n REQUEST_ERROR_COOLDOWN_CAP,\n COOKIE_MAX_AGE_S,\n WS_AUTH_TIMEOUT_MS,\n} from \"./constants\";\nimport { coerceString, coerceUuid, evictOldest, isValidRedirectUri, safeStringEqual } from \"./coerce\";\nimport { buildRedirectUrl, renderAuthorizeError, renderAuthorizeForm, renderAuthorizeRedirect } from \"./auth-page\";\nimport type { ClientRegistry } from \"./client-registry\";\nimport type { GlobalConfig } from \"./global-config\";\nimport { renderLandingPage } from \"./landing-page\";\nimport { getLocalIp, isWildcardBind } from \"./network\";\nimport { renderRedirectWrapper } from \"./redirect-wrapper\";\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// v1.32.0: `renderRedirectWrapper` ist nach `lib/redirect-wrapper.ts` ausgelagert\n// f\u00FCr Symmetrie zu `landing-page.ts` / `auth-page.ts`. `evictOldest` ist shared\n// helper aus `coerce.ts`.\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 * Light-my-request injection surface \u2014 exposed read-only as a TEST-ONLY seam so\n * unit tests can drive routes without opening a real socket. Not used in production.\n */\nexport type WebserverInject = FastifyInstance[\"inject\"];\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 * HA mobile_app registration response shape (home-assistant/android\n * RegisterDeviceResponse.kt): `webhookId` required, the cloud/remote/secret\n * fields null (no Nabu Casa cloud, the webhookId itself is the secret). Used\n * by the registration POST, the PUT update and the webhook `update_registration`.\n *\n * @param webhookId The issued webhook id (URL secret) to echo back to the App.\n */\nfunction mobileRegResponse(webhookId: string): {\n webhook_id: string;\n cloudhook_url: null;\n remote_ui_url: null;\n secret: null;\n} {\n return { webhook_id: webhookId, cloudhook_url: null, remote_ui_url: null, secret: null };\n}\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 * Mobile-App webhook registrations from `POST /api/mobile_app/registrations`\n * (v1.29.1). Key = webhookId (URL secret), Value = owning client cookie id.\n * Subsequent `POST /api/webhook/<id>` requests are validated against this\n * map. FIFO-capped at {@link WEBHOOK_REGISTRATIONS_CAP}; entries whose\n * owning client was removed are pruned in {@link cleanupSessions} (v1.35.2).\n *\n * Reused for Shelly Wall Display FW 2.6.0+ onboarding \u2014 the on-device HA\n * Companion App requires this endpoint to complete device registration\n * after the OAuth2 sign-in. Without it the App refuses to proceed with a\n * \"Mobile-App-Integration nicht verf\u00FCgbar\" error.\n *\n * **Design \u2014 in-memory only, by intent.** The map is NOT persisted across\n * adapter restarts. Restart-recovery relies on the\n * `POST /api/webhook/<unknown-id>` branch returning HTTP 200 with a\n * truly EMPTY body \u2014 the HA Companion App reads that as a stale webhook\n * and re-runs `registerDevice`, which on hassemu issues a fresh\n * webhookId. (Source, verified at tag 2026.4.4: home-assistant/android\n * IntegrationRepositoryImpl.kt:167-171 \u2014 the trigger is\n * `response.code() == 200 && response.body()?.contentLength() == 0L`.)\n *\n * If a future refactor changes the unknown-webhookId response from\n * `200 empty` to `404` or to any non-empty body (even JSON `null`),\n * displays will silently break across adapter restarts. Keep that\n * response shape OR add real persistence here.\n */\n public readonly webhookRegistrations: Map<string, string> = new Map();\n /**\n * v1.32.0 F1: last redirect-target seen per client by `/api/redirect_check`.\n * Used to log only-on-change (instead of every 30s poll). Pruned in\n * {@link cleanupSessions} against `registry.listAll()` \u2014 stale entries from\n * removed clients are dropped within max 5 min.\n */\n private readonly lastRedirectTargetByClient: Map<string, string | null> = new Map();\n private cleanupTimer: ioBroker.Interval | null = null;\n /**\n * Test-only injection surface ({@link WebserverInject}). v1.14.0 (H8): bound\n * once in the constructor instead of via a getter \u2014 a getter allocated a new\n * bound function on every `s.inject({...})` call, and tests call it in loops.\n */\n public readonly inject!: WebserverInject;\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 seine sichtbare IP f\u00E4lschen \u2192 verf\u00E4lscht\n // Logs + die per-IP-Burst-Erkennung defekter Cookies).\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: WebserverInject }).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 // v1.34.0: minimal read-only WebSocket for the HA Companion App. Its\n // `registerDevice` makes a best-effort `auth/current_user` WS call after the\n // REST registration; without a WS endpoint that fails (and the username is\n // not stored). Registered before the routes so `{ websocket: true }` works.\n await this.app.register(fastifyWebsocket);\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 redirect-target entries. */\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\n // v1.32.0 F1: prune lastRedirectTargetByClient against currently known\n // clients. A removed client leaves a stale entry that would never get\n // cleared otherwise \u2014 bounded growth over months.\n const activeClients = new Set(this.registry.listAll().map(r => r.id));\n let prunedTargets = 0;\n for (const clientId of this.lastRedirectTargetByClient.keys()) {\n if (!activeClients.has(clientId)) {\n this.lastRedirectTargetByClient.delete(clientId);\n prunedTargets++;\n }\n }\n if (prunedTargets > 0) {\n this.adapter.log.debug(`Cleanup: pruned ${prunedTargets} stale redirect-target entries`);\n }\n\n // v1.35.2: prune webhook registrations whose owning client was removed\n // (remove button / stale-GC). Without this, an orphaned display keeps\n // getting 200s on its webhook instead of falling into re-registration.\n // ownerId === \"\" means \"unowned\" (authRequired=false registration without\n // a Bearer token) \u2014 those have no client to check against and must stay.\n let prunedWebhooks = 0;\n for (const [webhookId, ownerId] of this.webhookRegistrations) {\n if (ownerId !== \"\" && !activeClients.has(ownerId)) {\n this.webhookRegistrations.delete(webhookId);\n prunedWebhooks++;\n }\n }\n if (prunedWebhooks > 0) {\n this.adapter.log.debug(`Cleanup: pruned ${prunedWebhooks} webhook registrations of removed clients`);\n }\n }\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 evictOldest(this.errorLogCooldown, REQUEST_ERROR_COOLDOWN_CAP);\n }\n this.errorLogCooldown.set(key, now);\n return true;\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 evictOldest(this.sessions, SESSIONS_CAP);\n this.sessions.set(key, data);\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 // v1.32.0 A1: cookie-state explizit traced. Drei Branches:\n // hit \u2014 cookie matched a known client, no setCookie needed\n // stale/new \u2014 cookie present but unknown, OR no cookie at all \u2192 new client created\n if (cookie === record.cookie) {\n this.adapter.log.debug(`identify: cookie-hit client=${record.id} ip=${ip ?? \"?\"}`);\n } else {\n const reason = cookie ? \"cookie-stale (unknown)\" : \"no-cookie\";\n this.adapter.log.debug(`identify: ${reason}, new client=${record.id} ip=${ip ?? \"?\"}`);\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 // v1.32.0 A2: Cookie-Secure-Decision tracen \u2014 wenn trustProxy-config\n // falsch ist, kriegt Display den Cookie evtl. nie zur\u00FCck.\n this.adapter.log.debug(`identify: setting cookie secure=${useSecure} (req.protocol=${req.protocol})`);\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 // v1.34.0: adapter-managed Timer (cancelt automatisch bei onUnload) + clear\n // sobald `dns.reverse` das Race gewinnt \u2014 sonst dangelt der Timer bis 5s\n // \u00FCber einen Restart hinaus (W5005).\n let timeoutHandle: ioBroker.Timeout | undefined;\n const timeout = new Promise<string[]>((_, reject) => {\n timeoutHandle = this.adapter.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 // v1.32.0 A4: Success-Trace \u2014 bei Diagnose \u201Ewarum hat Display\n // X den hostname Y?\" ist die IP\u2192hostname-Aufl\u00F6sung der Anker.\n this.adapter.log.debug(`resolveHostname: ip=${ip} \u2192 hostname=${name}`);\n this.registry.identifyOrCreate(record.cookie, ip, name).catch(() => {\n /* registry itself logs */\n });\n }\n })\n .catch(err => {\n // v1.32.0 A3: vorher silent. Reverse DNS scheitert auf LAN oft\n // legitim \u2014 daher debug-only (kein warn-Spam), aber jetzt mit\n // Diagnose-Anker f\u00FCr \u201Ehostname fehlt\"-Reports.\n this.adapter.log.debug(\n `resolveHostname: ip=${ip} failed \u2014 ${err instanceof Error ? err.message : String(err)}`,\n );\n })\n .finally(() => {\n if (timeoutHandle) {\n this.adapter.clearTimeout(timeoutHandle);\n }\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 // v1.34.0: the WebSocket does its own auth in the handshake\n // (`auth_required` \u2192 `auth` frame), not via a Bearer header \u2014 so the\n // HTTP upgrade itself must pass the guard.\n path === \"/api/websocket\" ||\n // v1.29.1: Mobile-App webhooks carry the secret in the URL\n // (`webhookId`) \u2014 HA core also serves these unauthenticated.\n // Source: home-assistant/core/.../mobile_app/webhook.py.\n path.startsWith(\"/api/webhook/\")\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.setupWebSocket();\n this.setupMiscRoutes();\n this.setupNotFound();\n }\n\n /**\n * HA `/api/config`-shaped object. Single source for REST `/api/config`, the\n * Companion webhook `get_config` and the WebSocket `get_config` command.\n * `mobile_app` in `components` advertises the integration the HA Companion App\n * probes during onboarding (v1.29.1, Shelly FW 2.6.0+).\n */\n private buildHaConfig(): Record<string, unknown> {\n return {\n components: [\"http\", \"api\", \"frontend\", \"homeassistant\", \"mobile_app\"],\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\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\", () => this.buildHaConfig());\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 // ---- Mobile-App integration (HA Companion + Shelly FW 2.6.0+) ----\n //\n // Source: home-assistant/android IntegrationRepositoryImpl.kt:120-159\n // calls POST /api/mobile_app/registrations after the OAuth2 sign-in.\n // A 404 here surfaces as \u201EMobile-App-Integration nicht verf\u00FCgbar\" in\n // the App's onboarding screen and blocks the display from finishing\n // setup. Detail in Ressourcen/hassemu/oauth2-browser-flow-shelly-fw26.md.\n //\n // The Bearer-token check is already done by the existing auth\n // pre-handler \u2014 `/api/mobile_app/registrations` is protected by\n // default, so by the time the handler runs we know the caller has\n // a valid access_token from /auth/token.\n this.app.post<{\n Body: {\n app_id?: string;\n app_name?: string;\n device_name?: string;\n device_id?: string;\n manufacturer?: string;\n model?: string;\n os_name?: string;\n os_version?: string;\n };\n }>(\"/api/mobile_app/registrations\", async (req, reply) => {\n const body = req.body ?? {};\n // Identify by Bearer token \u2014 the pre-handler already validated it.\n const authHeader = (req.headers.authorization as string) ?? \"\";\n const token = authHeader.startsWith(\"Bearer \") ? authHeader.substring(7).trim() : \"\";\n const client = this.registry.getByToken(token);\n const ownerId = client?.id ?? \"\";\n\n const webhookId = crypto.randomUUID().replace(/-/g, \"\");\n evictOldest(this.webhookRegistrations, WEBHOOK_REGISTRATIONS_CAP);\n this.webhookRegistrations.set(webhookId, ownerId);\n\n this.adapter.log.debug(\n `Mobile-App registration \u2014 client=${ownerId} app_id=${body.app_id ?? \"?\"} device_name=${body.device_name ?? \"?\"} \u2192 webhook=${webhookId}`,\n );\n\n reply.status(201);\n return mobileRegResponse(webhookId);\n });\n\n // PUT and DELETE on /api/mobile_app/registrations/:webhookId \u2014 the App\n // calls PUT to update its registration on token refresh or sensor\n // re-register. PUT echoes the registration for a KNOWN webhookId (200), but\n // returns 404 for an unknown one so a stale Pre-Restart token re-registers;\n // DELETE drops the registration and returns 204.\n this.app.put<{ Params: { webhookId: string } }>(\"/api/mobile_app/registrations/:webhookId\", async (req, reply) => {\n const id = req.params.webhookId;\n if (!this.webhookRegistrations.has(id)) {\n // v1.32.0 E1: stale-id signaliert dass Companion einen Token\n // aus Pre-Restart-Era hat \u2014 diagnostisch wertvoll f\u00FCr\n // re-registration-loop-Bugs.\n this.adapter.log.debug(`Mobile-App PUT registration: unknown webhookId=${id.substring(0, 8)}\u2026 \u2014 returning 404`);\n reply.status(404);\n return { error: \"unknown_registration\" };\n }\n return mobileRegResponse(id);\n });\n\n this.app.delete<{ Params: { webhookId: string } }>(\n \"/api/mobile_app/registrations/:webhookId\",\n async (req, reply) => {\n const id = req.params.webhookId;\n const wasPresent = this.webhookRegistrations.has(id);\n this.webhookRegistrations.delete(id);\n // v1.32.0 E2: Companion-Maintenance-Trace.\n this.adapter.log.debug(\n `Mobile-App DELETE registration: webhookId=${id.substring(0, 8)}\u2026 removed (was-present=${wasPresent})`,\n );\n reply.status(204);\n return null;\n },\n );\n\n // POST /api/webhook/:webhookId \u2014 Companion-App sensor updates,\n // location pings, registration updates etc. Public by design (URL\n // contains the webhookId secret). HA core dispatches on `type` field\n // in the JSON body and returns shape per type. For hassemu we accept\n // any payload and respond with the minimal-correct success per type;\n // the display use-case doesn't need actual state propagation, but\n // returning 200 prevents the App from re-trying in a loop and\n // surfacing onboarding-failure banners.\n this.app.post<{\n Params: { webhookId: string };\n Body: { type?: string; data?: unknown };\n }>(\"/api/webhook/:webhookId\", async (req, reply) => {\n const id = req.params.webhookId;\n if (!this.webhookRegistrations.has(id)) {\n // Unknown webhookId \u2014 match HA's 200-empty for stale webhooks so the\n // App re-registers. Source (verified at tag 2026.4.4):\n // home-assistant/android IntegrationRepositoryImpl.kt:167-171 \u2014\n // `updateRegistration` re-runs `registerDevice` ONLY when\n // `response.code() == 200 && response.body()?.contentLength() == 0L`.\n // The body MUST therefore be truly empty: `return null` would let\n // Fastify serialize the 4-byte JSON text \"null\" (contentLength 4),\n // the Companion would take the success branch and the display would\n // stay broken silently (v1.35.2 fix).\n // v1.32.0 E3: stale-id ist DAS Symptom f\u00FCr re-registration-loop \u2014\n // Companion macht webhook-call mit Token aus Pre-Restart-Era.\n this.adapter.log.debug(\n `Webhook fallthrough: stale id=${id.substring(0, 8)}\u2026 \u2014 App will trigger re-registration`,\n );\n return reply.status(200).send();\n }\n const body = req.body ?? {};\n const type = typeof body.type === \"string\" ? body.type : \"\";\n this.adapter.log.debug(`Webhook ${id.substring(0, 8)}\u2026 type=${type || \"(no type)\"}`);\n\n switch (type) {\n case \"get_config\":\n return this.buildHaConfig();\n case \"get_zones\":\n return [];\n case \"render_template\":\n return {};\n case \"update_registration\":\n return mobileRegResponse(id);\n case \"register_sensor\":\n return { success: true };\n case \"update_sensor_states\":\n return {};\n default:\n // Generic success for unknown types \u2014 fire_event,\n // call_service, conversation_process, update_location,\n // get_zones-with-data, etc. The display doesn't need\n // their semantics, just an HTTP 200 acknowledgement.\n return {};\n }\n });\n }\n\n /**\n * Issue a fresh authorization code and persist it in the sessions map.\n *\n * Single source for both the JSON login flow (`/auth/login_flow/<flowId>`\n * \u2192 `create_entry`) and the browser OAuth2 flow (`/auth/authorize` \u2192\n * 302). The code is exchanged for tokens at `/auth/token` (`grant_type =\n * authorization_code`); the existing token-view consumes the same map.\n *\n * @param clientId Identity cookie value of the requesting display, or\n * undefined for headless OAuth2-only flows.\n */\n private issueAuthorizationCode(clientId: string | null): string {\n const code = crypto.randomUUID();\n this.storeSession(code, { created: Date.now(), clientId });\n return code;\n }\n\n /**\n * Shared validation for GET and POST `/auth/authorize`. On failure it sets the\n * `400 text/html` reply and returns the rendered error page; on success it\n * returns the validated (string-typed) `client_id` / `redirect_uri`. Never\n * redirects on failure \u2014 the endpoint must not become an open redirector.\n *\n * @param reply Fastify reply (status + content-type set on failure).\n * @param method `\"GET\"` or `\"POST\"` \u2014 only used to label the debug log.\n * @param responseType The OAuth2 `response_type` (must be `\"code\"`).\n * @param clientId The OAuth2 `client_id` (must be a string).\n * @param redirectUri The OAuth2 `redirect_uri` (must be a string + allowlisted).\n */\n private validateAuthorizeRequest(\n reply: FastifyReply,\n method: \"GET\" | \"POST\",\n responseType: unknown,\n clientId: unknown,\n redirectUri: unknown,\n ): { ok: true; clientId: string; redirectUri: string } | { ok: false; html: string } {\n if (responseType !== \"code\") {\n this.adapter.log.debug(`Authorize ${method} rejected: response_type=${String(responseType)} (expected 'code')`);\n reply.status(400).type(\"text/html\");\n return {\n ok: false,\n html: renderAuthorizeError(\n \"unsupported_response_type\",\n \"This authorization server supports `response_type=code` only.\",\n ),\n };\n }\n if (typeof clientId !== \"string\" || typeof redirectUri !== \"string\") {\n this.adapter.log.debug(\n `Authorize ${method} rejected: missing client_id or redirect_uri (cid=${typeof clientId}, ru=${typeof redirectUri})`,\n );\n reply.status(400).type(\"text/html\");\n return {\n ok: false,\n html: renderAuthorizeError(\"invalid_request\", \"Missing or invalid `client_id` or `redirect_uri` parameter.\"),\n };\n }\n if (!isValidRedirectUri(clientId, redirectUri)) {\n this.adapter.log.debug(\n `Authorize ${method} rejected: redirect_uri \"${redirectUri}\" not allowed for client_id \"${clientId}\"`,\n );\n reply.status(400).type(\"text/html\");\n return {\n ok: false,\n html: renderAuthorizeError(\n \"invalid_redirect_uri\",\n \"The `redirect_uri` parameter is not on the allowlist for this client.\",\n ),\n };\n }\n return { ok: true, clientId, redirectUri };\n }\n\n /**\n * Issue an auth code, build the redirect target and render the auto-submit redirect page.\n *\n * @param reply Fastify reply (content-type set to text/html).\n * @param clientId Identity of the requesting display, or null for headless flows.\n * @param redirectUri Already-validated `redirect_uri` to append the code to.\n * @param state Optional OAuth2 `state` round-tripped verbatim.\n */\n private issueAuthorizeRedirect(\n reply: FastifyReply,\n clientId: string | null,\n redirectUri: string,\n state: string | undefined,\n ): string {\n const code = this.issueAuthorizationCode(clientId);\n const target = buildRedirectUrl(redirectUri, code, state);\n reply.type(\"text/html\");\n return renderAuthorizeRedirect(target);\n }\n\n /**\n * Best-effort token revocation, shared by `POST /auth/revoke` (HA \u22652022.9)\n * and the legacy `POST /auth/token` with `action=revoke`. The HA Companion\n * sends the refresh token; we look it up and clear both the refresh and the\n * access token of the owning client. Always succeeds from the caller's view \u2014\n * an unknown/missing token still yields 200 (matches HA, which never leaks\n * whether a token existed). Source: AuthenticationRepositoryImpl.revokeSession.\n *\n * @param token Refresh token to revoke (from the `token` form field).\n */\n private async revokeToken(token: string | undefined): Promise<void> {\n const refresh = typeof token === \"string\" ? token : \"\";\n const owner = refresh ? this.registry.getByRefreshToken(refresh) : null;\n if (owner) {\n await this.registry.setRefreshToken(owner.id, null);\n await this.registry.setToken(owner.id, null);\n this.adapter.log.debug(`Token revoked \u2014 client ${owner.id}`);\n } else {\n this.adapter.log.debug(\"Revoke: unknown/missing token \u2014 returning 200 (HA behavior)\");\n }\n }\n\n private setupAuthRoutes(): void {\n this.app.get(\"/auth/providers\", () => [{ name: \"Home Assistant Local\", type: \"homeassistant\", id: null }]);\n\n // Browser-OAuth2 flow at GET/POST /auth/authorize. Needed by the\n // HA Companion Android App (Shelly Wall Display FW 2.6.0+ embeds\n // the Companion App). Source-verified flow:\n // home-assistant/android UrlUtil.kt:buildAuthenticationUrl\n // home-assistant/core indieauth.py:verify_redirect_uri\n // home-assistant/frontend src/data/auth.ts:redirectWithAuthCode\n // Detail: Ressourcen/hassemu/oauth2-browser-flow-shelly-fw26.md\n this.app.get<{\n Querystring: { response_type?: string; client_id?: string; redirect_uri?: string; state?: string };\n }>(\"/auth/authorize\", async (req, reply) => {\n const { response_type, client_id, redirect_uri, state } = req.query ?? {};\n\n // v1.32.0 D2: rejection-Pfade traced \u2014 Triage \u201Ewarum bricht OAuth ab\"\n const v = this.validateAuthorizeRequest(reply, \"GET\", response_type, client_id, redirect_uri);\n if (!v.ok) {\n return v.html;\n }\n\n const client = await this.identify(req, reply);\n\n // No auth required \u2192 issue the code right away and redirect.\n if (!this.config.authRequired) {\n this.adapter.log.debug(`Authorize auto-grant \u2014 client ${client.id}`);\n return this.issueAuthorizeRedirect(reply, client.id, v.redirectUri, state);\n }\n\n // v1.32.0 D1: Form-render Trace \u2014 wenn Companion die Form nie absendet,\n // sieht User hier dass sie \u00FCberhaupt gerendert wurde.\n let redirectHost = \"?\";\n try {\n redirectHost = new URL(v.redirectUri).host || v.redirectUri;\n } catch {\n redirectHost = v.redirectUri;\n }\n this.adapter.log.debug(`Authorize form rendered \u2014 client_id=${v.clientId} redirect_uri-host=${redirectHost}`);\n reply.type(\"text/html\");\n return renderAuthorizeForm({ clientId: v.clientId, redirectUri: v.redirectUri, state });\n });\n\n this.app.post<{\n Body: {\n response_type?: string;\n client_id?: string;\n redirect_uri?: string;\n state?: string;\n username?: string;\n password?: string;\n };\n }>(\"/auth/authorize\", async (req, reply) => {\n const { response_type, client_id, redirect_uri, state, username, password } = req.body ?? {};\n\n const v = this.validateAuthorizeRequest(reply, \"POST\", response_type, client_id, redirect_uri);\n if (!v.ok) {\n return v.html;\n }\n\n const client = await this.identify(req, reply);\n\n // No auth required \u2192 straight to redirect even on POST.\n if (!this.config.authRequired) {\n return this.issueAuthorizeRedirect(reply, client.id, v.redirectUri, state);\n }\n\n const ip = WebServer.getClientIp(req);\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 const ipSuffix = ip ? ` (IP ${ip})` : \"\";\n this.adapter.log.warn(`Invalid credentials${ipSuffix}`);\n reply.status(401).type(\"text/html\");\n return renderAuthorizeForm(\n { clientId: v.clientId, redirectUri: v.redirectUri, state },\n \"Invalid username or password.\",\n );\n }\n\n this.adapter.log.debug(`Authorize grant \u2014 client ${client.id}`);\n return this.issueAuthorizeRedirect(reply, client.id, v.redirectUri, state);\n });\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 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 const ipSuffix = ip ? ` (IP ${ip})` : \"\";\n this.adapter.log.warn(`Invalid credentials${ipSuffix}`);\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 }\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 // HA \u22652022.9 logout: POST /auth/revoke with form field `token` (the refresh\n // token). Always 200 with empty body. Whitelisted by the `/auth/` prefix in\n // the auth guard. Source: AuthenticationRepositoryImpl.revokeSession.\n this.app.post<{ Body: { token?: string } }>(\"/auth/revoke\", async req => {\n await this.revokeToken(req.body?.token);\n return {};\n });\n\n this.app.post<{\n Body: { code?: string; grant_type?: string; refresh_token?: string; action?: string; token?: string };\n }>(\"/auth/token\", async (req, reply) => {\n const { code, grant_type, refresh_token, action } = req.body ?? {};\n\n // Legacy logout (HA <2022.9): POST /auth/token with action=revoke + token.\n // Newer apps use /auth/revoke; we accept both so a 400 never surfaces.\n if (action === \"revoke\") {\n await this.revokeToken(req.body?.token ?? refresh_token);\n return {};\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 // Persist VOR Response-Build: ein Crash zwischen Issue + Persist\n // w\u00FCrde sonst dem Client einen Token in der Hand lassen, den der\n // Server nicht kennt \u2014 beim ersten Refresh dann invalid_grant.\n await this.registry.setToken(session.clientId, token);\n await this.registry.setRefreshToken(session.clientId, refreshToken);\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 ownerRecord = incoming ? this.registry.getByRefreshToken(incoming) : null;\n if (!ownerRecord) {\n this.adapter.log.debug(\"Refresh token rejected \u2014 unknown or missing\");\n reply.status(400);\n return { error: \"invalid_grant\", error_description: \"Invalid refresh token\" };\n }\n // v1.31.0: refresh_token bleibt valid (NICHT mehr rotated). HA Core\n // selbst (homeassistant/components/auth/__init__.py:334-348) liefert\n // beim refresh-grant nie einen neuen refresh_token, nur access_token\n // + token_type + expires_in. HA Android Companion\n // (AuthenticationRepositoryImpl.kt:147) speichert beim Refresh den\n // GESENDETEN refresh_token (Function-Parameter), ignoriert den in der\n // Response zur\u00FCckgegebenen \u2014 Companion beh\u00E4lt daher immer ihren\n // initialen refresh_token. v1.28.3 (HW5) Rotation war RFC 6819\n // \u00A75.2.2.3-konform aber inkompatibel mit dem Companion-Datenmodell:\n // Server-Rotation killte den Companion-Token beim ersten Refresh.\n const newAccess = crypto.randomUUID();\n await this.registry.setToken(ownerRecord.id, newAccess);\n this.adapter.log.debug(`Refresh-token-grant \u2014 client=${ownerRecord.id} new access_token issued`);\n return {\n access_token: newAccess,\n token_type: \"Bearer\",\n refresh_token: incoming,\n expires_in: OAUTH_ACCESS_TOKEN_TTL_S,\n };\n }\n\n // \u201Ewrong grant_type\" ist ein Client-Format-Fehler, kein Server-Concern\n // \u2014 daher nur debug (legitime Client-Bugs sollen das Log nicht fluten).\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 * Minimal read-only HA WebSocket at `/api/websocket`. The HA Companion App's\n * `registerDevice` makes a best-effort `auth/current_user` WS call after the\n * REST registration to store the username (home-assistant/android\n * IntegrationRepositoryImpl.kt at tag 2026.4.4, line 154). Without a WS\n * endpoint that throws and the registration logs \"Unable to save device registration\".\n *\n * Auth happens in-band: server sends `auth_required`, client replies with an\n * `auth` frame, we validate the access token against the registry. FAIL-FAST:\n * a missing/invalid token or a missing `auth` frame within\n * {@link WS_AUTH_TIMEOUT_MS} closes the socket \u2014 so the WS never hangs the\n * App's call (which previously failed fast against a clean 404).\n */\n private setupWebSocket(): void {\n this.app.get(\"/api/websocket\", { websocket: true }, (socket: WebSocket) => {\n let authed = false;\n let authTimer: ioBroker.Timeout | undefined = this.adapter.setTimeout(() => {\n if (!authed) {\n this.adapter.log.debug(\"WS: no auth frame within timeout \u2014 closing\");\n this.wsSend(socket, { type: \"auth_invalid\", message: \"Authentication timed out\" });\n socket.close();\n }\n }, WS_AUTH_TIMEOUT_MS);\n\n this.wsSend(socket, { type: \"auth_required\", ha_version: HA_VERSION });\n\n socket.on(\"message\", raw => {\n // ws delivers text frames as Buffer by default; normalize every RawData\n // variant to a UTF-8 string (avoids Object's default stringification).\n const text = Buffer.isBuffer(raw)\n ? raw.toString(\"utf8\")\n : Array.isArray(raw)\n ? Buffer.concat(raw).toString(\"utf8\")\n : Buffer.from(raw).toString(\"utf8\");\n let msg: Record<string, unknown>;\n try {\n msg = JSON.parse(text) as Record<string, unknown>;\n } catch {\n return; // ignore non-JSON frames\n }\n if (!authed) {\n const token = typeof msg.access_token === \"string\" ? msg.access_token : \"\";\n if (msg.type === \"auth\" && token && this.registry.getByToken(token)) {\n authed = true;\n if (authTimer) {\n this.adapter.clearTimeout(authTimer);\n authTimer = undefined;\n }\n this.wsSend(socket, { type: \"auth_ok\", ha_version: HA_VERSION });\n } else {\n this.adapter.log.debug(\"WS: auth_invalid \u2014 unknown or missing access token\");\n this.wsSend(socket, { type: \"auth_invalid\", message: \"Invalid access token\" });\n socket.close();\n }\n return;\n }\n this.handleWsCommand(socket, msg);\n });\n\n socket.on(\"error\", () => {\n // Client vanished mid-stream \u2014 the socket is gone; the auth timer is\n // cleared by the close handler below.\n });\n\n socket.on(\"close\", () => {\n // Clear the auth timer if the client disconnects before authenticating,\n // so it never fires against an already-closed socket.\n if (authTimer) {\n this.adapter.clearTimeout(authTimer);\n authTimer = undefined;\n }\n });\n });\n }\n\n /**\n * Safely serialize + send a WS frame; swallows errors from an already-closed socket.\n *\n * @param socket The client WebSocket to write to.\n * @param payload Plain object serialized to a JSON text frame.\n */\n private wsSend(socket: WebSocket, payload: Record<string, unknown>): void {\n try {\n socket.send(JSON.stringify(payload));\n } catch {\n /* socket closing/closed \u2014 drop the frame */\n }\n }\n\n /**\n * Handle one authenticated WS command. hassemu emulates an empty-but-valid HA\n * server with only the components it advertises (http/api/frontend/\n * homeassistant/mobile_app). Responses use only shapes that are either\n * source-verified or trivially correct for an empty server:\n * - data queries \u2192 correct empty shape ([] / {}),\n * - subscriptions \u2192 ack that never emits (no entities/events on a shim),\n * - everything hassemu does NOT implement (call_service on a service-less\n * server, conversation, Matter/Thread, assist_pipeline, \u2026) \u2192 `unknown_command`,\n * which is exactly what real HA returns for an unregistered command type.\n *\n * The command SET is verified against home-assistant/android\n * WebSocketRepositoryImpl at tag 2026.4.4; the error code against\n * home-assistant/core websocket_api/const.py at tag 2026.4.0 (ERR_UNKNOWN_COMMAND).\n * No speculative response shapes are emitted.\n *\n * @param socket The authenticated client WebSocket.\n * @param msg The parsed incoming command frame (`{ id, type, ... }`).\n */\n private handleWsCommand(socket: WebSocket, msg: Record<string, unknown>): void {\n const id = msg.id;\n const type = typeof msg.type === \"string\" ? msg.type : \"\";\n const result = (r: unknown): void => this.wsSend(socket, { id, type: \"result\", success: true, result: r });\n switch (type) {\n case \"ping\":\n this.wsSend(socket, { id, type: \"pong\" });\n return;\n case \"auth/current_user\":\n // CurrentUserResponse.kt @2026.4.4: { id, name, isOwner, isAdmin } \u2014\n // the HA wire format is snake_case (is_owner / is_admin).\n result({\n id: this.instanceUuid,\n name: this.config.username || this.serviceName,\n is_owner: true,\n is_admin: true,\n });\n return;\n case \"get_config\":\n result(this.buildHaConfig());\n return;\n case \"get_states\":\n result([]);\n return;\n case \"get_services\":\n result({});\n return;\n // Registries on an entity-less emulated server \u2192 empty lists.\n case \"config/area_registry/list\":\n case \"config/device_registry/list\":\n case \"config/entity_registry/list\":\n result([]);\n return;\n // Valid subscriptions on an empty server \u2014 they ack but never emit.\n // mobile_app/* is an advertised component, so both its WS commands ack\n // consistently (the channel subscribe + the confirm).\n case \"subscribe_events\":\n case \"subscribe_entities\":\n case \"supported_features\":\n case \"mobile_app/push_notification_channel\":\n case \"mobile_app/push_notification_confirm\":\n result(null);\n return;\n default:\n // hassemu doesn't implement this command (call_service has no services;\n // conversation / matter / thread / assist_pipeline are integrations it\n // doesn't advertise). Real HA returns ERR_UNKNOWN_COMMAND for an\n // unregistered command type \u2014 a reply (no hang), honest (no fake success),\n // and grounded (no guessed response shape).\n this.wsSend(socket, {\n id,\n type: \"result\",\n success: false,\n error: { code: \"unknown_command\", message: `Command \"${type}\" is not supported by this server` },\n });\n return;\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` MUST be \"Home Assistant\" exactly \u2014 the HA Companion App\n // verifies the server identity by parsing this field. Source:\n // home-assistant/android DefaultConnectivityChecker.kt:isHomeAssistant\n // checks `name === \"Home Assistant\"`. Anything else (e.g. `serviceName`\n // = \"ioBroker\") fails the onboarding probe with \"Server ist nicht\n // Home Assistant\". Detail in Ressourcen/hassemu/oauth2-browser-flow-shelly-fw26.md.\n name: \"Home Assistant\",\n short_name: \"Home Assistant\",\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 // v1.32.0 B1: Resolver-Chain als Triage-Anker. Ohne Chain musste der\n // Maintainer den Resolver-Code lesen um zu verstehen warum genau\n // diese URL f\u00FCr diesen Client gew\u00E4hlt wurde.\n const { url, chain } = this.globalConfig.resolveUrlForWithChain(client);\n if (!url) {\n this.adapter.log.debug(`GET / client=${client.id} \u2192 landing (chain=${chain})`);\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(`GET / client=${client.id} \u2192 URL (chain=${chain})`);\n return reply\n .status(200)\n .type(\"text/html; charset=utf-8\")\n .send(renderRedirectWrapper(url, client.id, this.systemLanguage, client.ip));\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 // v1.32.0 F1: only-on-change-Trace. Jeder Poll (alle 30s \u00D7 N Displays)\n // w\u00E4re Flood \u2014 diagnostisch wertvoll ist nur der Target-Wechsel.\n // First-time-poll-pro-restart wird auch geloggt weil Map leer ist.\n const prev = this.lastRedirectTargetByClient.get(client.id);\n const next = url ?? null;\n if (prev !== next) {\n this.adapter.log.debug(\n `redirect_check client=${client.id}: ${prev === undefined ? \"first-poll\" : (prev ?? \"none\")} \u2192 ${next ?? \"none\"}`,\n );\n this.lastRedirectTargetByClient.set(client.id, next);\n }\n return { target: next };\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", "fastifyWebsocket", "dns", "crypto"]
|
|
7
7
|
}
|
package/build/main.js
CHANGED
|
@@ -5,6 +5,10 @@ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
|
5
5
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
6
|
var __getProtoOf = Object.getPrototypeOf;
|
|
7
7
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
8
12
|
var __copyProps = (to, from, except, desc) => {
|
|
9
13
|
if (from && typeof from === "object" || typeof from === "function") {
|
|
10
14
|
for (let key of __getOwnPropNames(from))
|
|
@@ -21,6 +25,12 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
21
25
|
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
22
26
|
mod
|
|
23
27
|
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
var main_exports = {};
|
|
30
|
+
__export(main_exports, {
|
|
31
|
+
HassEmu: () => HassEmu
|
|
32
|
+
});
|
|
33
|
+
module.exports = __toCommonJS(main_exports);
|
|
24
34
|
var import_node_crypto = __toESM(require("node:crypto"));
|
|
25
35
|
var import_node_path = require("node:path");
|
|
26
36
|
var import_adapter_core = require("@iobroker/adapter-core");
|
|
@@ -50,6 +60,15 @@ class HassEmu extends utils.Adapter {
|
|
|
50
60
|
registry = null;
|
|
51
61
|
globalConfig = null;
|
|
52
62
|
urlDiscovery = null;
|
|
63
|
+
// Factory seams — production builds the real collaborators; the orchestration
|
|
64
|
+
// unit tests (src/main.test.ts) override these fields with fakes so onReady &
|
|
65
|
+
// friends can run without sockets, mDNS or a js-controller.
|
|
66
|
+
makeGlobalConfig = () => new import_global_config.GlobalConfig(this);
|
|
67
|
+
makeRegistry = () => new import_client_registry.ClientRegistry(this);
|
|
68
|
+
makeUrlDiscovery = (onChange) => new import_url_discovery.UrlDiscovery(this, onChange);
|
|
69
|
+
makeWebServer = (instanceUuid) => new import_webserver.WebServer(this, this.config, this.registry, this.globalConfig, instanceUuid, this.systemLanguage);
|
|
70
|
+
makeMdnsService = (instanceUuid) => new import_mdns.MDNSService(this, this.config, instanceUuid);
|
|
71
|
+
/** @param options Adapter options forwarded to the ioBroker base class. */
|
|
53
72
|
constructor(options = {}) {
|
|
54
73
|
super({ ...options, name: "hassemu" });
|
|
55
74
|
this.on("ready", this.onReady.bind(this));
|
|
@@ -74,9 +93,9 @@ class HassEmu extends utils.Adapter {
|
|
|
74
93
|
await import_adapter_core.I18n.init((0, import_node_path.join)(this.adapterDir, "admin"), this);
|
|
75
94
|
await this.setState("info.connection", { val: false, ack: true });
|
|
76
95
|
this.systemLanguage = await this.readSystemLanguage();
|
|
77
|
-
this.globalConfig =
|
|
96
|
+
this.globalConfig = this.makeGlobalConfig();
|
|
78
97
|
await this.globalConfig.restore();
|
|
79
|
-
this.registry =
|
|
98
|
+
this.registry = this.makeRegistry();
|
|
80
99
|
await this.registry.restore();
|
|
81
100
|
await this.migrateLegacyDefaultVisUrl();
|
|
82
101
|
await this.migrateVisUrlToMode();
|
|
@@ -86,7 +105,7 @@ class HassEmu extends utils.Adapter {
|
|
|
86
105
|
this.log.debug(
|
|
87
106
|
`Config: port=${this.config.port}, auth=${this.config.authRequired}, mdns=${this.config.mdnsEnabled}`
|
|
88
107
|
);
|
|
89
|
-
this.urlDiscovery =
|
|
108
|
+
this.urlDiscovery = this.makeUrlDiscovery(async (states) => {
|
|
90
109
|
var _a3, _b;
|
|
91
110
|
await ((_a3 = this.globalConfig) == null ? void 0 : _a3.syncUrlDropdown(states));
|
|
92
111
|
await ((_b = this.registry) == null ? void 0 : _b.syncUrlDropdown(states));
|
|
@@ -94,14 +113,7 @@ class HassEmu extends utils.Adapter {
|
|
|
94
113
|
this.registry.setNewClientModeProvider(() => this.computeNewClientMode());
|
|
95
114
|
await this.urlDiscovery.collect();
|
|
96
115
|
try {
|
|
97
|
-
this.webServer =
|
|
98
|
-
this,
|
|
99
|
-
this.config,
|
|
100
|
-
this.registry,
|
|
101
|
-
this.globalConfig,
|
|
102
|
-
instanceUuid,
|
|
103
|
-
this.systemLanguage
|
|
104
|
-
);
|
|
116
|
+
this.webServer = this.makeWebServer(instanceUuid);
|
|
105
117
|
await this.webServer.start();
|
|
106
118
|
} catch (err) {
|
|
107
119
|
this.log.error(`Web server failed to start: ${String(err)}`);
|
|
@@ -114,7 +126,7 @@ class HassEmu extends utils.Adapter {
|
|
|
114
126
|
await this.subscribeStatesAsync("info.refresh_urls");
|
|
115
127
|
let mdnsActive = false;
|
|
116
128
|
if (this.config.mdnsEnabled) {
|
|
117
|
-
this.mdnsService =
|
|
129
|
+
this.mdnsService = this.makeMdnsService(instanceUuid);
|
|
118
130
|
this.mdnsService.start();
|
|
119
131
|
mdnsActive = this.mdnsService.isActive();
|
|
120
132
|
if (!mdnsActive) {
|
|
@@ -192,6 +204,25 @@ class HassEmu extends utils.Adapter {
|
|
|
192
204
|
return "en";
|
|
193
205
|
}
|
|
194
206
|
}
|
|
207
|
+
/**
|
|
208
|
+
* Drops the legacy `defaultVisUrl`/`visUrl` keys from the instance native
|
|
209
|
+
* config. Shared by both exits of {@link migrateLegacyDefaultVisUrl} —
|
|
210
|
+
* the unsafe-rejected path and the successfully-migrated path clean up
|
|
211
|
+
* identically. Best-effort: failures only warn.
|
|
212
|
+
*/
|
|
213
|
+
async cleanupLegacyNativeUrl() {
|
|
214
|
+
try {
|
|
215
|
+
const id = `system.adapter.${this.namespace}`;
|
|
216
|
+
const obj = await this.getForeignObjectAsync(id);
|
|
217
|
+
if (obj == null ? void 0 : obj.native) {
|
|
218
|
+
delete obj.native.defaultVisUrl;
|
|
219
|
+
delete obj.native.visUrl;
|
|
220
|
+
await this.setForeignObjectAsync(id, obj);
|
|
221
|
+
}
|
|
222
|
+
} catch (err) {
|
|
223
|
+
this.log.warn(`Legacy config cleanup failed: ${String(err)}`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
195
226
|
/**
|
|
196
227
|
* 1.0.x / 1.1.0 → 1.1.1 migration — move the legacy `defaultVisUrl` from
|
|
197
228
|
* instance native into `global.visUrl` + `global.enabled=true` and drop it
|
|
@@ -207,17 +238,7 @@ class HassEmu extends utils.Adapter {
|
|
|
207
238
|
const safe = (0, import_coerce.coerceSafeUrl)(url);
|
|
208
239
|
if (!safe) {
|
|
209
240
|
this.log.warn(`Migration: legacy global URL rejected as unsafe \u2014 please set global.manualUrl manually`);
|
|
210
|
-
|
|
211
|
-
const id = `system.adapter.${this.namespace}`;
|
|
212
|
-
const obj = await this.getForeignObjectAsync(id);
|
|
213
|
-
if (obj == null ? void 0 : obj.native) {
|
|
214
|
-
delete obj.native.defaultVisUrl;
|
|
215
|
-
delete obj.native.visUrl;
|
|
216
|
-
await this.setForeignObjectAsync(id, obj);
|
|
217
|
-
}
|
|
218
|
-
} catch (err) {
|
|
219
|
-
this.log.warn(`Legacy config cleanup failed: ${String(err)}`);
|
|
220
|
-
}
|
|
241
|
+
await this.cleanupLegacyNativeUrl();
|
|
221
242
|
return;
|
|
222
243
|
}
|
|
223
244
|
this.log.info(`Migrating legacy URL configuration to the new model`);
|
|
@@ -240,17 +261,7 @@ class HassEmu extends utils.Adapter {
|
|
|
240
261
|
this.log.warn(`Legacy URL preserved in instance config \u2014 neither global URL write succeeded`);
|
|
241
262
|
return;
|
|
242
263
|
}
|
|
243
|
-
|
|
244
|
-
const id = `system.adapter.${this.namespace}`;
|
|
245
|
-
const obj = await this.getForeignObjectAsync(id);
|
|
246
|
-
if (obj == null ? void 0 : obj.native) {
|
|
247
|
-
delete obj.native.defaultVisUrl;
|
|
248
|
-
delete obj.native.visUrl;
|
|
249
|
-
await this.setForeignObjectAsync(id, obj);
|
|
250
|
-
}
|
|
251
|
-
} catch (err) {
|
|
252
|
-
this.log.warn(`Legacy config cleanup failed: ${String(err)}`);
|
|
253
|
-
}
|
|
264
|
+
await this.cleanupLegacyNativeUrl();
|
|
254
265
|
}
|
|
255
266
|
/**
|
|
256
267
|
* 1.x → 1.2.0 migration — move legacy per-client `visUrl`-states to the
|
|
@@ -475,4 +486,8 @@ if (require.main !== module) {
|
|
|
475
486
|
} else {
|
|
476
487
|
(() => new HassEmu())();
|
|
477
488
|
}
|
|
489
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
490
|
+
0 && (module.exports = {
|
|
491
|
+
HassEmu
|
|
492
|
+
});
|
|
478
493
|
//# sourceMappingURL=main.js.map
|
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 { join } from \"node:path\";\nimport { I18n } from \"@iobroker/adapter-core\";\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 { type InstanceObjectSchema, repairGlobalSchemas } from \"./lib/schema-repair\";\nimport { isUrlSourceAdapterEvent, 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\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\", this.onReady.bind(this));\n this.on(\"stateChange\", this.onStateChange.bind(this));\n this.on(\"objectChange\", this.onObjectChange.bind(this));\n this.on(\"unload\", this.onUnload.bind(this));\n }\n\n private async onReady(): Promise<void> {\n try {\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 I18n.init(join(this.adapterDir, \"admin\"), this);\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 // These three target pre-1.2.0 \u2192 v1.2.0 upgrades; they run once and are\n // idempotent (cheap no-op on already-migrated installs). Removable in a\n // future major once pre-1.2.0 upgrades are no longer plausible \u2014 until then\n // dropping them would silently break those upgrade paths.\n await this.migrateLegacyDefaultVisUrl();\n await this.migrateVisUrlToMode();\n await repairGlobalSchemas(this, instanceObjectsList as InstanceObjectSchema[]);\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);\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 \u2014 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 } catch (err: unknown) {\n this.log.error(`onReady failed: ${String(err)}`);\n }\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 (typeof val === \"string\" && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(val)) {\n this.log.debug(`Server UUID reused from info.serverUuid: ${val}`);\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(`Migration shortcut: global.visUrl-state missing \u2014 wrote directly to manualUrl=${safe}`);\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(`Migration: client ${record.id} legacy URL rejected as unsafe \u2014 please set the URL manually`);\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 * 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 if (records.length > 0) {\n const ttlDays = Math.round(STALE_CLIENT_TTL_MS / (24 * 60 * 60 * 1000));\n this.log.debug(`gcStaleClients: scanning ${records.length} client(s) for staleness (TTL=${ttlDays}d)`);\n }\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 all\n * clients drop to `'0'` (no-choice) so the next display load shows the\n * landing page until the user picks a URL again (since v1.26 \u2014 earlier\n * versions auto-selected the first discovered URL which surprised users).\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 this.log.debug(`applyMasterSwitch: enabled=true \u2192 propagating mode='global' to all clients`);\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 this.log.debug(`applyMasterSwitch: enabled=false \u2192 propagating mode='0' (no-choice) to all clients`);\n await this.registry.bulkSetMode(\"0\");\n }\n\n private async onStateChange(id: string, state: ioBroker.State | null | undefined): Promise<void> {\n try {\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 } catch (err: unknown) {\n this.log.error(`stateChange failed: ${String(err)}`);\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 onObjectChange(id: string, obj: ioBroker.Object | null | undefined): void {\n try {\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 // v1.30.0 (R2): adapter prefix list lives in url-discovery.ts\n // alongside the actual discovery logic. Single source of truth \u2014\n // adding a new URL-source adapter only requires updating the\n // exported `URL_SOURCE_PREFIXES` (plus `collect()`).\n const isUrlSourceAdapter = isUrlSourceAdapterEvent(id);\n const isAddOrRemove = !obj || (obj.type === \"instance\" && !obj.common?.host);\n if (isUrlSourceAdapter || isAddOrRemove) {\n this.urlDiscovery?.scheduleRefresh();\n }\n } catch (err: unknown) {\n this.log.error(`objectChange failed: ${String(err)}`);\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 } 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": "
|
|
4
|
+
"sourcesContent": ["import crypto from \"node:crypto\";\nimport { join } from \"node:path\";\nimport { I18n } from \"@iobroker/adapter-core\";\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 { type InstanceObjectSchema, repairGlobalSchemas } from \"./lib/schema-repair\";\nimport { isUrlSourceAdapterEvent, 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/**\n * HA emulator adapter \u2014 lifecycle, migrations, state-dispatch, master switch.\n * Exported so the orchestration unit tests can drive its handlers directly.\n */\nexport class 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 // Factory seams \u2014 production builds the real collaborators; the orchestration\n // unit tests (src/main.test.ts) override these fields with fakes so onReady &\n // friends can run without sockets, mDNS or a js-controller.\n private makeGlobalConfig: () => GlobalConfig = () => new GlobalConfig(this);\n private makeRegistry: () => ClientRegistry = () => new ClientRegistry(this);\n private makeUrlDiscovery: (onChange: ConstructorParameters<typeof UrlDiscovery>[1]) => UrlDiscovery = onChange =>\n new UrlDiscovery(this, onChange);\n private makeWebServer: (instanceUuid: string) => WebServer = instanceUuid =>\n new WebServer(this, this.config, this.registry!, this.globalConfig!, instanceUuid, this.systemLanguage);\n private makeMdnsService: (instanceUuid: string) => MDNSService = instanceUuid =>\n new MDNSService(this, this.config, instanceUuid);\n\n declare config: AdapterConfig;\n\n /** @param options Adapter options forwarded to the ioBroker base class. */\n public constructor(options: Partial<utils.AdapterOptions> = {}) {\n super({ ...options, name: \"hassemu\" });\n\n this.on(\"ready\", this.onReady.bind(this));\n this.on(\"stateChange\", this.onStateChange.bind(this));\n this.on(\"objectChange\", this.onObjectChange.bind(this));\n this.on(\"unload\", this.onUnload.bind(this));\n }\n\n private async onReady(): Promise<void> {\n try {\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 I18n.init(join(this.adapterDir, \"admin\"), this);\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 = this.makeGlobalConfig();\n await this.globalConfig.restore();\n\n this.registry = this.makeRegistry();\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 // These three target pre-1.2.0 \u2192 v1.2.0 upgrades; they run once and are\n // idempotent (cheap no-op on already-migrated installs). Removable in a\n // future major once pre-1.2.0 upgrades are no longer plausible \u2014 until then\n // dropping them would silently break those upgrade paths.\n await this.migrateLegacyDefaultVisUrl();\n await this.migrateVisUrlToMode();\n await repairGlobalSchemas(this, instanceObjectsList as InstanceObjectSchema[]);\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 = this.makeUrlDiscovery(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 = this.makeWebServer(instanceUuid);\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);\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 = this.makeMdnsService(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 \u2014 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 } catch (err: unknown) {\n this.log.error(`onReady failed: ${String(err)}`);\n }\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 (typeof val === \"string\" && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(val)) {\n this.log.debug(`Server UUID reused from info.serverUuid: ${val}`);\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 * Drops the legacy `defaultVisUrl`/`visUrl` keys from the instance native\n * config. Shared by both exits of {@link migrateLegacyDefaultVisUrl} \u2014\n * the unsafe-rejected path and the successfully-migrated path clean up\n * identically. Best-effort: failures only warn.\n */\n private async cleanupLegacyNativeUrl(): Promise<void> {\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.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 await this.cleanupLegacyNativeUrl();\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(`Migration shortcut: global.visUrl-state missing \u2014 wrote directly to manualUrl=${safe}`);\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 await this.cleanupLegacyNativeUrl();\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(`Migration: client ${record.id} legacy URL rejected as unsafe \u2014 please set the URL manually`);\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 * 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 if (records.length > 0) {\n const ttlDays = Math.round(STALE_CLIENT_TTL_MS / (24 * 60 * 60 * 1000));\n this.log.debug(`gcStaleClients: scanning ${records.length} client(s) for staleness (TTL=${ttlDays}d)`);\n }\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 all\n * clients drop to `'0'` (no-choice) so the next display load shows the\n * landing page until the user picks a URL again (since v1.26 \u2014 earlier\n * versions auto-selected the first discovered URL which surprised users).\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 this.log.debug(`applyMasterSwitch: enabled=true \u2192 propagating mode='global' to all clients`);\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 this.log.debug(`applyMasterSwitch: enabled=false \u2192 propagating mode='0' (no-choice) to all clients`);\n await this.registry.bulkSetMode(\"0\");\n }\n\n private async onStateChange(id: string, state: ioBroker.State | null | undefined): Promise<void> {\n try {\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 } catch (err: unknown) {\n this.log.error(`stateChange failed: ${String(err)}`);\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 onObjectChange(id: string, obj: ioBroker.Object | null | undefined): void {\n try {\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 // v1.30.0 (R2): adapter prefix list lives in url-discovery.ts\n // alongside the actual discovery logic. Single source of truth \u2014\n // adding a new URL-source adapter only requires updating the\n // exported `URL_SOURCE_PREFIXES` (plus `collect()`).\n const isUrlSourceAdapter = isUrlSourceAdapterEvent(id);\n const isAddOrRemove = !obj || (obj.type === \"instance\" && !obj.common?.host);\n if (isUrlSourceAdapter || isAddOrRemove) {\n this.urlDiscovery?.scheduleRefresh();\n }\n } catch (err: unknown) {\n this.log.error(`objectChange failed: ${String(err)}`);\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 } 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", "crypto"]
|
|
7
7
|
}
|
package/io-package.json
CHANGED
|
@@ -1,8 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"common": {
|
|
3
3
|
"name": "hassemu",
|
|
4
|
-
"version": "1.35.
|
|
4
|
+
"version": "1.35.2",
|
|
5
5
|
"news": {
|
|
6
|
+
"1.35.2": {
|
|
7
|
+
"en": "Displays with a stale registration now re-register automatically after an adapter restart; removing a display also clears its leftover app registration",
|
|
8
|
+
"de": "Displays mit veralteter Registrierung registrieren sich nach einem Adapter-Neustart jetzt automatisch neu; beim Entfernen eines Displays wird auch seine App-Registrierung aufgeräumt",
|
|
9
|
+
"ru": "Дисплеи с устаревшей регистрацией теперь автоматически перерегистрируются после перезапуска адаптера; при удалении дисплея также очищается его регистрация приложения",
|
|
10
|
+
"pt": "Displays com registo desatualizado voltam a registar-se automaticamente após o reinício do adaptador; remover um display também limpa o seu registo de app restante",
|
|
11
|
+
"nl": "Displays met een verouderde registratie registreren zich nu automatisch opnieuw na een herstart van de adapter; bij het verwijderen van een display wordt ook de app-registratie opgeruimd",
|
|
12
|
+
"fr": "Les écrans avec un enregistrement obsolète se réenregistrent automatiquement après un redémarrage de l'adaptateur ; la suppression d'un écran nettoie aussi son enregistrement d'app restant",
|
|
13
|
+
"it": "I display con una registrazione obsoleta ora si ri-registrano automaticamente dopo un riavvio dell'adattatore; la rimozione di un display elimina anche la registrazione app residua",
|
|
14
|
+
"es": "Las pantallas con un registro obsoleto ahora se vuelven a registrar automáticamente tras reiniciar el adaptador; al eliminar una pantalla también se limpia su registro de app restante",
|
|
15
|
+
"pl": "Wyświetlacze z nieaktualną rejestracją po restarcie adaptera rejestrują się teraz automatycznie ponownie; usunięcie wyświetlacza czyści też jego pozostałą rejestrację aplikacji",
|
|
16
|
+
"uk": "Дисплеї із застарілою реєстрацією тепер автоматично перереєструються після перезапуску адаптера; видалення дисплея також очищає його залишкову реєстрацію застосунку",
|
|
17
|
+
"zh-cn": "注册信息过期的显示设备在适配器重启后现在会自动重新注册;移除显示设备时也会清理其遗留的应用注册"
|
|
18
|
+
},
|
|
6
19
|
"1.35.1": {
|
|
7
20
|
"en": "Internal cleanup. No user-facing changes.",
|
|
8
21
|
"de": "Interne Bereinigung. Keine für Nutzer sichtbaren Änderungen.",
|
|
@@ -80,19 +93,6 @@
|
|
|
80
93
|
"pl": "Nazwy stanów zmienione przez użytkownika nie są już nadpisywane przy restarcie adaptera.",
|
|
81
94
|
"uk": "Змінені користувачем назви станів більше не перезаписуються при перезапуску адаптера.",
|
|
82
95
|
"zh-cn": "用户修改的状态名称在适配器重启时不再被覆盖。"
|
|
83
|
-
},
|
|
84
|
-
"1.32.6": {
|
|
85
|
-
"en": "Improved error handling and stability.",
|
|
86
|
-
"de": "Verbesserte Fehlerbehandlung und Stabilität.",
|
|
87
|
-
"ru": "Улучшена обработка ошибок и стабильность.",
|
|
88
|
-
"pt": "Tratamento de erros e estabilidade melhorados.",
|
|
89
|
-
"nl": "Verbeterde foutafhandeling en stabiliteit.",
|
|
90
|
-
"fr": "Gestion des erreurs et stabilité améliorées.",
|
|
91
|
-
"it": "Gestione degli errori e stabilità migliorate.",
|
|
92
|
-
"es": "Mejora del manejo de errores y la estabilidad.",
|
|
93
|
-
"pl": "Poprawiona obsługa błędów i stabilność.",
|
|
94
|
-
"uk": "Покращено обробку помилок та стабільність.",
|
|
95
|
-
"zh-cn": "改进了错误处理和稳定性。"
|
|
96
96
|
}
|
|
97
97
|
},
|
|
98
98
|
"plugins": {
|
package/package.json
CHANGED