homematic-manager 3.0.0-beta.16 → 3.0.0-beta.18
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 +14 -3
- package/dist/occulite.js +35 -4
- package/dist/server.js +31 -10
- package/node_modules/@homematic-manager/backend/dist/api/backend.js +38 -24
- package/node_modules/@homematic-manager/backend/dist/index.d.ts +2 -1
- package/node_modules/@homematic-manager/backend/dist/index.js +2 -1
- package/node_modules/@homematic-manager/backend/dist/interfaces/manager.d.ts +2 -0
- package/node_modules/@homematic-manager/backend/dist/interfaces/manager.js +10 -3
- package/node_modules/@homematic-manager/backend/dist/meta/service.js +23 -1
- package/node_modules/@homematic-manager/backend/dist/rpc/client.d.ts +18 -5
- package/node_modules/@homematic-manager/backend/dist/rpc/client.js +11 -5
- package/node_modules/@homematic-manager/backend/dist/rpc/log.d.ts +86 -0
- package/node_modules/@homematic-manager/backend/dist/rpc/log.js +241 -0
- package/node_modules/@homematic-manager/backend/dist/rpc/origin.d.ts +30 -0
- package/node_modules/@homematic-manager/backend/dist/rpc/origin.js +35 -0
- package/node_modules/@homematic-manager/backend/package.json +1 -1
- package/node_modules/@homematic-manager/core/dist/api/types.d.ts +25 -5
- package/node_modules/@homematic-manager/core/dist/api/types.js +2 -1
- package/node_modules/@homematic-manager/core/dist/i18n/messages.js +45 -0
- package/node_modules/@homematic-manager/core/dist/rssi/index.d.ts +31 -14
- package/node_modules/@homematic-manager/core/dist/rssi/index.js +28 -30
- package/node_modules/@homematic-manager/core/package.json +1 -1
- package/package.json +3 -3
- package/ui/assets/index-CSaQ9232.js +14 -0
- package/ui/assets/index-CgW7l3BZ.css +1 -0
- package/ui/index.html +2 -2
- package/node_modules/@homematic-manager/backend/dist/write/log.d.ts +0 -51
- package/node_modules/@homematic-manager/backend/dist/write/log.js +0 -151
- package/ui/assets/index-BqOnC2jh.js +0 -14
- package/ui/assets/index-DHvC2NX0.css +0 -1
package/dist/occulite.d.ts
CHANGED
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
*
|
|
9
9
|
* On openccu-lite nobody logs in *here*. The box's shell has already done it, lighttpd's gate has
|
|
10
10
|
* already refused everyone who has no session (their D-28), and the shell opens an addon page with
|
|
11
|
-
* the session on the URL: `?sid=@xxxxxxxxxx@`, the CCU convention.
|
|
12
|
-
* credential for the box's own APIs, so the check is one
|
|
11
|
+
* the session on the URL: `?sid=@xxxxxxxxxx@`, the CCU convention. On an image from before their
|
|
12
|
+
* task 125 that session id **is** a valid credential for the box's own APIs, so the check is one
|
|
13
|
+
* request (on a newer image the shell sends the gate's `X-Occulite-Session` instead, see below):
|
|
13
14
|
*
|
|
14
15
|
* ```
|
|
15
16
|
* GET /api/meta/v1/enums Authorization: Bearer <sid> -> 200 valid, 401 not
|
|
@@ -58,8 +59,18 @@ export interface OcculiteCheckOptions {
|
|
|
58
59
|
*/
|
|
59
60
|
readonly requireState?: boolean;
|
|
60
61
|
}
|
|
61
|
-
/** A session id as the shell hands it over, `@…@` and all, or `undefined` when it is not one. */
|
|
62
|
+
/** A session id as the shell or the gate hands it over, `@…@` and all, or `undefined` when it is not one. */
|
|
62
63
|
export declare function parseSid(value: string | null | undefined): string | undefined;
|
|
64
|
+
/**
|
|
65
|
+
* B-36: can the `?sid=` of a request be a session of the box whose gate named `gate`?
|
|
66
|
+
*
|
|
67
|
+
* A gate header of the 26-character shape says the box issues session ids of that shape (task 125),
|
|
68
|
+
* so a `?sid=` of any other shape there is the session's legacy alias, which the box's API refuses:
|
|
69
|
+
* the answer is known without asking. Without such a header - a CCU, an image from before the header,
|
|
70
|
+
* a ten-character header - nothing can be concluded, and the box decides as before. This only ever
|
|
71
|
+
* *refuses*: a `true` still has to be confirmed by the box.
|
|
72
|
+
*/
|
|
73
|
+
export declare function sidCanBeSession(sid: string, gate: string | undefined): boolean;
|
|
63
74
|
/** Checks a session id against the box that issued it. */
|
|
64
75
|
export declare class OcculiteAuthenticator {
|
|
65
76
|
#private;
|
package/dist/occulite.js
CHANGED
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
*
|
|
9
9
|
* On openccu-lite nobody logs in *here*. The box's shell has already done it, lighttpd's gate has
|
|
10
10
|
* already refused everyone who has no session (their D-28), and the shell opens an addon page with
|
|
11
|
-
* the session on the URL: `?sid=@xxxxxxxxxx@`, the CCU convention.
|
|
12
|
-
* credential for the box's own APIs, so the check is one
|
|
11
|
+
* the session on the URL: `?sid=@xxxxxxxxxx@`, the CCU convention. On an image from before their
|
|
12
|
+
* task 125 that session id **is** a valid credential for the box's own APIs, so the check is one
|
|
13
|
+
* request (on a newer image the shell sends the gate's `X-Occulite-Session` instead, see below):
|
|
13
14
|
*
|
|
14
15
|
* ```
|
|
15
16
|
* GET /api/meta/v1/enums Authorization: Bearer <sid> -> 200 valid, 401 not
|
|
@@ -32,10 +33,40 @@ export const OCCULITE_TIMEOUT_MS = 5000;
|
|
|
32
33
|
* box - so the header is only a claim, and the box is asked about it like about any other session.
|
|
33
34
|
*/
|
|
34
35
|
export const OCCULITE_SESSION_HEADER = 'x-occulite-session';
|
|
35
|
-
/**
|
|
36
|
+
/**
|
|
37
|
+
* B-36: what an openccu-lite session id looks like, and nothing else is one.
|
|
38
|
+
*
|
|
39
|
+
* - **Since openccu-lite task 125** (occulited `1f71597`, their D-77) a session id is what Go's
|
|
40
|
+
* `crypto/rand.Text` hands out: 26 characters of base32, `A-Z` and `2-7`. The box's API and its gate
|
|
41
|
+
* match exactly this (`sidRe`, the gate's `SID_PATTERN`).
|
|
42
|
+
* - **Before task 125** it was the CCU's shape, ten of `[0-9a-zA-Z]` (auth-off's fixed id too). Since
|
|
43
|
+
* then that shape is only a session's *legacy alias*, which the addon CGIs parse and the box's API
|
|
44
|
+
* refuses as a credential from anywhere. The two cannot be told apart by their characters: a
|
|
45
|
+
* ten-character value is a session on an older box and never one on a newer box.
|
|
46
|
+
* - **An API token** (`olt_` and 32 hex digits) is neither and is never a session: the header path
|
|
47
|
+
* asks `/api/auth/v1/state`, which also answers for a token.
|
|
48
|
+
*
|
|
49
|
+
* A value of another shape or length cannot be an openccu-lite session on any image, so it is refused
|
|
50
|
+
* here, without asking the box.
|
|
51
|
+
*/
|
|
52
|
+
const SESSION_ID = /^[A-Z2-7]{26}$/;
|
|
53
|
+
const LEGACY_ID = /^[0-9a-zA-Z]{10}$/;
|
|
54
|
+
/** A session id as the shell or the gate hands it over, `@…@` and all, or `undefined` when it is not one. */
|
|
36
55
|
export function parseSid(value) {
|
|
37
56
|
const bare = (value ?? '').replace(/^@|@$/g, '').trim();
|
|
38
|
-
return
|
|
57
|
+
return SESSION_ID.test(bare) || LEGACY_ID.test(bare) ? bare : undefined;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* B-36: can the `?sid=` of a request be a session of the box whose gate named `gate`?
|
|
61
|
+
*
|
|
62
|
+
* A gate header of the 26-character shape says the box issues session ids of that shape (task 125),
|
|
63
|
+
* so a `?sid=` of any other shape there is the session's legacy alias, which the box's API refuses:
|
|
64
|
+
* the answer is known without asking. Without such a header - a CCU, an image from before the header,
|
|
65
|
+
* a ten-character header - nothing can be concluded, and the box decides as before. This only ever
|
|
66
|
+
* *refuses*: a `true` still has to be confirmed by the box.
|
|
67
|
+
*/
|
|
68
|
+
export function sidCanBeSession(sid, gate) {
|
|
69
|
+
return gate === undefined || !SESSION_ID.test(gate) || SESSION_ID.test(sid);
|
|
39
70
|
}
|
|
40
71
|
/** Checks a session id against the box that issued it. */
|
|
41
72
|
export class OcculiteAuthenticator {
|
package/dist/server.js
CHANGED
|
@@ -29,7 +29,7 @@ import { applyCookieToken, applySessionToken, clearedSessionCookie, createToken,
|
|
|
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 { OCCULITE_SESSION_HEADER, OcculiteAuthenticator, parseSid } from './occulite.js';
|
|
32
|
+
import { OCCULITE_SESSION_HEADER, OcculiteAuthenticator, parseSid, sidCanBeSession, } 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';
|
|
@@ -184,8 +184,27 @@ export async function createWebHost(options = {}) {
|
|
|
184
184
|
// session is checked against the box, turned into one of ours, and taken off the URL again
|
|
185
185
|
// so that it does not sit in the address bar, in a bookmark and in every referrer.
|
|
186
186
|
if (boxSessions && sessions && (method === 'GET' || method === 'HEAD')) {
|
|
187
|
-
const offered = url.searchParams.get('sid');
|
|
188
|
-
if (
|
|
187
|
+
const offered = parseSid(url.searchParams.get('sid'));
|
|
188
|
+
if (offered !== undefined) {
|
|
189
|
+
// B-36: the gate header first. Since openccu-lite task 125 the `?sid=` a shell puts on
|
|
190
|
+
// the URL is the session's legacy alias, which the box's API refuses, while the same
|
|
191
|
+
// request carries the session the gate validated: a confirmed header signs the request
|
|
192
|
+
// in, whatever `?sid=` says, and the `?sid=` only comes off the URL.
|
|
193
|
+
const gate = gateSid(request);
|
|
194
|
+
if (gate !== undefined) {
|
|
195
|
+
const session = await sessionFromGate(request, sessions.get(readCookie(request.headers.cookie, SESSION_COOKIE)));
|
|
196
|
+
if (session?.credential === gate) {
|
|
197
|
+
redirectWithoutSid(request, response, session, url, rest);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (!sidCanBeSession(offered, gate)) {
|
|
202
|
+
// what the box would answer, without asking it: see sidCanBeSession
|
|
203
|
+
log.warn(`login: the session offered from ${clientAddress(request)} cannot be one of this openccu-lite's (not asked)`);
|
|
204
|
+
response.writeHead(302, { Location: '/', 'Cache-Control': 'no-store' });
|
|
205
|
+
response.end();
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
189
208
|
await handleHandover(request, response, offered, url, rest);
|
|
190
209
|
return;
|
|
191
210
|
}
|
|
@@ -356,17 +375,19 @@ export async function createWebHost(options = {}) {
|
|
|
356
375
|
response.end();
|
|
357
376
|
return;
|
|
358
377
|
}
|
|
359
|
-
const
|
|
360
|
-
const session = store.create(checked.name, checked.level, checked.sid);
|
|
378
|
+
const session = sessions.create(checked.name, checked.level, checked.sid);
|
|
361
379
|
backend?.noteMetaSession(checked.sid);
|
|
362
380
|
log.info(`login: ${checked.name} (level ${String(checked.level)}) through the openccu-lite shell`);
|
|
363
|
-
|
|
381
|
+
redirectWithoutSid(request, response, session, url, rest);
|
|
382
|
+
}
|
|
383
|
+
/** The same page without the session in the URL, with the cookie of the session it was let in by. */
|
|
384
|
+
function redirectWithoutSid(request, response, session, url, rest) {
|
|
364
385
|
const query = new URLSearchParams(url.searchParams);
|
|
365
386
|
query.delete('sid');
|
|
366
387
|
const search = query.size === 0 ? '' : `?${query.toString()}`;
|
|
367
388
|
response.writeHead(302, {
|
|
368
389
|
Location: `${base}${rest}${search}`,
|
|
369
|
-
'Set-Cookie': sessionCookie(session.id, base,
|
|
390
|
+
'Set-Cookie': sessionCookie(session.id, base, sessions.ttlMs / 1000, isHttps(request)),
|
|
370
391
|
'Cache-Control': 'no-store',
|
|
371
392
|
});
|
|
372
393
|
response.end();
|
|
@@ -403,9 +424,9 @@ export async function createWebHost(options = {}) {
|
|
|
403
424
|
/**
|
|
404
425
|
* B-94, D-65: the session of a request that came through openccu-lite's gate.
|
|
405
426
|
*
|
|
406
|
-
* The
|
|
407
|
-
*
|
|
408
|
-
*
|
|
427
|
+
* The cookie stays as it is; this adds a third way in, for a request that has none - a bookmark,
|
|
428
|
+
* a reload after our session expired - or whose cookie belongs to another box session than the
|
|
429
|
+
* one the gate found. A request with `?sid=` asks this first too (B-36). The header is a claim until the box
|
|
409
430
|
* confirms it (`GET /api/auth/v1/state`): a CCU and an older openccu-lite image pass a client's
|
|
410
431
|
* header through, and the backend's port is open to every process on the box.
|
|
411
432
|
*/
|
|
@@ -28,8 +28,8 @@ import { MetaService } from '../meta/service.js';
|
|
|
28
28
|
import { RegaService } from '../rega/client.js';
|
|
29
29
|
import { listDevicesAnswer } from '../rpc/server.js';
|
|
30
30
|
import { ApiEventEmitter } from '../util/emitter.js';
|
|
31
|
-
import {
|
|
32
|
-
import {
|
|
31
|
+
import { RpcLog, isWriteMethod } from '../rpc/log.js';
|
|
32
|
+
import { currentOrigin, runWithOrigin } from '../rpc/origin.js';
|
|
33
33
|
import { ParamsetWriter } from '../write/paramset.js';
|
|
34
34
|
import { ConfigRepair } from '../write/repair.js';
|
|
35
35
|
import { WriteQueue } from '../write/queue.js';
|
|
@@ -56,7 +56,7 @@ export class Backend {
|
|
|
56
56
|
#options;
|
|
57
57
|
#config;
|
|
58
58
|
#queue;
|
|
59
|
-
#
|
|
59
|
+
#rpcLog;
|
|
60
60
|
#unknownMethodsSeen = new Set();
|
|
61
61
|
#writer;
|
|
62
62
|
#repair;
|
|
@@ -125,14 +125,14 @@ export class Backend {
|
|
|
125
125
|
this.#queue = new WriteQueue({
|
|
126
126
|
paceFor: (interfaceName) => writePaceFor(interfaceName, this.#config.connection.writePaceMs),
|
|
127
127
|
});
|
|
128
|
-
this.#
|
|
128
|
+
this.#rpcLog = new RpcLog({
|
|
129
129
|
file: config.cacheFile('write-log.json'),
|
|
130
130
|
rpcLogFolder: config.connection.rpcLogFolder,
|
|
131
131
|
onAppended: (entry) => {
|
|
132
|
-
this.events.emit('
|
|
132
|
+
this.events.emit('rpcLog.appended', entry);
|
|
133
133
|
},
|
|
134
134
|
onError: (error) => {
|
|
135
|
-
this.#notice('warn', `
|
|
135
|
+
this.#notice('warn', `RPC log: ${errorMessage(error)}`);
|
|
136
136
|
},
|
|
137
137
|
...(options.cacheWriteDelayMs === undefined ? {} : { writeDelayMs: options.cacheWriteDelayMs }),
|
|
138
138
|
});
|
|
@@ -166,7 +166,7 @@ export class Backend {
|
|
|
166
166
|
});
|
|
167
167
|
await caches.load();
|
|
168
168
|
const backend = new Backend(options, config, caches);
|
|
169
|
-
await backend.#
|
|
169
|
+
await backend.#rpcLog.load();
|
|
170
170
|
await backend.#linkTemplates.load();
|
|
171
171
|
return backend;
|
|
172
172
|
}
|
|
@@ -213,7 +213,7 @@ export class Backend {
|
|
|
213
213
|
await this.#meta?.stop();
|
|
214
214
|
this.#meta = undefined;
|
|
215
215
|
await this.#caches.flush();
|
|
216
|
-
await this.#
|
|
216
|
+
await this.#rpcLog.flush();
|
|
217
217
|
this.events.clear();
|
|
218
218
|
}
|
|
219
219
|
/**
|
|
@@ -236,7 +236,7 @@ export class Backend {
|
|
|
236
236
|
}
|
|
237
237
|
if (this.#sessions > 0) {
|
|
238
238
|
if (previous === 0) {
|
|
239
|
-
void this.#resubscribe();
|
|
239
|
+
void runWithOrigin('background', () => this.#resubscribe());
|
|
240
240
|
}
|
|
241
241
|
return;
|
|
242
242
|
}
|
|
@@ -246,7 +246,7 @@ export class Backend {
|
|
|
246
246
|
}
|
|
247
247
|
const timer = setTimeout(() => {
|
|
248
248
|
this.#idleTimer = undefined;
|
|
249
|
-
void this.#unsubscribeIdle();
|
|
249
|
+
void runWithOrigin('background', () => this.#unsubscribeIdle());
|
|
250
250
|
}, grace);
|
|
251
251
|
if (typeof timer.unref === 'function') {
|
|
252
252
|
timer.unref();
|
|
@@ -279,7 +279,9 @@ export class Backend {
|
|
|
279
279
|
*/
|
|
280
280
|
async request(method, ...params) {
|
|
281
281
|
try {
|
|
282
|
-
|
|
282
|
+
// Task 48: everything a request awaits is a UI action for the RPC log; the console's
|
|
283
|
+
// own call is the console's. Whatever runs outside a request is background work.
|
|
284
|
+
return await runWithOrigin(method === 'rpc.call' ? 'console' : 'ui', () => this.#dispatch(method, params));
|
|
283
285
|
}
|
|
284
286
|
catch (error) {
|
|
285
287
|
throw error instanceof BackendError ? error : internalError(errorMessage(error), error);
|
|
@@ -468,10 +470,10 @@ export class Backend {
|
|
|
468
470
|
return this.#methods(p[0]);
|
|
469
471
|
case 'write.cancel':
|
|
470
472
|
return this.#queue.cancel(p[0]);
|
|
471
|
-
case '
|
|
472
|
-
return this.#
|
|
473
|
-
case '
|
|
474
|
-
this.#
|
|
473
|
+
case 'rpcLog.list':
|
|
474
|
+
return this.#rpcLog.list(p[0]);
|
|
475
|
+
case 'rpcLog.clear':
|
|
476
|
+
this.#rpcLog.clear();
|
|
475
477
|
return null;
|
|
476
478
|
case 'data.file':
|
|
477
479
|
return this.#files.read(p[0]);
|
|
@@ -493,7 +495,15 @@ export class Backend {
|
|
|
493
495
|
}
|
|
494
496
|
return this.#manager;
|
|
495
497
|
}
|
|
496
|
-
|
|
498
|
+
/**
|
|
499
|
+
* Task 48: the connection is background work even when a `config.set` from the settings dialog
|
|
500
|
+
* asks for it - the watchdog, the callback servers and the polling timers it creates would
|
|
501
|
+
* otherwise inherit that request's origin for the rest of the session.
|
|
502
|
+
*/
|
|
503
|
+
#connect() {
|
|
504
|
+
return runWithOrigin('background', () => this.#connectNow());
|
|
505
|
+
}
|
|
506
|
+
async #connectNow() {
|
|
497
507
|
const connection = this.#config.connection;
|
|
498
508
|
this.#noServiceMessages.clear();
|
|
499
509
|
this.#serviceMessageFailures.clear();
|
|
@@ -508,10 +518,11 @@ export class Backend {
|
|
|
508
518
|
onNotice: (level, message, interfaceName) => {
|
|
509
519
|
this.#notice(level, message, interfaceName);
|
|
510
520
|
},
|
|
511
|
-
onConnected: (interfaceName) => this.#onInterfaceConnected(interfaceName),
|
|
521
|
+
onConnected: (interfaceName) => runWithOrigin('background', () => this.#onInterfaceConnected(interfaceName)),
|
|
512
522
|
onCall: (record) => {
|
|
513
523
|
this.#onCall(record);
|
|
514
524
|
},
|
|
525
|
+
originOf: currentOrigin,
|
|
515
526
|
...(this.#options.callbackHost === undefined ? {} : { callbackHost: this.#options.callbackHost }),
|
|
516
527
|
...(this.#options.defaultCallbackPorts === undefined
|
|
517
528
|
? {}
|
|
@@ -833,7 +844,7 @@ export class Backend {
|
|
|
833
844
|
* RPC
|
|
834
845
|
*/
|
|
835
846
|
#onCall(record) {
|
|
836
|
-
this.#
|
|
847
|
+
this.#rpcLog.append(record);
|
|
837
848
|
}
|
|
838
849
|
/** A read: straight to the interface, never queued. */
|
|
839
850
|
async #read(interfaceName, method, params) {
|
|
@@ -842,7 +853,10 @@ export class Backend {
|
|
|
842
853
|
/** A write: through the paced queue of that interface. */
|
|
843
854
|
async #write(interfaceName, method, params) {
|
|
844
855
|
const client = this.#requireManager().client(interfaceName);
|
|
845
|
-
|
|
856
|
+
// Task 48: the queue runs a task from the timer that drained it, which is the *previous*
|
|
857
|
+
// task's context; the origin is the one of whoever enqueued, so it is taken here.
|
|
858
|
+
const origin = currentOrigin();
|
|
859
|
+
return this.#queue.enqueue(interfaceName, () => client.call(method, params, { origin }));
|
|
846
860
|
}
|
|
847
861
|
/*
|
|
848
862
|
* configuration
|
|
@@ -877,7 +891,7 @@ export class Backend {
|
|
|
877
891
|
: [];
|
|
878
892
|
await this.#disconnect();
|
|
879
893
|
const config = this.#withHostFacts(await this.#config.setConnection(connection));
|
|
880
|
-
this.#
|
|
894
|
+
this.#rpcLog.setRpcLogFolder(config.connection.rpcLogFolder);
|
|
881
895
|
if (config.connection.host !== previousHost) {
|
|
882
896
|
await this.#caches.flush();
|
|
883
897
|
this.#caches = new CacheStore({
|
|
@@ -1282,7 +1296,7 @@ export class Backend {
|
|
|
1282
1296
|
return;
|
|
1283
1297
|
}
|
|
1284
1298
|
const timer = setInterval(() => {
|
|
1285
|
-
void this.pollServiceMessages();
|
|
1299
|
+
void runWithOrigin('background', () => this.pollServiceMessages());
|
|
1286
1300
|
}, interval);
|
|
1287
1301
|
if (typeof timer.unref === 'function') {
|
|
1288
1302
|
timer.unref();
|
|
@@ -1310,7 +1324,7 @@ export class Backend {
|
|
|
1310
1324
|
}
|
|
1311
1325
|
const timer = setTimeout(() => {
|
|
1312
1326
|
this.#hmipSweepTimer = undefined;
|
|
1313
|
-
void this.sweepHmip();
|
|
1327
|
+
void runWithOrigin('background', () => this.sweepHmip());
|
|
1314
1328
|
}, this.#options.hmipSweepDelayMs ?? HMIP_SWEEP_DELAY_MS);
|
|
1315
1329
|
if (typeof timer.unref === 'function') {
|
|
1316
1330
|
timer.unref();
|
|
@@ -1630,8 +1644,8 @@ export const API_METHOD_NAMES = [
|
|
|
1630
1644
|
'rpc.call',
|
|
1631
1645
|
'rpc.methods',
|
|
1632
1646
|
'write.cancel',
|
|
1633
|
-
'
|
|
1634
|
-
'
|
|
1647
|
+
'rpcLog.list',
|
|
1648
|
+
'rpcLog.clear',
|
|
1635
1649
|
'data.file',
|
|
1636
1650
|
'session.info',
|
|
1637
1651
|
];
|
|
@@ -21,6 +21,8 @@ export * from './config/legacyImport.js';
|
|
|
21
21
|
export * from './config/store.js';
|
|
22
22
|
export * from './config/linkTemplates.js';
|
|
23
23
|
export * from './rpc/client.js';
|
|
24
|
+
export * from './rpc/log.js';
|
|
25
|
+
export * from './rpc/origin.js';
|
|
24
26
|
export * from './rpc/server.js';
|
|
25
27
|
export * from './interfaces/manager.js';
|
|
26
28
|
export * from './cache/devices.js';
|
|
@@ -30,7 +32,6 @@ export * from './cache/unreach.js';
|
|
|
30
32
|
export * from './cache/store.js';
|
|
31
33
|
export * from './meta/index.js';
|
|
32
34
|
export * from './write/queue.js';
|
|
33
|
-
export * from './write/log.js';
|
|
34
35
|
export * from './write/paramset.js';
|
|
35
36
|
export * from './rega/auth.js';
|
|
36
37
|
export * from './rega/client.js';
|
|
@@ -24,6 +24,8 @@ export * from './config/store.js';
|
|
|
24
24
|
export * from './config/linkTemplates.js';
|
|
25
25
|
// the protocol layer
|
|
26
26
|
export * from './rpc/client.js';
|
|
27
|
+
export * from './rpc/log.js';
|
|
28
|
+
export * from './rpc/origin.js';
|
|
27
29
|
export * from './rpc/server.js';
|
|
28
30
|
export * from './interfaces/manager.js';
|
|
29
31
|
// state
|
|
@@ -36,7 +38,6 @@ export * from './cache/store.js';
|
|
|
36
38
|
export * from './meta/index.js';
|
|
37
39
|
// the write path (task 6)
|
|
38
40
|
export * from './write/queue.js';
|
|
39
|
-
export * from './write/log.js';
|
|
40
41
|
export * from './write/paramset.js';
|
|
41
42
|
// the optional and the peripheral
|
|
42
43
|
export * from './rega/auth.js';
|
|
@@ -65,6 +65,8 @@ export interface InterfaceManagerOptions {
|
|
|
65
65
|
/** Called after a successful `init`; the backend fills its caches there. */
|
|
66
66
|
readonly onConnected?: (interfaceName: string) => void | Promise<void>;
|
|
67
67
|
readonly onCall?: (record: RpcCallRecord) => void;
|
|
68
|
+
/** Task 48: the origin of a call that names none, handed to every client (the backend's request context). */
|
|
69
|
+
readonly originOf?: RpcClientOptions['originOf'];
|
|
68
70
|
readonly now?: () => number;
|
|
69
71
|
readonly rpcTimeoutMs?: number;
|
|
70
72
|
readonly watchdogIntervalMs?: number;
|
|
@@ -27,6 +27,12 @@ import { CallbackServers } from '../rpc/server.js';
|
|
|
27
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
|
+
/**
|
|
31
|
+
* Task 48: the manager's own calls - `init`, de-init, `ping` - are the backend's business whatever
|
|
32
|
+
* asked for the connection, so they name their origin instead of inheriting a request's context
|
|
33
|
+
* (a `config.set` from the settings dialog is a UI action; the watchdog it starts is not).
|
|
34
|
+
*/
|
|
35
|
+
const BACKGROUND = { origin: 'background' };
|
|
30
36
|
/** Where an interface process on this very box calls back (#144). */
|
|
31
37
|
export const LOOPBACK_IP = '127.0.0.1';
|
|
32
38
|
/** How long `stop()` waits for the de-registering `init(url, '')` calls. */
|
|
@@ -397,7 +403,7 @@ export class InterfaceManager {
|
|
|
397
403
|
}
|
|
398
404
|
const url = this.#callbackUrl(entry.target.resolved.protocol);
|
|
399
405
|
try {
|
|
400
|
-
await withTimeout(entry.client.call('init', [url, '']), SHUTDOWN_TIMEOUT_MS, () => connectionError(`${entry.name}: de-registering timed out`));
|
|
406
|
+
await withTimeout(entry.client.call('init', [url, ''], BACKGROUND), SHUTDOWN_TIMEOUT_MS, () => connectionError(`${entry.name}: de-registering timed out`));
|
|
401
407
|
}
|
|
402
408
|
catch {
|
|
403
409
|
// a CCU that is already gone cannot be told that we are going too
|
|
@@ -416,6 +422,7 @@ export class InterfaceManager {
|
|
|
416
422
|
auth: target.auth,
|
|
417
423
|
...(this.#options.rpcTimeoutMs === undefined ? {} : { timeoutMs: this.#options.rpcTimeoutMs }),
|
|
418
424
|
...(this.#options.onCall === undefined ? {} : { onCall: this.#options.onCall }),
|
|
425
|
+
...(this.#options.originOf === undefined ? {} : { originOf: this.#options.originOf }),
|
|
419
426
|
};
|
|
420
427
|
const client = (this.#options.createClient ?? ((clientOptions) => new RpcClient(clientOptions)))(options);
|
|
421
428
|
return {
|
|
@@ -518,7 +525,7 @@ export class InterfaceManager {
|
|
|
518
525
|
const url = this.#callbackUrl(resolved.protocol);
|
|
519
526
|
this.#update(entry, { callbackUrl: url, callbackFailure: undefined });
|
|
520
527
|
try {
|
|
521
|
-
await entry.client.call('init', [url, resolved.ident]);
|
|
528
|
+
await entry.client.call('init', [url, resolved.ident], BACKGROUND);
|
|
522
529
|
entry.lastEvent = this.#now();
|
|
523
530
|
const wasFailing = entry.failures > 0;
|
|
524
531
|
entry.failures = 0;
|
|
@@ -578,7 +585,7 @@ export class InterfaceManager {
|
|
|
578
585
|
}
|
|
579
586
|
async #ping(entry) {
|
|
580
587
|
try {
|
|
581
|
-
await entry.client.call('ping', ['hmm']);
|
|
588
|
+
await entry.client.call('ping', ['hmm'], BACKGROUND);
|
|
582
589
|
}
|
|
583
590
|
catch (error) {
|
|
584
591
|
// the answer to a ping is an event, so a failing ping is only a hint; the re-init
|
|
@@ -220,7 +220,7 @@ export class MetaService {
|
|
|
220
220
|
async assign(refs, nodePath, on) {
|
|
221
221
|
const document = this.#provider.document();
|
|
222
222
|
const entries = [];
|
|
223
|
-
for (const ref of refs) {
|
|
223
|
+
for (const ref of this.#provider.kind === 'rega' ? channelsOfDevices(refs, document) : refs) {
|
|
224
224
|
const object = document.objects[ref];
|
|
225
225
|
const current = object?.enums ?? [];
|
|
226
226
|
const has = current.includes(nodePath);
|
|
@@ -363,6 +363,28 @@ export class MetaService {
|
|
|
363
363
|
}
|
|
364
364
|
}
|
|
365
365
|
/** The ids already taken among the siblings a new node would join. */
|
|
366
|
+
/**
|
|
367
|
+
* The refs an assignment on ReGa works on: a device is replaced by its channels, `:0` aside.
|
|
368
|
+
*
|
|
369
|
+
* ReGa files channels, so a device holds no path of its own, and the ReGa provider applies a
|
|
370
|
+
* device's membership list to each of its channels as their complete list. Handing it the device's
|
|
371
|
+
* empty list plus the one room took the channels out of every other room and function, and a
|
|
372
|
+
* removal through the device removed nothing (task 49). Channel by channel, each keeps its own list
|
|
373
|
+
* and only the one path changes. A device the document knows no channels of stays as it is.
|
|
374
|
+
*/
|
|
375
|
+
function channelsOfDevices(refs, document) {
|
|
376
|
+
const result = new Set();
|
|
377
|
+
for (const ref of refs) {
|
|
378
|
+
const parsed = parseRef(ref);
|
|
379
|
+
const channels = parsed === undefined || parsed.address.includes(':')
|
|
380
|
+
? []
|
|
381
|
+
: Object.keys(document.objects).filter((key) => key.startsWith(`${ref}:`) && !key.endsWith(':0'));
|
|
382
|
+
for (const entry of channels.length === 0 ? [ref] : channels) {
|
|
383
|
+
result.add(entry);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return [...result];
|
|
387
|
+
}
|
|
366
388
|
function siblingIds(definition, parent, enumId) {
|
|
367
389
|
if (parent === null || parent === undefined || parent === enumId) {
|
|
368
390
|
return definition.tree.map((node) => node.id);
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
* `unitLabel()` in the core still repairs. See the README for what remains broken in the libraries
|
|
26
26
|
* (BIN-RPC decodes strings as UTF-8, and both request paths write UTF-8).
|
|
27
27
|
*/
|
|
28
|
-
import type { ExplicitDouble, RpcProtocol, RpcValue } from '@homematic-manager/core';
|
|
28
|
+
import type { ExplicitDouble, RpcOrigin, RpcProtocol, RpcValue } from '@homematic-manager/core';
|
|
29
29
|
/**
|
|
30
30
|
* What may be sent to an interface: everything an answer can carry, plus the explicit-double
|
|
31
31
|
* wrapper both encoders understand. A paramset payload is full of them, and an interface that gets
|
|
@@ -34,7 +34,7 @@ import type { ExplicitDouble, RpcProtocol, RpcValue } from '@homematic-manager/c
|
|
|
34
34
|
export type RpcOutValue = boolean | number | string | ExplicitDouble | RpcOutValue[] | {
|
|
35
35
|
[key: string]: RpcOutValue;
|
|
36
36
|
};
|
|
37
|
-
/** What the
|
|
37
|
+
/** What the RPC log records for one call - every call, task 48. */
|
|
38
38
|
export interface RpcCallRecord {
|
|
39
39
|
readonly interfaceName: string;
|
|
40
40
|
readonly method: string;
|
|
@@ -45,6 +45,13 @@ export interface RpcCallRecord {
|
|
|
45
45
|
readonly durationMs: number;
|
|
46
46
|
/** Milliseconds since epoch. */
|
|
47
47
|
readonly timestamp: number;
|
|
48
|
+
/** Who asked: the console, a UI action, or the backend on its own. */
|
|
49
|
+
readonly origin: RpcOrigin;
|
|
50
|
+
}
|
|
51
|
+
/** Per-call options of {@link RpcClient.call}. */
|
|
52
|
+
export interface RpcCallOptions {
|
|
53
|
+
/** The origin of this call, when the caller knows it better than the client's resolver does. */
|
|
54
|
+
readonly origin?: RpcOrigin;
|
|
48
55
|
}
|
|
49
56
|
/** Anything that answers `methodCall`; the tests pass a fake instead of a socket. */
|
|
50
57
|
export interface RpcTransport {
|
|
@@ -70,8 +77,13 @@ export interface RpcClientOptions {
|
|
|
70
77
|
readonly encoding?: string;
|
|
71
78
|
/** A CCU's TLS certificate is self-signed, so this is false by default. */
|
|
72
79
|
readonly rejectUnauthorized?: boolean;
|
|
73
|
-
/** Called for every finished call - the
|
|
80
|
+
/** Called for every finished call - the RPC log hangs off it. */
|
|
74
81
|
readonly onCall?: (record: RpcCallRecord) => void;
|
|
82
|
+
/**
|
|
83
|
+
* The origin of a call that names none: the backend answers from its request context (task
|
|
84
|
+
* 48). Without a resolver every call is `background`, which is right for a client on its own.
|
|
85
|
+
*/
|
|
86
|
+
readonly originOf?: () => RpcOrigin;
|
|
75
87
|
/** Injected by the tests in place of the real libraries. */
|
|
76
88
|
readonly createTransport?: (options: RpcClientOptions) => RpcTransport;
|
|
77
89
|
}
|
|
@@ -90,9 +102,10 @@ export declare class RpcClient {
|
|
|
90
102
|
get description(): string;
|
|
91
103
|
/**
|
|
92
104
|
* Calls a method. Rejects with a `BackendError`: `kind: 'rpc'` for a fault the interface
|
|
93
|
-
* answered, `kind: 'connection'` for a timeout or a socket problem.
|
|
105
|
+
* answered, `kind: 'connection'` for a timeout or a socket problem. Every call, however it
|
|
106
|
+
* ends, is reported through `onCall` with its origin - the one given here, or the resolver's.
|
|
94
107
|
*/
|
|
95
|
-
call(method: string, params?: readonly RpcOutValue[]): Promise<RpcValue>;
|
|
108
|
+
call(method: string, params?: readonly RpcOutValue[], options?: RpcCallOptions): Promise<RpcValue>;
|
|
96
109
|
/** Closes the underlying socket, if the library has one. */
|
|
97
110
|
close(): void;
|
|
98
111
|
get closed(): boolean;
|
|
@@ -113,6 +113,7 @@ export class RpcClient {
|
|
|
113
113
|
#transport;
|
|
114
114
|
#timeoutMs;
|
|
115
115
|
#onCall;
|
|
116
|
+
#originOf;
|
|
116
117
|
#closed = false;
|
|
117
118
|
constructor(options) {
|
|
118
119
|
this.name = options.name;
|
|
@@ -121,6 +122,7 @@ export class RpcClient {
|
|
|
121
122
|
this.protocol = options.protocol;
|
|
122
123
|
this.#timeoutMs = options.timeoutMs ?? DEFAULT_RPC_TIMEOUT_MS;
|
|
123
124
|
this.#onCall = options.onCall ?? (() => undefined);
|
|
125
|
+
this.#originOf = options.originOf ?? (() => 'background');
|
|
124
126
|
this.#transport = (options.createTransport ?? createTransport)(options);
|
|
125
127
|
}
|
|
126
128
|
/** A short description for error messages: `HmIP-RF (ccu.lan:2010, xmlrpc)`. */
|
|
@@ -129,21 +131,24 @@ export class RpcClient {
|
|
|
129
131
|
}
|
|
130
132
|
/**
|
|
131
133
|
* Calls a method. Rejects with a `BackendError`: `kind: 'rpc'` for a fault the interface
|
|
132
|
-
* answered, `kind: 'connection'` for a timeout or a socket problem.
|
|
134
|
+
* answered, `kind: 'connection'` for a timeout or a socket problem. Every call, however it
|
|
135
|
+
* ends, is reported through `onCall` with its origin - the one given here, or the resolver's.
|
|
133
136
|
*/
|
|
134
|
-
async call(method, params = []) {
|
|
137
|
+
async call(method, params = [], options = {}) {
|
|
135
138
|
if (this.#closed) {
|
|
136
139
|
throw connectionError(`${this.description}: the client is closed`);
|
|
137
140
|
}
|
|
141
|
+
// resolved before the first await: the context that asked is the one that counts
|
|
142
|
+
const origin = options.origin ?? this.#originOf();
|
|
138
143
|
const started = Date.now();
|
|
139
144
|
try {
|
|
140
145
|
const result = await withTimeout(this.#invoke(method, [...params]), this.#timeoutMs, () => connectionError(`${this.description}: ${method} timed out after ${String(this.#timeoutMs)} ms`));
|
|
141
|
-
this.#record(method, params, started, { ok: true, result });
|
|
146
|
+
this.#record(method, params, started, origin, { ok: true, result });
|
|
142
147
|
return result;
|
|
143
148
|
}
|
|
144
149
|
catch (error) {
|
|
145
150
|
const failure = this.#asBackendError(method, error);
|
|
146
|
-
this.#record(method, params, started, { ok: false, error: failure.message });
|
|
151
|
+
this.#record(method, params, started, origin, { ok: false, error: failure.message });
|
|
147
152
|
throw failure;
|
|
148
153
|
}
|
|
149
154
|
}
|
|
@@ -186,7 +191,7 @@ export class RpcClient {
|
|
|
186
191
|
const message = error instanceof Error ? error.message : String(error);
|
|
187
192
|
return connectionError(`${this.description}: ${method} failed: ${message}`, error);
|
|
188
193
|
}
|
|
189
|
-
#record(method, params, started, outcome) {
|
|
194
|
+
#record(method, params, started, origin, outcome) {
|
|
190
195
|
this.#onCall({
|
|
191
196
|
interfaceName: this.name,
|
|
192
197
|
method,
|
|
@@ -196,6 +201,7 @@ export class RpcClient {
|
|
|
196
201
|
...(outcome.error === undefined ? {} : { error: outcome.error }),
|
|
197
202
|
durationMs: Date.now() - started,
|
|
198
203
|
timestamp: started,
|
|
204
|
+
origin,
|
|
199
205
|
});
|
|
200
206
|
}
|
|
201
207
|
}
|