@dronedeploy/rocos-js-sdk 4.4.4 → 4.4.6

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 (72) hide show
  1. package/LICENSE +3 -0
  2. package/cjs/api/StreamRegister.d.ts +18 -3
  3. package/cjs/api/StreamRegister.js +71 -38
  4. package/cjs/api/atoms/StreamHeartbeat.js +2 -2
  5. package/cjs/api/streams/telemetry/TelemetryStreamAbstract.d.ts +8 -1
  6. package/cjs/api/streams/telemetry/TelemetryStreamAbstract.js +87 -40
  7. package/cjs/api/streams/webRTCSignalling/WebRTCSignallingStreamAbstract.js +1 -1
  8. package/cjs/constants/api.d.ts +3 -2
  9. package/cjs/constants/api.js +5 -4
  10. package/cjs/constants/identifier.d.ts +1 -0
  11. package/cjs/constants/identifier.js +2 -1
  12. package/cjs/helpers/getUniqueId.js +2 -1
  13. package/cjs/models/callsigns/CallsignsLookup.d.ts +4 -0
  14. package/cjs/models/callsigns/CallsignsLookup.js +19 -0
  15. package/cjs/models/robot-status/RobotStatus.d.ts +23 -0
  16. package/cjs/models/robot-status/RobotStatusEnums.d.ts +18 -6
  17. package/cjs/models/robot-status/RobotStatusEnums.js +18 -6
  18. package/cjs/services/AssetStorageService.js +6 -1
  19. package/cjs/services/BaseServiceAbstract.js +1 -1
  20. package/cjs/services/BaseStreamService.d.ts +12 -1
  21. package/cjs/services/BaseStreamService.js +19 -30
  22. package/cjs/services/EnvironmentService.d.ts +4 -0
  23. package/cjs/services/EnvironmentService.js +5 -1
  24. package/cjs/services/MapService.d.ts +15 -3
  25. package/cjs/services/MapService.js +25 -13
  26. package/cjs/services/RobotStatusService.d.ts +20 -1
  27. package/cjs/services/RobotStatusService.js +5 -0
  28. package/cjs/services/SearchService.js +1 -1
  29. package/cjs/services/TargetService.js +2 -2
  30. package/cjs/services/TelemetryService.d.ts +1 -2
  31. package/cjs/services/TelemetryService.js +30 -28
  32. package/cjs/services/WebRTCSignallingService.js +7 -9
  33. package/cjs/services/robot-status/connectivity.d.ts +16 -12
  34. package/cjs/services/robot-status/connectivity.js +26 -15
  35. package/cjs/services/robot-status/reportedStatus.d.ts +14 -1
  36. package/cjs/services/robot-status/reportedStatus.js +36 -1
  37. package/esm/api/StreamRegister.d.ts +18 -3
  38. package/esm/api/StreamRegister.js +71 -38
  39. package/esm/api/atoms/StreamHeartbeat.js +2 -2
  40. package/esm/api/streams/telemetry/TelemetryStreamAbstract.d.ts +8 -1
  41. package/esm/api/streams/telemetry/TelemetryStreamAbstract.js +88 -41
  42. package/esm/api/streams/webRTCSignalling/WebRTCSignallingStreamAbstract.js +2 -2
  43. package/esm/constants/api.d.ts +3 -2
  44. package/esm/constants/api.js +3 -2
  45. package/esm/constants/identifier.d.ts +1 -0
  46. package/esm/constants/identifier.js +1 -0
  47. package/esm/helpers/getUniqueId.js +2 -1
  48. package/esm/models/callsigns/CallsignsLookup.d.ts +4 -0
  49. package/esm/models/callsigns/CallsignsLookup.js +19 -0
  50. package/esm/models/robot-status/RobotStatus.d.ts +23 -0
  51. package/esm/models/robot-status/RobotStatusEnums.d.ts +18 -6
  52. package/esm/models/robot-status/RobotStatusEnums.js +18 -6
  53. package/esm/services/AssetStorageService.js +6 -1
  54. package/esm/services/BaseServiceAbstract.js +1 -1
  55. package/esm/services/BaseStreamService.d.ts +12 -1
  56. package/esm/services/BaseStreamService.js +19 -30
  57. package/esm/services/EnvironmentService.d.ts +4 -0
  58. package/esm/services/EnvironmentService.js +5 -1
  59. package/esm/services/MapService.d.ts +15 -3
  60. package/esm/services/MapService.js +26 -14
  61. package/esm/services/RobotStatusService.d.ts +20 -1
  62. package/esm/services/RobotStatusService.js +6 -1
  63. package/esm/services/SearchService.js +2 -2
  64. package/esm/services/TargetService.js +3 -3
  65. package/esm/services/TelemetryService.d.ts +1 -2
  66. package/esm/services/TelemetryService.js +31 -29
  67. package/esm/services/WebRTCSignallingService.js +8 -10
  68. package/esm/services/robot-status/connectivity.d.ts +16 -12
  69. package/esm/services/robot-status/connectivity.js +26 -15
  70. package/esm/services/robot-status/reportedStatus.d.ts +14 -1
  71. package/esm/services/robot-status/reportedStatus.js +35 -1
  72. package/package.json +7 -2
@@ -58,21 +58,24 @@ const mapConnectionStatus = (connection) => {
58
58
  exports.mapConnectionStatus = mapConnectionStatus;
59
59
  /**
60
60
  * Aggregates per-service connection states into an overall verdict:
61
- * - `ONLINE` at least one service connected and none disconnected.
62
- * - `DEGRADED` — a genuine mix: some connected, some disconnected.
61
+ * - `DEGRADED` any service reports an explicit `FAILED` fault, or a genuine mix (some connected,
62
+ * some disconnected).
63
+ * - `ONLINE` — at least one service connected and none disconnected or failed.
63
64
  * - `OFFLINE` — nothing connected.
64
65
  *
65
- * `UNKNOWN` services (e.g. not yet polled) never force `DEGRADED`, so startup
66
- * settles on `OFFLINE` (gray) rather than a spurious flashing-red degraded state.
66
+ * A `FAILED` service forces `DEGRADED` even when nothing else is connected, so a fault (e.g. a failed
67
+ * deployment) raises attention rather than reading as a plain, switched-off `OFFLINE`.
67
68
  *
68
- * Note: this optimism also means a service held `UNKNOWN` indefinitely (e.g. an
69
- * endpoint whose polls keep failing) does not by itself pull the aggregate below
70
- * `ONLINE` a deliberate v1 tradeoff to keep transient errors from flapping the
71
- * indicator. Surfacing a long-lived failure distinctly (likely via health rather
72
- * than connectivity) is left as a future refinement.
69
+ * `UNKNOWN` services (e.g. not yet polled) never force `DEGRADED`, so startup settles on `OFFLINE`
70
+ * (gray) rather than a spurious flashing-red degraded state. This optimism also means a service held
71
+ * `UNKNOWN` indefinitely (e.g. an endpoint whose polls keep failing) does not by itself pull the
72
+ * aggregate below `ONLINE` — a deliberate tradeoff to keep transient errors from flapping the
73
+ * indicator; a service that wants to be surfaced reports `FAILED` explicitly.
73
74
  */
74
75
  const aggregateConnectivity = (services) => {
75
76
  const values = Object.values(services);
77
+ if (values.includes(models_1.ServiceConnection.FAILED))
78
+ return models_1.RobotConnectivity.DEGRADED;
76
79
  const hasConnected = values.includes(models_1.ServiceConnection.CONNECTED);
77
80
  const hasDisconnected = values.includes(models_1.ServiceConnection.DISCONNECTED);
78
81
  if (hasConnected && hasDisconnected)
@@ -107,24 +110,32 @@ const connectivityEquals = (a, b) => {
107
110
  };
108
111
  /**
109
112
  * Emits the robot's {@link RobotConnectivityStatus} — the per-service breakdown
110
- * plus the aggregate verdict — for the configured `expectedServices`. HTTP
111
- * services are polled; `telemetry-liveness` uses the supplied observable; any
112
- * unrecognised service id resolves to `UNKNOWN`.
113
+ * plus the aggregate verdict — for the configured `expectedServices`, plus any
114
+ * caller-injected `extraServices` (folded in on equal footing). HTTP services
115
+ * are polled; `telemetry-liveness` and injected services use their supplied
116
+ * observable; any unrecognised service id resolves to `UNKNOWN`.
113
117
  */
114
118
  const getConnectivityChanges = (deps, projectId, callsign, expectedServices = exports.DEFAULT_EXPECTED_SERVICES, pollMs = DEFAULT_CONNECTIVITY_POLL_MS) => {
119
+ const extraServices = deps.extraServices ?? {};
120
+ const serviceIds = [...expectedServices, ...Object.keys(extraServices).filter((id) => !expectedServices.includes(id))];
115
121
  // combineLatest([]) completes without emitting; emit an explicit empty/offline status instead.
116
- if (expectedServices.length === 0) {
122
+ if (serviceIds.length === 0) {
117
123
  return (0, rxjs_1.of)({ overall: models_1.RobotConnectivity.OFFLINE, services: {} });
118
124
  }
119
- const perService = expectedServices.map((serviceId) => {
125
+ const perService = serviceIds.map((serviceId) => {
120
126
  let source$;
121
127
  if (serviceId === exports.TELEMETRY_LIVENESS_SERVICE) {
122
128
  source$ = deps.telemetryLiveness$;
123
129
  }
124
- else if (serviceId in exports.CONNECTION_SERVICE_PREFIXES) {
130
+ else if (Object.prototype.hasOwnProperty.call(exports.CONNECTION_SERVICE_PREFIXES, serviceId)) {
125
131
  const url = (0, exports.buildConnectionsUrl)(serviceId, deps.baseUrl, projectId, callsign, deps.insecure);
126
132
  source$ = (0, exports.getServiceConnectionChanges)(deps.httpGet, url, pollMs);
127
133
  }
134
+ else if (Object.prototype.hasOwnProperty.call(extraServices, serviceId)) {
135
+ // Contain an injected observable's errors as UNKNOWN, mirroring the built-in sources, so a
136
+ // faulty caller signal can't error out combineLatest and tear down the whole aggregate.
137
+ source$ = extraServices[serviceId].pipe((0, rxjs_1.catchError)(() => (0, rxjs_1.of)(models_1.ServiceConnection.UNKNOWN)));
138
+ }
128
139
  else {
129
140
  source$ = (0, rxjs_1.of)(models_1.ServiceConnection.UNKNOWN);
130
141
  }
@@ -1,10 +1,12 @@
1
1
  import { Observable } from 'rxjs';
2
- import { RobotHealth, RobotReadiness, ServiceConnection } from '../../models';
2
+ import { RobotHealth, RobotReadiness, RobotStatusContributors, ServiceConnection } from '../../models';
3
3
  import { TelemetryService } from '../TelemetryService';
4
4
  /** The new agent topic carrying health + readiness (replaces the heartbeat). */
5
5
  export declare const ROBOT_STATUS_SOURCE = "/diagnostics/robot/status";
6
6
  /** The legacy heartbeat topic — liveness only, no status payload. */
7
7
  export declare const LEGACY_HEARTBEAT_SOURCE = "/rocos/agent/telemetry/heartbeat";
8
+ /** The per-contributor breakdown behind the reported status (health + readiness). */
9
+ export declare const ROBOT_STATUS_CONTRIBUTORS_SOURCE = "/diagnostics/robot/status/contributors";
8
10
  export interface ReportedRobotStatus {
9
11
  health: RobotHealth;
10
12
  readiness: RobotReadiness;
@@ -28,6 +30,17 @@ export declare const parseRobotReadiness: (value: unknown) => RobotReadiness;
28
30
  * new topic.
29
31
  */
30
32
  export declare const getReportedStatusChanges: (telemetry: TelemetryService, projectId: string, callsign: string) => Observable<ReportedRobotStatus>;
33
+ /**
34
+ * Emits the per-contributor breakdown behind the robot's reported health and
35
+ * readiness, from the agent's `robot/status/contributors` topic. Each
36
+ * contributor's integer wire value is mapped onto {@link RobotHealth} /
37
+ * {@link RobotReadiness}; a missing `health`/`readiness` array becomes `[]`.
38
+ *
39
+ * Starts with an empty breakdown so consumers combining this with other streams
40
+ * emit immediately, and — like {@link getReportedStatusChanges} — a telemetry
41
+ * error falls back to an empty breakdown rather than terminating the stream.
42
+ */
43
+ export declare const getStatusContributorsChanges: (telemetry: TelemetryService, projectId: string, callsign: string) => Observable<RobotStatusContributors>;
31
44
  /**
32
45
  * Emits the `telemetry-liveness` connectivity signal: whether a heartbeat (from
33
46
  * either the new `robot/status` topic or the legacy heartbeat) has been received
@@ -1,12 +1,14 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.getTelemetryLiveness = exports.getReportedStatusChanges = exports.parseRobotReadiness = exports.parseRobotHealth = exports.LEGACY_HEARTBEAT_SOURCE = exports.ROBOT_STATUS_SOURCE = void 0;
3
+ exports.getTelemetryLiveness = exports.getStatusContributorsChanges = exports.getReportedStatusChanges = exports.parseRobotReadiness = exports.parseRobotHealth = exports.ROBOT_STATUS_CONTRIBUTORS_SOURCE = exports.LEGACY_HEARTBEAT_SOURCE = exports.ROBOT_STATUS_SOURCE = void 0;
4
4
  const rxjs_1 = require("rxjs");
5
5
  const models_1 = require("../../models");
6
6
  /** The new agent topic carrying health + readiness (replaces the heartbeat). */
7
7
  exports.ROBOT_STATUS_SOURCE = '/diagnostics/robot/status';
8
8
  /** The legacy heartbeat topic — liveness only, no status payload. */
9
9
  exports.LEGACY_HEARTBEAT_SOURCE = '/rocos/agent/telemetry/heartbeat';
10
+ /** The per-contributor breakdown behind the reported status (health + readiness). */
11
+ exports.ROBOT_STATUS_CONTRIBUTORS_SOURCE = `${exports.ROBOT_STATUS_SOURCE}/contributors`;
10
12
  const DEFAULT_HEARTBEAT_TIMEOUT_MS = 5000;
11
13
  const DEFAULT_INTERVAL_MS = 2000;
12
14
  /**
@@ -84,6 +86,39 @@ const getReportedStatusChanges = (telemetry, projectId, callsign) => {
84
86
  (0, rxjs_1.catchError)(() => (0, rxjs_1.of)({ health: models_1.RobotHealth.UNKNOWN, readiness: models_1.RobotReadiness.UNKNOWN })));
85
87
  };
86
88
  exports.getReportedStatusChanges = getReportedStatusChanges;
89
+ const toContributor = (raw, parse) => ({
90
+ id: raw.id ?? '',
91
+ component: raw.component ?? '',
92
+ reason: raw.reason ?? '',
93
+ status: parse(raw.value),
94
+ });
95
+ const EMPTY_CONTRIBUTORS = { health: [], readiness: [] };
96
+ /**
97
+ * Emits the per-contributor breakdown behind the robot's reported health and
98
+ * readiness, from the agent's `robot/status/contributors` topic. Each
99
+ * contributor's integer wire value is mapped onto {@link RobotHealth} /
100
+ * {@link RobotReadiness}; a missing `health`/`readiness` array becomes `[]`.
101
+ *
102
+ * Starts with an empty breakdown so consumers combining this with other streams
103
+ * emit immediately, and — like {@link getReportedStatusChanges} — a telemetry
104
+ * error falls back to an empty breakdown rather than terminating the stream.
105
+ */
106
+ const getStatusContributorsChanges = (telemetry, projectId, callsign) => {
107
+ return telemetry
108
+ .subscribe({
109
+ projectId,
110
+ callsigns: [callsign],
111
+ sources: [exports.ROBOT_STATUS_CONTRIBUTORS_SOURCE],
112
+ })
113
+ .pipe((0, rxjs_1.map)((message) => {
114
+ const payload = message.payload ?? {};
115
+ return {
116
+ health: (payload.health ?? []).map((c) => toContributor(c, exports.parseRobotHealth)),
117
+ readiness: (payload.readiness ?? []).map((c) => toContributor(c, exports.parseRobotReadiness)),
118
+ };
119
+ }), (0, rxjs_1.startWith)(EMPTY_CONTRIBUTORS), (0, rxjs_1.distinctUntilChanged)((a, b) => JSON.stringify(a) === JSON.stringify(b)), (0, rxjs_1.catchError)(() => (0, rxjs_1.of)(EMPTY_CONTRIBUTORS)));
120
+ };
121
+ exports.getStatusContributorsChanges = getStatusContributorsChanges;
87
122
  /**
88
123
  * Emits the `telemetry-liveness` connectivity signal: whether a heartbeat (from
89
124
  * either the new `robot/status` topic or the legacy heartbeat) has been received
@@ -1,13 +1,28 @@
1
1
  import { IBaseStream } from '../models';
2
+ export interface IAcquiredStream<T extends IBaseStream> {
3
+ stream: T;
4
+ isNew: boolean;
5
+ /** Settles when the stream's one-time init completes; rejects (and evicts) if init fails. */
6
+ ready: Promise<void>;
7
+ /** Releases this holder's refcount. One-shot: extra calls are ignored. */
8
+ release: () => void;
9
+ }
10
+ /**
11
+ * Sole owner of stream lifecycle. Streams are shared per identifier and refcounted: acquireStream
12
+ * gets-or-creates and increments in one synchronous step, and the returned handle's release()
13
+ * tears the stream down when the last holder leaves. Teardown never happens outside
14
+ * release/removeAllStreams, so a held stream can not be stopped or replaced underneath its holders.
15
+ */
2
16
  export declare class StreamRegister {
3
- private teleStreams;
17
+ private entries;
4
18
  private logger;
5
19
  private static instance;
6
20
  private constructor();
7
21
  static getInstance(): StreamRegister;
8
22
  static getIdentifier($identifier: string, scope?: string): string;
9
- addStream(stream: IBaseStream): void;
23
+ acquireStream<T extends IBaseStream>(identifier: string, create: () => T, init: (stream: T) => Promise<void>): IAcquiredStream<T>;
10
24
  getStream(identifier: string): IBaseStream | undefined;
11
- removeStream(stream: IBaseStream): void;
12
25
  removeAllStreams(): boolean;
26
+ private release;
27
+ private evict;
13
28
  }
@@ -1,8 +1,14 @@
1
1
  import { RocosError, errorCodes } from '../models';
2
2
  import { RocosLogger } from '../logger/RocosLogger';
3
+ /**
4
+ * Sole owner of stream lifecycle. Streams are shared per identifier and refcounted: acquireStream
5
+ * gets-or-creates and increments in one synchronous step, and the returned handle's release()
6
+ * tears the stream down when the last holder leaves. Teardown never happens outside
7
+ * release/removeAllStreams, so a held stream can not be stopped or replaced underneath its holders.
8
+ */
3
9
  export class StreamRegister {
4
10
  constructor() {
5
- this.teleStreams = new Map();
11
+ this.entries = new Map();
6
12
  this.logger = RocosLogger.getInstance('StreamRegister');
7
13
  }
8
14
  static getInstance() {
@@ -14,55 +20,83 @@ export class StreamRegister {
14
20
  static getIdentifier($identifier, scope) {
15
21
  return `${$identifier}-${scope ?? ''}`;
16
22
  }
17
- addStream(stream) {
18
- try {
19
- // Stop any existing occupant of this key before overwriting it.
20
- const existing = this.teleStreams.get(stream.identifier);
21
- if (existing && existing !== stream) {
22
- existing.stopStream();
23
- }
24
- this.teleStreams.set(stream.identifier, stream);
23
+ acquireStream(identifier, create, init) {
24
+ let entry = this.entries.get(identifier);
25
+ let isNew = false;
26
+ if (entry) {
27
+ entry.refCount++;
25
28
  }
26
- catch (e) {
27
- this.logger.error(`Failed to add stream to list. identifier: ${stream.identifier}`, e);
28
- if (e instanceof Error || typeof e === 'string') {
29
- throw new RocosError(e, errorCodes.STREAM_LISTENER_ERROR);
30
- }
31
- else {
32
- throw e;
33
- }
29
+ else {
30
+ isNew = true;
31
+ const stream = create();
32
+ const ready = init(stream).catch((error) => {
33
+ this.logger.error(`Stream init failed, evicting. identifier: ${identifier}`, error);
34
+ try {
35
+ this.evict(identifier, stream);
36
+ }
37
+ catch (evictionError) {
38
+ this.logger.error(`Failed to evict after init failure. identifier: ${identifier}`, evictionError);
39
+ }
40
+ throw error;
41
+ });
42
+ // `ready` is stored and shared; this discarded catch pins a rejection observer so an init
43
+ // failure can never surface as an unhandled rejection, while holders still see the rejection.
44
+ void ready.catch(() => undefined);
45
+ entry = { stream, refCount: 1, ready };
46
+ this.entries.set(identifier, entry);
34
47
  }
48
+ const held = entry;
49
+ let released = false;
50
+ const release = () => {
51
+ // One-shot so a double release can never consume another holder's refcount.
52
+ if (released)
53
+ return;
54
+ released = true;
55
+ this.release(identifier, held);
56
+ };
57
+ return { stream: entry.stream, isNew, ready: entry.ready, release };
35
58
  }
36
59
  getStream(identifier) {
37
- return this.teleStreams.get(identifier);
60
+ return this.entries.get(identifier)?.stream;
38
61
  }
39
- removeStream(stream) {
40
- try {
41
- stream.stopStream();
42
- // Only clear the entry if this instance still owns the key: a replacement may already hold it,
43
- // and evicting that would leave subscribers forking duplicate streams for the scope.
44
- if (this.teleStreams.get(stream.identifier) === stream) {
45
- this.teleStreams.delete(stream.identifier);
46
- }
47
- }
48
- catch (e) {
49
- this.logger.error(`Failed to remove stream from list. identifier: ${stream.identifier}`, e);
50
- if (e instanceof Error || typeof e === 'string') {
51
- throw new RocosError(e, errorCodes.STREAM_LISTENER_ERROR);
62
+ removeAllStreams() {
63
+ for (const [, entry] of this.entries.entries()) {
64
+ try {
65
+ entry.stream.stopStream();
52
66
  }
53
- else {
54
- throw e;
67
+ catch (e) {
68
+ this.logger.error(`error removing stream, ${e}`);
55
69
  }
56
70
  }
71
+ this.entries.clear();
72
+ return true;
57
73
  }
58
- removeAllStreams() {
74
+ release(identifier, entry) {
75
+ if (this.entries.get(identifier) !== entry)
76
+ return; // already evicted, or force-removed
77
+ entry.refCount--;
78
+ if (entry.refCount > 0)
79
+ return;
80
+ // Wait for init to settle before tearing down, so init can never open resources on an already
81
+ // stopped stream. A re-acquire in the meantime revives the entry and cancels the teardown.
82
+ void entry.ready
83
+ .catch(() => undefined)
84
+ .then(() => {
85
+ if (this.entries.get(identifier) === entry && entry.refCount <= 0) {
86
+ this.evict(identifier, entry.stream);
87
+ }
88
+ })
89
+ .catch((e) => this.logger.error(`Failed to evict stream. identifier: ${identifier}`, e));
90
+ }
91
+ evict(identifier, stream) {
59
92
  try {
60
- for (const [, stream] of this.teleStreams.entries()) {
61
- this.removeStream(stream);
93
+ if (this.entries.get(identifier)?.stream === stream) {
94
+ this.entries.delete(identifier);
62
95
  }
96
+ stream.stopStream();
63
97
  }
64
98
  catch (e) {
65
- this.logger.error(`error removing stream, ${e}`);
99
+ this.logger.error(`Failed to remove stream from list. identifier: ${identifier}`, e);
66
100
  if (e instanceof Error || typeof e === 'string') {
67
101
  throw new RocosError(e, errorCodes.STREAM_LISTENER_ERROR);
68
102
  }
@@ -70,6 +104,5 @@ export class StreamRegister {
70
104
  throw e;
71
105
  }
72
106
  }
73
- return true;
74
107
  }
75
108
  }
@@ -35,7 +35,7 @@ export class StreamHeartbeat {
35
35
  this.callback = callback;
36
36
  this.timeout = timeout;
37
37
  this.logger.info('Creating stream heartbeat');
38
- this.interval = setInterval(this.callback, this.timeout);
38
+ this.interval = setInterval(() => this.callback?.(), this.timeout);
39
39
  }
40
40
  else {
41
41
  this.logger.warn('Stream heartbeat already exists');
@@ -55,7 +55,7 @@ export class StreamHeartbeat {
55
55
  // if the health check has not started yet, we initialise it
56
56
  if (!this.healthInterval) {
57
57
  this.logger.debug('Starting health check timer');
58
- this.healthInterval = setInterval(this.checkHealth, this.healthTimeout);
58
+ this.healthInterval = setInterval(() => void this.checkHealth(), this.healthTimeout);
59
59
  this.healthLastSeenDate = new Date();
60
60
  this.healthMisses = 0;
61
61
  }
@@ -14,6 +14,7 @@ export declare abstract class TelemetryStreamAbstract implements ITelemetryStrea
14
14
  protected token?: string;
15
15
  private scope;
16
16
  protected url: string;
17
+ private telemetryActionQueue;
17
18
  private timerIntervalInSec;
18
19
  protected subscriberStatus: SubscriberStatusEnum;
19
20
  private checkerStartedAt?;
@@ -40,10 +41,17 @@ export declare abstract class TelemetryStreamAbstract implements ITelemetryStrea
40
41
  stopStream(): void;
41
42
  addSubscription(params: ITelemetryParams): void;
42
43
  removeSubscription(params: ITelemetryParams, terminateReceiverGroup?: boolean): Promise<void>;
44
+ private isQueryLookup;
45
+ private getSubscribedSources;
43
46
  sendAcknowledgment(uid: string, status: TelemetryAckStatus, noRetry: boolean): boolean;
44
47
  private getSubscriptions;
45
48
  protected onData(message: TelemetryStreamMessage, isStream: true): void;
46
49
  protected onData(message: TelemetryMessage, isStream: false): void;
50
+ /**
51
+ * Gateway subscription state is a set mutated by deltas, so ordering is correctness: a concurrent
52
+ * unsubscribe landing last strands a subscribe and nothing re-adds it.
53
+ */
54
+ private enqueueTelemetryAction;
47
55
  private takeTelemetryAction;
48
56
  protected listenMessagesAndRenew(): void;
49
57
  private addTerminateToAction;
@@ -52,7 +60,6 @@ export declare abstract class TelemetryStreamAbstract implements ITelemetryStrea
52
60
  * Auto resubscribe to reduce CPU usage.
53
61
  */
54
62
  private autoResubscribe;
55
- private isFoundCallsign;
56
63
  private isFoundSource;
57
64
  private isRegisteredMessage;
58
65
  private updateReceivedDataStatsWithMessage;
@@ -1,16 +1,18 @@
1
1
  import { BehaviorSubject, Subject } from 'rxjs';
2
- import { CallsignsLookup, CallsignsLookupType, RocosTelemetryMessage, StreamOptions, SubscriberStatusEnum, } from '../../../models';
2
+ import { CallsignsLookup, RocosTelemetryMessage, StreamOptions, SubscriberStatusEnum, } from '../../../models';
3
3
  import { GRPC_SOURCE_NOOP, GRPC_SOURCE_SUBSCRIBED } from '../../../constants/grpc';
4
4
  import { QueryOrPredicate, TelemetryAckStatus, TelemetryQueryRequest, TelemetryRequest, UnsubscribeOperation, } from '../../../grpc/teletubby_pb';
5
5
  import { IDENTIFIER_NAME_TELEMETRY } from '../../../constants/identifier';
6
6
  import { RocosStore } from '../../../store/RocosStore';
7
7
  import { StreamHeartbeat } from '../../atoms/StreamHeartbeat';
8
8
  import { StreamRegister } from '../../StreamRegister';
9
+ import { arrayRemove } from '../../../helpers/arrayRemove';
9
10
  import { arrayUnique } from '../../../helpers/arrayUnique';
10
11
  import { filter } from 'rxjs/operators';
11
12
  import { getSubscriptionsDifference } from '../../../helpers/getSubscriptionsDifference';
12
13
  export class TelemetryStreamAbstract {
13
14
  constructor(config) {
15
+ this.telemetryActionQueue = Promise.resolve();
14
16
  // /////////////////
15
17
  // Subscriber Check
16
18
  this.timerIntervalInSec = 1;
@@ -57,27 +59,44 @@ export class TelemetryStreamAbstract {
57
59
  }
58
60
  this.subscriberStatus = SubscriberStatusEnum.STOPPED;
59
61
  this.statusStream$.next(this.subscriberStatus);
62
+ // The stream is being torn down (the registry only stops a stream when it is evicted), so
63
+ // complete the status stream: this releases every service-level forwarder subscribed to it
64
+ // rather than leaving them attached to a dead instance.
65
+ this.statusStream$.complete();
60
66
  }
61
67
  addSubscription(params) {
62
68
  this.logger.info('Adding subscriptions from stream', params.uniqueId);
63
69
  if (!this.subscriptions.has(params.uniqueId)) {
70
+ const isQuery = this.isQueryLookup();
64
71
  // get subscriptions before change
65
- const before = this.getSubscriptions();
72
+ const before = isQuery ? {} : this.getSubscriptions();
66
73
  this.subscriptions.set(params.uniqueId, {
67
74
  ...params,
68
75
  count: 1, // set the count to one just in case it's set in param
69
76
  });
70
77
  // get subscriptions after change
71
- const after = this.getSubscriptions();
72
- // assign the current values from state after change
73
- this.callsignsLookup = new CallsignsLookup(Object.keys(after));
74
- this.sources = arrayUnique(Object.values(after).reduce((a, v) => a.concat(v), []));
75
- // compare before and after subscriptions and only add the difference
76
- const { toAdd } = getSubscriptionsDifference(before, after);
77
- // only subscribe if the subscriberId exists, otherwise the messages will be subscribed on lazy load
78
- if (toAdd.callsigns.length && toAdd.sources.length) {
79
- this.logger.debug('New subscriptions added, we need to refresh sources', { toAdd });
80
- void this.takeTelemetryAction('subscribe', new CallsignsLookup(toAdd.callsigns), toAdd.sources);
78
+ const after = isQuery ? {} : this.getSubscriptions();
79
+ if (isQuery) {
80
+ // A query stream selects its callsigns server-side, so the callsign-keyed view above holds
81
+ // nothing for it: keep the query and diff on sources alone.
82
+ const sourcesBefore = this.sources;
83
+ this.sources = this.getSubscribedSources();
84
+ const toAdd = arrayRemove(this.sources, sourcesBefore);
85
+ if (toAdd.length) {
86
+ this.logger.debug('New query subscriptions added, we need to refresh sources', { toAdd });
87
+ void this.enqueueTelemetryAction('subscribe', this.callsignsLookup, toAdd);
88
+ }
89
+ }
90
+ else {
91
+ // assign the current values from state after change
92
+ this.callsignsLookup = new CallsignsLookup(Object.keys(after));
93
+ this.sources = arrayUnique(Object.values(after).reduce((a, v) => a.concat(v), []));
94
+ // compare before and after subscriptions and only add the difference
95
+ const { toAdd } = getSubscriptionsDifference(before, after);
96
+ if (toAdd.callsigns.length && toAdd.sources.length) {
97
+ this.logger.debug('New subscriptions added, we need to refresh sources', { toAdd });
98
+ void this.enqueueTelemetryAction('subscribe', new CallsignsLookup(toAdd.callsigns), toAdd.sources);
99
+ }
81
100
  }
82
101
  }
83
102
  else {
@@ -97,16 +116,24 @@ export class TelemetryStreamAbstract {
97
116
  if (subs.count)
98
117
  return;
99
118
  this.logger.info('Removing subscriptions from stream registry', params.uniqueId);
100
- const before = this.getSubscriptions();
119
+ const isQuery = this.isQueryLookup();
120
+ const before = isQuery ? {} : this.getSubscriptions();
101
121
  this.subscriptions.delete(params.uniqueId);
102
- const after = this.getSubscriptions();
103
- // Do this before we send the telemetry request so that new subscriptions don't get this stream from the register while it is closing
104
- if (!this.subscriptions.size) {
105
- this.logger.info('No subscriptions remaining closing stream', params.uniqueId);
106
- this.stopStream();
107
- // self remove when no subscriptions are left
108
- StreamRegister.getInstance().removeStream(this);
109
- this.logger.info('Stream closed');
122
+ const after = isQuery ? {} : this.getSubscriptions();
123
+ if (isQuery) {
124
+ const sourcesBefore = this.sources;
125
+ this.sources = this.getSubscribedSources();
126
+ const toRemove = arrayRemove(sourcesBefore, this.sources);
127
+ if (toRemove.length) {
128
+ this.logger.debug('Query subscriptions removed, we need to refresh sources', { toRemove });
129
+ try {
130
+ await this.enqueueTelemetryAction('unsubscribe', this.callsignsLookup, toRemove, terminateReceiverGroup);
131
+ }
132
+ catch (err) {
133
+ this.logger.error(`Failed to unsubscribe: ${err}`);
134
+ }
135
+ }
136
+ return;
110
137
  }
111
138
  // assign the current values from state after change
112
139
  this.callsignsLookup = new CallsignsLookup(Object.keys(after));
@@ -115,13 +142,23 @@ export class TelemetryStreamAbstract {
115
142
  if (toRemove.callsigns.length && toRemove.sources.length) {
116
143
  this.logger.debug('Subscriptions removed, we need to refresh sources', { toRemove });
117
144
  try {
118
- await this.takeTelemetryAction('unsubscribe', new CallsignsLookup(toRemove.callsigns), toRemove.sources, terminateReceiverGroup);
145
+ await this.enqueueTelemetryAction('unsubscribe', new CallsignsLookup(toRemove.callsigns), toRemove.sources, terminateReceiverGroup);
119
146
  }
120
147
  catch (err) {
121
148
  this.logger.error(`Failed to unsubscribe: ${err}`);
122
149
  }
123
150
  }
124
151
  }
152
+ isQueryLookup() {
153
+ return this.callsignsLookup?.isQuery() ?? false;
154
+ }
155
+ getSubscribedSources() {
156
+ const sources = [];
157
+ this.subscriptions.forEach((data) => {
158
+ sources.push(...data.sources);
159
+ });
160
+ return arrayUnique(sources);
161
+ }
125
162
  sendAcknowledgment(uid, status, noRetry) {
126
163
  return this.sendAcknowledgmentInternal(uid, status, noRetry);
127
164
  }
@@ -171,8 +208,8 @@ export class TelemetryStreamAbstract {
171
208
  this.subscriberId = subscriberId;
172
209
  rocosMessage.subscriberId = this.subscriberId;
173
210
  this.logger.info('onData', `subscriberId has been updated - ${subscriberId}`, { subscriberId });
174
- void this.takeTelemetryAction('subscribe');
175
- this.heartbeat.start(this.sendHeartbeat, this.sendHeartbeatTime);
211
+ void this.enqueueTelemetryAction('subscribe');
212
+ this.heartbeat.start(() => this.sendHeartbeat(), this.sendHeartbeatTime);
176
213
  isRobotMessage = false;
177
214
  break;
178
215
  }
@@ -208,25 +245,40 @@ export class TelemetryStreamAbstract {
208
245
  });
209
246
  }
210
247
  }
248
+ /**
249
+ * Gateway subscription state is a set mutated by deltas, so ordering is correctness: a concurrent
250
+ * unsubscribe landing last strands a subscribe and nothing re-adds it.
251
+ */
252
+ enqueueTelemetryAction(actionType, callsignsLookup, sources, terminate) {
253
+ const takeAction = () => this.takeTelemetryAction(actionType, callsignsLookup, sources, terminate);
254
+ const run = this.telemetryActionQueue.then(takeAction);
255
+ // The tail only signals whose turn it is, so it must never reject, otherwise one failed op
256
+ // would wedge every op queued behind it. Callers still see the failure through `run`.
257
+ this.telemetryActionQueue = run.then(() => undefined, () => undefined);
258
+ return run;
259
+ }
211
260
  async takeTelemetryAction(actionType, callsignsLookup, sources, terminate) {
212
261
  this.logger.debug('takeTelemetryAction', actionType);
213
- if (!this.subscriberId || !this.projectId) {
214
- this.logger.debug('takeTelemetryAction', actionType, `${actionType} without subscriberId or projectId - message will not send out`, {
215
- subscriberId: this.subscriberId,
216
- projectId: this.projectId,
217
- });
262
+ // A config fault, not a race: this stream can never address a request.
263
+ if (!this.projectId) {
264
+ this.logger.warn('takeTelemetryAction', actionType, `${actionType} without a projectId - dropped`);
265
+ return Promise.resolve();
266
+ }
267
+ if (!this.subscriberId) {
268
+ // Deferred, not lost: `onData` re-asserts `this.sources` when the subscriberId arrives.
269
+ this.logger.debug('takeTelemetryAction', actionType, `${actionType} before the receiver was registered - deferred to the post-registration re-assert`);
218
270
  return Promise.resolve();
219
271
  }
220
272
  const actionSources = sources ?? this.sources;
221
273
  const actionCallsigns = callsignsLookup ?? this.callsignsLookup;
222
- if (this.callsignsLookup.lookupType === CallsignsLookupType.List) {
274
+ if (!actionCallsigns.isQuery()) {
223
275
  // Compose the request message
224
276
  const req = TelemetryRequest.create({
225
277
  subscriberId: this.subscriberId,
226
278
  requestedActions: [
227
279
  {
228
280
  operation: actionType,
229
- callsigns: actionCallsigns.lookupValue,
281
+ callsigns: actionCallsigns.getCallsigns(),
230
282
  sources: actionSources,
231
283
  },
232
284
  ],
@@ -238,12 +290,12 @@ export class TelemetryStreamAbstract {
238
290
  // Send the message to back-end.
239
291
  return this.requestTelemetry(req);
240
292
  }
241
- else if (actionCallsigns.lookupType === CallsignsLookupType.Query) {
242
- const lookupValue = actionCallsigns.lookupValue;
293
+ const query = actionCallsigns.getQuery();
294
+ if (query) {
243
295
  // We don't support nested query at the moment
244
296
  const queryOrPredicatesList = [];
245
297
  // Loop through user provided predicates to construct a telemetry query request
246
- lookupValue.predicates.forEach((callsignsPredicate) => {
298
+ query.predicates.forEach((callsignsPredicate) => {
247
299
  const queryOrPredicate = QueryOrPredicate.create({
248
300
  content: {
249
301
  oneofKind: 'predicate',
@@ -262,7 +314,7 @@ export class TelemetryStreamAbstract {
262
314
  sources: actionSources,
263
315
  operation: actionType,
264
316
  callsignQuery: {
265
- operation: lookupValue.operation.valueOf(),
317
+ operation: query.operation.valueOf(),
266
318
  queryOrPredicates: queryOrPredicatesList,
267
319
  },
268
320
  });
@@ -379,16 +431,11 @@ export class TelemetryStreamAbstract {
379
431
  this.lastRobotMessageReceived = undefined;
380
432
  this.registerReceiver();
381
433
  }
382
- isFoundCallsign(callsign) {
383
- return this.callsignsLookup.lookupType === CallsignsLookupType.List
384
- ? this.callsignsLookup.lookupValue.indexOf(callsign) !== -1
385
- : true; // If callsigns lookup option is query, we always return true
386
- }
387
434
  isFoundSource(source) {
388
435
  return this.sources.indexOf(source) !== -1;
389
436
  }
390
437
  isRegisteredMessage(callsign, source) {
391
- return this.isFoundCallsign(callsign) && this.isFoundSource(source);
438
+ return this.callsignsLookup.includesCallsign(callsign) && this.isFoundSource(source);
392
439
  }
393
440
  updateReceivedDataStatsWithMessage(msg) {
394
441
  const { source } = msg;
@@ -1,7 +1,7 @@
1
1
  import { AddIceCandidateRequest, GetDetailsRequest, OfferRequest, OperatorConnectRequest, } from '../../../grpc/pigeon_pb';
2
2
  import { SubscriberStatusEnum, } from '../../../models';
3
3
  import { BehaviorSubject } from 'rxjs';
4
- import { IDENTIFIER_NAME_COMMAND } from '../../../constants/identifier';
4
+ import { IDENTIFIER_NAME_WEBRTC_SIGNALLING } from '../../../constants/identifier';
5
5
  import { RocosStore } from '../../../store/RocosStore';
6
6
  import { StreamRegister } from '../../StreamRegister';
7
7
  import { filter } from 'rxjs/operators';
@@ -9,7 +9,7 @@ export class WebRTCSignallingStreamAbstract {
9
9
  constructor(config) {
10
10
  this.subscriberStatus = SubscriberStatusEnum.STOPPED;
11
11
  this.scope = config.scope;
12
- this.identifier = StreamRegister.getIdentifier(IDENTIFIER_NAME_COMMAND, this.scope);
12
+ this.identifier = StreamRegister.getIdentifier(IDENTIFIER_NAME_WEBRTC_SIGNALLING, this.scope);
13
13
  this.token = config.token;
14
14
  this.url = config.url;
15
15
  this.statusStream$ = new BehaviorSubject(SubscriberStatusEnum.STOPPED);