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 +16 -31
- package/build/lib/mdns.js +7 -0
- package/build/lib/mdns.js.map +2 -2
- package/build/lib/webserver.js +2 -0
- package/build/lib/webserver.js.map +3 -3
- package/build/main.js +64 -2
- package/build/main.js.map +2 -2
- package/io-package.json +98 -84
- package/package.json +5 -4
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# ioBroker.hassemu
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/iobroker.hassemu)
|
|
4
|
-

|
|
5
5
|

|
|
6
6
|
[](LICENSE)
|
|
7
7
|
[](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 >=
|
|
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.
|
|
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
|
-
-
|
|
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.
|
|
154
|
+
### 1.4.0 (2026-05-05)
|
|
162
155
|
|
|
163
|
-
-
|
|
164
|
-
-
|
|
165
|
-
-
|
|
166
|
-
-
|
|
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.
|
|
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
|
-
|
|
172
|
-
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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 */
|
package/build/lib/mdns.js.map
CHANGED
|
@@ -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;
|
|
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
|
}
|
package/build/lib/webserver.js
CHANGED
|
@@ -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
|
-
|
|
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": ";;;;;;;;;;;;;;;;;;;;;;;AAAA,yBAAmB;AACnB,YAAuB;AACvB,6BAAmD;AACnD,oBAA8B;AAC9B,2BAA2E;AAC3E,kBAA4B;AAC5B,2BAA6B;AAC7B,uBAA0B;AAI1B,MAAM,sBAAsB,KAAK,KAAK,KAAK,KAAK;AAEhD,MAAM,gBAAgB,MAAM,QAAQ;AAAA,EACxB,cAAkC;AAAA,EAClC,YAA8B;AAAA,EAC9B,WAAkC;AAAA,EAClC,eAAoC;AAAA,EACpC,eAAoC;AAAA,EACpC,4BAAgE;AAAA,EAChE,2BAA0D;AAAA,EAI3D,YAAY,UAAyC,CAAC,GAAG;AAC5D,UAAM,EAAE,GAAG,SAAS,MAAM,UAAU,CAAC;AAErC,SAAK,GAAG,SAAS,MAAM;AACnB,WAAK,QAAQ,EAAE,MAAM,SAAO,KAAK,IAAI,MAAM,sBAAsB,OAAO,GAAG,CAAC,EAAE,CAAC;AAAA,IACnF,CAAC;AACD,SAAK,GAAG,eAAe,CAAC,IAAI,UAAU;AAClC,WAAK,cAAc,IAAI,KAAK,EAAE,MAAM,SAAO,KAAK,IAAI,MAAM,0BAA0B,OAAO,GAAG,CAAC,EAAE,CAAC;AAAA,IACtG,CAAC;AACD,SAAK,GAAG,gBAAgB,MAAM;AAjCtC;AAmCY,iBAAK,iBAAL,mBAAmB;AAAA,IACvB,CAAC;AACD,SAAK,GAAG,UAAU,KAAK,SAAS,KAAK,IAAI,CAAC;AAK1C,SAAK,4BAA4B,CAAC,WAAoB;AAClD,WAAK,IAAI,MAAM,wBAAwB,kBAAkB,QAAQ,OAAO,UAAU,OAAO,MAAM,CAAC,EAAE;AAAA,IACtG;AACA,SAAK,2BAA2B,CAAC,QAAe;AAC5C,WAAK,IAAI,MAAM,uBAAuB,IAAI,OAAO,EAAE;AAAA,IACvD;AACA,YAAQ,GAAG,sBAAsB,KAAK,yBAAyB;AAC/D,YAAQ,GAAG,qBAAqB,KAAK,wBAAwB;AAAA,EACjE;AAAA,EAEA,MAAc,UAAyB;AACnC,UAAM,KAAK,SAAS,mBAAmB,EAAE,KAAK,OAAO,KAAK,KAAK,CAAC;AAEhE,SAAK,eAAe,IAAI,kCAAa,IAAI;AACzC,UAAM,KAAK,aAAa,QAAQ;AAEhC,SAAK,WAAW,IAAI,sCAAe,IAAI;AACvC,UAAM,KAAK,SAAS,QAAQ;AAO5B,UAAM,KAAK,2BAA2B;AACtC,UAAM,KAAK,oBAAoB;AAC/B,UAAM,KAAK,oBAAoB;AAG/B,UAAM,KAAK,eAAe;AAE1B,UAAM,eAAe,mBAAAA,QAAO,WAAW;AACvC,SAAK,IAAI;AAAA,MACL,gBAAgB,KAAK,OAAO,IAAI,UAAU,KAAK,OAAO,YAAY,UAAU,KAAK,OAAO,WAAW;AAAA,IACvG;AAEA,SAAK,eAAe,IAAI,kCAAa,MAAM,OAAM,WAAU;AA9EnE;AA+EY,cAAM,UAAK,iBAAL,mBAAmB,gBAAgB;AACzC,cAAM,UAAK,aAAL,mBAAe,gBAAgB;AAAA,IACzC,CAAC;AACD,UAAM,KAAK,aAAa,QAAQ;AAKhC,SAAK,SAAS,yBAAyB,MAAM,KAAK,qBAAqB,CAAC;AAGxE,UAAM,KAAK,6BAA6B,kBAAkB;AAC1D,UAAM,KAAK,qBAAqB,WAAW;AAC3C,UAAM,KAAK,qBAAqB,UAAU;
|
|
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.
|
|
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": "
|
|
8
|
-
"de": "
|
|
9
|
-
"ru": "
|
|
10
|
-
"pt": "
|
|
11
|
-
"nl": "
|
|
12
|
-
"fr": "
|
|
13
|
-
"it": "
|
|
14
|
-
"es": "
|
|
15
|
-
"pl": "
|
|
16
|
-
"uk": "
|
|
17
|
-
"zh-cn": "
|
|
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": "
|
|
21
|
-
"de": "
|
|
22
|
-
"ru": "
|
|
23
|
-
"pt": "
|
|
24
|
-
"nl": "
|
|
25
|
-
"fr": "
|
|
26
|
-
"it": "
|
|
27
|
-
"es": "
|
|
28
|
-
"pl": "
|
|
29
|
-
"uk": "
|
|
30
|
-
"zh-cn": "
|
|
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 są 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
|
|
34
|
-
"de": "Sicherheit:
|
|
35
|
-
"ru": "Безопасность: блокировка
|
|
36
|
-
"pt": "Segurança: bloqueio
|
|
37
|
-
"nl": "Beveiliging: brute-force
|
|
38
|
-
"fr": "
|
|
39
|
-
"it": "Sicurezza: blocco
|
|
40
|
-
"es": "Seguridad: bloqueo
|
|
41
|
-
"pl": "Bezpieczeństwo:
|
|
42
|
-
"uk": "Безпека:
|
|
43
|
-
"zh-cn": "
|
|
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": "
|
|
47
|
-
"de": "
|
|
48
|
-
"ru": "
|
|
49
|
-
"pt": "
|
|
50
|
-
"nl": "
|
|
51
|
-
"fr": "
|
|
52
|
-
"it": "
|
|
53
|
-
"es": "
|
|
54
|
-
"pl": "
|
|
55
|
-
"uk": "
|
|
56
|
-
"zh-cn": "
|
|
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 konfiguracje są migrowane 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
|
+
"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": ">=
|
|
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": "^
|
|
53
|
-
"nyc": "^
|
|
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",
|