homematic-manager 3.0.0-beta.14 → 3.0.0-beta.16
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/dist/occulite.d.ts +17 -1
- package/dist/occulite.js +13 -1
- package/dist/server.d.ts +8 -1
- package/dist/server.js +87 -2
- package/node_modules/@homematic-manager/backend/dist/api/backend.js +78 -7
- package/node_modules/@homematic-manager/backend/dist/errors.d.ts +12 -0
- package/node_modules/@homematic-manager/backend/dist/errors.js +35 -2
- package/node_modules/@homematic-manager/backend/dist/interfaces/manager.d.ts +12 -4
- package/node_modules/@homematic-manager/backend/dist/interfaces/manager.js +70 -29
- package/node_modules/@homematic-manager/backend/dist/util/net.d.ts +12 -0
- package/node_modules/@homematic-manager/backend/dist/util/net.js +14 -8
- package/node_modules/@homematic-manager/backend/package.json +1 -1
- package/node_modules/@homematic-manager/core/dist/api/types.d.ts +7 -0
- package/node_modules/@homematic-manager/core/dist/interfaces/table.d.ts +9 -2
- package/node_modules/@homematic-manager/core/dist/interfaces/table.js +6 -1
- package/node_modules/@homematic-manager/core/package.json +1 -1
- package/package.json +3 -3
- package/ui/assets/index-BqOnC2jh.js +14 -0
- package/ui/assets/index-DHvC2NX0.css +1 -0
- package/ui/index.html +2 -2
- package/ui/assets/index-CfMq3RQ-.css +0 -1
- package/ui/assets/index-Ykn5vZig.js +0 -14
package/dist/occulite.d.ts
CHANGED
|
@@ -42,6 +42,22 @@ export interface OcculiteAuthOptions {
|
|
|
42
42
|
readonly onNotice?: (level: 'info' | 'warn' | 'error', message: string) => void;
|
|
43
43
|
}
|
|
44
44
|
export declare const OCCULITE_TIMEOUT_MS = 5000;
|
|
45
|
+
/**
|
|
46
|
+
* B-94, D-65: the header openccu-lite's lighttpd gate sets on every request it lets through under
|
|
47
|
+
* `/addons/`, to the bare id of the session it found (from either session cookie or `?sid=`). The
|
|
48
|
+
* gate removes a copy the client sent first. A CCU has no gate and passes a client's header through,
|
|
49
|
+
* an older openccu-lite image does the same, and the backend's port is open to every process on the
|
|
50
|
+
* box - so the header is only a claim, and the box is asked about it like about any other session.
|
|
51
|
+
*/
|
|
52
|
+
export declare const OCCULITE_SESSION_HEADER = "x-occulite-session";
|
|
53
|
+
export interface OcculiteCheckOptions {
|
|
54
|
+
/**
|
|
55
|
+
* The addon contract's validation (openccu-lite `docs/PORTING-PROMPT.md`): the session only
|
|
56
|
+
* counts when `GET /api/auth/v1/state` confirms it. The `?sid=` hand-over keeps the D-40 check,
|
|
57
|
+
* where that second answer only supplies the name.
|
|
58
|
+
*/
|
|
59
|
+
readonly requireState?: boolean;
|
|
60
|
+
}
|
|
45
61
|
/** A session id as the shell hands it over, `@…@` and all, or `undefined` when it is not one. */
|
|
46
62
|
export declare function parseSid(value: string | null | undefined): string | undefined;
|
|
47
63
|
/** Checks a session id against the box that issued it. */
|
|
@@ -54,6 +70,6 @@ export declare class OcculiteAuthenticator {
|
|
|
54
70
|
* that cannot be asked alike. A caller cannot tell them apart, and therefore cannot use this to
|
|
55
71
|
* find out whether a session exists.
|
|
56
72
|
*/
|
|
57
|
-
check(rawSid: string | null | undefined): Promise<OcculiteSession | undefined>;
|
|
73
|
+
check(rawSid: string | null | undefined, options?: OcculiteCheckOptions): Promise<OcculiteSession | undefined>;
|
|
58
74
|
}
|
|
59
75
|
//# sourceMappingURL=occulite.d.ts.map
|
package/dist/occulite.js
CHANGED
|
@@ -24,6 +24,14 @@
|
|
|
24
24
|
* that cannot read the metadata store is of no use to a device manager whatever else it can do.
|
|
25
25
|
*/
|
|
26
26
|
export const OCCULITE_TIMEOUT_MS = 5000;
|
|
27
|
+
/**
|
|
28
|
+
* B-94, D-65: the header openccu-lite's lighttpd gate sets on every request it lets through under
|
|
29
|
+
* `/addons/`, to the bare id of the session it found (from either session cookie or `?sid=`). The
|
|
30
|
+
* gate removes a copy the client sent first. A CCU has no gate and passes a client's header through,
|
|
31
|
+
* an older openccu-lite image does the same, and the backend's port is open to every process on the
|
|
32
|
+
* box - so the header is only a claim, and the box is asked about it like about any other session.
|
|
33
|
+
*/
|
|
34
|
+
export const OCCULITE_SESSION_HEADER = 'x-occulite-session';
|
|
27
35
|
/** A session id as the shell hands it over, `@…@` and all, or `undefined` when it is not one. */
|
|
28
36
|
export function parseSid(value) {
|
|
29
37
|
const bare = (value ?? '').replace(/^@|@$/g, '').trim();
|
|
@@ -45,7 +53,7 @@ export class OcculiteAuthenticator {
|
|
|
45
53
|
* that cannot be asked alike. A caller cannot tell them apart, and therefore cannot use this to
|
|
46
54
|
* find out whether a session exists.
|
|
47
55
|
*/
|
|
48
|
-
async check(rawSid) {
|
|
56
|
+
async check(rawSid, options = {}) {
|
|
49
57
|
const sid = parseSid(rawSid);
|
|
50
58
|
if (sid === undefined) {
|
|
51
59
|
return undefined;
|
|
@@ -68,6 +76,10 @@ export class OcculiteAuthenticator {
|
|
|
68
76
|
return undefined;
|
|
69
77
|
}
|
|
70
78
|
const who = await this.#who(sid);
|
|
79
|
+
if (who === undefined && options.requireState === true) {
|
|
80
|
+
this.#options.onNotice?.('warn', 'the box did not confirm the session of the X-Occulite-Session header');
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
71
83
|
return { sid, name: who?.name ?? 'openccu-lite', level: who?.level ?? 2 };
|
|
72
84
|
}
|
|
73
85
|
/**
|
package/dist/server.d.ts
CHANGED
|
@@ -26,6 +26,7 @@ import type { SessionInfo } from '@homematic-manager/core';
|
|
|
26
26
|
import { ApiWebSocketServer, Backend, type BackendOptions } from '@homematic-manager/backend';
|
|
27
27
|
import { DeviceImageService } from './images.js';
|
|
28
28
|
import { type Logger, type LogLevel } from './log.js';
|
|
29
|
+
import { type OcculiteCheckOptions } from './occulite.js';
|
|
29
30
|
import { SessionStore } from './sessions.js';
|
|
30
31
|
/** The port the CLI binds by default - 8090, next to nothing else a Homematic user runs. */
|
|
31
32
|
export declare const DEFAULT_PORT = 8090;
|
|
@@ -33,6 +34,12 @@ export declare const DEFAULT_PORT = 8090;
|
|
|
33
34
|
export declare const DEFAULT_HOST = "127.0.0.1";
|
|
34
35
|
/** How long `close()` waits for `backend.stop()` before it gives up and exits anyway. */
|
|
35
36
|
export declare const SHUTDOWN_TIMEOUT_MS = 5000;
|
|
37
|
+
/**
|
|
38
|
+
* B-94, D-65: how long the box's answer about an `X-Occulite-Session` is believed without asking
|
|
39
|
+
* again. The addon contract says to cache it briefly, if at all; a minute covers the burst of
|
|
40
|
+
* assets and the socket of one page load.
|
|
41
|
+
*/
|
|
42
|
+
export declare const GATE_RECHECK_MS = 60000;
|
|
36
43
|
/**
|
|
37
44
|
* How often an idle API socket is pinged. Below lighttpd's `server.max-read-idle` /
|
|
38
45
|
* `max-write-idle` default of 60 s, which is what would otherwise cut the proxied socket of the
|
|
@@ -63,7 +70,7 @@ export interface CredentialChecker {
|
|
|
63
70
|
}
|
|
64
71
|
/** D-40: what the `occulite` hand-over asks. `OcculiteAuthenticator` is the real one. */
|
|
65
72
|
export interface SessionChecker {
|
|
66
|
-
check(sid: string | null | undefined): Promise<{
|
|
73
|
+
check(sid: string | null | undefined, options?: OcculiteCheckOptions): Promise<{
|
|
67
74
|
name: string;
|
|
68
75
|
level: number;
|
|
69
76
|
sid: string;
|
package/dist/server.js
CHANGED
|
@@ -25,11 +25,11 @@ import fs from 'node:fs/promises';
|
|
|
25
25
|
import http from 'node:http';
|
|
26
26
|
import path from 'node:path';
|
|
27
27
|
import { ApiWebSocketServer, Backend, RegaAuthenticator, } from '@homematic-manager/backend';
|
|
28
|
-
import { applyCookieToken, applySessionToken, clearedSessionCookie, createToken, isLoopbackHost, readCookie, SESSION_COOKIE, sessionCookie, TOKEN_COOKIE, tokenCookie, } from './auth.js';
|
|
28
|
+
import { applyCookieToken, applySessionToken, clearedSessionCookie, createToken, hasTokenQuery, isLoopbackHost, readCookie, SESSION_COOKIE, sessionCookie, TOKEN_COOKIE, tokenCookie, withTokenQuery, } from './auth.js';
|
|
29
29
|
import { DeviceImageService, readIconMapFile } from './images.js';
|
|
30
30
|
import { clientAddress, parseLoginForm, pickLanguage, readBody, renderLoginPage, } from './login.js';
|
|
31
31
|
import { createLogger, silentLogger } from './log.js';
|
|
32
|
-
import { OcculiteAuthenticator, parseSid } from './occulite.js';
|
|
32
|
+
import { OCCULITE_SESSION_HEADER, OcculiteAuthenticator, parseSid } from './occulite.js';
|
|
33
33
|
import { defaultDataDir, defaultMetadataDir, defaultUiDir, packageVersion } from './paths.js';
|
|
34
34
|
import { proxyRequest, proxyUpgrade } from './proxy.js';
|
|
35
35
|
import { RateLimiter, SessionStore } from './sessions.js';
|
|
@@ -40,6 +40,12 @@ export const DEFAULT_PORT = 8090;
|
|
|
40
40
|
export const DEFAULT_HOST = '127.0.0.1';
|
|
41
41
|
/** How long `close()` waits for `backend.stop()` before it gives up and exits anyway. */
|
|
42
42
|
export const SHUTDOWN_TIMEOUT_MS = 5000;
|
|
43
|
+
/**
|
|
44
|
+
* B-94, D-65: how long the box's answer about an `X-Occulite-Session` is believed without asking
|
|
45
|
+
* again. The addon contract says to cache it briefly, if at all; a minute covers the burst of
|
|
46
|
+
* assets and the socket of one page load.
|
|
47
|
+
*/
|
|
48
|
+
export const GATE_RECHECK_MS = 60_000;
|
|
43
49
|
/**
|
|
44
50
|
* How often an idle API socket is pinged. Below lighttpd's `server.max-read-idle` /
|
|
45
51
|
* `max-write-idle` default of 60 s, which is what would otherwise cut the proxied socket of the
|
|
@@ -216,6 +222,9 @@ export async function createWebHost(options = {}) {
|
|
|
216
222
|
return;
|
|
217
223
|
}
|
|
218
224
|
session = sessions.get(readCookie(request.headers.cookie, SESSION_COOKIE));
|
|
225
|
+
// B-94, D-65: on openccu-lite the gate names the session it let through. It is checked
|
|
226
|
+
// with the box like the `?sid=` hand-over, and ignored when the box does not confirm it.
|
|
227
|
+
session = await sessionFromGate(request, session);
|
|
219
228
|
if (session?.credential !== undefined) {
|
|
220
229
|
// D-40: writes to the box go out as the session of whoever is looking at the page
|
|
221
230
|
backend?.noteMetaSession(session.credential);
|
|
@@ -362,6 +371,74 @@ export async function createWebHost(options = {}) {
|
|
|
362
371
|
});
|
|
363
372
|
response.end();
|
|
364
373
|
}
|
|
374
|
+
/**
|
|
375
|
+
* B-94, D-65: the sessions made from the gate's `X-Occulite-Session`, by the box's session id,
|
|
376
|
+
* with the time the box confirmed it. A request of the same page load that has no cookie of ours
|
|
377
|
+
* yet - an asset, the socket - finds its session here instead of asking the box again.
|
|
378
|
+
*/
|
|
379
|
+
const gateSessions = new Map();
|
|
380
|
+
let lastGateWarning = 0;
|
|
381
|
+
/** The session id the gate put on a request; `undefined` outside `occulite` mode and for a malformed one. */
|
|
382
|
+
function gateSid(request) {
|
|
383
|
+
if (!boxSessions) {
|
|
384
|
+
return undefined;
|
|
385
|
+
}
|
|
386
|
+
const value = request.headers[OCCULITE_SESSION_HEADER];
|
|
387
|
+
// two copies are joined with a comma by Node, which no session id contains: refused
|
|
388
|
+
return parseSid(Array.isArray(value) ? value.join(', ') : value);
|
|
389
|
+
}
|
|
390
|
+
/** A session made from this box session and confirmed less than `GATE_RECHECK_MS` ago. */
|
|
391
|
+
function cachedGateSession(sid) {
|
|
392
|
+
const entry = gateSessions.get(sid);
|
|
393
|
+
if (!entry) {
|
|
394
|
+
return undefined;
|
|
395
|
+
}
|
|
396
|
+
const session = sessions?.get(entry.id);
|
|
397
|
+
if (!session || Date.now() - entry.checkedAt > GATE_RECHECK_MS) {
|
|
398
|
+
gateSessions.delete(sid);
|
|
399
|
+
return undefined;
|
|
400
|
+
}
|
|
401
|
+
return session;
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* B-94, D-65: the session of a request that came through openccu-lite's gate.
|
|
405
|
+
*
|
|
406
|
+
* The `?sid=` hand-over and the cookie stay as they are; this only adds a third way in, for a
|
|
407
|
+
* request that has neither - a bookmark, a reload after our session expired - or whose cookie
|
|
408
|
+
* belongs to another box session than the one the gate found. The header is a claim until the box
|
|
409
|
+
* confirms it (`GET /api/auth/v1/state`): a CCU and an older openccu-lite image pass a client's
|
|
410
|
+
* header through, and the backend's port is open to every process on the box.
|
|
411
|
+
*/
|
|
412
|
+
async function sessionFromGate(request, current) {
|
|
413
|
+
const offered = gateSid(request);
|
|
414
|
+
if (!boxSessions || !sessions || offered === undefined || current?.credential === offered) {
|
|
415
|
+
return current;
|
|
416
|
+
}
|
|
417
|
+
const cached = cachedGateSession(offered);
|
|
418
|
+
if (cached) {
|
|
419
|
+
return cached;
|
|
420
|
+
}
|
|
421
|
+
const checked = await boxSessions.check(offered, { requireState: true });
|
|
422
|
+
if (!checked) {
|
|
423
|
+
// a header the box does not confirm counts as no header: the cookie, the token and the
|
|
424
|
+
// redirect to the box decide, as they did before there was a header
|
|
425
|
+
if (Date.now() - lastGateWarning > GATE_RECHECK_MS) {
|
|
426
|
+
lastGateWarning = Date.now();
|
|
427
|
+
log.warn(`login: openccu-lite did not confirm the X-Occulite-Session from ${clientAddress(request)}`);
|
|
428
|
+
}
|
|
429
|
+
return current;
|
|
430
|
+
}
|
|
431
|
+
const now = Date.now();
|
|
432
|
+
for (const [sid, entry] of gateSessions) {
|
|
433
|
+
if (now - entry.checkedAt > GATE_RECHECK_MS) {
|
|
434
|
+
gateSessions.delete(sid);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
const session = sessions.create(checked.name, checked.level, checked.sid);
|
|
438
|
+
gateSessions.set(checked.sid, { id: session.id, checkedAt: now });
|
|
439
|
+
log.info(`login: ${checked.name} (level ${String(checked.level)}) through the openccu-lite gate`);
|
|
440
|
+
return session;
|
|
441
|
+
}
|
|
365
442
|
/** The token cookie of task 13 - `settings.cgi`'s hand-over still opens every door. */
|
|
366
443
|
function hasValidToken(request) {
|
|
367
444
|
return token !== undefined && readCookie(request.headers.cookie, TOKEN_COOKIE) === token;
|
|
@@ -433,6 +510,14 @@ export async function createWebHost(options = {}) {
|
|
|
433
510
|
// D-32: and a login session opens the same socket, in the same way
|
|
434
511
|
if (sessions) {
|
|
435
512
|
applySessionToken(request, token, (id) => sessions.get(id) !== undefined);
|
|
513
|
+
// B-94: the socket of a page that came in through the gate, before our cookie is there
|
|
514
|
+
const gate = gateSid(request);
|
|
515
|
+
if (token !== undefined &&
|
|
516
|
+
gate !== undefined &&
|
|
517
|
+
!hasTokenQuery(request.url) &&
|
|
518
|
+
cachedGateSession(gate)) {
|
|
519
|
+
request.url = withTokenQuery(request.url, token);
|
|
520
|
+
}
|
|
436
521
|
}
|
|
437
522
|
api.handleUpgrade(request, socket, head);
|
|
438
523
|
return;
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* main process with it, issue #127).
|
|
15
15
|
*/
|
|
16
16
|
import path from 'node:path';
|
|
17
|
-
import { RPC_METHOD_NAMES, RSSI_UNKNOWN, countsAsServiceMessage, isAcknowledgeable, maintenanceAddress, mergeMethodHelp, methodsFor, callbackPinOption, normaliseDescription, repairMisdecodedUtf8, } from '@homematic-manager/core';
|
|
17
|
+
import { RPC_METHOD_NAMES, RSSI_UNKNOWN, countsAsServiceMessage, isAcknowledgeable, isKnownInterface, maintenanceAddress, mergeMethodHelp, methodsFor, callbackPinOption, normaliseDescription, repairMisdecodedUtf8, } from '@homematic-manager/core';
|
|
18
18
|
import { CacheStore } from '../cache/store.js';
|
|
19
19
|
import { ConfigStore } from '../config/store.js';
|
|
20
20
|
import { LinkTemplateStore } from '../config/linkTemplates.js';
|
|
@@ -92,6 +92,20 @@ export class Backend {
|
|
|
92
92
|
* table - and it is emptied whenever the connection is rebuilt.
|
|
93
93
|
*/
|
|
94
94
|
#noServiceMessages = new Set();
|
|
95
|
+
/**
|
|
96
|
+
* B-26 (#158): interfaces whose last `getServiceMessages` failed for a reason that may pass - a
|
|
97
|
+
* timeout, a process that is restarting. The failure is logged when the interface lands in
|
|
98
|
+
* here and not again until a call succeeds; before, the five-minute poll logged it every round.
|
|
99
|
+
*/
|
|
100
|
+
#serviceMessageFailures = new Set();
|
|
101
|
+
/**
|
|
102
|
+
* B-26 (#158): the `system.listMethods` answer of a user-defined interface, asked once and
|
|
103
|
+
* dropped when the interface subscribes again - a re-`init` may be a restarted, updated process.
|
|
104
|
+
* `undefined` inside means "no usable list", and the method is then tried once, as before.
|
|
105
|
+
*/
|
|
106
|
+
#listedMethods = new Map();
|
|
107
|
+
/** B-26: interfaces that do not have `system.listMethods` itself; kept for the connection. */
|
|
108
|
+
#noListMethods = new Set();
|
|
95
109
|
#idleTimer;
|
|
96
110
|
/** Task 38: the saved callback values a pinned one replaces are logged by the first `start()` only. */
|
|
97
111
|
#pinsReported = false;
|
|
@@ -482,6 +496,9 @@ export class Backend {
|
|
|
482
496
|
async #connect() {
|
|
483
497
|
const connection = this.#config.connection;
|
|
484
498
|
this.#noServiceMessages.clear();
|
|
499
|
+
this.#serviceMessageFailures.clear();
|
|
500
|
+
this.#listedMethods.clear();
|
|
501
|
+
this.#noListMethods.clear();
|
|
485
502
|
const manager = (this.#options.createInterfaceManager ?? ((options) => new InterfaceManager(options)))({
|
|
486
503
|
connection,
|
|
487
504
|
handler: this.#callbackHandler(),
|
|
@@ -664,6 +681,8 @@ export class Backend {
|
|
|
664
681
|
}
|
|
665
682
|
/** Fills the caches of an interface that has just subscribed. */
|
|
666
683
|
async #onInterfaceConnected(interfaceName) {
|
|
684
|
+
// B-26: a (re-)`init` may be a restarted process, so its method list is asked again
|
|
685
|
+
this.#listedMethods.delete(interfaceName);
|
|
667
686
|
try {
|
|
668
687
|
await this.#refreshDevices(interfaceName);
|
|
669
688
|
}
|
|
@@ -1157,23 +1176,69 @@ export class Backend {
|
|
|
1157
1176
|
* Does it make sense to ask this interface for its service messages?
|
|
1158
1177
|
*
|
|
1159
1178
|
* The built-in answer comes from the core's table: hmipserver has no `getServiceMessages` (the
|
|
1160
|
-
* HmIP sweep below reads the `:0` channels instead)
|
|
1161
|
-
*
|
|
1162
|
-
*
|
|
1179
|
+
* HmIP sweep below reads the `:0` channels instead), the group process behind `VirtualDevices`
|
|
1180
|
+
* answers it with invalid XML-RPC, and CUxD with a fault and a warning in the CCU's syslog
|
|
1181
|
+
* (B-26, #158). A user-defined interface is judged by its own `system.listMethods` where that
|
|
1182
|
+
* gives a list, and is otherwise asked once and then remembered for this session - see
|
|
1183
|
+
* {@link isMethodUnsupported}.
|
|
1163
1184
|
*/
|
|
1164
|
-
#hasServiceMessages(interfaceName) {
|
|
1185
|
+
async #hasServiceMessages(interfaceName) {
|
|
1165
1186
|
if (this.#noServiceMessages.has(interfaceName)) {
|
|
1166
1187
|
return false;
|
|
1167
1188
|
}
|
|
1168
|
-
|
|
1189
|
+
if (this.#manager?.resolved(interfaceName)?.serviceMessages === false) {
|
|
1190
|
+
return false;
|
|
1191
|
+
}
|
|
1192
|
+
// the table is measured for the built-in processes; asking rfd for its method list after
|
|
1193
|
+
// every `init` would buy nothing
|
|
1194
|
+
if (isKnownInterface(interfaceName)) {
|
|
1195
|
+
return true;
|
|
1196
|
+
}
|
|
1197
|
+
const listed = await this.#methodList(interfaceName);
|
|
1198
|
+
return listed === undefined || listed.has('getServiceMessages');
|
|
1199
|
+
}
|
|
1200
|
+
/**
|
|
1201
|
+
* B-26 (#158): the methods a user-defined interface lists in `system.listMethods`, or
|
|
1202
|
+
* `undefined` when it gave no usable list.
|
|
1203
|
+
*
|
|
1204
|
+
* Asked once and shared by concurrent callers; the answer is dropped when the interface
|
|
1205
|
+
* subscribes again. A process that does not have `system.listMethods` either is remembered for
|
|
1206
|
+
* the connection, so it is not asked after every re-`init`; any other failure is forgotten, and
|
|
1207
|
+
* the next caller asks again.
|
|
1208
|
+
*/
|
|
1209
|
+
#methodList(interfaceName) {
|
|
1210
|
+
if (this.#noListMethods.has(interfaceName)) {
|
|
1211
|
+
return Promise.resolve(undefined);
|
|
1212
|
+
}
|
|
1213
|
+
const cached = this.#listedMethods.get(interfaceName);
|
|
1214
|
+
if (cached) {
|
|
1215
|
+
return cached;
|
|
1216
|
+
}
|
|
1217
|
+
const pending = this.#read(interfaceName, 'system.listMethods', []).then((answer) => {
|
|
1218
|
+
const names = asStrings(answer);
|
|
1219
|
+
return names.length === 0 ? undefined : new Set(names);
|
|
1220
|
+
}, (error) => {
|
|
1221
|
+
if (isMethodUnsupported(error)) {
|
|
1222
|
+
this.#noListMethods.add(interfaceName);
|
|
1223
|
+
}
|
|
1224
|
+
if (this.#listedMethods.get(interfaceName) === pending) {
|
|
1225
|
+
this.#listedMethods.delete(interfaceName);
|
|
1226
|
+
}
|
|
1227
|
+
return undefined;
|
|
1228
|
+
});
|
|
1229
|
+
this.#listedMethods.set(interfaceName, pending);
|
|
1230
|
+
return pending;
|
|
1169
1231
|
}
|
|
1170
1232
|
/** Reads the BidCos service messages of one interface into the store. */
|
|
1171
1233
|
async #refreshServiceMessages(interfaceName) {
|
|
1172
|
-
if (!this.#hasServiceMessages(interfaceName)) {
|
|
1234
|
+
if (!(await this.#hasServiceMessages(interfaceName))) {
|
|
1173
1235
|
return;
|
|
1174
1236
|
}
|
|
1175
1237
|
try {
|
|
1176
1238
|
const answer = await this.#read(interfaceName, 'getServiceMessages', []);
|
|
1239
|
+
if (this.#serviceMessageFailures.delete(interfaceName)) {
|
|
1240
|
+
this.#notice('info', `${interfaceName}: getServiceMessages answers again`, interfaceName);
|
|
1241
|
+
}
|
|
1177
1242
|
if (!Array.isArray(answer)) {
|
|
1178
1243
|
return;
|
|
1179
1244
|
}
|
|
@@ -1200,8 +1265,14 @@ export class Backend {
|
|
|
1200
1265
|
// of it on hardware (task 17). Remembered, and never asked again this session.
|
|
1201
1266
|
if (isMethodUnsupported(error)) {
|
|
1202
1267
|
this.#noServiceMessages.add(interfaceName);
|
|
1268
|
+
this.#serviceMessageFailures.delete(interfaceName);
|
|
1269
|
+
return;
|
|
1270
|
+
}
|
|
1271
|
+
// B-26 (#158): once per interface until a call succeeds again, not once per poll
|
|
1272
|
+
if (this.#serviceMessageFailures.has(interfaceName)) {
|
|
1203
1273
|
return;
|
|
1204
1274
|
}
|
|
1275
|
+
this.#serviceMessageFailures.add(interfaceName);
|
|
1205
1276
|
this.#notice('info', `${interfaceName}: getServiceMessages failed: ${errorMessage(error)}`, interfaceName);
|
|
1206
1277
|
}
|
|
1207
1278
|
}
|
|
@@ -73,6 +73,18 @@ export declare function isMethodUnsupported(value: unknown): boolean;
|
|
|
73
73
|
* the message text as well, because `connectionError()` composes its text before the cause is set.
|
|
74
74
|
*/
|
|
75
75
|
export declare function isConnectionRefused(value: unknown): boolean;
|
|
76
|
+
/**
|
|
77
|
+
* B-28: did the interface not answer - a timeout, or no route to its host?
|
|
78
|
+
*
|
|
79
|
+
* The counterpart of {@link isConnectionRefused}, and deliberately not the same state: a refused
|
|
80
|
+
* port means the process is not there, a call that times out means it is slow or its host is away
|
|
81
|
+
* for a moment. A CCU-Jack or a remote CUxD that does not answer for a minute must stay what it is,
|
|
82
|
+
* a configured interface that is not answering, and be tried again - not "not present".
|
|
83
|
+
*
|
|
84
|
+
* `RpcClient` rejects a call that runs past its timeout with a `kind: 'connection'` error whose text
|
|
85
|
+
* says "timed out", so the text is looked at as well as the codes along the `cause` chain.
|
|
86
|
+
*/
|
|
87
|
+
export declare function isNotAnswering(value: unknown): boolean;
|
|
76
88
|
/**
|
|
77
89
|
* Task 38: a callback server's bind failed because another process holds the port - hm2mqtt.js on
|
|
78
90
|
* the same pair, or a second container on the host network. Looked for along the `cause` chain and
|
|
@@ -133,8 +133,10 @@ const UNSUPPORTED_METHOD_PATTERNS = [
|
|
|
133
133
|
/unknown xml-rpc tag/i,
|
|
134
134
|
// binrpc's own parser
|
|
135
135
|
/malformed response/i,
|
|
136
|
-
// an interface that answers XML-RPC properly but has no handler for the method
|
|
137
|
-
|
|
136
|
+
// an interface that answers XML-RPC properly but has no handler for the method. rfd words it
|
|
137
|
+
// `unknown method name`, CUxD `unknown.method name` - both with faultCode -1, which is also
|
|
138
|
+
// eq-3's "generic error", so the code alone says nothing (B-26, #158)
|
|
139
|
+
/unknown[ ._]method/i,
|
|
138
140
|
/method not found/i,
|
|
139
141
|
];
|
|
140
142
|
/** The standard XML-RPC fault code for a method the server does not have. */
|
|
@@ -182,6 +184,37 @@ export function isConnectionRefused(value) {
|
|
|
182
184
|
}
|
|
183
185
|
return /\bECONNREFUSED\b/.test(errorMessage(value));
|
|
184
186
|
}
|
|
187
|
+
/** The socket codes of a host that did not answer in time or cannot be reached at all. */
|
|
188
|
+
const NOT_ANSWERING_CODES = new Set(['ETIMEDOUT', 'EHOSTUNREACH', 'ENETUNREACH', 'EHOSTDOWN']);
|
|
189
|
+
/**
|
|
190
|
+
* B-28: did the interface not answer - a timeout, or no route to its host?
|
|
191
|
+
*
|
|
192
|
+
* The counterpart of {@link isConnectionRefused}, and deliberately not the same state: a refused
|
|
193
|
+
* port means the process is not there, a call that times out means it is slow or its host is away
|
|
194
|
+
* for a moment. A CCU-Jack or a remote CUxD that does not answer for a minute must stay what it is,
|
|
195
|
+
* a configured interface that is not answering, and be tried again - not "not present".
|
|
196
|
+
*
|
|
197
|
+
* `RpcClient` rejects a call that runs past its timeout with a `kind: 'connection'` error whose text
|
|
198
|
+
* says "timed out", so the text is looked at as well as the codes along the `cause` chain.
|
|
199
|
+
*/
|
|
200
|
+
export function isNotAnswering(value) {
|
|
201
|
+
if (isConnectionRefused(value)) {
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
let current = value;
|
|
205
|
+
for (let depth = 0; current !== undefined && current !== null && depth < 10; depth += 1) {
|
|
206
|
+
const candidate = current;
|
|
207
|
+
if (typeof candidate.code === 'string' && NOT_ANSWERING_CODES.has(candidate.code)) {
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
if (Array.isArray(candidate.errors) && candidate.errors.some((entry) => isNotAnswering(entry))) {
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
current = candidate.cause;
|
|
214
|
+
}
|
|
215
|
+
const text = errorMessage(value);
|
|
216
|
+
return /\btimed out\b/i.test(text) || /\b(ETIMEDOUT|EHOSTUNREACH|ENETUNREACH|EHOSTDOWN)\b/.test(text);
|
|
217
|
+
}
|
|
185
218
|
/**
|
|
186
219
|
* Task 38: a callback server's bind failed because another process holds the port - hm2mqtt.js on
|
|
187
220
|
* the same pair, or a second container on the host network. Looked for along the `cause` chain and
|
|
@@ -23,6 +23,7 @@ import type { CallbackPins, ConnectionConfig, InterfaceState, ResolvedInterface
|
|
|
23
23
|
import { type InterfaceTarget } from '../config/defaults.js';
|
|
24
24
|
import { RpcClient, type RpcCallRecord, type RpcClientOptions } from '../rpc/client.js';
|
|
25
25
|
import { type CallbackHandler, type CallbackServerSet } from '../rpc/server.js';
|
|
26
|
+
import { type PortProbe } from '../util/net.js';
|
|
26
27
|
/** How often the watchdog looks at every interface. 2.x used the same 15 s. */
|
|
27
28
|
export declare const WATCHDOG_INTERVAL_MS = 15000;
|
|
28
29
|
/** Where an interface process on this very box calls back (#144). */
|
|
@@ -91,7 +92,7 @@ export interface InterfaceManagerOptions {
|
|
|
91
92
|
/** Injected by the tests. */
|
|
92
93
|
readonly createClient?: (options: RpcClientOptions) => RpcClient;
|
|
93
94
|
readonly createCallbackServers?: (handler: CallbackHandler) => CallbackServerSet;
|
|
94
|
-
readonly probe?: (host: string, port: number) => Promise<
|
|
95
|
+
readonly probe?: (host: string, port: number) => Promise<PortProbe>;
|
|
95
96
|
/** Injected for the callback address; defaults to this machine's IPv4 addresses. */
|
|
96
97
|
readonly localAddresses?: () => string[];
|
|
97
98
|
/**
|
|
@@ -181,9 +182,16 @@ export declare class InterfaceManager {
|
|
|
181
182
|
*/
|
|
182
183
|
stop(): Promise<void>;
|
|
183
184
|
/**
|
|
184
|
-
* Probes the well-known ports of every built-in interface on the configured host,
|
|
185
|
-
*
|
|
186
|
-
*
|
|
185
|
+
* Probes the well-known ports of every built-in interface on the configured host, and the port
|
|
186
|
+
* of every configured user-defined interface where it is configured, in the background. Nothing
|
|
187
|
+
* waits for this; the result is a hint for the configuration dialog and a mark for an interface
|
|
188
|
+
* that is configured and not connected.
|
|
189
|
+
*
|
|
190
|
+
* B-28: only a *refused* port makes an interface "not present". Until beta.15 a probe that timed
|
|
191
|
+
* out counted the same, and a user-defined interface - which the built-in probe never looks at -
|
|
192
|
+
* was marked absent whenever it was not connected at that moment: a slow CCU-Jack or a remote
|
|
193
|
+
* CUxD on a box that was still booting disappeared from the popup's healthy states until the next
|
|
194
|
+
* start. A port that does not answer marks the interface as not answering, and it stays what it is.
|
|
187
195
|
*/
|
|
188
196
|
probeInterfaces(): Promise<string[]>;
|
|
189
197
|
}
|
|
@@ -20,11 +20,11 @@
|
|
|
20
20
|
* the root of the "endless loading" issues #121, #126, #128 and #134.
|
|
21
21
|
*/
|
|
22
22
|
import { INTERFACE_NAMES, callbackPinOption, interfaceDefinition, interfacePort, isKnownInterface, } from '@homematic-manager/core';
|
|
23
|
-
import { configError, connectionError, errorMessage, isAddressInUse, isConnectionRefused } from '../errors.js';
|
|
23
|
+
import { configError, connectionError, errorMessage, isAddressInUse, isConnectionRefused, isNotAnswering, } from '../errors.js';
|
|
24
24
|
import { interfaceTargets } from '../config/defaults.js';
|
|
25
25
|
import { RpcClient } from '../rpc/client.js';
|
|
26
26
|
import { CallbackServers } from '../rpc/server.js';
|
|
27
|
-
import { localIPv4Addresses,
|
|
27
|
+
import { localIPv4Addresses, probePortState, withTimeout } from '../util/net.js';
|
|
28
28
|
/** How often the watchdog looks at every interface. 2.x used the same 15 s. */
|
|
29
29
|
export const WATCHDOG_INTERVAL_MS = 15_000;
|
|
30
30
|
/** Where an interface process on this very box calls back (#144). */
|
|
@@ -232,7 +232,13 @@ export class InterfaceManager {
|
|
|
232
232
|
entry.lastEvent = 0;
|
|
233
233
|
entry.failures = 0;
|
|
234
234
|
entry.retryAt = 0;
|
|
235
|
-
this.#update(entry, {
|
|
235
|
+
this.#update(entry, {
|
|
236
|
+
connected: false,
|
|
237
|
+
error: undefined,
|
|
238
|
+
idle: true,
|
|
239
|
+
subscribing: false,
|
|
240
|
+
unreachable: false,
|
|
241
|
+
});
|
|
236
242
|
}
|
|
237
243
|
this.#options.onStateChanged(this.states());
|
|
238
244
|
}
|
|
@@ -281,7 +287,7 @@ export class InterfaceManager {
|
|
|
281
287
|
}
|
|
282
288
|
entry.lastEvent = this.#now();
|
|
283
289
|
if (!entry.state.connected) {
|
|
284
|
-
this.#update(entry, { connected: true, error: undefined });
|
|
290
|
+
this.#update(entry, { connected: true, error: undefined, unreachable: false });
|
|
285
291
|
this.#options.onStateChanged(this.states());
|
|
286
292
|
}
|
|
287
293
|
}
|
|
@@ -324,36 +330,61 @@ export class InterfaceManager {
|
|
|
324
330
|
this.#options.onStateChanged([]);
|
|
325
331
|
}
|
|
326
332
|
/**
|
|
327
|
-
* Probes the well-known ports of every built-in interface on the configured host,
|
|
328
|
-
*
|
|
329
|
-
*
|
|
333
|
+
* Probes the well-known ports of every built-in interface on the configured host, and the port
|
|
334
|
+
* of every configured user-defined interface where it is configured, in the background. Nothing
|
|
335
|
+
* waits for this; the result is a hint for the configuration dialog and a mark for an interface
|
|
336
|
+
* that is configured and not connected.
|
|
337
|
+
*
|
|
338
|
+
* B-28: only a *refused* port makes an interface "not present". Until beta.15 a probe that timed
|
|
339
|
+
* out counted the same, and a user-defined interface - which the built-in probe never looks at -
|
|
340
|
+
* was marked absent whenever it was not connected at that moment: a slow CCU-Jack or a remote
|
|
341
|
+
* CUxD on a box that was still booting disappeared from the popup's healthy states until the next
|
|
342
|
+
* start. A port that does not answer marks the interface as not answering, and it stays what it is.
|
|
330
343
|
*/
|
|
331
344
|
async probeInterfaces() {
|
|
332
345
|
const connection = this.#options.connection;
|
|
333
|
-
const probe = this.#options.probe ?? ((host, port) =>
|
|
334
|
-
const
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
346
|
+
const probe = this.#options.probe ?? ((host, port) => probePortState(host, port, { timeoutMs: 2000 }));
|
|
347
|
+
const results = new Map();
|
|
348
|
+
const extraProbes = connection.interfaces
|
|
349
|
+
.filter((name) => !isKnownInterface(name))
|
|
350
|
+
.map(async (name) => {
|
|
351
|
+
const entry = this.#interfaces.get(name);
|
|
352
|
+
if (entry) {
|
|
353
|
+
results.set(name, await probe(entry.state.host, entry.state.port));
|
|
339
354
|
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
355
|
+
});
|
|
356
|
+
const builtInProbes = INTERFACE_NAMES.map(async (name) => {
|
|
357
|
+
const definition = interfaceDefinition(name);
|
|
358
|
+
if (definition) {
|
|
359
|
+
results.set(name, await probe(connection.host, interfacePort(definition, { tls: connection.tls })));
|
|
343
360
|
}
|
|
344
|
-
})
|
|
345
|
-
|
|
361
|
+
});
|
|
362
|
+
await Promise.all([...builtInProbes, ...extraProbes]);
|
|
363
|
+
this.#detected = INTERFACE_NAMES.filter((name) => results.get(name) === 'open');
|
|
346
364
|
for (const name of connection.interfaces) {
|
|
347
|
-
|
|
365
|
+
const result = results.get(name);
|
|
366
|
+
if (this.isConnected(name) || result === undefined || result === 'open') {
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
348
369
|
const known = this.#interfaces.get(name);
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
known?.state.absent !== true) {
|
|
353
|
-
|
|
370
|
+
const where = known === undefined ? connection.host : `${known.state.host}:${String(known.state.port)}`;
|
|
371
|
+
if (result === 'refused') {
|
|
372
|
+
// an interface whose `init` already refused says the same thing; one notice is enough
|
|
373
|
+
if (known?.state.absent !== true) {
|
|
374
|
+
this.#options.onNotice('warn', `${name}: the port is closed on ${where}`, name);
|
|
375
|
+
}
|
|
376
|
+
if (known) {
|
|
377
|
+
this.#update(known, { absent: true, unreachable: false });
|
|
378
|
+
}
|
|
354
379
|
}
|
|
355
|
-
|
|
356
|
-
|
|
380
|
+
else {
|
|
381
|
+
// and one whose `init` timed out has said this already
|
|
382
|
+
if (known?.state.unreachable !== true) {
|
|
383
|
+
this.#options.onNotice('warn', `${name}: ${where} does not answer - kept as configured and tried again`, name);
|
|
384
|
+
}
|
|
385
|
+
if (known) {
|
|
386
|
+
this.#update(known, { absent: false, unreachable: true });
|
|
387
|
+
}
|
|
357
388
|
}
|
|
358
389
|
}
|
|
359
390
|
this.#options.onStateChanged(this.states());
|
|
@@ -459,6 +490,7 @@ export class InterfaceManager {
|
|
|
459
490
|
connected: false,
|
|
460
491
|
error: failure.message,
|
|
461
492
|
absent: false,
|
|
493
|
+
unreachable: false,
|
|
462
494
|
subscribing: false,
|
|
463
495
|
callbackUrl: undefined,
|
|
464
496
|
callbackFailure: { port: failure.port, inUse: failure.inUse },
|
|
@@ -493,7 +525,13 @@ export class InterfaceManager {
|
|
|
493
525
|
entry.retryAt = 0;
|
|
494
526
|
// hmipserver re-sends every device on `init` (occu#45), so the grids are not complete
|
|
495
527
|
// until the sweep below is through; the UI shows "subscribing" until then
|
|
496
|
-
this.#update(entry, {
|
|
528
|
+
this.#update(entry, {
|
|
529
|
+
connected: true,
|
|
530
|
+
error: undefined,
|
|
531
|
+
absent: false,
|
|
532
|
+
unreachable: false,
|
|
533
|
+
subscribing: true,
|
|
534
|
+
});
|
|
497
535
|
if (wasFailing) {
|
|
498
536
|
this.#options.onNotice('info', `${interfaceName}: answering again`, interfaceName);
|
|
499
537
|
}
|
|
@@ -516,17 +554,19 @@ export class InterfaceManager {
|
|
|
516
554
|
*
|
|
517
555
|
* Whether the port refuses the connection decides both the wording and the `absent` flag the
|
|
518
556
|
* indicator reads - "not present" is a different thing from "the CCU is unreachable", and only
|
|
519
|
-
* the first one is the normal state of BidCos-Wired on a CCU without a wired gateway.
|
|
557
|
+
* the first one is the normal state of BidCos-Wired on a CCU without a wired gateway. A timeout
|
|
558
|
+
* or a host without a route is the second one, `unreachable` (B-28).
|
|
520
559
|
*/
|
|
521
560
|
#noteInitFailure(entry, error) {
|
|
522
561
|
const message = errorMessage(error);
|
|
523
562
|
const absent = isConnectionRefused(error);
|
|
563
|
+
const unreachable = !absent && isNotAnswering(error);
|
|
524
564
|
const first = entry.failures === 0;
|
|
525
565
|
entry.failures += 1;
|
|
526
566
|
const base = this.#options.initBackoffMs ?? WATCHDOG_INTERVAL_MS;
|
|
527
567
|
const wait = Math.min(base * 2 ** (entry.failures - 1), MAX_INIT_BACKOFF_MS);
|
|
528
568
|
entry.retryAt = this.#now() + wait;
|
|
529
|
-
this.#update(entry, { connected: false, error: message, absent, subscribing: false });
|
|
569
|
+
this.#update(entry, { connected: false, error: message, absent, unreachable, subscribing: false });
|
|
530
570
|
if (!first) {
|
|
531
571
|
return;
|
|
532
572
|
}
|
|
@@ -553,6 +593,7 @@ export class InterfaceManager {
|
|
|
553
593
|
...(entry.lastEvent > 0 ? { lastEvent: entry.lastEvent } : {}),
|
|
554
594
|
};
|
|
555
595
|
setFlag(state, 'absent', changes.absent);
|
|
596
|
+
setFlag(state, 'unreachable', changes.unreachable);
|
|
556
597
|
setFlag(state, 'subscribing', changes.subscribing);
|
|
557
598
|
setFlag(state, 'idle', changes.idle);
|
|
558
599
|
if ('error' in changes) {
|