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.
@@ -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) and the group process behind
1161
- * `VirtualDevices` answers it with invalid XML-RPC. A user-defined interface is asked once and
1162
- * then remembered for this session - see {@link isMethodUnsupported}.
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
- return this.#manager?.resolved(interfaceName)?.serviceMessages !== false;
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
- /unknown method/i,
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<boolean>;
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, in the
185
- * background. Nothing waits for this; the result is a hint for the configuration dialog and a
186
- * notice for an interface that is configured but whose port is closed.
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, probePort, withTimeout } from '../util/net.js';
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, { connected: false, error: undefined, idle: true, subscribing: false });
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, in the
328
- * background. Nothing waits for this; the result is a hint for the configuration dialog and a
329
- * notice for an interface that is configured but whose port is closed.
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) => probePort(host, port, { timeoutMs: 2000 }));
334
- const found = [];
335
- await Promise.all(INTERFACE_NAMES.map(async (name) => {
336
- const definition = interfaceDefinition(name);
337
- if (!definition) {
338
- return;
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
- const port = interfacePort(definition, { tls: connection.tls });
341
- if (await probe(connection.host, port)) {
342
- found.push(name);
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
- this.#detected = INTERFACE_NAMES.filter((name) => found.includes(name));
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
- // an interface whose `init` already refused says the same thing; one notice is enough
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
- if (isKnownInterface(name) &&
350
- !this.#detected.includes(name) &&
351
- !this.isConnected(name) &&
352
- known?.state.absent !== true) {
353
- this.#options.onNotice('warn', `${name}: the port is closed on ${connection.host}`, name);
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
- if (known && !this.#detected.includes(name) && !this.isConnected(name)) {
356
- this.#update(known, { absent: true });
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, { connected: true, error: undefined, absent: false, subscribing: true });
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) {