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.
Files changed (30) hide show
  1. package/dist/occulite.d.ts +14 -3
  2. package/dist/occulite.js +35 -4
  3. package/dist/server.js +31 -10
  4. package/node_modules/@homematic-manager/backend/dist/api/backend.js +38 -24
  5. package/node_modules/@homematic-manager/backend/dist/index.d.ts +2 -1
  6. package/node_modules/@homematic-manager/backend/dist/index.js +2 -1
  7. package/node_modules/@homematic-manager/backend/dist/interfaces/manager.d.ts +2 -0
  8. package/node_modules/@homematic-manager/backend/dist/interfaces/manager.js +10 -3
  9. package/node_modules/@homematic-manager/backend/dist/meta/service.js +23 -1
  10. package/node_modules/@homematic-manager/backend/dist/rpc/client.d.ts +18 -5
  11. package/node_modules/@homematic-manager/backend/dist/rpc/client.js +11 -5
  12. package/node_modules/@homematic-manager/backend/dist/rpc/log.d.ts +86 -0
  13. package/node_modules/@homematic-manager/backend/dist/rpc/log.js +241 -0
  14. package/node_modules/@homematic-manager/backend/dist/rpc/origin.d.ts +30 -0
  15. package/node_modules/@homematic-manager/backend/dist/rpc/origin.js +35 -0
  16. package/node_modules/@homematic-manager/backend/package.json +1 -1
  17. package/node_modules/@homematic-manager/core/dist/api/types.d.ts +25 -5
  18. package/node_modules/@homematic-manager/core/dist/api/types.js +2 -1
  19. package/node_modules/@homematic-manager/core/dist/i18n/messages.js +45 -0
  20. package/node_modules/@homematic-manager/core/dist/rssi/index.d.ts +31 -14
  21. package/node_modules/@homematic-manager/core/dist/rssi/index.js +28 -30
  22. package/node_modules/@homematic-manager/core/package.json +1 -1
  23. package/package.json +3 -3
  24. package/ui/assets/index-CSaQ9232.js +14 -0
  25. package/ui/assets/index-CgW7l3BZ.css +1 -0
  26. package/ui/index.html +2 -2
  27. package/node_modules/@homematic-manager/backend/dist/write/log.d.ts +0 -51
  28. package/node_modules/@homematic-manager/backend/dist/write/log.js +0 -151
  29. package/ui/assets/index-BqOnC2jh.js +0 -14
  30. package/ui/assets/index-DHvC2NX0.css +0 -1
@@ -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. That session id **is** a valid
12
- * credential for the box's own APIs, so the check is one request:
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. That session id **is** a valid
12
- * credential for the box's own APIs, so the check is one request:
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
- /** A session id as the shell hands it over, `@…@` and all, or `undefined` when it is not one. */
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 /^[0-9a-zA-Z]{6,64}$/.test(bare) ? bare : undefined;
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 (parseSid(offered) !== undefined) {
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 store = sessions;
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
- // the same page, without the session in the URL
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, store.ttlMs / 1000, isHttps(request)),
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 `?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
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 { WriteLog } from '../write/log.js';
32
- import { isWriteMethod } from '../write/log.js';
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
- #writeLog;
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.#writeLog = new WriteLog({
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('writeLog.appended', entry);
132
+ this.events.emit('rpcLog.appended', entry);
133
133
  },
134
134
  onError: (error) => {
135
- this.#notice('warn', `write log: ${errorMessage(error)}`);
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.#writeLog.load();
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.#writeLog.flush();
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
- return await this.#dispatch(method, params);
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 'writeLog.list':
472
- return this.#writeLog.list(p[0]);
473
- case 'writeLog.clear':
474
- this.#writeLog.clear();
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
- async #connect() {
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.#writeLog.append(record);
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
- return this.#queue.enqueue(interfaceName, () => client.call(method, params));
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.#writeLog.setRpcLogFolder(config.connection.rpcLogFolder);
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
- 'writeLog.list',
1634
- 'writeLog.clear',
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 write log and the RPC console record for one call. */
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 write log and the console history hang off it. */
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
  }