iobroker.hassemu 1.3.2 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # ioBroker.hassemu
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/iobroker.hassemu)](https://www.npmjs.com/package/iobroker.hassemu)
4
- ![Node](https://img.shields.io/badge/node-%3E%3D20-brightgreen)
4
+ ![Node](https://img.shields.io/badge/node-%3E%3D22-brightgreen)
5
5
  ![TypeScript](https://img.shields.io/badge/TypeScript-strict-blue)
6
6
  [![License](https://img.shields.io/badge/license-MIT-green)](LICENSE)
7
7
  [![npm downloads](https://img.shields.io/npm/dt/iobroker.hassemu)](https://www.npmjs.com/package/iobroker.hassemu)
@@ -33,7 +33,7 @@ ioBroker adapter that emulates a [Home Assistant](https://www.home-assistant.io)
33
33
 
34
34
  ## Requirements
35
35
 
36
- - **Node.js >= 20**
36
+ - **Node.js >= 22**
37
37
  - **ioBroker js-controller >= 7.0.7**
38
38
  - **ioBroker Admin >= 7.7.22**
39
39
  - **ioBroker web >= 8.0.0**
@@ -147,41 +147,26 @@ Reverse DNS on a home LAN depends on your router/DHCP server and often fails. Th
147
147
  ---
148
148
 
149
149
  ## Changelog
150
- ### 1.3.2 (2026-04-30)
151
-
152
- - Hotfix for v1.3.1: `setObjectNotExistsAsync` is a no-op on objects that already exist as partial-formed leftovers from the v1.2.0 migration bug. v1.3.2 uses `extendObjectAsync` for `clients.<id>.mode` + `clients.<id>.manualUrl` so the missing properties (top-level `type`, name, role, read, write, def) are merged into the existing partial object — js-controller's "obj.type has to exist" warning goes away and the dropdown renders the labels.
153
- - New `repairGlobalSchemas()` in main.ts does the same defensive merge for `global.mode` + `global.manualUrl`. Runs unconditionally on every start so users upgrading from v1.2.0/v1.3.0/v1.3.1 (where the legacy `visUrl` is already gone) also get the schema repaired.
154
- - Restore step now promotes a blank state-value (`''` left over from v1.2.0) to numeric `0`, so the dropdown actually shows the `0='---'` option as selected on first start after the upgrade.
155
-
156
- ### 1.3.1 (2026-04-30)
150
+ ### 1.4.1 (2026-05-05)
157
151
 
158
- - Hotfix for legacy v1.1.x clients: their `visUrl` channel did not have `mode` / `manualUrl` objects. The v1.2.0 migration wrote states without the matching objects, which the broker logged as `State has no existing object` and rendered the `mode` datapoint without a name or dropdown in the object browser. `ClientRegistry.restore()` now calls an idempotent `ensureObjects()` for every client, so the v1.2.0+ object shapes exist before any migration writes happen.
159
- - Mode dropdown gains a numeric `0 = "---"` no-choice fallback (analogous to govee-smart's pattern). Existing displays keep their setting; new displays start at `0` and the resolver falls back to the landing page until a real choice is made.
152
+ - CI: Deploy-Schritt nutzt jetzt Node 24 (Node 22 + `npm@latest` hatte einen `MODULE_NOT_FOUND`-Bug für `promise-retry`, dadurch kam v1.4.0 nicht auf npm).
160
153
 
161
- ### 1.3.0 (2026-04-30)
154
+ ### 1.4.0 (2026-05-05)
162
155
 
163
- - Security: brute-force lockout on `/auth/login_flow/:flowId` after 5 failed credential attempts an IP is rejected with HTTP 429 for 15 min. Successful login resets the counter.
164
- - DRY refactor: shared `parseManualUrlWrite` helper between client + global config; FIFO-cap helper in WebServer; OAuth access-token TTL + lockout window/threshold are now named constants instead of magic numbers.
165
- - Dead-code cleanup: `resolveBindToReachable`, `coerceUuid` strict-V4 parameter, `DEFAULT_REFRESH_DEBOUNCE_MS` export, internal `getMode`/`getManualUrl` test affordances all removed; tests rewritten to assert observable behaviour.
166
- - New `landing-page` test suite with XSS-escape coverage and 11-language fallback verification.
167
- - Emulated Home Assistant version bumped from 2026.3.1 to 2026.4.0.
156
+ - Neuer Datenpunkt `info.refresh_urls` (Button) — auf `true` setzen lädt VIS/VIS-2-Projekte und Admin-Tile-URLs neu, ohne den Adapter neu zu starten. Praktisch nach einer neuen VIS-Seite, die im Mode-Dropdown erscheinen soll.
157
+ - `/auth/token` akzeptiert jetzt auch `application/x-www-form-urlencoded` Bodies (OAuth2-Spec) manche HA-Clients senden den Token-Request urlencoded statt JSON, das Login lief sonst ins Leere.
158
+ - mDNS: Bei einem Bonjour-Startfehler wird die Service-Instanz jetzt sauber freigegeben (vorher leakte der UDP-Socket über die Adapter-Lifetime).
159
+ - Legacy-Migration (1.0.x/1.1.0 → 1.1.1) härter: ungültige Legacy-URLs werden nicht mehr durchgereicht, und Native-Cleanup passiert nur nach erfolgreichem State-Write verhindert silent URL-Verlust auf Edge-Cases.
168
160
 
169
- ### 1.2.0 (2026-04-29)
161
+ ### 1.3.3 (2026-05-01)
162
+ - Documentation: rewrote release notes for v1.1.4–v1.3.2 in user-friendly style across all languages.
170
163
 
171
- - Redirect target now configured via `mode` (dropdown) + `manualUrl` (free text) instead of the old `visUrl`. Migration runs automatically.
172
- - Master switch `global.enabled` syncs every display: on all follow the global URL, off each display picks up its own again.
173
- - Idle displays without auth token are auto-removed after 30 days.
174
- - Security hardening of the auth flow.
175
- - `web` adapter declared as dependency.
176
-
177
- ### 1.1.6 (2026-04-28)
164
+ ### 1.3.2 (2026-04-30)
165
+ - Fix: dropdown default `---` now applied correctly on upgrades from older v1.1.x clients (was empty after migration).
178
166
 
179
- - Audit cleanup against the upstream `ioBroker.example/TypeScript` full standard:
180
- - Test setup migrated: tests now live next to source as `src/lib/*.test.ts` and run directly via `ts-node/register`. Removed `tsconfig.test.json` + `build-test/`, added `test/mocharc.custom.json` + `test/mocha.setup.js` + `test/tsconfig.json` + `test/.eslintrc.json`
181
- - `@types/node` rolled back from `^25.6.0` to `^20.19.24` so type defs match `engines.node: ">=20"`
182
- - Dependabot now ignores major bumps for `@types/node`, `typescript`, `eslint`, `actions/checkout`, `actions/setup-node`
183
- - `nyc` config + `coverage` script added
184
- - Orphan `.github/auto-merge.yml` removed (active workflow is `automerge-dependabot.yml` using `gh pr merge`)
167
+ ### 1.3.1 (2026-04-30)
168
+ - Fix: legacy v1.1.x clients without `mode`/`manualUrl` objects now get migrated correctly on first start.
169
+ - Mode dropdown gains a `0 = "---"` no-choice fallback new displays start without a target until a real choice is made.
185
170
 
186
171
  Older entries are in [CHANGELOG_OLD.md](CHANGELOG_OLD.md).
187
172
 
package/build/lib/mdns.js CHANGED
@@ -59,6 +59,7 @@ class MDNSService {
59
59
  }
60
60
  /** Start mDNS broadcasting via bonjour-service */
61
61
  start() {
62
+ var _a;
62
63
  const localIP = (0, import_network.getLocalIp)();
63
64
  const baseUrl = `http://${localIP}:${this.config.port}`;
64
65
  const serviceName = this.config.serviceName || "ioBroker";
@@ -87,6 +88,12 @@ class MDNSService {
87
88
  } catch (error) {
88
89
  const err = error;
89
90
  this.adapter.log.warn(`mDNS: Failed to start: ${err.message}`);
91
+ try {
92
+ (_a = this.bonjour) == null ? void 0 : _a.destroy();
93
+ } catch {
94
+ }
95
+ this.bonjour = null;
96
+ this.published = null;
90
97
  }
91
98
  }
92
99
  /** Stop mDNS broadcasting */
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/lib/mdns.ts"],
4
- "sourcesContent": ["import Bonjour, { type Service } from 'bonjour-service';\nimport { HA_VERSION } from './constants';\nimport { getLocalIp } from './network';\nimport type { AdapterConfig, AdapterInterface } from './types';\n\n/** mDNS service for Home Assistant discovery via bonjour-service */\nexport class MDNSService {\n private readonly adapter: AdapterInterface;\n private readonly config: AdapterConfig;\n public readonly uuid: string;\n public active = false;\n private bonjour: Bonjour | null = null;\n private published: Service | null = null;\n\n /**\n * Creates a new MDNSService instance\n *\n * @param adapter - Adapter interface for logging\n * @param config - Adapter configuration\n * @param uuid - Shared UUID for consistent identity across WebServer and mDNS\n */\n constructor(adapter: AdapterInterface, config: AdapterConfig, uuid: string) {\n this.adapter = adapter;\n this.config = config;\n this.uuid = uuid;\n }\n\n /** First non-internal IPv4 address (wraps shared helper for backwards-compat). */\n getLocalIP(): string {\n return getLocalIp();\n }\n\n /** Start mDNS broadcasting via bonjour-service */\n start(): void {\n const localIP = getLocalIp();\n const baseUrl = `http://${localIP}:${this.config.port}`;\n const serviceName = this.config.serviceName || 'ioBroker';\n\n try {\n this.bonjour = new Bonjour();\n\n // Empty TXT records are dropped \u2014 bonjour-service publishes them as\n // empty strings otherwise, which clutters the discovery payload.\n const txt: Record<string, string> = {\n base_url: baseUrl,\n internal_url: baseUrl,\n version: HA_VERSION,\n uuid: this.uuid,\n location_name: serviceName,\n requires_api_password: 'True',\n };\n\n this.published = this.bonjour.publish({\n name: serviceName,\n type: 'home-assistant',\n protocol: 'tcp',\n port: this.config.port,\n txt,\n });\n\n this.active = true;\n\n this.adapter.log.debug(\n `mDNS: Broadcasting ${serviceName}._home-assistant._tcp.local on ${localIP}:${this.config.port}`,\n );\n this.adapter.log.debug(`mDNS: UUID: ${this.uuid}`);\n } catch (error) {\n const err = error as Error;\n this.adapter.log.warn(`mDNS: Failed to start: ${err.message}`);\n }\n }\n\n /** Stop mDNS broadcasting */\n stop(): void {\n if (!this.active) {\n return;\n }\n\n try {\n if (this.published) {\n this.published.stop?.();\n this.published = null;\n }\n if (this.bonjour) {\n this.bonjour.destroy();\n this.bonjour = null;\n }\n this.adapter.log.debug('mDNS: Service stopped');\n } catch (error) {\n const err = error as Error;\n this.adapter.log.warn(`mDNS: Could not stop cleanly: ${err.message}`);\n }\n\n this.active = false;\n }\n}\n"],
5
- "mappings": ";;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,6BAAsC;AACtC,uBAA2B;AAC3B,qBAA2B;AAIpB,MAAM,YAAY;AAAA,EACJ;AAAA,EACA;AAAA,EACD;AAAA,EACT,SAAS;AAAA,EACR,UAA0B;AAAA,EAC1B,YAA4B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EASpC,YAAY,SAA2B,QAAuB,MAAc;AACxE,SAAK,UAAU;AACf,SAAK,SAAS;AACd,SAAK,OAAO;AAAA,EAChB;AAAA;AAAA,EAGA,aAAqB;AACjB,eAAO,2BAAW;AAAA,EACtB;AAAA;AAAA,EAGA,QAAc;AACV,UAAM,cAAU,2BAAW;AAC3B,UAAM,UAAU,UAAU,OAAO,IAAI,KAAK,OAAO,IAAI;AACrD,UAAM,cAAc,KAAK,OAAO,eAAe;AAE/C,QAAI;AACA,WAAK,UAAU,IAAI,uBAAAA,QAAQ;AAI3B,YAAM,MAA8B;AAAA,QAChC,UAAU;AAAA,QACV,cAAc;AAAA,QACd,SAAS;AAAA,QACT,MAAM,KAAK;AAAA,QACX,eAAe;AAAA,QACf,uBAAuB;AAAA,MAC3B;AAEA,WAAK,YAAY,KAAK,QAAQ,QAAQ;AAAA,QAClC,MAAM;AAAA,QACN,MAAM;AAAA,QACN,UAAU;AAAA,QACV,MAAM,KAAK,OAAO;AAAA,QAClB;AAAA,MACJ,CAAC;AAED,WAAK,SAAS;AAEd,WAAK,QAAQ,IAAI;AAAA,QACb,sBAAsB,WAAW,kCAAkC,OAAO,IAAI,KAAK,OAAO,IAAI;AAAA,MAClG;AACA,WAAK,QAAQ,IAAI,MAAM,eAAe,KAAK,IAAI,EAAE;AAAA,IACrD,SAAS,OAAO;AACZ,YAAM,MAAM;AACZ,WAAK,QAAQ,IAAI,KAAK,0BAA0B,IAAI,OAAO,EAAE;AAAA,IACjE;AAAA,EACJ;AAAA;AAAA,EAGA,OAAa;AAzEjB;AA0EQ,QAAI,CAAC,KAAK,QAAQ;AACd;AAAA,IACJ;AAEA,QAAI;AACA,UAAI,KAAK,WAAW;AAChB,yBAAK,WAAU,SAAf;AACA,aAAK,YAAY;AAAA,MACrB;AACA,UAAI,KAAK,SAAS;AACd,aAAK,QAAQ,QAAQ;AACrB,aAAK,UAAU;AAAA,MACnB;AACA,WAAK,QAAQ,IAAI,MAAM,uBAAuB;AAAA,IAClD,SAAS,OAAO;AACZ,YAAM,MAAM;AACZ,WAAK,QAAQ,IAAI,KAAK,iCAAiC,IAAI,OAAO,EAAE;AAAA,IACxE;AAEA,SAAK,SAAS;AAAA,EAClB;AACJ;",
4
+ "sourcesContent": ["import Bonjour, { type Service } from 'bonjour-service';\nimport { HA_VERSION } from './constants';\nimport { getLocalIp } from './network';\nimport type { AdapterConfig, AdapterInterface } from './types';\n\n/** mDNS service for Home Assistant discovery via bonjour-service */\nexport class MDNSService {\n private readonly adapter: AdapterInterface;\n private readonly config: AdapterConfig;\n public readonly uuid: string;\n public active = false;\n private bonjour: Bonjour | null = null;\n private published: Service | null = null;\n\n /**\n * Creates a new MDNSService instance\n *\n * @param adapter - Adapter interface for logging\n * @param config - Adapter configuration\n * @param uuid - Shared UUID for consistent identity across WebServer and mDNS\n */\n constructor(adapter: AdapterInterface, config: AdapterConfig, uuid: string) {\n this.adapter = adapter;\n this.config = config;\n this.uuid = uuid;\n }\n\n /** First non-internal IPv4 address (wraps shared helper for backwards-compat). */\n getLocalIP(): string {\n return getLocalIp();\n }\n\n /** Start mDNS broadcasting via bonjour-service */\n start(): void {\n const localIP = getLocalIp();\n const baseUrl = `http://${localIP}:${this.config.port}`;\n const serviceName = this.config.serviceName || 'ioBroker';\n\n try {\n this.bonjour = new Bonjour();\n\n // Empty TXT records are dropped \u2014 bonjour-service publishes them as\n // empty strings otherwise, which clutters the discovery payload.\n const txt: Record<string, string> = {\n base_url: baseUrl,\n internal_url: baseUrl,\n version: HA_VERSION,\n uuid: this.uuid,\n location_name: serviceName,\n requires_api_password: 'True',\n };\n\n this.published = this.bonjour.publish({\n name: serviceName,\n type: 'home-assistant',\n protocol: 'tcp',\n port: this.config.port,\n txt,\n });\n\n this.active = true;\n\n this.adapter.log.debug(\n `mDNS: Broadcasting ${serviceName}._home-assistant._tcp.local on ${localIP}:${this.config.port}`,\n );\n this.adapter.log.debug(`mDNS: UUID: ${this.uuid}`);\n } catch (error) {\n const err = error as Error;\n this.adapter.log.warn(`mDNS: Failed to start: ${err.message}`);\n // Wichtig: bonjour-instance freigeben sonst leakt der UDP-Socket\n // \u00FCber die Adapter-Lifetime. `stop()` short-circuit'd auf\n // `!this.active` und w\u00FCrde nichts cleanen.\n try {\n this.bonjour?.destroy();\n } catch {\n /* destroy darf re-throwen \u2014 wir wollen nur die Resource lossen */\n }\n this.bonjour = null;\n this.published = null;\n }\n }\n\n /** Stop mDNS broadcasting */\n stop(): void {\n if (!this.active) {\n return;\n }\n\n try {\n if (this.published) {\n this.published.stop?.();\n this.published = null;\n }\n if (this.bonjour) {\n this.bonjour.destroy();\n this.bonjour = null;\n }\n this.adapter.log.debug('mDNS: Service stopped');\n } catch (error) {\n const err = error as Error;\n this.adapter.log.warn(`mDNS: Could not stop cleanly: ${err.message}`);\n }\n\n this.active = false;\n }\n}\n"],
5
+ "mappings": ";;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,6BAAsC;AACtC,uBAA2B;AAC3B,qBAA2B;AAIpB,MAAM,YAAY;AAAA,EACJ;AAAA,EACA;AAAA,EACD;AAAA,EACT,SAAS;AAAA,EACR,UAA0B;AAAA,EAC1B,YAA4B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EASpC,YAAY,SAA2B,QAAuB,MAAc;AACxE,SAAK,UAAU;AACf,SAAK,SAAS;AACd,SAAK,OAAO;AAAA,EAChB;AAAA;AAAA,EAGA,aAAqB;AACjB,eAAO,2BAAW;AAAA,EACtB;AAAA;AAAA,EAGA,QAAc;AAjClB;AAkCQ,UAAM,cAAU,2BAAW;AAC3B,UAAM,UAAU,UAAU,OAAO,IAAI,KAAK,OAAO,IAAI;AACrD,UAAM,cAAc,KAAK,OAAO,eAAe;AAE/C,QAAI;AACA,WAAK,UAAU,IAAI,uBAAAA,QAAQ;AAI3B,YAAM,MAA8B;AAAA,QAChC,UAAU;AAAA,QACV,cAAc;AAAA,QACd,SAAS;AAAA,QACT,MAAM,KAAK;AAAA,QACX,eAAe;AAAA,QACf,uBAAuB;AAAA,MAC3B;AAEA,WAAK,YAAY,KAAK,QAAQ,QAAQ;AAAA,QAClC,MAAM;AAAA,QACN,MAAM;AAAA,QACN,UAAU;AAAA,QACV,MAAM,KAAK,OAAO;AAAA,QAClB;AAAA,MACJ,CAAC;AAED,WAAK,SAAS;AAEd,WAAK,QAAQ,IAAI;AAAA,QACb,sBAAsB,WAAW,kCAAkC,OAAO,IAAI,KAAK,OAAO,IAAI;AAAA,MAClG;AACA,WAAK,QAAQ,IAAI,MAAM,eAAe,KAAK,IAAI,EAAE;AAAA,IACrD,SAAS,OAAO;AACZ,YAAM,MAAM;AACZ,WAAK,QAAQ,IAAI,KAAK,0BAA0B,IAAI,OAAO,EAAE;AAI7D,UAAI;AACA,mBAAK,YAAL,mBAAc;AAAA,MAClB,QAAQ;AAAA,MAER;AACA,WAAK,UAAU;AACf,WAAK,YAAY;AAAA,IACrB;AAAA,EACJ;AAAA;AAAA,EAGA,OAAa;AAnFjB;AAoFQ,QAAI,CAAC,KAAK,QAAQ;AACd;AAAA,IACJ;AAEA,QAAI;AACA,UAAI,KAAK,WAAW;AAChB,yBAAK,WAAU,SAAf;AACA,aAAK,YAAY;AAAA,MACrB;AACA,UAAI,KAAK,SAAS;AACd,aAAK,QAAQ,QAAQ;AACrB,aAAK,UAAU;AAAA,MACnB;AACA,WAAK,QAAQ,IAAI,MAAM,uBAAuB;AAAA,IAClD,SAAS,OAAO;AACZ,YAAM,MAAM;AACZ,WAAK,QAAQ,IAAI,KAAK,iCAAiC,IAAI,OAAO,EAAE;AAAA,IACxE;AAEA,SAAK,SAAS;AAAA,EAClB;AACJ;",
6
6
  "names": ["Bonjour"]
7
7
  }
@@ -35,6 +35,7 @@ module.exports = __toCommonJS(webserver_exports);
35
35
  var import_node_crypto = __toESM(require("node:crypto"));
36
36
  var import_promises = __toESM(require("node:dns/promises"));
37
37
  var import_cookie = __toESM(require("@fastify/cookie"));
38
+ var import_formbody = __toESM(require("@fastify/formbody"));
38
39
  var import_fastify = __toESM(require("fastify"));
39
40
  var import_constants = require("./constants");
40
41
  var import_coerce = require("./coerce");
@@ -110,6 +111,7 @@ class WebServer {
110
111
  async start() {
111
112
  var _a;
112
113
  await this.app.register(import_cookie.default);
114
+ await this.app.register(import_formbody.default);
113
115
  this.setupErrorHandler();
114
116
  this.setupRoutes();
115
117
  const bindAddress = this.config.bindAddress || "0.0.0.0";
@@ -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 Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from 'fastify';\nimport {\n HA_VERSION,\n SESSION_TTL_MS,\n CLEANUP_INTERVAL_MS,\n LOGIN_SCHEMA,\n OAUTH_ACCESS_TOKEN_TTL_S,\n LOGIN_LOCKOUT_THRESHOLD,\n LOGIN_LOCKOUT_WINDOW_MS,\n} from './constants';\nimport { coerceString, coerceUuid } from './coerce';\nimport type { ClientRegistry } from './client-registry';\nimport type { GlobalConfig } from './global-config';\nimport { renderLandingPage } from './landing-page';\nimport type { AdapterConfig, AdapterInterface, ClientRecord, SessionData } from './types';\n\n/** Hard cap on in-flight auth flow sessions. Older entries are dropped FIFO when full. */\nconst SESSIONS_CAP = 100;\n/** Hard cap on remembered refresh tokens. Older entries are dropped FIFO when full. */\nconst REFRESH_TOKENS_CAP = 200;\n\n/**\n * Constant-time string comparison for credential checks. Returns false for length mismatch.\n *\n * @param a First string to compare.\n * @param b Second string to compare.\n */\nfunction safeStringEqual(a: string, b: string): boolean {\n const ab = Buffer.from(a, 'utf8');\n const bb = Buffer.from(b, 'utf8');\n if (ab.length !== bb.length) {\n return false;\n }\n return crypto.timingSafeEqual(ab, bb);\n}\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/** Browser cookie name. Client identity lives here \u2014 auto-sent on every page navigation. */\nexport const CLIENT_COOKIE = 'hassemu_client';\n/** Cookie lifetime (10 years). Clients stay identified essentially forever unless removed. */\nconst COOKIE_MAX_AGE_S = 10 * 365 * 24 * 60 * 60;\n\n/**\n * Fastify web server emulating the HA REST API.\n *\n * Each incoming request is identified by cookie \u2192 {@link ClientRegistry} entry; new clients\n * get a channel created on first hit. Express was swapped for Fastify in 1.1.0 for first-party\n * cookie support, schema validation and a lighter runtime.\n */\nexport class WebServer {\n private readonly adapter: WebServerAdapter;\n private readonly config: AdapterConfig;\n private readonly registry: ClientRegistry;\n private readonly globalConfig: GlobalConfig;\n private readonly app: FastifyInstance;\n public readonly sessions: Map<string, SessionData> = new Map();\n /**\n * Issued refresh tokens \u2192 owning clientId. Validated on every refresh-grant \u2014\n * unknown tokens are rejected (was: any string accepted).\n */\n public readonly refreshTokens: Map<string, string> = new Map();\n /**\n * Brute-force lockout state per remote IP. Each entry tracks failed login\n * attempts; once {@link LOGIN_LOCKOUT_THRESHOLD} is reached, `lockedUntil`\n * is set and further attempts from that IP are rejected with HTTP 429\n * until the window passes. Expired entries are pruned in {@link cleanupSessions}.\n */\n public readonly loginAttempts: Map<string, { failedCount: number; lockedUntil: number }> = new Map();\n private cleanupTimer: ioBroker.Interval | null = null;\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 /**\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 this.app = Fastify({ logger: false, trustProxy: false });\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 await this.app.register(fastifyCookie);\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' ? `Port ${this.config.port} is already in use!` : `Server error: ${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 this.adapter.log.error(`Web server stop error: ${String(err)}`);\n }\n }\n\n /** Exposed for testing \u2014 fires injected requests without a real socket. */\n get inject(): FastifyInstance['inject'] {\n return this.app.inject.bind(this.app);\n }\n\n /** Periodic cleanup of expired in-flight auth sessions and stale lockouts. */\n public cleanupSessions(): void {\n const now = Date.now();\n let cleanedSessions = 0;\n for (const [key, session] of this.sessions) {\n if (now - session.created > SESSION_TTL_MS) {\n this.sessions.delete(key);\n cleanedSessions++;\n }\n }\n if (cleanedSessions > 0) {\n this.adapter.log.debug(`Session cleanup: removed ${cleanedSessions} expired sessions`);\n }\n let cleanedLockouts = 0;\n for (const [ip, entry] of this.loginAttempts) {\n if (entry.lockedUntil > 0 && entry.lockedUntil <= now) {\n this.loginAttempts.delete(ip);\n cleanedLockouts++;\n }\n }\n if (cleanedLockouts > 0) {\n this.adapter.log.debug(`Lockout cleanup: cleared ${cleanedLockouts} expired IP lockouts`);\n }\n }\n\n /**\n * Drops the oldest entry of a Map if it would exceed `cap` after the next insert.\n * Map iteration order in JS is insertion order, so `keys().next()` is the oldest.\n *\n * @param map Map to evict from.\n * @param cap Hard cap; when `map.size >= cap`, the oldest entry is removed.\n */\n private static evictOldest<V>(map: Map<string, V>, cap: number): void {\n if (map.size < cap) {\n return;\n }\n const oldest = map.keys().next().value;\n if (oldest !== undefined) {\n map.delete(oldest);\n }\n }\n\n /**\n * Inserts a session, dropping the oldest entry if {@link SESSIONS_CAP} is exceeded.\n *\n * @param key Session key (flow id or auth code).\n * @param data Session payload.\n */\n private storeSession(key: string, data: SessionData): void {\n WebServer.evictOldest(this.sessions, SESSIONS_CAP);\n this.sessions.set(key, data);\n }\n\n /**\n * Inserts a refresh token mapping, dropping the oldest if cap exceeded.\n *\n * @param token Refresh token issued in `/auth/token`.\n * @param clientId Owning client id.\n */\n private storeRefreshToken(token: string, clientId: string): void {\n WebServer.evictOldest(this.refreshTokens, REFRESH_TOKENS_CAP);\n this.refreshTokens.set(token, clientId);\n }\n\n /**\n * Brute-force lockout: returns true if `ip` is currently in the timeout window.\n * Lazy-resets entries whose lockout already expired (caller can immediately try again).\n *\n * @param ip Remote IP, or null when unavailable.\n */\n private isIpLocked(ip: string | null): boolean {\n if (!ip) {\n return false;\n }\n const entry = this.loginAttempts.get(ip);\n if (!entry || entry.lockedUntil === 0) {\n return false;\n }\n if (entry.lockedUntil > Date.now()) {\n return true;\n }\n // Lockout window passed \u2014 drop the entry, IP gets a fresh budget.\n this.loginAttempts.delete(ip);\n return false;\n }\n\n /**\n * Records a failed login attempt for `ip`. When the running count reaches\n * {@link LOGIN_LOCKOUT_THRESHOLD}, the IP is locked for\n * {@link LOGIN_LOCKOUT_WINDOW_MS}.\n *\n * @param ip Remote IP that failed authentication.\n */\n private recordLoginFailure(ip: string | null): void {\n if (!ip) {\n return;\n }\n const entry = this.loginAttempts.get(ip) ?? { failedCount: 0, lockedUntil: 0 };\n entry.failedCount += 1;\n if (entry.failedCount >= LOGIN_LOCKOUT_THRESHOLD) {\n entry.lockedUntil = Date.now() + LOGIN_LOCKOUT_WINDOW_MS;\n this.adapter.log.warn(\n `Login lockout: IP ${ip} reached ${LOGIN_LOCKOUT_THRESHOLD} failed attempts \u2014 ` +\n `locked for ${Math.round(LOGIN_LOCKOUT_WINDOW_MS / 60000)} min`,\n );\n }\n this.loginAttempts.set(ip, entry);\n }\n\n /**\n * Resets the failure counter and any active lockout for `ip`. Called after\n * a successful credential check so legit clients don't accumulate counts\n * across long-lived sessions.\n *\n * @param ip Remote IP that just authenticated successfully.\n */\n private clearLoginAttempts(ip: string | null): void {\n if (ip) {\n this.loginAttempts.delete(ip);\n }\n }\n\n // --- client identification ---\n\n private async identify(req: FastifyRequest, reply: FastifyReply): Promise<ClientRecord> {\n const cookie = coerceUuid(req.cookies?.[CLIENT_COOKIE]);\n const ip = coerceString(req.ip);\n const record = await this.registry.identifyOrCreate(cookie, ip, null);\n if (cookie !== record.cookie) {\n reply.setCookie(CLIENT_COOKIE, record.cookie, {\n path: '/',\n httpOnly: true,\n sameSite: 'lax',\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 dns.reverse(ip)\n .then(names => {\n const name = names[0];\n if (name) {\n this.registry.identifyOrCreate(record.cookie, ip, name).catch(() => {\n /* registry itself logs */\n });\n }\n })\n .catch(() => {\n // Reverse DNS often fails on LAN \u2014 intentionally silent.\n })\n .finally(() => {\n this.dnsInFlight.delete(ip);\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 this.adapter.log.warn(`Request error: ${error.message}`);\n reply.status(500).send({ error: 'Internal server error' });\n });\n }\n\n // --- routes ---\n\n private setupRoutes(): void {\n this.setupApiRoutes();\n this.setupAuthRoutes();\n this.setupMiscRoutes();\n this.setupNotFound();\n }\n\n private setupApiRoutes(): void {\n // CRITICAL: trailing slash \u2014 HA clients check this endpoint for discovery\n this.app.get('/api/', () => ({ message: 'API running.' }));\n\n this.app.get('/api/config', () => ({\n components: ['http', 'api', 'frontend', 'homeassistant'],\n config_dir: '/config',\n elevation: 0,\n latitude: 0,\n longitude: 0,\n location_name: this.serviceName,\n time_zone: 'UTC',\n unit_system: { length: 'km', mass: 'g', temperature: '\u00B0C', volume: 'L' },\n version: HA_VERSION,\n whitelist_external_dirs: [],\n }));\n\n this.app.get('/api/discovery_info', req => {\n const host = req.hostname || this.config.bindAddress || '0.0.0.0';\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 requires_api_password: true,\n uuid: this.instanceUuid,\n version: HA_VERSION,\n };\n });\n\n for (const path of ['/api/states', '/api/services', '/api/events']) {\n this.app.get(path, () => []);\n }\n this.app.get('/api/error_log', () => '');\n }\n\n private setupAuthRoutes(): void {\n this.app.get('/auth/providers', () => [{ name: 'Home Assistant Local', type: 'homeassistant', id: null }]);\n\n this.app.post('/auth/login_flow', async (req, reply) => {\n const client = await this.identify(req, reply);\n const flowId = crypto.randomUUID();\n this.storeSession(flowId, { created: Date.now(), clientId: client.id });\n this.adapter.log.debug(`Auth flow created: ${flowId} for client ${client.id}`);\n\n return {\n type: 'form',\n flow_id: flowId,\n handler: ['homeassistant', null],\n step_id: 'init',\n data_schema: LOGIN_SCHEMA,\n description_placeholders: null,\n errors: null,\n };\n });\n\n this.app.post<{\n Params: { flowId: string };\n Body: { username?: string; password?: string };\n }>(\n '/auth/login_flow/:flowId',\n {\n schema: {\n params: {\n type: 'object',\n properties: { flowId: { type: 'string', minLength: 1 } },\n required: ['flowId'],\n },\n },\n },\n async (req, reply) => {\n const flowId = req.params.flowId;\n const session = this.sessions.get(flowId);\n if (!session) {\n this.adapter.log.warn(`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 = coerceString(req.ip);\n if (this.isIpLocked(ip)) {\n this.adapter.log.warn(`Login rejected: IP ${ip} is currently locked out`);\n reply.status(429);\n return { type: 'abort', flow_id: flowId, reason: 'too_many_failed_attempts' };\n }\n const { username, password } = req.body ?? {};\n const userOk = typeof username === 'string' && safeStringEqual(username, this.config.username);\n const passOk = typeof password === 'string' && safeStringEqual(password, this.config.password);\n if (!userOk || !passOk) {\n this.recordLoginFailure(ip);\n this.adapter.log.warn('Invalid credentials');\n reply.status(400);\n return {\n type: 'form',\n flow_id: flowId,\n handler: ['homeassistant', null],\n step_id: 'init',\n data_schema: LOGIN_SCHEMA,\n errors: { base: 'invalid_auth' },\n description_placeholders: null,\n };\n }\n this.clearLoginAttempts(ip);\n }\n\n this.sessions.delete(flowId);\n const code = crypto.randomUUID();\n this.storeSession(code, { created: Date.now(), clientId: session.clientId });\n this.adapter.log.debug('Auth flow completed \u2014 code issued');\n\n return {\n version: 1,\n type: 'create_entry',\n flow_id: flowId,\n handler: ['homeassistant', null],\n result: code,\n description: null,\n description_placeholders: null,\n };\n },\n );\n\n this.app.post<{ Body: { code?: string; grant_type?: string; refresh_token?: string } }>(\n '/auth/token',\n async (req, reply) => {\n const { code, grant_type, refresh_token } = req.body ?? {};\n\n if (grant_type === 'authorization_code' && code && this.sessions.has(code)) {\n const session = this.sessions.get(code)!;\n this.sessions.delete(code);\n const token = crypto.randomUUID();\n const refreshToken = crypto.randomUUID();\n if (session.clientId) {\n await this.registry.setToken(session.clientId, token);\n this.storeRefreshToken(refreshToken, session.clientId);\n this.adapter.log.debug(`Display authenticated \u2014 client ${session.clientId}`);\n }\n return {\n access_token: token,\n token_type: 'Bearer',\n refresh_token: refreshToken,\n expires_in: OAUTH_ACCESS_TOKEN_TTL_S,\n };\n }\n\n if (grant_type === 'refresh_token') {\n // Validate the refresh token against issued ones \u2014 was previously\n // accepting any string and minting a new access_token (security fix v1.2.0).\n const incoming = typeof refresh_token === 'string' ? refresh_token : '';\n const ownerId = incoming ? this.refreshTokens.get(incoming) : undefined;\n if (!ownerId) {\n this.adapter.log.warn('Refresh token rejected \u2014 unknown or missing');\n reply.status(400);\n return { error: 'invalid_grant', error_description: 'Invalid refresh token' };\n }\n const newAccess = crypto.randomUUID();\n await this.registry.setToken(ownerId, newAccess);\n return {\n access_token: newAccess,\n token_type: 'Bearer',\n expires_in: OAUTH_ACCESS_TOKEN_TTL_S,\n };\n }\n\n this.adapter.log.warn(`Token exchange failed: grant_type=${String(grant_type)}`);\n reply.status(400);\n return { error: 'invalid_request', error_description: 'Invalid or expired code' };\n },\n );\n }\n\n private setupMiscRoutes(): void {\n // Liveness only \u2014 no config leak. Earlier versions exposed the global\n // redirect URL via /health which is unauthenticated; removed in v1.2.0.\n this.app.get('/health', () => ({\n status: 'ok',\n adapter: 'hassemu',\n version: HA_VERSION,\n config: {\n mdns: this.config.mdnsEnabled,\n auth: this.config.authRequired,\n },\n }));\n\n this.app.get('/manifest.json', () => ({\n name: this.serviceName,\n short_name: this.serviceName,\n start_url: '/',\n display: 'standalone',\n background_color: '#ffffff',\n theme_color: '#03a9f4',\n }));\n\n // Root \u2014 302 redirect, or landing page when no URL is configured\n this.app.get('/', async (req, reply) => {\n const client = await this.identify(req, reply);\n const url = this.globalConfig.resolveUrlFor(client);\n if (!url) {\n this.adapter.log.debug(`No redirect URL for client ${client.id} \u2014 serving landing page`);\n return reply\n .status(200)\n .type('text/html; charset=utf-8')\n .send(renderLandingPage(client.id, this.adapter.namespace, this.systemLanguage, client.ip));\n }\n this.adapter.log.debug(`Redirecting client ${client.id} \u2192 ${url}`);\n return reply.redirect(url, 302);\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
- "names": ["crypto", "Fastify", "fastifyCookie", "dns"]
4
+ "sourcesContent": ["import crypto from 'node:crypto';\nimport dns from 'node:dns/promises';\nimport fastifyCookie from '@fastify/cookie';\nimport fastifyFormbody from '@fastify/formbody';\nimport Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from 'fastify';\nimport {\n HA_VERSION,\n SESSION_TTL_MS,\n CLEANUP_INTERVAL_MS,\n LOGIN_SCHEMA,\n OAUTH_ACCESS_TOKEN_TTL_S,\n LOGIN_LOCKOUT_THRESHOLD,\n LOGIN_LOCKOUT_WINDOW_MS,\n} from './constants';\nimport { coerceString, coerceUuid } from './coerce';\nimport type { ClientRegistry } from './client-registry';\nimport type { GlobalConfig } from './global-config';\nimport { renderLandingPage } from './landing-page';\nimport type { AdapterConfig, AdapterInterface, ClientRecord, SessionData } from './types';\n\n/** Hard cap on in-flight auth flow sessions. Older entries are dropped FIFO when full. */\nconst SESSIONS_CAP = 100;\n/** Hard cap on remembered refresh tokens. Older entries are dropped FIFO when full. */\nconst REFRESH_TOKENS_CAP = 200;\n\n/**\n * Constant-time string comparison for credential checks. Returns false for length mismatch.\n *\n * @param a First string to compare.\n * @param b Second string to compare.\n */\nfunction safeStringEqual(a: string, b: string): boolean {\n const ab = Buffer.from(a, 'utf8');\n const bb = Buffer.from(b, 'utf8');\n if (ab.length !== bb.length) {\n return false;\n }\n return crypto.timingSafeEqual(ab, bb);\n}\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/** Browser cookie name. Client identity lives here \u2014 auto-sent on every page navigation. */\nexport const CLIENT_COOKIE = 'hassemu_client';\n/** Cookie lifetime (10 years). Clients stay identified essentially forever unless removed. */\nconst COOKIE_MAX_AGE_S = 10 * 365 * 24 * 60 * 60;\n\n/**\n * Fastify web server emulating the HA REST API.\n *\n * Each incoming request is identified by cookie \u2192 {@link ClientRegistry} entry; new clients\n * get a channel created on first hit. Express was swapped for Fastify in 1.1.0 for first-party\n * cookie support, schema validation and a lighter runtime.\n */\nexport class WebServer {\n private readonly adapter: WebServerAdapter;\n private readonly config: AdapterConfig;\n private readonly registry: ClientRegistry;\n private readonly globalConfig: GlobalConfig;\n private readonly app: FastifyInstance;\n public readonly sessions: Map<string, SessionData> = new Map();\n /**\n * Issued refresh tokens \u2192 owning clientId. Validated on every refresh-grant \u2014\n * unknown tokens are rejected (was: any string accepted).\n */\n public readonly refreshTokens: Map<string, string> = new Map();\n /**\n * Brute-force lockout state per remote IP. Each entry tracks failed login\n * attempts; once {@link LOGIN_LOCKOUT_THRESHOLD} is reached, `lockedUntil`\n * is set and further attempts from that IP are rejected with HTTP 429\n * until the window passes. Expired entries are pruned in {@link cleanupSessions}.\n */\n public readonly loginAttempts: Map<string, { failedCount: number; lockedUntil: number }> = new Map();\n private cleanupTimer: ioBroker.Interval | null = null;\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 /**\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 this.app = Fastify({ logger: false, trustProxy: false });\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 await this.app.register(fastifyCookie);\n // OAuth2-Spec verlangt `application/x-www-form-urlencoded` f\u00FCr `/auth/token`.\n // Echte HA-Reference-Clients (frontend/Wall Display SDK) folgen dem.\n // Fastify hat by-default nur einen JSON-Bodyparser \u2014 ohne diesen Plugin\n // beantwortet `/auth/token` mit form-Body 415 und der Login bleibt komplett\n // h\u00E4ngen. Tests via `app.inject({payload:{...}})` serialisieren zu JSON\n // und maskieren das.\n await this.app.register(fastifyFormbody);\n this.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' ? `Port ${this.config.port} is already in use!` : `Server error: ${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 this.adapter.log.error(`Web server stop error: ${String(err)}`);\n }\n }\n\n /** Exposed for testing \u2014 fires injected requests without a real socket. */\n get inject(): FastifyInstance['inject'] {\n return this.app.inject.bind(this.app);\n }\n\n /** Periodic cleanup of expired in-flight auth sessions and stale lockouts. */\n public cleanupSessions(): void {\n const now = Date.now();\n let cleanedSessions = 0;\n for (const [key, session] of this.sessions) {\n if (now - session.created > SESSION_TTL_MS) {\n this.sessions.delete(key);\n cleanedSessions++;\n }\n }\n if (cleanedSessions > 0) {\n this.adapter.log.debug(`Session cleanup: removed ${cleanedSessions} expired sessions`);\n }\n let cleanedLockouts = 0;\n for (const [ip, entry] of this.loginAttempts) {\n if (entry.lockedUntil > 0 && entry.lockedUntil <= now) {\n this.loginAttempts.delete(ip);\n cleanedLockouts++;\n }\n }\n if (cleanedLockouts > 0) {\n this.adapter.log.debug(`Lockout cleanup: cleared ${cleanedLockouts} expired IP lockouts`);\n }\n }\n\n /**\n * Drops the oldest entry of a Map if it would exceed `cap` after the next insert.\n * Map iteration order in JS is insertion order, so `keys().next()` is the oldest.\n *\n * @param map Map to evict from.\n * @param cap Hard cap; when `map.size >= cap`, the oldest entry is removed.\n */\n private static evictOldest<V>(map: Map<string, V>, cap: number): void {\n if (map.size < cap) {\n return;\n }\n const oldest = map.keys().next().value;\n if (oldest !== undefined) {\n map.delete(oldest);\n }\n }\n\n /**\n * Inserts a session, dropping the oldest entry if {@link SESSIONS_CAP} is exceeded.\n *\n * @param key Session key (flow id or auth code).\n * @param data Session payload.\n */\n private storeSession(key: string, data: SessionData): void {\n WebServer.evictOldest(this.sessions, SESSIONS_CAP);\n this.sessions.set(key, data);\n }\n\n /**\n * Inserts a refresh token mapping, dropping the oldest if cap exceeded.\n *\n * @param token Refresh token issued in `/auth/token`.\n * @param clientId Owning client id.\n */\n private storeRefreshToken(token: string, clientId: string): void {\n WebServer.evictOldest(this.refreshTokens, REFRESH_TOKENS_CAP);\n this.refreshTokens.set(token, clientId);\n }\n\n /**\n * Brute-force lockout: returns true if `ip` is currently in the timeout window.\n * Lazy-resets entries whose lockout already expired (caller can immediately try again).\n *\n * @param ip Remote IP, or null when unavailable.\n */\n private isIpLocked(ip: string | null): boolean {\n if (!ip) {\n return false;\n }\n const entry = this.loginAttempts.get(ip);\n if (!entry || entry.lockedUntil === 0) {\n return false;\n }\n if (entry.lockedUntil > Date.now()) {\n return true;\n }\n // Lockout window passed \u2014 drop the entry, IP gets a fresh budget.\n this.loginAttempts.delete(ip);\n return false;\n }\n\n /**\n * Records a failed login attempt for `ip`. When the running count reaches\n * {@link LOGIN_LOCKOUT_THRESHOLD}, the IP is locked for\n * {@link LOGIN_LOCKOUT_WINDOW_MS}.\n *\n * @param ip Remote IP that failed authentication.\n */\n private recordLoginFailure(ip: string | null): void {\n if (!ip) {\n return;\n }\n const entry = this.loginAttempts.get(ip) ?? { failedCount: 0, lockedUntil: 0 };\n entry.failedCount += 1;\n if (entry.failedCount >= LOGIN_LOCKOUT_THRESHOLD) {\n entry.lockedUntil = Date.now() + LOGIN_LOCKOUT_WINDOW_MS;\n this.adapter.log.warn(\n `Login lockout: IP ${ip} reached ${LOGIN_LOCKOUT_THRESHOLD} failed attempts \u2014 ` +\n `locked for ${Math.round(LOGIN_LOCKOUT_WINDOW_MS / 60000)} min`,\n );\n }\n this.loginAttempts.set(ip, entry);\n }\n\n /**\n * Resets the failure counter and any active lockout for `ip`. Called after\n * a successful credential check so legit clients don't accumulate counts\n * across long-lived sessions.\n *\n * @param ip Remote IP that just authenticated successfully.\n */\n private clearLoginAttempts(ip: string | null): void {\n if (ip) {\n this.loginAttempts.delete(ip);\n }\n }\n\n // --- client identification ---\n\n private async identify(req: FastifyRequest, reply: FastifyReply): Promise<ClientRecord> {\n const cookie = coerceUuid(req.cookies?.[CLIENT_COOKIE]);\n const ip = coerceString(req.ip);\n const record = await this.registry.identifyOrCreate(cookie, ip, null);\n if (cookie !== record.cookie) {\n reply.setCookie(CLIENT_COOKIE, record.cookie, {\n path: '/',\n httpOnly: true,\n sameSite: 'lax',\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 dns.reverse(ip)\n .then(names => {\n const name = names[0];\n if (name) {\n this.registry.identifyOrCreate(record.cookie, ip, name).catch(() => {\n /* registry itself logs */\n });\n }\n })\n .catch(() => {\n // Reverse DNS often fails on LAN \u2014 intentionally silent.\n })\n .finally(() => {\n this.dnsInFlight.delete(ip);\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 this.adapter.log.warn(`Request error: ${error.message}`);\n reply.status(500).send({ error: 'Internal server error' });\n });\n }\n\n // --- routes ---\n\n private setupRoutes(): void {\n this.setupApiRoutes();\n this.setupAuthRoutes();\n this.setupMiscRoutes();\n this.setupNotFound();\n }\n\n private setupApiRoutes(): void {\n // CRITICAL: trailing slash \u2014 HA clients check this endpoint for discovery\n this.app.get('/api/', () => ({ message: 'API running.' }));\n\n this.app.get('/api/config', () => ({\n components: ['http', 'api', 'frontend', 'homeassistant'],\n config_dir: '/config',\n elevation: 0,\n latitude: 0,\n longitude: 0,\n location_name: this.serviceName,\n time_zone: 'UTC',\n unit_system: { length: 'km', mass: 'g', temperature: '\u00B0C', volume: 'L' },\n version: HA_VERSION,\n whitelist_external_dirs: [],\n }));\n\n this.app.get('/api/discovery_info', req => {\n const host = req.hostname || this.config.bindAddress || '0.0.0.0';\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 requires_api_password: true,\n uuid: this.instanceUuid,\n version: HA_VERSION,\n };\n });\n\n for (const path of ['/api/states', '/api/services', '/api/events']) {\n this.app.get(path, () => []);\n }\n this.app.get('/api/error_log', () => '');\n }\n\n private setupAuthRoutes(): void {\n this.app.get('/auth/providers', () => [{ name: 'Home Assistant Local', type: 'homeassistant', id: null }]);\n\n this.app.post('/auth/login_flow', async (req, reply) => {\n const client = await this.identify(req, reply);\n const flowId = crypto.randomUUID();\n this.storeSession(flowId, { created: Date.now(), clientId: client.id });\n this.adapter.log.debug(`Auth flow created: ${flowId} for client ${client.id}`);\n\n return {\n type: 'form',\n flow_id: flowId,\n handler: ['homeassistant', null],\n step_id: 'init',\n data_schema: LOGIN_SCHEMA,\n description_placeholders: null,\n errors: null,\n };\n });\n\n this.app.post<{\n Params: { flowId: string };\n Body: { username?: string; password?: string };\n }>(\n '/auth/login_flow/:flowId',\n {\n schema: {\n params: {\n type: 'object',\n properties: { flowId: { type: 'string', minLength: 1 } },\n required: ['flowId'],\n },\n },\n },\n async (req, reply) => {\n const flowId = req.params.flowId;\n const session = this.sessions.get(flowId);\n if (!session) {\n this.adapter.log.warn(`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 = coerceString(req.ip);\n if (this.isIpLocked(ip)) {\n this.adapter.log.warn(`Login rejected: IP ${ip} is currently locked out`);\n reply.status(429);\n return { type: 'abort', flow_id: flowId, reason: 'too_many_failed_attempts' };\n }\n const { username, password } = req.body ?? {};\n const userOk = typeof username === 'string' && safeStringEqual(username, this.config.username);\n const passOk = typeof password === 'string' && safeStringEqual(password, this.config.password);\n if (!userOk || !passOk) {\n this.recordLoginFailure(ip);\n this.adapter.log.warn('Invalid credentials');\n reply.status(400);\n return {\n type: 'form',\n flow_id: flowId,\n handler: ['homeassistant', null],\n step_id: 'init',\n data_schema: LOGIN_SCHEMA,\n errors: { base: 'invalid_auth' },\n description_placeholders: null,\n };\n }\n this.clearLoginAttempts(ip);\n }\n\n this.sessions.delete(flowId);\n const code = crypto.randomUUID();\n this.storeSession(code, { created: Date.now(), clientId: session.clientId });\n this.adapter.log.debug('Auth flow completed \u2014 code issued');\n\n return {\n version: 1,\n type: 'create_entry',\n flow_id: flowId,\n handler: ['homeassistant', null],\n result: code,\n description: null,\n description_placeholders: null,\n };\n },\n );\n\n this.app.post<{ Body: { code?: string; grant_type?: string; refresh_token?: string } }>(\n '/auth/token',\n async (req, reply) => {\n const { code, grant_type, refresh_token } = req.body ?? {};\n\n if (grant_type === 'authorization_code' && code && this.sessions.has(code)) {\n const session = this.sessions.get(code)!;\n this.sessions.delete(code);\n const token = crypto.randomUUID();\n const refreshToken = crypto.randomUUID();\n if (session.clientId) {\n await this.registry.setToken(session.clientId, token);\n this.storeRefreshToken(refreshToken, session.clientId);\n this.adapter.log.debug(`Display authenticated \u2014 client ${session.clientId}`);\n }\n return {\n access_token: token,\n token_type: 'Bearer',\n refresh_token: refreshToken,\n expires_in: OAUTH_ACCESS_TOKEN_TTL_S,\n };\n }\n\n if (grant_type === 'refresh_token') {\n // Validate the refresh token against issued ones \u2014 was previously\n // accepting any string and minting a new access_token (security fix v1.2.0).\n const incoming = typeof refresh_token === 'string' ? refresh_token : '';\n const ownerId = incoming ? this.refreshTokens.get(incoming) : undefined;\n if (!ownerId) {\n this.adapter.log.warn('Refresh token rejected \u2014 unknown or missing');\n reply.status(400);\n return { error: 'invalid_grant', error_description: 'Invalid refresh token' };\n }\n const newAccess = crypto.randomUUID();\n await this.registry.setToken(ownerId, newAccess);\n return {\n access_token: newAccess,\n token_type: 'Bearer',\n expires_in: OAUTH_ACCESS_TOKEN_TTL_S,\n };\n }\n\n this.adapter.log.warn(`Token exchange failed: grant_type=${String(grant_type)}`);\n reply.status(400);\n return { error: 'invalid_request', error_description: 'Invalid or expired code' };\n },\n );\n }\n\n private setupMiscRoutes(): void {\n // Liveness only \u2014 no config leak. Earlier versions exposed the global\n // redirect URL via /health which is unauthenticated; removed in v1.2.0.\n this.app.get('/health', () => ({\n status: 'ok',\n adapter: 'hassemu',\n version: HA_VERSION,\n config: {\n mdns: this.config.mdnsEnabled,\n auth: this.config.authRequired,\n },\n }));\n\n this.app.get('/manifest.json', () => ({\n name: this.serviceName,\n short_name: this.serviceName,\n start_url: '/',\n display: 'standalone',\n background_color: '#ffffff',\n theme_color: '#03a9f4',\n }));\n\n // Root \u2014 302 redirect, or landing page when no URL is configured\n this.app.get('/', async (req, reply) => {\n const client = await this.identify(req, reply);\n const url = this.globalConfig.resolveUrlFor(client);\n if (!url) {\n this.adapter.log.debug(`No redirect URL for client ${client.id} \u2014 serving landing page`);\n return reply\n .status(200)\n .type('text/html; charset=utf-8')\n .send(renderLandingPage(client.id, this.adapter.namespace, this.systemLanguage, client.ip));\n }\n this.adapter.log.debug(`Redirecting client ${client.id} \u2192 ${url}`);\n return reply.redirect(url, 302);\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
+ "names": ["crypto", "Fastify", "fastifyCookie", "fastifyFormbody", "dns"]
7
7
  }
package/build/main.js CHANGED
@@ -84,6 +84,7 @@ class HassEmu extends utils.Adapter {
84
84
  await this.subscribeForeignObjectsAsync("system.adapter.*");
85
85
  await this.subscribeStatesAsync("clients.*");
86
86
  await this.subscribeStatesAsync("global.*");
87
+ await this.subscribeStatesAsync("info.refresh_urls");
87
88
  const systemLanguage = await this.readSystemLanguage();
88
89
  try {
89
90
  this.webServer = new import_webserver.WebServer(
@@ -154,9 +155,46 @@ class HassEmu extends utils.Adapter {
154
155
  if (!url) {
155
156
  return;
156
157
  }
158
+ const safe = (0, import_coerce.coerceSafeUrl)(url);
159
+ if (!safe) {
160
+ this.log.warn(
161
+ `Legacy URL rejected as unsafe \u2014 dropping native.defaultVisUrl/visUrl without migration: ${String(url)}`
162
+ );
163
+ try {
164
+ const id = `system.adapter.${this.namespace}`;
165
+ const obj = await this.getForeignObjectAsync(id);
166
+ if (obj == null ? void 0 : obj.native) {
167
+ delete obj.native.defaultVisUrl;
168
+ delete obj.native.visUrl;
169
+ await this.setForeignObjectAsync(id, obj);
170
+ }
171
+ } catch (err) {
172
+ this.log.warn(`Legacy config cleanup failed: ${String(err)}`);
173
+ }
174
+ return;
175
+ }
157
176
  this.log.info("Migrating legacy native.defaultVisUrl/visUrl \u2192 global.visUrl");
158
- await this.setStateAsync("global.visUrl", { val: url, ack: true }).catch(() => {
159
- });
177
+ let stateWritten = false;
178
+ try {
179
+ await this.setStateAsync("global.visUrl", { val: safe, ack: true });
180
+ stateWritten = true;
181
+ } catch {
182
+ try {
183
+ if (this.globalConfig) {
184
+ await this.globalConfig.migrationSet(import_global_config.MODE_MANUAL, safe);
185
+ this.log.info(
186
+ `Migration shortcut: global.visUrl-state missing \u2192 wrote directly to global.mode='manual', manualUrl='${safe}'`
187
+ );
188
+ stateWritten = true;
189
+ }
190
+ } catch (err) {
191
+ this.log.warn(`Legacy URL migration failed at fallback: ${String(err)}`);
192
+ }
193
+ }
194
+ if (!stateWritten) {
195
+ this.log.warn("Legacy URL preserved in native \u2014 neither global.visUrl nor global.mode write succeeded");
196
+ return;
197
+ }
160
198
  try {
161
199
  const id = `system.adapter.${this.namespace}`;
162
200
  const obj = await this.getForeignObjectAsync(id);
@@ -357,6 +395,30 @@ class HassEmu extends utils.Adapter {
357
395
  } else if (globalParsed === "enabled") {
358
396
  await this.globalConfig.handleEnabledWrite(state.val);
359
397
  await this.applyMasterSwitch(this.globalConfig.isEnabled());
398
+ return;
399
+ }
400
+ if (id === `${this.namespace}.info.refresh_urls` && state.val === true) {
401
+ await this.handleRefreshUrlsWrite();
402
+ }
403
+ }
404
+ /**
405
+ * Handler for the `info.refresh_urls` button.
406
+ * Triggert eine sofortige `urlDiscovery.collect()` (statt Debounce-Schedule),
407
+ * damit der User nicht 2s warten muss. Schreibt anschließend `false ack` damit
408
+ * der Button in der Admin-UI wieder „klickbar" wird.
409
+ */
410
+ async handleRefreshUrlsWrite() {
411
+ if (!this.urlDiscovery) {
412
+ return;
413
+ }
414
+ try {
415
+ await this.urlDiscovery.collect();
416
+ this.log.info("URL discovery refreshed on user request");
417
+ } catch (err) {
418
+ this.log.warn(`URL refresh failed: ${err instanceof Error ? err.message : String(err)}`);
419
+ } finally {
420
+ await this.setStateAsync("info.refresh_urls", { val: false, ack: true }).catch(() => {
421
+ });
360
422
  }
361
423
  }
362
424
  onUnload(callback) {
package/build/main.js.map CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/main.ts"],
4
- "sourcesContent": ["import crypto from 'node:crypto';\nimport * as utils from '@iobroker/adapter-core';\nimport { ClientRegistry, parseClientStateId } from './lib/client-registry';\nimport { coerceSafeUrl } from './lib/coerce';\nimport { GlobalConfig, MODE_GLOBAL, MODE_MANUAL, parseGlobalStateId } from './lib/global-config';\nimport { MDNSService } from './lib/mdns';\nimport { UrlDiscovery } from './lib/url-discovery';\nimport { WebServer } from './lib/webserver';\nimport type { AdapterConfig } from './lib/types';\n\n/** Stale-Client-GC threshold: clients without token + lastSeen older are auto-removed. */\nconst STALE_CLIENT_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days\n\nclass HassEmu extends utils.Adapter {\n private mdnsService: MDNSService | null = null;\n private webServer: WebServer | null = null;\n private registry: ClientRegistry | null = null;\n private globalConfig: GlobalConfig | null = null;\n private urlDiscovery: UrlDiscovery | null = null;\n private unhandledRejectionHandler: ((reason: unknown) => void) | null = null;\n private uncaughtExceptionHandler: ((err: Error) => void) | null = null;\n\n declare config: AdapterConfig;\n\n public constructor(options: Partial<utils.AdapterOptions> = {}) {\n super({ ...options, name: 'hassemu' });\n\n this.on('ready', () => {\n this.onReady().catch(err => this.log.error(`onReady unhandled: ${String(err)}`));\n });\n this.on('stateChange', (id, state) => {\n this.onStateChange(id, state).catch(err => this.log.error(`stateChange unhandled: ${String(err)}`));\n });\n this.on('objectChange', () => {\n // Foreign object changed \u2014 refresh URL dropdown (debounced inside discovery)\n this.urlDiscovery?.scheduleRefresh();\n });\n this.on('unload', this.onUnload.bind(this));\n\n // Last-line-of-defence against unhandled rejections / sync throws from\n // fire-and-forget paths. The per-handler wrappers cover documented async\n // paths; this catches anything that slips past during refactors.\n this.unhandledRejectionHandler = (reason: unknown) => {\n this.log.error(`Unhandled rejection: ${reason instanceof Error ? reason.message : String(reason)}`);\n };\n this.uncaughtExceptionHandler = (err: Error) => {\n this.log.error(`Uncaught exception: ${err.message}`);\n };\n process.on('unhandledRejection', this.unhandledRejectionHandler);\n process.on('uncaughtException', this.uncaughtExceptionHandler);\n }\n\n private async onReady(): Promise<void> {\n await this.setState('info.connection', { val: false, ack: true });\n\n this.globalConfig = new GlobalConfig(this);\n await this.globalConfig.restore();\n\n this.registry = new ClientRegistry(this);\n await this.registry.restore();\n\n // Migrations run before subscriptions / webserver \u2014 first the legacy\n // 1.0.x-style native config, then the visUrl \u2192 mode/manualUrl move,\n // then a defensive schema repair for users upgrading from v1.2.0+\n // (where the partial-formed mode-object from the v1.2.0 extend-bug\n // persists since `legacy.visUrl` is already gone and migrate doesn't trigger).\n await this.migrateLegacyDefaultVisUrl();\n await this.migrateVisUrlToMode();\n await this.repairGlobalSchemas();\n\n // Garbage-collect stale clients (no token + lastSeen older than 30 days).\n await this.gcStaleClients();\n\n const instanceUuid = crypto.randomUUID();\n this.log.debug(\n `Config: port=${this.config.port}, auth=${this.config.authRequired}, mdns=${this.config.mdnsEnabled}`,\n );\n\n this.urlDiscovery = new UrlDiscovery(this, async states => {\n await this.globalConfig?.syncUrlDropdown(states);\n await this.registry?.syncUrlDropdown(states);\n });\n await this.urlDiscovery.collect();\n\n // After discovery: wire the default-mode provider for new clients.\n // - global.enabled=true \u2192 new clients default to 'global' (follow master)\n // - global.enabled=false \u2192 first discovered URL, fallback 'manual'\n this.registry.setNewClientModeProvider(() => this.computeNewClientMode());\n\n // Watch broker state for new/removed instances, VIS projects and client/global writes\n await this.subscribeForeignObjectsAsync('system.adapter.*');\n await this.subscribeStatesAsync('clients.*');\n await this.subscribeStatesAsync('global.*');\n\n const systemLanguage = await this.readSystemLanguage();\n\n try {\n this.webServer = new WebServer(\n this,\n this.config,\n this.registry,\n this.globalConfig,\n instanceUuid,\n systemLanguage,\n );\n await this.webServer.start();\n } catch (err) {\n this.log.error(`Web server failed to start: ${String(err)}`);\n return;\n }\n\n if (this.config.mdnsEnabled) {\n this.mdnsService = new MDNSService(this, this.config, instanceUuid);\n this.mdnsService.start();\n } else {\n this.log.debug('mDNS disabled \u2014 clients must enter the URL manually.');\n }\n\n await this.setState('info.connection', { val: true, ack: true });\n const bindAddr = this.config.bindAddress || '0.0.0.0';\n this.log.info(\n `HA emulation running on ${bindAddr}:${this.config.port}${this.config.mdnsEnabled ? ', mDNS active' : ''}`,\n );\n }\n\n /**\n * Default mode for newly registered clients. Respects the master switch:\n * - `global.enabled=true` \u2192 `'global'` (follow master)\n * - `global.enabled=false` \u2192 first discovered URL, fallback `'manual'`\n */\n private computeNewClientMode(): string {\n if (this.globalConfig?.isEnabled()) {\n return MODE_GLOBAL;\n }\n const first = this.urlDiscovery?.getFirstDiscoveredUrl();\n return first ?? MODE_MANUAL;\n }\n\n /**\n * Read the ioBroker system language (set in Admin \u2192 Main Settings).\n * Used for the landing page so the end-user sees the same language as\n * their admin UI. Falls back to `en` when `system.config` can't be read\n * or holds a language we don't translate. Read once on startup \u2014 a\n * language switch at runtime only takes effect after an adapter restart,\n * which is fine for a setup-hint page that most users see once.\n */\n private async readSystemLanguage(): Promise<string> {\n try {\n const cfg = await this.getForeignObjectAsync('system.config');\n const lang = (cfg?.common as { language?: string } | undefined)?.language;\n return typeof lang === 'string' && lang.length > 0 ? lang : 'en';\n } catch {\n return 'en';\n }\n }\n\n /**\n * 1.0.x / 1.1.0 \u2192 1.1.1 migration \u2014 move the legacy `defaultVisUrl` from\n * instance native into `global.visUrl` + `global.enabled=true` and drop it\n * from native. Subsequent migrations (`migrateVisUrlToMode`) then move\n * `global.visUrl` into the mode/manualUrl model.\n */\n private async migrateLegacyDefaultVisUrl(): Promise<void> {\n const legacy = this.config as AdapterConfig & { defaultVisUrl?: string; visUrl?: string };\n const url = legacy.defaultVisUrl || legacy.visUrl;\n if (!url) {\n return;\n }\n this.log.info('Migrating legacy native.defaultVisUrl/visUrl \u2192 global.visUrl');\n // We cannot call globalConfig.handleVisUrlWrite \u2014 that method is gone in\n // v1.2.0. Write the legacy state directly so migrateVisUrlToMode picks it up.\n await this.setStateAsync('global.visUrl', { val: url, ack: true }).catch(() => {\n // global.visUrl object may not exist anymore (v1.2.0 instanceObjects);\n // create it transparently for the migration step.\n });\n try {\n const id = `system.adapter.${this.namespace}`;\n const obj = await this.getForeignObjectAsync(id);\n if (obj?.native) {\n delete obj.native.defaultVisUrl;\n delete obj.native.visUrl;\n await this.setForeignObjectAsync(id, obj);\n }\n } catch (err) {\n this.log.warn(`Legacy config cleanup failed: ${String(err)}`);\n }\n }\n\n /**\n * 1.x \u2192 1.2.0 migration \u2014 move legacy per-client `visUrl`-states to the\n * `mode`/`manualUrl` model, plus the global `visUrl` to `global.mode` +\n * `global.manualUrl`. Old datapoints are removed, type of mode-states\n * upgraded to 'mixed'. Idempotent \u2014 does nothing on subsequent starts.\n */\n private async migrateVisUrlToMode(): Promise<void> {\n // 1) Global visUrl \u2192 mode + manualUrl\n try {\n const legacyGlobal = await this.getStateAsync('global.visUrl');\n if (\n legacyGlobal &&\n legacyGlobal.val !== undefined &&\n legacyGlobal.val !== null &&\n legacyGlobal.val !== ''\n ) {\n const safe = coerceSafeUrl(legacyGlobal.val);\n if (safe) {\n await this.globalConfig!.migrationSet(MODE_MANUAL, safe);\n this.log.info(`Migration: global.visUrl \u2192 mode='manual', manualUrl='${safe}'`);\n } else {\n await this.globalConfig!.migrationSet(MODE_MANUAL, null);\n this.log.warn(`Migration: legacy global.visUrl rejected as unsafe \u2014 set global.manualUrl manually`);\n }\n }\n } catch {\n /* state didn't exist \u2014 fresh install or already migrated */\n }\n try {\n await this.delObjectAsync('global.visUrl');\n } catch {\n /* didn't exist */\n }\n\n // 2) Per-client visUrl \u2192 mode='manual' + manualUrl\n const records = this.registry?.listAll() ?? [];\n for (const record of records) {\n try {\n const legacy = await this.getStateAsync(`clients.${record.id}.visUrl`);\n if (legacy && legacy.val !== undefined && legacy.val !== null && legacy.val !== '') {\n const safe = coerceSafeUrl(legacy.val);\n if (safe) {\n record.mode = MODE_MANUAL;\n record.manualUrl = safe;\n await this.setStateAsync(`clients.${record.id}.mode`, { val: MODE_MANUAL, ack: true });\n await this.setStateAsync(`clients.${record.id}.manualUrl`, { val: safe, ack: true });\n this.log.info(\n `Migration: client ${record.id} visUrl='${safe}' \u2192 mode='manual', manualUrl='${safe}'`,\n );\n } else {\n this.log.warn(\n `Migration: client ${record.id} legacy visUrl rejected as unsafe \u2014 set clients.${record.id}.manualUrl manually`,\n );\n }\n }\n } catch {\n /* state didn't exist for this client */\n }\n try {\n await this.delObjectAsync(`clients.${record.id}.visUrl`);\n } catch {\n /* didn't exist */\n }\n }\n\n // 3) global.mode + global.manualUrl repair handled by repairGlobalSchemas()\n // (called separately in onReady so it ALSO runs for users upgrading from\n // v1.2.0/v1.3.0/v1.3.1 where the legacy visUrl is already gone but the\n // partial-formed mode-object from the v1.2.0 extendObject-bug persists).\n }\n\n /**\n * Repairs partial-formed `global.mode` / `global.manualUrl` objects from\n * the v1.2.0 migration bug (extendObjectAsync was called with only\n * `common.type:'mixed'` \u2014 leaving the object without top-level `type`,\n * name, role, read, write, def). `extendObjectAsync` here merges the full\n * instanceObjects schema onto the existing partial object so js-controller\n * stops warning \"obj.type has to exist\" and the dropdown renders correctly.\n *\n * Idempotent \u2014 extending an already-complete object is a no-op write.\n */\n private async repairGlobalSchemas(): Promise<void> {\n try {\n await this.extendObjectAsync('global.mode', {\n type: 'state',\n common: {\n name: 'Global redirect mode',\n type: 'mixed',\n role: 'value',\n read: true,\n write: true,\n def: 0,\n },\n native: {},\n });\n } catch (err) {\n this.log.debug(`repair global.mode failed: ${String(err)}`);\n }\n try {\n await this.extendObjectAsync('global.manualUrl', {\n type: 'state',\n common: {\n name: \"Global manual URL (used when mode='manual')\",\n type: 'string',\n role: 'url',\n read: true,\n write: true,\n def: '',\n },\n native: {},\n });\n } catch (err) {\n this.log.debug(`repair global.manualUrl failed: ${String(err)}`);\n }\n }\n\n /**\n * Removes clients that are clearly stale: no auth token (= never authenticated\n * or revoked) AND `native.lastSeen` older than {@link STALE_CLIENT_TTL_MS}.\n * Clients without `lastSeen` (pre-1.2.0) get the timestamp seeded on this run\n * \u2014 GC kicks in only on subsequent restarts.\n */\n private async gcStaleClients(): Promise<void> {\n const now = Date.now();\n const records = this.registry?.listAll() ?? [];\n let removed = 0;\n for (const record of records) {\n if (record.token) {\n continue;\n }\n try {\n const obj = await this.getObjectAsync(`clients.${record.id}`);\n const native = (obj?.native as { lastSeen?: number } | undefined) ?? {};\n const lastSeen = typeof native.lastSeen === 'number' ? native.lastSeen : 0;\n if (lastSeen === 0) {\n // Pre-v1.2.0 client \u2014 seed timestamp, GC waits one cycle.\n await this.extendObjectAsync(`clients.${record.id}`, { native: { lastSeen: now } });\n continue;\n }\n if (now - lastSeen > STALE_CLIENT_TTL_MS) {\n await this.registry!.remove(record.id);\n removed++;\n }\n } catch (err) {\n this.log.debug(`Stale-GC: failed for ${record.id}: ${String(err)}`);\n }\n }\n if (removed > 0) {\n this.log.info(`Stale-Client-GC: removed ${removed} client(s) (no token + idle >30 days)`);\n }\n }\n\n /**\n * Master-switch action: when `global.enabled` flips, propagate to every\n * client's `mode`. true \u2192 all clients follow `'global'`. false \u2192 fall back\n * to the first discovered URL, or `'manual'` if discovery is empty.\n *\n * @param enabled New value of `global.enabled`.\n */\n private async applyMasterSwitch(enabled: boolean): Promise<void> {\n if (!this.registry) {\n return;\n }\n if (enabled) {\n await this.registry.bulkSetMode(MODE_GLOBAL);\n return;\n }\n const first = this.urlDiscovery?.getFirstDiscoveredUrl();\n if (first) {\n await this.registry.bulkSetMode(first);\n } else {\n await this.registry.bulkSetMode(MODE_MANUAL);\n this.log.warn(\n \"global.enabled=false but no discovered VIS URL \u2014 clients set to 'manual'; \" +\n 'fill clients.<id>.manualUrl per client',\n );\n }\n }\n\n private async onStateChange(id: string, state: ioBroker.State | null | undefined): Promise<void> {\n if (!state || state.ack) {\n return;\n }\n const clientParsed = this.registry ? parseClientStateId(id, this.namespace) : null;\n if (clientParsed) {\n if (clientParsed.kind === 'mode') {\n await this.registry!.handleModeWrite(clientParsed.id, state.val);\n // B4: if the user picked 'global' but global resolves to nothing,\n // give them a one-shot heads-up so the cause of the empty redirect\n // is obvious without digging through the resolver code.\n const record = this.registry!.getById(clientParsed.id);\n if (record?.mode === MODE_GLOBAL && this.globalConfig!.resolveUrlFor(record) === null) {\n this.log.warn(\n `Client ${record.id}: mode='global' but global has no resolvable URL \u2014 ` +\n 'fill global.mode/manualUrl, or pick a different client mode',\n );\n }\n } else if (clientParsed.kind === 'manualUrl') {\n await this.registry!.handleManualUrlWrite(clientParsed.id, state.val);\n } else if (clientParsed.kind === 'remove' && state.val === true) {\n await this.registry!.remove(clientParsed.id);\n }\n return;\n }\n const globalParsed = this.globalConfig ? parseGlobalStateId(id, this.namespace) : null;\n if (globalParsed === 'mode') {\n await this.globalConfig!.handleModeWrite(state.val);\n } else if (globalParsed === 'manualUrl') {\n await this.globalConfig!.handleManualUrlWrite(state.val);\n } else if (globalParsed === 'enabled') {\n await this.globalConfig!.handleEnabledWrite(state.val);\n await this.applyMasterSwitch(this.globalConfig!.isEnabled());\n }\n }\n\n private onUnload(callback: () => void): void {\n try {\n this.urlDiscovery?.cancelRefresh();\n this.urlDiscovery = null;\n\n if (this.mdnsService) {\n this.mdnsService.stop();\n this.mdnsService = null;\n }\n\n if (this.webServer) {\n this.webServer.stop().catch((err: Error) => this.log.error(`Server stop error: ${err.message}`));\n this.webServer = null;\n }\n\n this.registry = null;\n this.globalConfig = null;\n\n // Detach process-level last-line-of-defence handlers\n if (this.unhandledRejectionHandler) {\n process.off('unhandledRejection', this.unhandledRejectionHandler);\n this.unhandledRejectionHandler = null;\n }\n if (this.uncaughtExceptionHandler) {\n process.off('uncaughtException', this.uncaughtExceptionHandler);\n this.uncaughtExceptionHandler = null;\n }\n\n void this.setState('info.connection', { val: false, ack: true });\n } catch (error) {\n const err = error as Error;\n this.log.error(`Shutdown error: ${err.message}`);\n } finally {\n callback();\n }\n }\n}\n\nif (require.main !== module) {\n module.exports = (options: Partial<utils.AdapterOptions> | undefined) => new HassEmu(options);\n} else {\n (() => new HassEmu())();\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import crypto from 'node:crypto';\nimport * as utils from '@iobroker/adapter-core';\nimport { ClientRegistry, parseClientStateId } from './lib/client-registry';\nimport { coerceSafeUrl } from './lib/coerce';\nimport { GlobalConfig, MODE_GLOBAL, MODE_MANUAL, parseGlobalStateId } from './lib/global-config';\nimport { MDNSService } from './lib/mdns';\nimport { UrlDiscovery } from './lib/url-discovery';\nimport { WebServer } from './lib/webserver';\nimport type { AdapterConfig } from './lib/types';\n\n/** Stale-Client-GC threshold: clients without token + lastSeen older are auto-removed. */\nconst STALE_CLIENT_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days\n\nclass HassEmu extends utils.Adapter {\n private mdnsService: MDNSService | null = null;\n private webServer: WebServer | null = null;\n private registry: ClientRegistry | null = null;\n private globalConfig: GlobalConfig | null = null;\n private urlDiscovery: UrlDiscovery | null = null;\n private unhandledRejectionHandler: ((reason: unknown) => void) | null = null;\n private uncaughtExceptionHandler: ((err: Error) => void) | null = null;\n\n declare config: AdapterConfig;\n\n public constructor(options: Partial<utils.AdapterOptions> = {}) {\n super({ ...options, name: 'hassemu' });\n\n this.on('ready', () => {\n this.onReady().catch(err => this.log.error(`onReady unhandled: ${String(err)}`));\n });\n this.on('stateChange', (id, state) => {\n this.onStateChange(id, state).catch(err => this.log.error(`stateChange unhandled: ${String(err)}`));\n });\n this.on('objectChange', () => {\n // Foreign object changed \u2014 refresh URL dropdown (debounced inside discovery)\n this.urlDiscovery?.scheduleRefresh();\n });\n this.on('unload', this.onUnload.bind(this));\n\n // Last-line-of-defence against unhandled rejections / sync throws from\n // fire-and-forget paths. The per-handler wrappers cover documented async\n // paths; this catches anything that slips past during refactors.\n this.unhandledRejectionHandler = (reason: unknown) => {\n this.log.error(`Unhandled rejection: ${reason instanceof Error ? reason.message : String(reason)}`);\n };\n this.uncaughtExceptionHandler = (err: Error) => {\n this.log.error(`Uncaught exception: ${err.message}`);\n };\n process.on('unhandledRejection', this.unhandledRejectionHandler);\n process.on('uncaughtException', this.uncaughtExceptionHandler);\n }\n\n private async onReady(): Promise<void> {\n await this.setState('info.connection', { val: false, ack: true });\n\n this.globalConfig = new GlobalConfig(this);\n await this.globalConfig.restore();\n\n this.registry = new ClientRegistry(this);\n await this.registry.restore();\n\n // Migrations run before subscriptions / webserver \u2014 first the legacy\n // 1.0.x-style native config, then the visUrl \u2192 mode/manualUrl move,\n // then a defensive schema repair for users upgrading from v1.2.0+\n // (where the partial-formed mode-object from the v1.2.0 extend-bug\n // persists since `legacy.visUrl` is already gone and migrate doesn't trigger).\n await this.migrateLegacyDefaultVisUrl();\n await this.migrateVisUrlToMode();\n await this.repairGlobalSchemas();\n\n // Garbage-collect stale clients (no token + lastSeen older than 30 days).\n await this.gcStaleClients();\n\n const instanceUuid = crypto.randomUUID();\n this.log.debug(\n `Config: port=${this.config.port}, auth=${this.config.authRequired}, mdns=${this.config.mdnsEnabled}`,\n );\n\n this.urlDiscovery = new UrlDiscovery(this, async states => {\n await this.globalConfig?.syncUrlDropdown(states);\n await this.registry?.syncUrlDropdown(states);\n });\n await this.urlDiscovery.collect();\n\n // After discovery: wire the default-mode provider for new clients.\n // - global.enabled=true \u2192 new clients default to 'global' (follow master)\n // - global.enabled=false \u2192 first discovered URL, fallback 'manual'\n this.registry.setNewClientModeProvider(() => this.computeNewClientMode());\n\n // Watch broker state for new/removed instances, VIS projects and client/global writes\n await this.subscribeForeignObjectsAsync('system.adapter.*');\n await this.subscribeStatesAsync('clients.*');\n await this.subscribeStatesAsync('global.*');\n await this.subscribeStatesAsync('info.refresh_urls');\n\n const systemLanguage = await this.readSystemLanguage();\n\n try {\n this.webServer = new WebServer(\n this,\n this.config,\n this.registry,\n this.globalConfig,\n instanceUuid,\n systemLanguage,\n );\n await this.webServer.start();\n } catch (err) {\n this.log.error(`Web server failed to start: ${String(err)}`);\n return;\n }\n\n if (this.config.mdnsEnabled) {\n this.mdnsService = new MDNSService(this, this.config, instanceUuid);\n this.mdnsService.start();\n } else {\n this.log.debug('mDNS disabled \u2014 clients must enter the URL manually.');\n }\n\n await this.setState('info.connection', { val: true, ack: true });\n const bindAddr = this.config.bindAddress || '0.0.0.0';\n this.log.info(\n `HA emulation running on ${bindAddr}:${this.config.port}${this.config.mdnsEnabled ? ', mDNS active' : ''}`,\n );\n }\n\n /**\n * Default mode for newly registered clients. Respects the master switch:\n * - `global.enabled=true` \u2192 `'global'` (follow master)\n * - `global.enabled=false` \u2192 first discovered URL, fallback `'manual'`\n */\n private computeNewClientMode(): string {\n if (this.globalConfig?.isEnabled()) {\n return MODE_GLOBAL;\n }\n const first = this.urlDiscovery?.getFirstDiscoveredUrl();\n return first ?? MODE_MANUAL;\n }\n\n /**\n * Read the ioBroker system language (set in Admin \u2192 Main Settings).\n * Used for the landing page so the end-user sees the same language as\n * their admin UI. Falls back to `en` when `system.config` can't be read\n * or holds a language we don't translate. Read once on startup \u2014 a\n * language switch at runtime only takes effect after an adapter restart,\n * which is fine for a setup-hint page that most users see once.\n */\n private async readSystemLanguage(): Promise<string> {\n try {\n const cfg = await this.getForeignObjectAsync('system.config');\n const lang = (cfg?.common as { language?: string } | undefined)?.language;\n return typeof lang === 'string' && lang.length > 0 ? lang : 'en';\n } catch {\n return 'en';\n }\n }\n\n /**\n * 1.0.x / 1.1.0 \u2192 1.1.1 migration \u2014 move the legacy `defaultVisUrl` from\n * instance native into `global.visUrl` + `global.enabled=true` and drop it\n * from native. Subsequent migrations (`migrateVisUrlToMode`) then move\n * `global.visUrl` into the mode/manualUrl model.\n */\n private async migrateLegacyDefaultVisUrl(): Promise<void> {\n const legacy = this.config as AdapterConfig & { defaultVisUrl?: string; visUrl?: string };\n const url = legacy.defaultVisUrl || legacy.visUrl;\n if (!url) {\n return;\n }\n // 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(\n `Legacy URL rejected as unsafe \u2014 dropping native.defaultVisUrl/visUrl without migration: ${String(url)}`,\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 return;\n }\n\n this.log.info('Migrating legacy native.defaultVisUrl/visUrl \u2192 global.visUrl');\n // We cannot call globalConfig.handleVisUrlWrite \u2014 that method is gone in\n // v1.2.0. Write the legacy state directly so migrateVisUrlToMode picks it up.\n // 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 this.log.info(\n `Migration shortcut: global.visUrl-state missing \u2192 wrote directly to global.mode='manual', manualUrl='${safe}'`,\n );\n stateWritten = true;\n }\n } catch (err) {\n this.log.warn(`Legacy URL migration failed at fallback: ${String(err)}`);\n }\n }\n\n if (!stateWritten) {\n // Both paths failed \u2014 keep native values as a recovery anchor for\n // the user. Don't clean up.\n this.log.warn('Legacy URL preserved in native \u2014 neither global.visUrl nor global.mode 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 // 1) Global visUrl \u2192 mode + manualUrl\n try {\n const legacyGlobal = await this.getStateAsync('global.visUrl');\n if (\n legacyGlobal &&\n legacyGlobal.val !== undefined &&\n legacyGlobal.val !== null &&\n legacyGlobal.val !== ''\n ) {\n const safe = coerceSafeUrl(legacyGlobal.val);\n if (safe) {\n await this.globalConfig!.migrationSet(MODE_MANUAL, safe);\n this.log.info(`Migration: global.visUrl \u2192 mode='manual', manualUrl='${safe}'`);\n } else {\n await this.globalConfig!.migrationSet(MODE_MANUAL, null);\n this.log.warn(`Migration: legacy global.visUrl rejected as unsafe \u2014 set global.manualUrl manually`);\n }\n }\n } catch {\n /* state didn't exist \u2014 fresh install or already migrated */\n }\n try {\n await this.delObjectAsync('global.visUrl');\n } catch {\n /* didn't exist */\n }\n\n // 2) Per-client visUrl \u2192 mode='manual' + manualUrl\n const records = this.registry?.listAll() ?? [];\n for (const record of records) {\n try {\n const legacy = await this.getStateAsync(`clients.${record.id}.visUrl`);\n if (legacy && legacy.val !== undefined && legacy.val !== null && legacy.val !== '') {\n const safe = coerceSafeUrl(legacy.val);\n if (safe) {\n record.mode = MODE_MANUAL;\n record.manualUrl = safe;\n await this.setStateAsync(`clients.${record.id}.mode`, { val: MODE_MANUAL, ack: true });\n await this.setStateAsync(`clients.${record.id}.manualUrl`, { val: safe, ack: true });\n this.log.info(\n `Migration: client ${record.id} visUrl='${safe}' \u2192 mode='manual', manualUrl='${safe}'`,\n );\n } else {\n this.log.warn(\n `Migration: client ${record.id} legacy visUrl rejected as unsafe \u2014 set clients.${record.id}.manualUrl manually`,\n );\n }\n }\n } catch {\n /* state didn't exist for this client */\n }\n try {\n await this.delObjectAsync(`clients.${record.id}.visUrl`);\n } catch {\n /* didn't exist */\n }\n }\n\n // 3) global.mode + global.manualUrl repair handled by repairGlobalSchemas()\n // (called separately in onReady so it ALSO runs for users upgrading from\n // v1.2.0/v1.3.0/v1.3.1 where the legacy visUrl is already gone but the\n // partial-formed mode-object from the v1.2.0 extendObject-bug persists).\n }\n\n /**\n * Repairs partial-formed `global.mode` / `global.manualUrl` objects from\n * the v1.2.0 migration bug (extendObjectAsync was called with only\n * `common.type:'mixed'` \u2014 leaving the object without top-level `type`,\n * name, role, read, write, def). `extendObjectAsync` here merges the full\n * instanceObjects schema onto the existing partial object so js-controller\n * stops warning \"obj.type has to exist\" and the dropdown renders correctly.\n *\n * Idempotent \u2014 extending an already-complete object is a no-op write.\n */\n private async repairGlobalSchemas(): Promise<void> {\n try {\n await this.extendObjectAsync('global.mode', {\n type: 'state',\n common: {\n name: 'Global redirect mode',\n type: 'mixed',\n role: 'value',\n read: true,\n write: true,\n def: 0,\n },\n native: {},\n });\n } catch (err) {\n this.log.debug(`repair global.mode failed: ${String(err)}`);\n }\n try {\n await this.extendObjectAsync('global.manualUrl', {\n type: 'state',\n common: {\n name: \"Global manual URL (used when mode='manual')\",\n type: 'string',\n role: 'url',\n read: true,\n write: true,\n def: '',\n },\n native: {},\n });\n } catch (err) {\n this.log.debug(`repair global.manualUrl failed: ${String(err)}`);\n }\n }\n\n /**\n * Removes clients that are clearly stale: no auth token (= never authenticated\n * or revoked) AND `native.lastSeen` older than {@link STALE_CLIENT_TTL_MS}.\n * Clients without `lastSeen` (pre-1.2.0) get the timestamp seeded on this run\n * \u2014 GC kicks in only on subsequent restarts.\n */\n private async gcStaleClients(): Promise<void> {\n const now = Date.now();\n const records = this.registry?.listAll() ?? [];\n let removed = 0;\n for (const record of records) {\n if (record.token) {\n continue;\n }\n try {\n const obj = await this.getObjectAsync(`clients.${record.id}`);\n const native = (obj?.native as { lastSeen?: number } | undefined) ?? {};\n const lastSeen = typeof native.lastSeen === 'number' ? native.lastSeen : 0;\n if (lastSeen === 0) {\n // Pre-v1.2.0 client \u2014 seed timestamp, GC waits one cycle.\n await this.extendObjectAsync(`clients.${record.id}`, { native: { lastSeen: now } });\n continue;\n }\n if (now - lastSeen > STALE_CLIENT_TTL_MS) {\n await this.registry!.remove(record.id);\n removed++;\n }\n } catch (err) {\n this.log.debug(`Stale-GC: failed for ${record.id}: ${String(err)}`);\n }\n }\n if (removed > 0) {\n this.log.info(`Stale-Client-GC: removed ${removed} client(s) (no token + idle >30 days)`);\n }\n }\n\n /**\n * Master-switch action: when `global.enabled` flips, propagate to every\n * client's `mode`. true \u2192 all clients follow `'global'`. false \u2192 fall back\n * to the first discovered URL, or `'manual'` if discovery is empty.\n *\n * @param enabled New value of `global.enabled`.\n */\n private async applyMasterSwitch(enabled: boolean): Promise<void> {\n if (!this.registry) {\n return;\n }\n if (enabled) {\n await this.registry.bulkSetMode(MODE_GLOBAL);\n return;\n }\n const first = this.urlDiscovery?.getFirstDiscoveredUrl();\n if (first) {\n await this.registry.bulkSetMode(first);\n } else {\n await this.registry.bulkSetMode(MODE_MANUAL);\n this.log.warn(\n \"global.enabled=false but no discovered VIS URL \u2014 clients set to 'manual'; \" +\n 'fill clients.<id>.manualUrl per client',\n );\n }\n }\n\n private async onStateChange(id: string, state: ioBroker.State | null | undefined): Promise<void> {\n if (!state || state.ack) {\n return;\n }\n const clientParsed = this.registry ? parseClientStateId(id, this.namespace) : null;\n if (clientParsed) {\n if (clientParsed.kind === 'mode') {\n await this.registry!.handleModeWrite(clientParsed.id, state.val);\n // B4: if the user picked 'global' but global resolves to nothing,\n // give them a one-shot heads-up so the cause of the empty redirect\n // is obvious without digging through the resolver code.\n const record = this.registry!.getById(clientParsed.id);\n if (record?.mode === MODE_GLOBAL && this.globalConfig!.resolveUrlFor(record) === null) {\n this.log.warn(\n `Client ${record.id}: mode='global' but global has no resolvable URL \u2014 ` +\n 'fill global.mode/manualUrl, or pick a different client mode',\n );\n }\n } else if (clientParsed.kind === 'manualUrl') {\n await this.registry!.handleManualUrlWrite(clientParsed.id, state.val);\n } else if (clientParsed.kind === 'remove' && state.val === true) {\n await this.registry!.remove(clientParsed.id);\n }\n return;\n }\n const globalParsed = this.globalConfig ? parseGlobalStateId(id, this.namespace) : null;\n if (globalParsed === 'mode') {\n await this.globalConfig!.handleModeWrite(state.val);\n } else if (globalParsed === 'manualUrl') {\n await this.globalConfig!.handleManualUrlWrite(state.val);\n } else if (globalParsed === 'enabled') {\n await this.globalConfig!.handleEnabledWrite(state.val);\n await this.applyMasterSwitch(this.globalConfig!.isEnabled());\n return;\n }\n\n // info.refresh_urls \u2014 User-Trigger f\u00FCr manuelles Dropdown-Refresh ohne\n // Adapter-Neustart. Re-scan'd den Broker nach VIS/VIS-2-Projekten und\n // Admin-Tiles, schreibt die neuen states-Maps in alle Mode-Dropdowns.\n if (id === `${this.namespace}.info.refresh_urls` && state.val === true) {\n await this.handleRefreshUrlsWrite();\n }\n }\n\n /**\n * Handler for the `info.refresh_urls` button.\n * Triggert eine sofortige `urlDiscovery.collect()` (statt Debounce-Schedule),\n * damit der User nicht 2s warten muss. Schreibt anschlie\u00DFend `false ack` damit\n * der Button in der Admin-UI wieder \u201Eklickbar\" wird.\n */\n private async handleRefreshUrlsWrite(): Promise<void> {\n if (!this.urlDiscovery) {\n return;\n }\n try {\n await this.urlDiscovery.collect();\n this.log.info('URL discovery refreshed on user request');\n } catch (err) {\n this.log.warn(`URL refresh failed: ${err instanceof Error ? err.message : String(err)}`);\n } finally {\n await this.setStateAsync('info.refresh_urls', { val: false, ack: true }).catch(() => {});\n }\n }\n\n private onUnload(callback: () => void): void {\n try {\n this.urlDiscovery?.cancelRefresh();\n this.urlDiscovery = null;\n\n if (this.mdnsService) {\n this.mdnsService.stop();\n this.mdnsService = null;\n }\n\n if (this.webServer) {\n this.webServer.stop().catch((err: Error) => this.log.error(`Server stop error: ${err.message}`));\n this.webServer = null;\n }\n\n this.registry = null;\n this.globalConfig = null;\n\n // Detach process-level last-line-of-defence handlers\n if (this.unhandledRejectionHandler) {\n process.off('unhandledRejection', this.unhandledRejectionHandler);\n this.unhandledRejectionHandler = null;\n }\n if (this.uncaughtExceptionHandler) {\n process.off('uncaughtException', this.uncaughtExceptionHandler);\n this.uncaughtExceptionHandler = null;\n }\n\n void this.setState('info.connection', { val: false, ack: true });\n } catch (error) {\n const err = error as Error;\n this.log.error(`Shutdown error: ${err.message}`);\n } finally {\n callback();\n }\n }\n}\n\nif (require.main !== module) {\n module.exports = (options: Partial<utils.AdapterOptions> | undefined) => new HassEmu(options);\n} else {\n (() => new HassEmu())();\n}\n"],
5
+ "mappings": "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",
6
6
  "names": ["crypto"]
7
7
  }
package/io-package.json CHANGED
@@ -1,98 +1,98 @@
1
1
  {
2
2
  "common": {
3
3
  "name": "hassemu",
4
- "version": "1.3.2",
4
+ "version": "1.4.1",
5
5
  "news": {
6
+ "1.4.1": {
7
+ "en": "CI fix: deploy step now uses Node 24 (Node 22 + npm@latest had a MODULE_NOT_FOUND bug, blocked v1.4.0 from reaching npm). v1.4.0 changes are bundled into this release.",
8
+ "de": "CI-Fix: Deploy-Schritt nutzt jetzt Node 24 (Node 22 + npm@latest hatte einen MODULE_NOT_FOUND-Bug, hinderte v1.4.0 daran, auf npm zu kommen). v1.4.0-Änderungen sind in diesem Release gebündelt.",
9
+ "ru": "CI fix: deploy на Node 24 (Node 22 + npm@latest был MODULE_NOT_FOUND bug, v1.4.0 не дошёл до npm). v1.4.0 изменения включены в этот релиз.",
10
+ "pt": "CI fix: deploy agora usa Node 24 (Node 22 + npm@latest tinha bug MODULE_NOT_FOUND, v1.4.0 nao chegou ao npm). Mudancas de v1.4.0 incluidas neste release.",
11
+ "nl": "CI fix: deploy stap gebruikt nu Node 24 (Node 22 + npm@latest had MODULE_NOT_FOUND bug, v1.4.0 bereikte npm niet). v1.4.0 wijzigingen zijn in deze release.",
12
+ "fr": "CI fix : etape deploy utilise Node 24 (Node 22 + npm@latest avait un bug MODULE_NOT_FOUND, v1.4.0 n'a pas atteint npm). Modifications v1.4.0 incluses dans cette release.",
13
+ "it": "CI fix: deploy ora usa Node 24 (Node 22 + npm@latest aveva bug MODULE_NOT_FOUND, v1.4.0 non e arrivato a npm). Modifiche v1.4.0 incluse in questa release.",
14
+ "es": "CI fix: paso deploy ahora usa Node 24 (Node 22 + npm@latest tenia bug MODULE_NOT_FOUND, v1.4.0 no llego a npm). Cambios de v1.4.0 incluidos en este release.",
15
+ "pl": "CI fix: deploy uzywa teraz Node 24 (Node 22 + npm@latest mial bug MODULE_NOT_FOUND, v1.4.0 nie dotarl do npm). Zmiany v1.4.0 wlaczone w tym release.",
16
+ "uk": "CI fix: крок deploy тепер використовує Node 24 (Node 22 + npm@latest мав bug MODULE_NOT_FOUND, v1.4.0 не дійшов до npm). Зміни v1.4.0 включені в цей реліз.",
17
+ "zh-cn": "CI 修复:deploy 步骤现使用 Node 24 (Node 22 + npm@latest 有 MODULE_NOT_FOUND bug,v1.4.0 未到达 npm)。v1.4.0 变更包含在此发布。"
18
+ },
19
+ "1.4.0": {
20
+ "en": "New `info.refresh_urls` button reloads the VIS/Admin URL dropdown without an adapter restart. Plus: `/auth/token` now accepts form-urlencoded bodies (OAuth2 spec), mDNS no longer leaks sockets on start failure, and legacy URL migration drops unsafe values cleanly.",
21
+ "de": "Neuer Button `info.refresh_urls` lädt das VIS-/Admin-URL-Dropdown ohne Adapter-Neustart neu. Plus: `/auth/token` akzeptiert form-urlencoded Bodies (OAuth2-Spec), mDNS leakt keine Sockets mehr bei Startfehler, Legacy-URL-Migration verwirft unsichere Werte sauber.",
22
+ "ru": "Новая кнопка `info.refresh_urls` перезагружает VIS/Admin URL-dropdown без перезапуска адаптера. Plus: `/auth/token` принимает form-urlencoded body (OAuth2), mDNS не утекает сокеты при ошибке старта, legacy URL-migration корректно отклоняет небезопасные значения.",
23
+ "pt": "Novo botao `info.refresh_urls` recarrega o dropdown VIS/Admin sem reiniciar o adapter. Plus: `/auth/token` aceita form-urlencoded (OAuth2 spec), mDNS nao vaza sockets em falha de start, migration de URL legada descarta valores unsafe.",
24
+ "nl": "Nieuwe `info.refresh_urls` knop herlaadt VIS/Admin URL-dropdown zonder adapter-restart. Plus: `/auth/token` accepteert form-urlencoded body (OAuth2 spec), mDNS lekt geen sockets meer bij startfout, legacy URL-migratie verwerpt unsafe waarden netjes.",
25
+ "fr": "Nouveau bouton `info.refresh_urls` recharge le dropdown VIS/Admin sans redemarrage de l'adaptateur. Plus : `/auth/token` accepte form-urlencoded (OAuth2 spec), mDNS ne laisse plus fuir les sockets en cas d'echec de demarrage, migration legacy URL rejette proprement les valeurs unsafe.",
26
+ "it": "Nuovo pulsante `info.refresh_urls` ricarica il dropdown VIS/Admin senza riavvio dell'adapter. Plus: `/auth/token` accetta body form-urlencoded (OAuth2 spec), mDNS non fa piu leak di socket in caso di errore di start, migration URL legacy scarta valori unsafe in modo pulito.",
27
+ "es": "Nuevo boton `info.refresh_urls` recarga el dropdown VIS/Admin sin reiniciar el adaptador. Plus: `/auth/token` acepta form-urlencoded body (OAuth2 spec), mDNS ya no fuga sockets en fallo de start, migration URL legacy descarta valores unsafe limpiamente.",
28
+ "pl": "Nowy przycisk `info.refresh_urls` przeladowuje dropdown VIS/Admin URL bez restartu adaptera. Plus: `/auth/token` akceptuje form-urlencoded body (OAuth2 spec), mDNS nie wycieka socketow przy bledzie startu, migration URL legacy odrzuca unsafe wartosci czysto.",
29
+ "uk": "Нова кнопка `info.refresh_urls` перезавантажує VIS/Admin URL-dropdown без перезапуску адаптера. Plus: `/auth/token` приймає form-urlencoded body (OAuth2 spec), mDNS більше не витікає сокети при помилці старту, legacy URL-migration коректно відхиляє небезпечні значення.",
30
+ "zh-cn": "新增 `info.refresh_urls` 按钮:无需重启即可刷新 VIS/Admin URL 下拉。另:`/auth/token` 现接受 form-urlencoded 请求体 (OAuth2 规范),mDNS 启动失败不再泄漏 socket,legacy URL 迁移正确丢弃不安全值。"
31
+ },
32
+ "1.3.3": {
33
+ "en": "Documentation: rewrote release notes in user-friendly style across all languages.",
34
+ "de": "Dokumentation: Release-Notes in alle Sprachen User-orientiert neu geschrieben.",
35
+ "ru": "Документация: примечания к релизу переписаны в дружественном к пользователю стиле на всех языках.",
36
+ "pt": "Documentação: notas de release reescritas em estilo amigável ao usuário em todos os idiomas.",
37
+ "nl": "Documentatie: release-notes herschreven in gebruikersvriendelijke stijl in alle talen.",
38
+ "fr": "Documentation : notes de version réécrites dans un style adapté à l'utilisateur dans toutes les langues.",
39
+ "it": "Documentazione: note di release riscritte in stile user-friendly in tutte le lingue.",
40
+ "es": "Documentación: notas de versión reescritas en estilo amigable para el usuario en todos los idiomas.",
41
+ "pl": "Dokumentacja: notatki wydania przepisane w przyjaznym dla użytkownika stylu we wszystkich językach.",
42
+ "uk": "Документація: нотатки до релізу переписано у дружньому до користувача стилі усіма мовами.",
43
+ "zh-cn": "文档:所有语言的发布说明已重写为用户友好的风格。"
44
+ },
6
45
  "1.3.2": {
7
- "en": "Hotfix for v1.3.1: `setObjectNotExistsAsync` is a no-op on objects that already exist as partial-formed leftovers from the v1.2.0 migration bug. v1.3.2 uses `extendObjectAsync` for `clients.<id>.mode` + `clients.<id>.manualUrl` so the missing properties (top-level `type`, name, role, read, write, def) are merged into the existing partial object — js-controller's \"obj.type has to exist\" warning goes away and the dropdown renders the labels.\nNew `repairGlobalSchemas()` in main.ts does the same defensive merge for `global.mode` + `global.manualUrl`. Runs unconditionally on every start so users upgrading from v1.2.0/v1.3.0/v1.3.1 (where the legacy `visUrl` is already gone) also get the schema repaired.\nRestore step now promotes a blank state-value (`''` left over from v1.2.0) to numeric `0`, so the dropdown actually shows the `0='---'` option as selected on first start after the upgrade.",
8
- "de": "Hotfix für v1.3.1: `setObjectNotExistsAsync` ist ein No-op auf Objekten, die bereits als partielle Linksover aus dem v1.2.0 Migrationsfehler existieren. v1.3.2 verwendet `extendObjectAsync` für `clients.<id>.mode` + `clients.<id>.manualUrl`, so dass die fehlenden Eigenschaften (top-level `type`type`, Name, Rolle, Lesen, Schreiben, def) in das bestehende Teilobjekt zusammengefasst werden - js-controllers \"obj.type muss existieren\" warn und die Warnung geht weg und.\nNew `repairGlobalSchemas()` in main.ts tut die gleiche defensive Zusammenführung für `global.mode` + `global.manualUrl`. Lauft bedingungslos an jedem Start, so dass die Benutzer von v1.2.0/v1.3.0/v1.3.1 (wo das Vermächtnis `visUrl` bereits weg ist) auch das Schema reparieren.\nDer Wiederherstellungsschritt fördert nun einen leeren Zustandswert (`''' links von v1.2.0) auf numerisch `0`, so dass der Dropdown tatsächlich die `0='--'-Option zeigt, wie am ersten Start nach dem Upgrade ausgewählt.",
9
- "ru": "Hotfix для v1.3.1: 'setObjectNotExistsAsync' - это no-op на объектах, которые уже существуют как частично сформированные остатки от ошибки миграции v1.2.0. v1.3.2 использует «extendObjectAsync» для «клиентов.<id>.mode» + «клиентов.<id>.manualUrl», поэтому недостающие свойства (тип верхнего уровня, имя, роль, чтение, запись, def) сливаются в существующий частичный объект предупреждение js-контроллера «obj.type должен существовать» уходит, и выпадающий отображает ярлыки.\nНовый «repairGlobalSchemas()» в main.ts делает то же защитное слияние для «global.mode» + «global.manualUrl». Работает без каких-либо условий при каждом запуске, поэтому пользователи, обновляющиеся с v1.2.0/v1.3.0/v1.3.1 (где устаревший «visUrl» уже исчез), также получают схему исправления.\nШаг восстановления теперь способствует пустому значению состояния (оставленному от v1.2.0) до числового «0», поэтому выпадающее значение фактически показывает опцию «0=---», выбранную при первом запуске после обновления.",
10
- "pt": "Hotfix para v1.3: `setObjectNotExistsAsync` é um no-op em objetos que já existem como restos de forma parcial do bug de migração v1.2.2. v1.3.2 usa `extendendObjectAsync` para `clients.<id>.mode` + `clients.<id>.manualUrl` para que as propriedades em falta (tipo, nome, papel, leitura, escrita, def) sejam fundidas no objeto parcial existente – o aviso \"obj.type tem que existir\" do js-controller vai embora e o dropdown renderiza os rótulos.\nNovo `repairGlobalSchemas()` no main.ts faz a mesma mesclagem defensiva para `global.mode` + `global.manualUrl`. Executa incondicionalmente em cada início para que os usuários atualizem de v1.20/v1.3.0/v1.3.1.3.1 (onde o legado 'visUrl' já se foi) também obter o esquema reparado.\nO passo de restauração agora promove um valor de estado em branco (`''`` sobra de v1.2.2) para numérico `0`, então o dropdown mostra a opção `0='---'` como selecionada no primeiro início após a atualização.",
11
- "nl": "Hotfix voor v1.3.1: v1.3.2 maakt gebruik van .\nDe nieuwe GlobalSchemas in main.ts doet dezelfde defensieve merge voor Loopt onvoorwaardelijk op elke start, zodat gebruikers upgraden van v1.2.0/v1.3.0/v1.3.1 (waar de legacy .\nHerstel stap nu bevordert een lege staat-waarde (...'\" overgebleven van v1.2.0) tot numerieke .",
12
- "fr": "Hotfix pour v1.3.1: `setObjectNotExistsAsync` est un non-op sur les objets qui existent déjà en tant que restes en formation partielle du bug de migration v1.2.0. v1.3.2 utilise `extendObjectAsync` pour `clients.<id>.mode` + `clients.<id>.manualUrl` de sorte que les propriétés manquantes (de haut niveau `type`, nom, rôle, lecture, écriture, def) sont fusionnées dans l'objet partiel existant — l'avertissement \"obj.type\" de js-controller disparaît et le menu déroulant rend les étiquettes.\nLa nouvelle `reparationGlobalSchemas()` dans main.ts fait la même fusion défensive pour `global.mode` + `global.manualUrl`. Exécute inconditionnellement sur chaque démarrage de sorte que les utilisateurs de mise à jour de v1.2.0/v1.3.0/v1.3.1 ( l'héritage `visUrl` est déjà parti) obtiennent également le schéma réparé.\nRestaurer l'étape favorise maintenant une valeur d'état vide (`''' gauche de v1.2.0) à la valeur numérique `0`, de sorte que le menu déroulant affiche en fait l'option `0='--'` comme sélectionné au premier départ après la mise à niveau.",
13
- "it": "Hotfix per v1.3.1: `setObjectNotExistsAsync` è un no-op su oggetti che già esistono come avanzi di forma parziale dal bug di migrazione v1.2.0. v1.3.2 utilizza `extendObjectAsync` per `clients. <id>.mode` + `clients.\nIl nuovo `repairGlobalSchemas()` in main.ts fa la stessa fusione difensiva per `global.mode` + `global.manualUrl`. Esegue incondizionatamente su ogni inizio così gli utenti che si aggiornano da v1.2.0/v1.3.0/v1.3.1 (dove l'eredità `visUrl` è già andato) anche ottenere lo schema riparato.\nRipristino passo ora promuove uno stato-valore vuoto (`'`'` lasciato oltre da v1.2.0) a numerico `0`, quindi la discesa mostra effettivamente l'opzione `0='--'` come selezionato al primo avvio dopo l'aggiornamento.",
14
- "es": "Hotfix for v1.3.1: `setObjectNotExistsAsync` es un no-op en objetos que ya existen como sobras de forma parcial del fallo de migración v1.2.0. v1.3.2 utiliza `extendObjectAsync` para 'clientes. interpretadoid ratio.mode` + `clientes. empleados.manualUrl` por lo que las propiedades desaparecidas (tipo 'de alto nivel, nombre, papel, lectura, escritura, def) se fusionan en el objeto parcial existente — el \"obj.tipo de js-controller tiene que existir\" la advertencia desaparece y la etiqueta des.\nNew `repairGlobalSchemas()` in main.ts hace la misma fusión defensiva para `global.mode` + `global.manualUrl`. Corre incondicionalmente en cada inicio por lo que los usuarios que se actualizan desde v1.2.0/v1.3.0/v1.3.1 (donde el legado `visUrl` ya se ha ido) también consigue el esquema reparado.\nRestore step now promotes a blank state-value (`'''' left over from v1.2.0) to numeric `0`, so the dropdown actually shows the `0='--'` opción as selected on first start after the upgrade.",
15
- "pl": "Hotfix dla v1.3.1: 'settObjectNotExistsAsync' to nie-op dla obiektów, które już istnieją jako częściowo utworzone pozostałości z błędu migracji v1.2.0. v1.3.2 używa 'extendObjectAsync' dla 'klientów. < id > .mode' + 'klientów. < id > .manualUrl' tak więc brakujące właściwości (typ \"top- level\", nazwa, rola, czytanie, pisanie, def) są połączone w istniejący obiekt częściowy - \"obj.type\" kontrolera js- istnieje \"ostrzeżenie znika i kropla w dół powoduje, że etykiety.\nNowe \"naprawa GlobalSchemas ()\" w main.ts dokonuje tego samego połączenia obronnego dla 'global.mode' + 'global.manualUrl'. Działa bezwarunkowo na każdym starcie, więc użytkownicy aktualizacji z v1.2.0 / v1.3.0 / v1.3.1 (gdzie spuścizna 'visUrl' już zniknęła) również uzyskać schemat naprawiony.\nPrzywróć krok teraz promuje pustą wartość stanu (\"\" left over from v1.2.0) do numerycznego '0', tak więc kropla w dół faktycznie pokazuje opcję '0 =' --- \", wybraną na pierwszym starcie po aktualizacji.",
16
- "uk": "Hotfix для v1.3.1: `setObjectNotExistsAsync` є іменем на об'єктах, які вже існують як часткові реле з v1.2.0. v1.3.2 використовує `extendObjectAsync` для `clients.<id>.mode` + `clients.<id>.manualUrl` так відсутні властивості (на рівні `тип`, ім'я, роль, читання, запис, def) об'єднуються в існуючий частковий об'єкт — js-controller's \"obj.type повинен існувати\" попередження йде і відкидає етикетки.\nНовий `repairGlobalSchemas()` в main.ts є однаковою оборонною зливою для `global.mode` + `global.manualUrl`. Запуски безумовно на кожному старті, тому користувачі, що модернізують від v1.2.0/v1.3.0/v1.3.1 (де вже йде спадщина `visUrl`) і отримують ремонт схеми.\nВідновити крок тепер пропагує порожній стан-значення (```````` пішов з v1.2.0) до н.е.н. `0`, тому попадання фактично показує варіант `0='-'''', як вибраний на першому етапі після оновлення.",
17
- "zh-cn": "V1.3.1 的 Hotfix: “ set ObjectNotExists Async” 是对 v1. 2.0 迁移错误中已作为部分形式遗留物存在的对象的禁用。 v1.3.2 使用“ extend ObjectAsync ” 表示“ 客户”。 <id>.mode` + `客户.<id>.manualUrl' 从而将缺失属性(顶级“类型”、名称、角色、读写、写写写、def)合并到现有的部分对象——js控制器的“obj.type必须存在”警告消失,下拉使标签失效.\n新的“修复全球计划()”主要用于“Global.mode”+“Global.manualUrl”的防御性合并。 用户从v1.2.0/v1.3.0/v1.3.1(遗留的`visUrl'已经消失)升级.\n恢复步骤现在将从v1.2.0中遗留的空白状态值(`'')推广到数字`0',因此降级实际上显示升级后第一个开始时所选择的`0=-'`选项."
46
+ "en": "Fix: dropdown default `---` now applied correctly on upgrades from older v1.1.x clients (was empty after migration).",
47
+ "de": "Fix: Dropdown-Standard `---` wird bei Upgrades von älteren v1.1.x-Clients jetzt korrekt gesetzt (war nach der Migration leer).",
48
+ "ru": "Fix: значение по умолчанию `---` в dropdown теперь правильно применяется при обновлении со старых клиентов v1.1.x (было пустым после миграции).",
49
+ "pt": "Fix: valor padrão `---` do dropdown agora é aplicado corretamente em atualizações de clientes v1.1.x antigos (estava vazio após a migração).",
50
+ "nl": "Fix: dropdown-standaard `---` wordt nu correct toegepast bij upgrades van oudere v1.1.x-clients (was leeg na de migratie).",
51
+ "fr": "Fix : la valeur par défaut `---` du dropdown est désormais correctement appliquée lors des mises à niveau depuis les anciens clients v1.1.x (était vide après la migration).",
52
+ "it": "Fix: il valore predefinito `---` del dropdown ora viene applicato correttamente sugli aggiornamenti da client v1.1.x più vecchi (era vuoto dopo la migrazione).",
53
+ "es": "Fix: el valor predeterminado `---` del dropdown ahora se aplica correctamente en actualizaciones desde clientes v1.1.x antiguos (estaba vacío tras la migración).",
54
+ "pl": "Fix: wartość domyślna `---` w dropdown jest teraz poprawnie ustawiana przy aktualizacjach ze starszych klientów v1.1.x (była pusta po migracji).",
55
+ "uk": "Fix: значення за замовчуванням `---` у dropdown тепер коректно застосовується при оновленнях зі старих клієнтів v1.1.x (було порожнім після міграції).",
56
+ "zh-cn": "修复:从旧的 v1.1.x 客户端升级时,下拉框默认值 `---` 现在能正确显示(迁移后曾为空)。"
18
57
  },
19
58
  "1.3.1": {
20
- "en": "Hotfix for legacy v1.1.x clients: their `visUrl` channel did not have `mode` / `manualUrl` objects. The v1.2.0 migration wrote states without the matching objects, which the broker logged as `State has no existing object` and rendered the `mode` datapoint without a name or dropdown in the object browser. `ClientRegistry.restore()` now calls an idempotent `ensureObjects()` for every client, so the v1.2.0+ object shapes exist before any migration writes happen.\nMode dropdown gains a numeric `0 = \"---\"` no-choice fallback (analogous to govee-smart's pattern). Existing displays keep their setting; new displays start at `0` and the resolver falls back to the landing page until a real choice is made.",
21
- "de": "Hotfix für ältere v1.1.x-Clients: ihr `visUrl`-Kanal hatte keine `mode` / `manualUrl`-Objekte. Die v1.2.0 Migration schrieb Zustände ohne die passenden Objekte, die der als `State eingeloggte Broker hat kein bestehendes Objekt` und den `mode`-Datenpunkt ohne Namen oder Dropdown im Objektbrowser. `ClientRegistry.restore()` ruft nun für jeden Client ein idempotent `ensureObjects()` auf, so dass die v1.2.0+ Objektformen existieren, bevor Migrationsschreiben passieren.\nModus Dropdown gewinnt einen numerischen `0 = \"--\"` no-choice fallback (analog zu govee-smart's Muster). Vorhandene Displays halten ihre Einstellung; neue Displays starten bei `0` und der Resolver fällt auf die Landingpage zurück, bis eine echte Wahl getroffen wird.",
22
- "ru": "Hotfix для устаревших клиентов v1.1.x: их канал «visUrl» не имел объектов «режима» / «ручного Урла». Миграция v1.2.0 писала состояния без соответствующих объектов, которые брокер зарегистрировал как «Государство не имеет существующего объекта», и отображала точку данных «режима» без имени или выпадения в браузере объекта. «ClientRegistry.restore()» теперь называет идемпотентом «ensureObjects()» для каждого клиента, поэтому формы объектов v1.2.0+ существуют до того, как произойдет какая-либо миграция.\nРежим выпадения получает числовое '0 = '--'' без выбора запасного варианта (аналогично шаблону Govee-smart). Существующие дисплеи сохраняют свою настройку; новые дисплеи начинаются с 0, и решатель возвращается на целевую страницу, пока не будет сделан реальный выбор.",
23
- "pt": "Hotfix para clientes legados v1.1.x: seu canal 'visUrl' não tinha objetos 'modo' / 'manualUrl'. A migração v1.2.0 escreveu estados sem os objetos correspondentes, que o corretor logou como `Estado não tem objeto existente' e renderizou o ponto de dados `modo' sem um nome ou dropdown no navegador objeto. `ClientRegistry.restore()` agora chama um idempotent `ensureObjects()` para cada cliente, então as formas do objeto v1.20+ existem antes que qualquer migração escreva.\nO dropdown do modo ganha um valor numérico `0 = \"---\"` sem escolha (análogo para o padrão govee-smart). Os displays existentes mantêm sua configuração; os novos displays começam em `0` e o resolvedor volta para a landing page até que uma escolha real seja feita.",
24
- "nl": "Hotfix voor legacy v1.1.x clients: hun De v1.2.0 migratie schreef staten zonder de bijbehorende objecten, die de makelaar aangemeld als Staat heeft geen bestaand object heeft en gaf de \"mode\" datapoint zonder een naam of dropdown in de object browser. .\nMode dropdown krijgt een numerieke Bestaande displays houden hun instelling; nieuwe displays beginnen bij .",
25
- "fr": "Hotfix pour les clients left v1.1.x: leur canal `visUrl` n'avait pas d'objets `mode` / `manuelUrl`. La migration v1.2.0 a écrit des états sans les objets correspondants, que le courtier connecté comme `État n'a pas d'objet existant` et a rendu le point de données `mode` sans nom ou déroulant dans le navigateur objet. `ClientRegistry.restore()` appelle maintenant un idémpotent `ensurerObjects()` pour chaque client, de sorte que les formes d'objet v1.2.0+ existent avant toute migration écrite.\nLe mode déroulant gagne un résultat numérique `0 = \"---\"` sans choix (analogue au modèle govee-smart). Les affichages existants gardent leur réglage; les nouveaux affichages commencent à `0` et le résolveur retourne à la page d'atterrissage jusqu'à ce qu'un choix réel soit fait.",
26
- "it": "Hotfix per client v1.1.x legacy: il loro canale `visUrl` non ha avuto oggetti `mode` / `manualUrl`. La migrazione v1.2.0 ha scritto stati senza gli oggetti corrispondenti, che il broker ha registrato come `State non ha oggetto esistente` e ha reso il `mode` datapoint senza un nome o dropdown nel browser dell'oggetto. `ClientRegistry.restore()` ora chiama un idempotent `ensureObjects()` per ogni cliente, in modo che le forme dell'oggetto v1.2.0+ esistano prima che qualsiasi migrazione scriva.\nLa mode dropdown ottiene un `0 numerico = \"---\"` non-choice Fallback (analogo del modello di govee-smart). I display esistenti mantengono la loro impostazione; i nuovi display iniziano a `0` e il risolutore torna alla pagina di atterraggio fino a quando non viene fatta una vera scelta.",
27
- "es": "Hotfix for legacy v1.1.x clients: their `visUrl` channel did not have `mode` / `manualUrl` objects. La migración v1.2.0 escribió estados sin los objetos coincidentes, que el corredor registró como `Estado no tiene objeto existente ' y emitió el punto de datos de `mode ' sin un nombre o desplegable en el navegador objeto. `ClientRegistry.restore()` ahora llama un \"objetos de seguridad\" idempotente para cada cliente, por lo que las formas de objetos v1.2.0+ existen antes de que ocurra cualquier migración escribe.\nLa caída del modo gana un numérico `0 = \"---\"` retroceso sin elección (análogo para el patrón de govee-smart). Las pantallas existentes mantienen su configuración; las nuevas pantallas comienzan en `0` y el solucionador vuelve a la página de aterrizaje hasta que se haga una elección real.",
28
- "pl": "Hotfix dla dotychczasowych klientów v1.1.x: ich kanał 'visUrl' nie miał obiektów 'mode' / 'manualUrl'. Migracja v1.2.0 napisała stany bez dopasowujących się obiektów, które broker zalogował jako 'Stan nie ma istniejącego obiektu', a w przeglądarce obiektu dodano punkt 'mode' bez nazwy lub zrzutu. 'ClientRegistry.recovery ()' teraz nazywa idemstrong 'ensureObjects ()' dla każdego klienta, tak więc kształty obiektu v1.2.0 + istnieją zanim jakaś migracja się wydarzy.\nMode dropdown zyskuje numeryczny '0 = \"---\"' no-choice fallback (analogiczny do wzorca govee- smart). Istniejące wyświetlacze utrzymują ustawienia; nowe wyświetlacze rozpoczynają się na '0', a rozdzielczość wraca do strony docelowej do momentu dokonania prawdziwego wyboru.",
29
- "uk": "Hotfix для Legacy v1.1.x клієнтів: їх `visUrl` канал не мав `mode` / `manualUrl` об'єкти. Переміщення v1.2.0 писали стани без відповідних об'єктів, які були зареєстровані як `Державний не має наявних об'єктів і надано `mode` datapoint без назви або випадання в браузері об'єкта. `ClientRegistry.restore()` тепер називає idempotent `ensureObjects()` для кожного клієнта, тобто v1.2.0+ об’єктів існують до будь-яких міграційних записів.\nПохибка в режимі реального часу отримує нумерний `0 = \"--\"``````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````` Випробувано нові покази на сайті `0` і вирішувач повертається на сторінку посадки, доки не буде виконано реальний вибір.",
30
- "zh-cn": "V1.1.x客户端的热补:他们的`visUrl ' 频道没有`mode ' /`manualUrl ' 对象。 v1.2.0 迁移时写了没有匹配对象的状态,经纪人将其记录为`状态没有现有对象 ' ,并在对象浏览器中没有名称或下拉时使“模式”数据点。 `ClientRegistry.restore()'现在将每个客户称为“确保对象()”,因此在任何迁移发生之前,v1.2.0+对象形状就已存在.\n模式降级获得一个数字 `0 = \"--'' `不选择回落' (反感到gove-smart的图案). 现有显示器保持其设置;新的显示器开始于`0',解析器回到着陆页,直到作出真正的选择."
59
+ "en": "Fix: legacy v1.1.x clients without mode/manualUrl objects now get migrated correctly on first start.",
60
+ "de": "Fix: Legacy-v1.1.x-Clients ohne mode/manualUrl-Objekte werden beim ersten Start jetzt korrekt migriert.",
61
+ "ru": "Fix: устаревшие клиенты v1.1.x без объектов mode/manualUrl теперь корректно мигрируются при первом запуске.",
62
+ "pt": "Fix: clientes v1.1.x antigos sem objetos mode/manualUrl agora são migrados corretamente na primeira inicialização.",
63
+ "nl": "Fix: oude v1.1.x-clients zonder mode/manualUrl-objecten worden nu correct gemigreerd bij de eerste start.",
64
+ "fr": "Fix : les anciens clients v1.1.x sans objets mode/manualUrl sont désormais correctement migrés au premier démarrage.",
65
+ "it": "Fix: i client v1.1.x legacy senza oggetti mode/manualUrl ora vengono migrati correttamente al primo avvio.",
66
+ "es": "Fix: los clientes v1.1.x antiguos sin objetos mode/manualUrl ahora se migran correctamente en el primer inicio.",
67
+ "pl": "Fix: starsze klienty v1.1.x bez obiektów mode/manualUrl teraz poprawnie migrowane przy pierwszym uruchomieniu.",
68
+ "uk": "Fix: застарілі клієнти v1.1.x без об'єктів mode/manualUrl тепер коректно мігрують при першому запуску.",
69
+ "zh-cn": "修复:缺少 mode/manualUrl 对象的旧版 v1.1.x 客户端现在能在首次启动时正确迁移。"
31
70
  },
32
71
  "1.3.0": {
33
- "en": "Security: brute-force lockout on `/auth/login_flow/:flowId` — after 5 failed credential attempts an IP is rejected with HTTP 429 for 15 min. Successful login resets the counter.\nDRY refactor: shared `parseManualUrlWrite` helper between client + global config; FIFO-cap helper in WebServer; OAuth access-token TTL + lockout window/threshold are now named constants instead of magic numbers.\nDead-code cleanup: `resolveBindToReachable`, `coerceUuid` strict-V4 parameter, `DEFAULT_REFRESH_DEBOUNCE_MS` export, internal `getMode`/`getManualUrl` test affordances — all removed; tests rewritten to assert observable behaviour.\nNew `landing-page` test suite with XSS-escape coverage and 11-language fallback verification.\nEmulated Home Assistant version bumped from 2026.3.1 to 2026.4.0.",
34
- "de": "Sicherheit: brute-force lockout auf `/auth/login flow/:flow Id` — nach 5 fehlgeschlagenen Anmeldeversuchen wird eine IP mit HTTP 429 für 15 min abgelehnt. Erfolgreiche Anmeldung setzt den Zähler zurück.\nDRY Refactor: geteilter `parseManualUrlWrite` Helfer zwischen Client + global config; FIFO-cap-Helfer im WebServer; OAuth Access-token TTL + Lockout-Fenster/Threshold werden jetzt als Konstanten anstelle von magischen Zahlen bezeichnet.\nTotcode-Reinigung: `resolveBindToReachable`, `coerceUuid` streng-V4-Parameter, `DEFAULT REFRESH DEBOUNCE MS` Export, interne `getMode`/`getManualUrl` Test Provisionances - alle entfernt; Tests neu geschrieben, um beobachtbares Verhalten zu behaupten.\nNeue `landing-page` Testsuite mit XSS-Escape-Abdeckung und 11-sprachiger Fallback-Verifikation.\nEmulated Home Assistant Version von 2026.3.1 bis 2026.4.0.",
35
- "ru": "Безопасность: блокировка грубой силы на `/auth/login flow/:flow Id' - после 5 неудачных попыток проверки подлинности IP отклоняется с помощью HTTP 429 в течение 15 минут. Успешный логин сбрасывает счетчик.\nРефактор DRY: общий помощник «parseManualUrlWrite» между клиентом + глобальной конфигурацией; помощник FIFO-кап в WebServer; токен доступа OAuth TTL + окно / порог блокировки теперь называются константами вместо магических чисел.\nОчистка от мертвого кода: «resolveBindToReachable», «coerceUuid» строгий параметр V4, «DEFAULT REFRESH DEBOUNCE MS» экспорт, внутренние «getMode» / «getManualUrl» доступ к тесту — все удалены; тесты переписаны, чтобы утверждать наблюдаемое поведение.\nНовый тестовый пакет «посадочная страница» с покрытием XSS-escape и проверкой резервного копирования на 11 языках.\nЭмулированная версия Home Assistant выросла с 2026.3.1 до 2026.4.0.",
36
- "pt": "Segurança: bloqueio de força bruta em `/auth/login flow/:flow Id` — após 5 tentativas de credencial falhadas, um IP é rejeitado com HTTP 429 por 15 min. O login bem- sucedido reinicia o contador.\nDRY refator: compartilhado `parseManualUrlWrite` helper between client + global config; FIFO-cap helper in WebServer; OAuth access-token TTL + lockout window/threshold são agora chamadas constantes em vez de números mágicos.\nLimpeza de código morto: `resolveBindToReachable`, `coerceUuid` strict-V4 parameter, `DEFAULT REFRESH DEBOUNCIE MS' export, interna `getMode`/`getManualUrl` test affordances — todos removidos; testes reescritos para afirmar comportamento observável.\nNovo conjunto de testes `landing-page` com cobertura XSS-scape e verificação de retorno de 11 idiomas.\nEmulated Home Assistant versão bateu de 2026.3.1 para 2026.4.0.",
37
- "nl": "Beveiliging: brute-force lockout op Id Succesvolle login resetten de teller.\nDRY-refactor: gedeelde .\nDead-code cleanup: .\nNieuwe testsuite met XSS-escape dekking en 11-taal fallback verificatie.\nEmulated Home Assistant versie stootte van 2026.3.1 naar 2026.4.0.",
38
- "fr": "Sécurité: verrouillage de la force brute sur `/auth/login flow/:flow Id` — après 5 tentatives de reconnaissance ratées, une IP est rejetée avec HTTP 429 pendant 15 min. Une connexion réussie réinitialise le compteur.\nRefactor DRY: helper partagé `parseManualUrlWrite` entre client + config global; helper FIFO-cap dans WebServer; OAuth access-token TTL + lockout window/threshold sont maintenant nommés constantes au lieu de nombres magiques.\nNettoyage en code mort: `resolveBindToReachable`, paramètre `coerceUuid` strict-V4, `DEFAULT REFRESH DEBOUNCE MS` export, interne `getMode`/`getManualUrl` test provideances - tous supprimés; tests réécrits pour affirmer un comportement observable.\nNouvelle suite d ' essais < < atterrissage-page > > avec couverture XSS-escape et vérification en 11 langues.\nLa version de l'Emuled Home Assistant est tombée de 2026.3.1 à 2026.4.0.",
39
- "it": "Sicurezza: blocco forza bruta su `/auth/login flow/:flow Id` — dopo 5 tentativi di credenziali falliti un IP viene respinto con HTTP 429 per 15 min. Il successo del login reimposta il contatore.\nDRY refactor: condiviso `parseManualUrlWrite` helper tra client + configurazione globale; FIFO-cap helper in WebServer; OAuth access-token TTL + lockout finestra / soglia sono ora chiamate costanti invece di numeri magici.\nPulitura codice morto: `resolveBindToReachable`, `coerceUuid` parametro rigoroso-V4, `DEFAULT REFRESH DEBOUNCE MS` export, `getMode`/`getManualUrl` offerte di prova — tutti rimossi; test riscritti per affermare il comportamento osservabile.\nNuova suite di prova `landing-page` con copertura XSS-escape e verifica di caduta in 11 lingue.\nEmulated Home Assistant versione urtata da 2026.3.1 a 2026.4.0.",
40
- "es": "Seguridad: bloqueo de fuerza bruta en `/auth/login flow/:flow Id` - después de 5 intentos de credencial fallidos una IP es rechazada con HTTP 429 por 15 min. Acceso exitoso restaura el contador.\nDRY refactor: compartido `parseManualUrlWrite` helper between client + global config; FIFO-cap helper in WebServer; OAuth access-token TTL + lockout window/threshold are now named constants instead of magic numbers.\nLimpieza del código muerto: `resolveBindToReachable`, `coerceUuid` strict-V4 parámetro, `DEFAULT REFRESH DEBOUNCE MS` export, internal `getMode`/`getManualUrl` test affordances — all removed; tests rewritten to assert observable behaviour.\nNew `landing-page` test suite with XSS-escape coverage and 11-language fallback verification.\nLa versión Emulated Home Assistant bajó de 2026.3.1 a 2026.4.0.",
41
- "pl": "Bezpieczeństwo: zablokowanie siły brutalnej na '/ auth / login _ flow /: flow Id' - po 5 nieudanych próbach kredytowych IP jest odrzucane przez HTTP 429 przez 15 min. Udane logowanie resetuje licznik.\nReczynnik DRY: wspólny \"parseManualUrlWrite\" pomocnik pomiędzy klientem + globalny config; helper FIFO- cap w WebServer; OAuth access- token TTL + blockout okno / próg są teraz nazywane stałe zamiast magicznych liczb.\nDead- code cleanup: 'resolveBindToReavable', 'coerceUuid' parametr strict- V4, 'DEFAULT _ REFRESH _ DEBOUNCE _ MS' export, 'internal' getMode '/' getManualUrl 'test foredances - all removed; tests rewrited to assert observable behavior.\nNowy zestaw testowy 'Landing- page' z pokryciem XSS- escape i weryfikacją 11- językową.\nWersja Emulated Home Assistant spadła z 2026.3.1 do 2026.4.0.",
42
- "uk": "Безпека: броньований замок на `/auth/login flow/:flow Id` — після 5 невдалих спроб IP відхилений HTTP 429 за 15 хв. Успішний логін скидає лічильник.\nРефактор DRY: загальний `parseManualUrlWrite` помічник між клієнтом + глобальним налаштуванням; помічник FIFO-cap в WebServer; OAuth access-token TTL + вікно блокування / поле тепер іменуються константи замість чарівних чисел.\nВидалення коду: `resolveBindToReachable`, `coerceUuid` строгий-V4 параметр, `DEFAULT REFRESH DEBOUNCE MS` експорт, внутрішня `getMode`/`getManualUrl` test allowances — всі видалені; тести, що повторюються, щоб стверджувати спостережну поведінку.\nНовий `landing-page` тест-люкс з висвітленням S-escape і 11-мовою перевіркою повернення.\nВбудований домашній помічник варіант з 2026.3.1 до 2026.4.0.",
43
- "zh-cn": "安全: \" /auth/login flow/:flow \" 上的野蛮武力封锁 Id ' - 在5次证书尝试失败后,一个IP在15分钟内被HTTP 429拒绝。 成功登录重置计数器 .\nDRY Refactor:在客户端+全局配置之间共享“parseManualUrlWrite”帮助器;WebServer中的FIFO-cap帮助器;OAuth访问-token TTL+锁定窗口/阈值现在被命名为常数而不是魔法数.\n死码清理:`溶解BindToeachable ' 、`coerceUuid ' 严格V4参数、`DEFAULT REFRESH DEBOUNCE MS ' 输出、内部`GetMode'/`GetManualUrl ' 测试支付能力-全部取消;测试重写以申明可观察到的行为.\n新的 \" 登陆页 \" 测试套件,包括XSS-escape覆盖范围和11种语言的回落核查.\n模拟家庭助理版本从2026.3.1到2026.4.0."
72
+ "en": "Security: brute-force lockout on login (5 failed attempts IP blocked for 15 min). Emulated Home Assistant version bumped to 2026.4.0.",
73
+ "de": "Sicherheit: Brute-Force-Sperre beim Login (5 Fehlversuche IP für 15 Min gesperrt). Emulierte Home-Assistant-Version auf 2026.4.0 erhöht.",
74
+ "ru": "Безопасность: блокировка от brute-force при входе (5 неудачных попыток IP блокируется на 15 мин). Версия эмулируемого Home Assistant повышена до 2026.4.0.",
75
+ "pt": "Segurança: bloqueio brute-force no login (5 tentativas falhadas IP bloqueado por 15 min). Versão emulada do Home Assistant atualizada para 2026.4.0.",
76
+ "nl": "Beveiliging: brute-force-blokkering bij login (5 mislukte pogingen IP 15 min geblokkeerd). Geëmuleerde Home Assistant-versie naar 2026.4.0.",
77
+ "fr": "Sécurité : verrouillage brute-force sur la connexion (5 tentatives échouées IP bloquée 15 min). Version Home Assistant émulée portée à 2026.4.0.",
78
+ "it": "Sicurezza: blocco brute-force al login (5 tentativi falliti IP bloccato per 15 min). Versione Home Assistant emulata aggiornata a 2026.4.0.",
79
+ "es": "Seguridad: bloqueo brute-force al iniciar sesión (5 intentos fallidos IP bloqueada 15 min). Versión emulada de Home Assistant actualizada a 2026.4.0.",
80
+ "pl": "Bezpieczeństwo: blokada brute-force przy logowaniu (5 nieudanych prób IP zablokowane na 15 min). Wersja emulowanego Home Assistant podniesiona do 2026.4.0.",
81
+ "uk": "Безпека: блокування brute-force при вході (5 невдалих спроб IP заблоковано на 15 хв). Версія емульованого Home Assistant оновлена до 2026.4.0.",
82
+ "zh-cn": "安全:登录暴力破解防护(5 次失败 IP 阻断 15 分钟)。模拟的 Home Assistant 版本提升至 2026.4.0"
44
83
  },
45
84
  "1.2.0": {
46
- "en": "Redirect target now configured via `mode` (dropdown) + `manualUrl` (free text) instead of the old `visUrl`. Migration runs automatically.\nMaster switch `global.enabled` syncs every display: on → all follow the global URL, off → each display picks up its own again.\nIdle displays without auth token are auto-removed after 30 days.\nSecurity hardening of the auth flow.\n`web` adapter declared as dependency.",
47
- "de": "Redirect-Ziel jetzt über `mode` (Dropdown) + `manualUrl` (Freitext) statt des alten `visUrl`. Migration läuft automatisch.\nMaster-Switch `global.enabled` synct jedes Display: an → alle folgen der globalen URL, aus → jedes Display nutzt wieder seine eigene.\nDisplay-Channels ohne Auth-Token werden nach 30 Tagen Leerlauf automatisch entfernt.\nAuth-Flow gehärtet.\n`web`-Adapter als Abhängigkeit eingetragen.",
48
- "ru": "Цель перенаправления теперь настраивается через `mode` (выпадающий список) + `manualUrl` (свободный текст) вместо старого `visUrl`. Миграция выполняется автоматически.\nГлавный переключатель `global.enabled` синхронизирует все дисплеи: вкл → все следуют глобальному URL, выкл → каждый дисплей использует свой.\nПростаивающие дисплеи без токена авторизации удаляются автоматически через 30 дней.\nУсилена безопасность потока авторизации.\nАдаптер `web` объявлен как зависимость.",
49
- "pt": "Destino do redirecionamento agora configurado via `mode` (dropdown) + `manualUrl` (texto livre) em vez do antigo `visUrl`. Migração executa automaticamente.\nInterruptor mestre `global.enabled` sincroniza cada display: ligado → todos seguem o URL global, desligado → cada display usa o seu próprio.\nDisplays inativos sem token de autenticação são removidos automaticamente após 30 dias.\nFluxo de autenticação reforçado.\nAdaptador `web` declarado como dependência.",
50
- "nl": "Redirect-doel wordt nu geconfigureerd via `mode` (dropdown) + `manualUrl` (vrije tekst) in plaats van de oude `visUrl`. Migratie verloopt automatisch.\nHoofdschakelaar `global.enabled` synchroniseert elk display: aan → allemaal volgen de globale URL, uit → elk display gebruikt zijn eigen.\nInactieve displays zonder auth-token worden na 30 dagen automatisch verwijderd.\nAuth-flow versterkt.\n`web`-adapter als afhankelijkheid gedeclareerd.",
51
- "fr": "Cible de redirection désormais configurée via `mode` (liste déroulante) + `manualUrl` (texte libre) au lieu de l'ancien `visUrl`. La migration est automatique.\nL'interrupteur principal `global.enabled` synchronise chaque écran : activé → tous suivent l'URL globale, désactivé → chaque écran utilise la sienne.\nLes écrans inactifs sans jeton d'authentification sont supprimés automatiquement après 30 jours.\nFlux d'authentification renforcé.\nAdaptateur `web` déclaré comme dépendance.",
52
- "it": "Destinazione del redirect ora configurata tramite `mode` (menu a discesa) + `manualUrl` (testo libero) invece del vecchio `visUrl`. La migrazione è automatica.\nInterruttore principale `global.enabled` sincronizza ogni display: on → tutti seguono l'URL globale, off → ogni display usa il proprio.\nI display inattivi senza token di autenticazione vengono rimossi automaticamente dopo 30 giorni.\nFlusso di autenticazione rafforzato.\nAdattatore `web` dichiarato come dipendenza.",
53
- "es": "Destino de redirección ahora configurado vía `mode` (desplegable) + `manualUrl` (texto libre) en lugar del antiguo `visUrl`. La migración es automática.\nInterruptor principal `global.enabled` sincroniza cada pantalla: activado → todas siguen la URL global, desactivado → cada pantalla usa la suya.\nPantallas inactivas sin token de autenticación se eliminan automáticamente tras 30 días.\nFlujo de autenticación reforzado.\nAdaptador `web` declarado como dependencia.",
54
- "pl": "Cel przekierowania jest teraz konfigurowany przez `mode` (lista rozwijana) + `manualUrl` (tekst dowolny) zamiast starego `visUrl`. Migracja przebiega automatycznie.\nGłówny przełącznik `global.enabled` synchronizuje każdy wyświetlacz: wł → wszystkie podążają za globalnym URL, wył → każdy używa własnego.\nNieaktywne wyświetlacze bez tokena uwierzytelniania usuwane automatycznie po 30 dniach.\nWzmocniono bezpieczeństwo procesu uwierzytelniania.\nAdapter `web` zadeklarowany jako zależność.",
55
- "uk": "Ціль перенаправлення тепер налаштовується через `mode` (випадаючий список) + `manualUrl` (вільний текст) замість старого `visUrl`. Міграція виконується автоматично.\nГоловний перемикач `global.enabled` синхронізує кожен дисплей: увімк → усі слідують глобальній URL, вимк → кожен дисплей використовує власну.\nНеактивні дисплеї без токена автентифікації автоматично видаляються через 30 днів.\nПотік автентифікації посилено.\nАдаптер `web` оголошено залежністю.",
56
- "zh-cn": "重定向目标现在通过 `mode`(下拉菜单)+ `manualUrl`(自由文本)配置,取代旧的 `visUrl`。迁移自动运行。\n主开关 `global.enabled` 同步每个显示器:开 → 全部跟随全局 URL,关 → 每个显示器使用自己的。\n无认证令牌且闲置 30 天的显示器会自动移除。\n认证流程已加固。\n`web` 适配器已声明为依赖。"
57
- },
58
- "1.1.6": {
59
- "en": "Audit cleanup against the upstream `ioBroker.example/TypeScript` full standard:\nTest setup migrated: tests now live next to source as `src/lib/*.test.ts` and run directly via `ts-node/register`. Removed `tsconfig.test.json` + `build-test/`, added `test/mocharc.custom.json` + `test/mocha.setup.js` + `test/tsconfig.json` + `test/.eslintrc.json`\n`@types/node` rolled back from `^25.6.0` to `^20.19.24` so type defs match `engines.node: \">=20\"`\nDependabot now ignores major bumps for `@types/node`, `typescript`, `eslint`, `actions/checkout`, `actions/setup-node`\n`nyc` config + `coverage` script added\nOrphan `.github/auto-merge.yml` removed (active workflow is `automerge-dependabot.yml` using `gh pr merge`)",
60
- "de": "Audit-Aufräumung gegen den vorgeschalteten `ioBroker.example/TypeScript` Vollstandard:\nTest-Setup migriert: Tests leben jetzt neben der Quelle als `src/lib/*.test.ts` und laufen direkt über `ts-node/register`. Entfernen `tsconfig.test.json` + `build-test/`, add `test/mocharc.custom.json` + `test/mocha.setup.js` + `test/tsconfig.json` + `test/.eslintrc.json`\n`@types/node` rollte zurück von `^25.6.0` zu `^20.19.24` so type defs match `engines.node: \">=20\"``\nDependabot ignoriert nun wichtige Beulen für `@types/node`, `typescript`, `eslint`, `actions/checkout`, `actions/setup-node`\n`nyc` config + `coverage` skript hinzugefügt\nOrphan `.github/auto-merge.yml` entfernt (aktiver Workflow ist `automerge-dependabot.yml` mit `gh pr merge`)",
61
- "ru": "Очистка аудита по полному стандарту «ioBroker.example/TypeScript»:\nНастройка теста мигрировала: тесты теперь живут рядом с источником как «src/lib/*.test.ts» и запускаются непосредственно через «ts-узел/регистр». Снято \"tsconfig.test.json\" + \"build-test/\", добавлено \"test/mocharc.custom.json\" + \"test/mocha.setup.js\" + \"test/tsconfig.json\" + \"test/.eslintrc.json\"\n'@types/node' откатился от '^25.6.0' к '^20.19.24', так что type defs соответствуют 'engines.node: '>=20'\nDependabot теперь игнорирует основные проблемы для «@types/node», «typescript», «eslint», «actions/checkout», «actions/setup-node»\n«nyc» config + скрипт «coverage»\nOrphan '.github/auto-merge.yml' removed (активный рабочий процесс - 'automerge-dependabot.yml' using 'gh pr merge')",
62
- "pt": "Limpeza de auditoria contra o padrão completo 'ioBroker.example/TypeScript` upstream:\nTest setup migrated: tests now live next to source as `src/lib/*.test.ts` e execute diretamente via `ts-node/register`. Removido `tsconfig.test.json` + `build-test/`, adicionado `test/mocharc.custom.json` + `test/mocha.setup.js` + `test/tsconfig.json` + `test/.eslintrc.json'\n`@types/node` rolled back from `^25.6.0` to `^20.19.24` então tipo defs match `engines.node: \">=20\"`\nDependabot agora ignora grandes saliências para `@types/node`, `typescript`, `eslint`, `actions/checkout`, `actions/setup-node'\n'nyc' config + 'coverage' script adicionado\nÓrfão `.github/auto-merge.yml` removido (fluxo de trabalho ativo é `automerge-dependabot.yml` usando `gh pr merge`)",
63
- "nl": "Audit cleanup tegen de upstream .example/TypeScript\nTestopstelling gemigreerd: de tests leven nu naast de bron als Verwijderd \nhet type defs komt overeen met de motors.node: \">=20\"\nDependabot negeert nu de grote hobbels voor \n`nyc` config + `coverage` script added\nOrphan ",
64
- "fr": "Nettoyage de l'audit par rapport à la norme en amont `ioBroker.example/TypeScript`:\nConfiguration de test migrée: teste maintenant en direct à côté de source comme `src/lib/*.test.ts` et fonctionne directement via `ts-node/register`. Supprimé `tsconfig.test.json` + `build-test/`, ajouté `test/mocharc.custom.json` + `test/mocha.setup.js` + `test/tsconfig.json` + `test/.estintrc.json`\n`@types/node` retourné de `^25.6.0` à `^20.19.24` de sorte que le type defs correspond `moteurs.node: \">=20\"`\nDependabot ignore maintenant les principales bosses pour `@types/node`, `typescript`, `estint`, `actions/checkout`, `actions/setup-node`\nscript `nyc` config + `coverage` ajouté\nOrphan `.github/auto-merge.yml` supprimé (le workflow actif est `automerge-dependabot.yml` en utilisant `gh pr merge`)",
65
- "it": "Audit cleanup contro il `ioBroker.example/TypeScript` standard completo:\nConfigurazione del test migrata: i test ora vivono accanto alla sorgente come `src/lib/*.test.ts` e vengono eseguiti direttamente tramite `ts-node/register`. `tsconfig.test.json` + `build-test/`, aggiunto `test/mocharc.custom.json` + `test/mocha.setup.js` + `test/tsconfig.json` + `test/.eslintrc.json`\n`@types/node` rotolato indietro da `^25.6.0` a `^20.19.24` così tipo defs match `engines.node: \">=20\"`\nDependabot ora ignora urti principali per `@types/node`, `typescript`, `eslint`, `actions/checkout`, `actions/setup-node`\n`nyc` config + `coverage` script aggiunto\nOrphan `.github/auto-merge.yml` rimosso (il flusso di lavoro attivo è `automerge-dependabot.yml` utilizzando `gh pr merge`)",
66
- "es": "Limpieza de auditorías contra el estándar completo del `ioBroker.example/TypeScript`:\nSe migraron las pruebas: las pruebas ahora viven al lado de la fuente como `src/lib/*.test.ts` y se ejecutan directamente a través de `ts-node/register`. Removed `tsconfig.test.json` + `build-test/`, added `test/mocharc.custom.json` + `test/mocha.setup.js` + `test/tsconfig.json` + `test/.eslintrc.json`\n`@types/node` rolled back from `^25.6.0` to `^20.19.24` so type defs match `engines.node: \"consign=20\"\nDependabot hace caso omiso de los principales golpes de `@tipos/nodo`, `tiposcript`, `eslint`, `actions/checkout`, `actions/setup-node`\n`nyc` config + `coverage` script añadido\nOrphan `.github/auto-merge.yml` removed (active workflow is `automerge-dependabot.yml` using `gh pr merge`)",
67
- "pl": "Oczyszczanie audytów w stosunku do pełnego standardu 'iBroker.example / TypeScript':\nUstawienie testowe migrowane: testy są teraz dostępne obok źródła jako 'src / lib / * .test.ts' i uruchamiane bezpośrednio przez 'ts- node / register'. Usunięto 'tsconfig.test.json' + 'build- test /', dodano 'test / mocharc.customi.json' + 'test / mocha.setup.js' + 'test / tsconfig.json' + 'test / .eslintrc.json'\n'@ types / node' rolled back from '^ 25.6.0' to '^ 20.19.24' so type defs match 'engins.node: \"> = 20\"'\nDependabot ignoruje teraz główne przeszkody dla '@ types / node', 'typescript', 'eslint', 'actions / checkout', 'actions / setup-node'\ndodano skrypt 'nyc' config + 'cover'\nUsunięty Orphan '.github / auto- merge.yml' (aktywny przepływ pracy to 'automaterge- dependiabot.yml' przy użyciu 'gh pr merge')",
68
- "uk": "Аудиторське очищення від потоку `ioBroker.example/TypeScript`\nТестові налаштування мігровані: тести тепер живуть поруч з джерелом як `src/lib/*.test.ts` і запустити безпосередньо через `ts-node/register`. Вилучено `tsconfig.test.json` + `build-test/``, додано `test/mocharc.custom.json` + `test/mocha.setup.js` +`test/tsconfig.json` + `test/.eslintrc.json`\n`@types/node` від `^25.6.0`` до `^20.19.24` так типу defs match `engines.node: \">=20\"`\nВ залежності від того, як `slint`, `slint`, `actions/checkout`, `actions/setup-node`\n`nyc` config + скрипт `coverage` додано\nOrphan `.github/auto-merge.yml` вилучено (активний робочий процес `automerge-залежніabot.yml` за допомогою `gh pr злиття`)",
69
- "zh-cn": "对照上游`ioBroker.example/TypeScript ' 全面标准进行审计清理:\n测试设置迁移:现在测试作为`src/lib/*.test.ts ' 在源头旁边进行,直接通过`ts-node/register ' 运行。 删除`tsconfig.test.json ' +`building-test/ ' ,增加`test/mocharc.custom.json ' +`test/mocha.setup.js ' +`test/tsconfig.json ' +`test/.eslintrac.json'\n`@型/节点`从`^25.6.0`回滚至`^20.19.24`,因此 type型与 型相同\n现在,依赖忽略了`Q ' 类型/节点 ' 、`字典 ' 、`slint ' 、`行动/检查 ' 、`行动/设置节点 ' 的重大颠峰\n添加了 \" nyc \" 配置 + \" 覆盖 \" 文字\n孤儿`.github/自动合并.yml ' 被删除(使用`gh pr 合并 ' 的主动工作流程为`自动合并-依赖.yml')"
70
- },
71
- "1.1.5": {
72
- "en": "Process-level unhandledRejection/uncaughtException handlers added as last-line-of-defence. Stop shipping the manual-review release-script plugin. Audit-driven boilerplate sync with the other krobi adapters. Min js-controller correction: was >=7.0.0, restored to repochecker-recommended >=6.0.11 (Source: ioBroker.repochecker/lib/M1000_IOPackageJson.js). @types/iobroker bumped to ^7.1.1.",
73
- "de": "Process-level unhandledRejection/uncaughtException-Handler als Last-Line-of-Defence hinzugefügt. Das manual-review-Release-Script-Plugin entfällt. Audit-getriebener Boilerplate-Sync gegenüber den anderen krobi-Adaptern. js-controller-Korrektur: war >=7.0.0, korrigiert auf Repochecker-recommended >=6.0.11 (Quelle: ioBroker.repochecker/lib/M1000_IOPackageJson.js). @types/iobroker auf ^7.1.1 angehoben.",
74
- "ru": "Process-level обробники unhandledRejection/uncaughtException додано як last-line-of-defence. Manual-review release-script plugin більше не постачається. Audit-керована синхронізація boilerplate з іншими krobi-адаптерами. Корекція js-controller: було >=7.0.0, відновлено на repochecker-recommended >=6.0.11 (Джерело: ioBroker.repochecker/lib/M1000_IOPackageJson.js). @types/iobroker оновлено до ^7.1.1.",
75
- "pt": "Handlers process-level unhandledRejection/uncaughtException adicionados como last-line-of-defence. O plugin manual-review do release-script foi removido. Sincronização de boilerplate orientada por audit com os outros adaptadores krobi. Correção js-controller: era >=7.0.0, restaurada para repochecker-recommended >=6.0.11 (Fonte: ioBroker.repochecker/lib/M1000_IOPackageJson.js). @types/iobroker atualizado para ^7.1.1.",
76
- "nl": "Process-level unhandledRejection/uncaughtException-handlers toegevoegd als last-line-of-defence. Het manual-review release-script plugin wordt niet meer meegeleverd. Audit-gestuurde boilerplate-sync met de andere krobi-adapters. js-controller-correctie: was >=7.0.0, hersteld naar repochecker-recommended >=6.0.11 (Bron: ioBroker.repochecker/lib/M1000_IOPackageJson.js). @types/iobroker verhoogd naar ^7.1.1.",
77
- "fr": "Handlers process-level unhandledRejection/uncaughtException ajoutés en last-line-of-defence. Le plugin manual-review du release-script n'est plus livré. Synchronisation boilerplate audit-driven avec les autres adaptateurs krobi. Correction js-controller: était >=7.0.0, restaurée à repochecker-recommended >=6.0.11 (Source: ioBroker.repochecker/lib/M1000_IOPackageJson.js). @types/iobroker porté à ^7.1.1.",
78
- "it": "Handler process-level unhandledRejection/uncaughtException aggiunti come last-line-of-defence. Il plugin manual-review del release-script non viene più distribuito. Sincronizzazione boilerplate audit-driven con gli altri adapter krobi. Correzione js-controller: era >=7.0.0, ripristinata a repochecker-recommended >=6.0.11 (Fonte: ioBroker.repochecker/lib/M1000_IOPackageJson.js). @types/iobroker alzato a ^7.1.1.",
79
- "es": "Handlers process-level unhandledRejection/uncaughtException añadidos como last-line-of-defence. El plugin manual-review del release-script ya no se incluye. Sincronización de boilerplate guiada por audit con los demás adaptadores krobi. Corrección js-controller: era >=7.0.0, restaurada a repochecker-recommended >=6.0.11 (Fuente: ioBroker.repochecker/lib/M1000_IOPackageJson.js). @types/iobroker elevado a ^7.1.1.",
80
- "pl": "Handlery process-level unhandledRejection/uncaughtException dodane jako last-line-of-defence. Plugin manual-review release-script nie jest już dostarczany. Synchronizacja boilerplate'u audit-driven z pozostałymi adapterami krobi. Korekcja js-controller: było >=7.0.0, przywrócono do repochecker-recommended >=6.0.11 (Źródło: ioBroker.repochecker/lib/M1000_IOPackageJson.js). @types/iobroker podniesione do ^7.1.1.",
81
- "uk": "Process-level обробники unhandledRejection/uncaughtException додано як last-line-of-defence. Manual-review release-script plugin більше не постачається. Audit-керована синхронізація boilerplate з іншими krobi-адаптерами. js-controller-корекція: було >=7.0.0, відновлено на repochecker-recommended >=6.0.11 (Джерело: ioBroker.repochecker/lib/M1000_IOPackageJson.js). @types/iobroker оновлено до ^7.1.1.",
82
- "zh-cn": "新增进程级 unhandledRejection/uncaughtException 处理器作为 last-line-of-defence。manual-review release-script 插件不再随包发布。与其他 krobi 适配器进行 audit 驱动的 boilerplate 同步。js-controller 更正:从 >=7.0.0 恢复为 repochecker 推荐的 >=6.0.11(来源:ioBroker.repochecker/lib/M1000_IOPackageJson.js)。@types/iobroker 升级至 ^7.1.1。"
83
- },
84
- "1.1.4": {
85
- "en": "Separate test-build output (`build-test/`) from production `build/` — `npm test` no longer risks leaving duplicated `build/src` + `build/test` trees in the published package. No runtime change.",
86
- "de": "Separate Test-Building-Ausgang (`Building-Test/`) von der Produktion `Building/` — `npm-Test` nicht mehr Risiken verlassen duplizierte `Building/Src` + `Building/test` Bäume im veröffentlichten Paket. Keine Laufzeitänderung.",
87
- "ru": "Отдельная продукция испытательной постройки (‘build-test/’) от производства ‘build/’ — ‘npm test’ больше не рискует оставить дублированные деревья ‘build/src’ + ‘build/test’ в опубликованном пакете. Никаких изменений.",
88
- "pt": "Saída de construção de teste separada (`build-test/`) da produção `build/` — `npm test` já não corre o risco de deixar árvores duplicadas `build/src` + `build/test` no pacote publicado. Nenhuma mudança de tempo de execução.",
89
- "nl": "Aparte test-build output van de productie Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build-Build- Geen verandering in de looptijd.",
90
- "fr": "La sortie séparée de la construction d'essai (`build-test/`) de la production `build/` — `npm test` ne risque plus de laisser des arbres dupliqués `build/src` + `build/test` dans le paquet publié. Pas de changement d'exécution.",
91
- "it": "Uscita separata di test-build (`build-test/`) dalla produzione `build/` — `npm test` non rischia più di lasciare duplicati `build/src` + `build/test` alberi nel pacchetto pubblicato. Nessun cambiamento di runtime.",
92
- "es": "La producción separada de la producción de pruebas ( \" pruebas impresas \" ) de la producción \" , \" prueba npm \" ya no corre el riesgo de dejar árboles duplicados \" construidos/src \" + \" construidos/test \" en el paquete publicado. No hay cambio de horario.",
93
- "pl": "Oddzielna produkcja testowa (\"build-test /\") z produkcji \"build /\" - \"npm test\" nie może już pozostawiać powielonych \"build / src\" + \"build / test\" drzew w opublikowanym pakiecie. Bez zmian.",
94
- "uk": "Окремий тестово-будівельний вихід (`build-test/``) від виробництва `build/`` — `npm test’ не більше ризиків, що залишають дублікатом `build/src` + `build/test` дерев у опублікованому пакеті. Немає змін робочого часу.",
95
- "zh-cn": "将试验-建设产出(`建设-测试/')与生产`建设/'——`npm测试 ' 分开,不再有将`建设/src ' +`建设/测试 ' 树木复制到已公布的成套材料的风险。 无运行时间变化 ."
85
+ "en": "Breaking: redirect target now configured via mode dropdown + manualUrl free text instead of the old visUrl. Existing setups auto-migrated.",
86
+ "de": "Breaking: Weiterleitungs-Ziel jetzt über mode-Dropdown + manualUrl-Freitext konfiguriert statt altem visUrl. Bestehende Setups werden automatisch migriert.",
87
+ "ru": "Breaking: цель перенаправления теперь настраивается через dropdown mode + свободный текст manualUrl вместо старого visUrl. Существующие настройки мигрируют автоматически.",
88
+ "pt": "Breaking: alvo de redirecionamento agora configurado via dropdown mode + texto livre manualUrl em vez do antigo visUrl. Configurações existentes são migradas automaticamente.",
89
+ "nl": "Breaking: redirect-doel nu via mode-dropdown + manualUrl-vrije tekst geconfigureerd in plaats van het oude visUrl. Bestaande setups worden automatisch gemigreerd.",
90
+ "fr": "Breaking : la cible de redirection se configure désormais via le dropdown mode + le texte libre manualUrl au lieu de l'ancien visUrl. Les configurations existantes sont migrées automatiquement.",
91
+ "it": "Breaking: obiettivo di redirect ora configurato tramite dropdown mode + testo libero manualUrl invece del vecchio visUrl. Le configurazioni esistenti vengono migrate automaticamente.",
92
+ "es": "Breaking: el destino de redirección ahora se configura mediante el dropdown mode + el texto libre manualUrl en lugar del antiguo visUrl. Las configuraciones existentes se migran automáticamente.",
93
+ "pl": "Breaking: cel przekierowania jest teraz konfigurowany przez dropdown mode + wolny tekst manualUrl zamiast starego visUrl. Istniejące konfiguracjemigrowane automatycznie.",
94
+ "uk": "Breaking: ціль перенаправлення тепер налаштовується через dropdown mode + вільний текст manualUrl замість старого visUrl. Існуючі налаштування мігрують автоматично.",
95
+ "zh-cn": "Breaking:重定向目标现在通过 mode 下拉框 + manualUrl 自由文本配置,取代旧的 visUrl。现有配置会自动迁移。"
96
96
  }
97
97
  },
98
98
  "titleLang": {
@@ -235,6 +235,20 @@
235
235
  },
236
236
  "native": {}
237
237
  },
238
+ {
239
+ "_id": "info.refresh_urls",
240
+ "type": "state",
241
+ "common": {
242
+ "name": "Refresh URL discovery",
243
+ "desc": "Write true to re-scan the broker for VIS/VIS-2 projects, Admin tiles and other discovered URLs. Useful after creating a new VIS view without restarting the adapter.",
244
+ "type": "boolean",
245
+ "role": "button",
246
+ "read": true,
247
+ "write": true,
248
+ "def": false
249
+ },
250
+ "native": {}
251
+ },
238
252
  {
239
253
  "_id": "global.mode",
240
254
  "type": "state",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iobroker.hassemu",
3
- "version": "1.3.2",
3
+ "version": "1.4.1",
4
4
  "description": "Emulates a minimal Home Assistant server so devices expecting a Home Assistant dashboard can display any custom web URL.",
5
5
  "author": {
6
6
  "name": "krobi",
@@ -31,11 +31,12 @@
31
31
  "url": "https://github.com/krobipd/ioBroker.hassemu/issues"
32
32
  },
33
33
  "engines": {
34
- "node": ">=20",
34
+ "node": ">=22",
35
35
  "npm": ">=10"
36
36
  },
37
37
  "dependencies": {
38
38
  "@fastify/cookie": "^11.0.2",
39
+ "@fastify/formbody": "^8.0.2",
39
40
  "@iobroker/adapter-core": "^3.3.2",
40
41
  "bonjour-service": "^1.3.0",
41
42
  "fastify": "^5.8.5"
@@ -49,8 +50,8 @@
49
50
  "@iobroker/testing": "^5.2.2",
50
51
  "@tsconfig/node20": "^20.1.9",
51
52
  "@types/iobroker": "npm:@iobroker/types@^7.1.1",
52
- "@types/node": "^20.19.24",
53
- "nyc": "^17.1.0",
53
+ "@types/node": "^22.0.0",
54
+ "nyc": "^18.0.0",
54
55
  "rimraf": "^6.1.3",
55
56
  "source-map-support": "^0.5.21",
56
57
  "ts-node": "^10.9.2",