@dronedeploy/rocos-js-sdk 4.4.5 → 4.4.7

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 (49) hide show
  1. package/cjs/api/StreamRegister.js +4 -4
  2. package/cjs/api/streams/caller/CallerStreamAbstract.js +2 -1
  3. package/cjs/api/streams/command/CommandStreamAbstract.js +2 -1
  4. package/cjs/api/streams/control/ControlStreamAbstract.js +2 -1
  5. package/cjs/api/streams/fileAccessor/FileAccessorStreamAbstract.js +2 -1
  6. package/cjs/api/streams/search/SearchStreamAbstract.js +2 -1
  7. package/cjs/api/streams/telemetry/TelemetryStream.js +1 -0
  8. package/cjs/api/streams/telemetry/TelemetryStreamAbstract.js +130 -25
  9. package/cjs/api/streams/telemetry/TelemetryStreamConnect.js +1 -0
  10. package/cjs/api/streams/telemetry/TelemetryStreamNode.js +1 -0
  11. package/cjs/api/streams/webRTCSignalling/WebRTCSignallingStreamAbstract.js +2 -1
  12. package/cjs/models/maps/Map.d.ts +1 -1
  13. package/cjs/models/robot-status/RobotStatus.d.ts +23 -0
  14. package/cjs/models/robot-status/RobotStatusEnums.d.ts +18 -6
  15. package/cjs/models/robot-status/RobotStatusEnums.js +18 -6
  16. package/cjs/services/RobotStatusService.d.ts +20 -1
  17. package/cjs/services/RobotStatusService.js +5 -0
  18. package/cjs/services/SearchService.js +2 -7
  19. package/cjs/services/TelemetryService.d.ts +4 -1
  20. package/cjs/services/TelemetryService.js +17 -12
  21. package/cjs/services/robot-status/connectivity.d.ts +16 -12
  22. package/cjs/services/robot-status/connectivity.js +26 -15
  23. package/cjs/services/robot-status/reportedStatus.d.ts +14 -1
  24. package/cjs/services/robot-status/reportedStatus.js +36 -1
  25. package/esm/api/StreamRegister.js +4 -4
  26. package/esm/api/streams/caller/CallerStreamAbstract.js +2 -1
  27. package/esm/api/streams/command/CommandStreamAbstract.js +2 -1
  28. package/esm/api/streams/control/ControlStreamAbstract.js +2 -1
  29. package/esm/api/streams/fileAccessor/FileAccessorStreamAbstract.js +2 -1
  30. package/esm/api/streams/search/SearchStreamAbstract.js +2 -1
  31. package/esm/api/streams/telemetry/TelemetryStream.js +1 -0
  32. package/esm/api/streams/telemetry/TelemetryStreamAbstract.js +131 -26
  33. package/esm/api/streams/telemetry/TelemetryStreamConnect.js +1 -0
  34. package/esm/api/streams/telemetry/TelemetryStreamNode.js +1 -0
  35. package/esm/api/streams/webRTCSignalling/WebRTCSignallingStreamAbstract.js +2 -1
  36. package/esm/models/maps/Map.d.ts +1 -1
  37. package/esm/models/robot-status/RobotStatus.d.ts +23 -0
  38. package/esm/models/robot-status/RobotStatusEnums.d.ts +18 -6
  39. package/esm/models/robot-status/RobotStatusEnums.js +18 -6
  40. package/esm/services/RobotStatusService.d.ts +20 -1
  41. package/esm/services/RobotStatusService.js +6 -1
  42. package/esm/services/SearchService.js +2 -7
  43. package/esm/services/TelemetryService.d.ts +4 -1
  44. package/esm/services/TelemetryService.js +17 -12
  45. package/esm/services/robot-status/connectivity.d.ts +16 -12
  46. package/esm/services/robot-status/connectivity.js +26 -15
  47. package/esm/services/robot-status/reportedStatus.d.ts +14 -1
  48. package/esm/services/robot-status/reportedStatus.js +35 -1
  49. package/package.json +7 -2
@@ -36,18 +36,19 @@ export declare const buildConnectionsUrl: (serviceId: string, baseUrl: string, p
36
36
  export declare const mapConnectionStatus: (connection?: IConnectedCallsign | null) => ServiceConnection;
37
37
  /**
38
38
  * Aggregates per-service connection states into an overall verdict:
39
- * - `ONLINE` — at least one service connected and none disconnected.
40
- * - `DEGRADED` — a genuine mix: some connected, some disconnected.
39
+ * - `DEGRADED` — any service reports an explicit `FAILED` fault, or a genuine mix (some connected,
40
+ * some disconnected).
41
+ * - `ONLINE` — at least one service connected and none disconnected or failed.
41
42
  * - `OFFLINE` — nothing connected.
42
43
  *
43
- * `UNKNOWN` services (e.g. not yet polled) never force `DEGRADED`, so startup
44
- * settles on `OFFLINE` (gray) rather than a spurious flashing-red degraded state.
44
+ * A `FAILED` service forces `DEGRADED` even when nothing else is connected, so a fault (e.g. a failed
45
+ * deployment) raises attention rather than reading as a plain, switched-off `OFFLINE`.
45
46
  *
46
- * Note: this optimism also means a service held `UNKNOWN` indefinitely (e.g. an
47
- * endpoint whose polls keep failing) does not by itself pull the aggregate below
48
- * `ONLINE` — a deliberate v1 tradeoff to keep transient errors from flapping the
49
- * indicator. Surfacing a long-lived failure distinctly (likely via health rather
50
- * than connectivity) is left as a future refinement.
47
+ * `UNKNOWN` services (e.g. not yet polled) never force `DEGRADED`, so startup settles on `OFFLINE`
48
+ * (gray) rather than a spurious flashing-red degraded state. This optimism also means a service held
49
+ * `UNKNOWN` indefinitely (e.g. an endpoint whose polls keep failing) does not by itself pull the
50
+ * aggregate below `ONLINE` — a deliberate tradeoff to keep transient errors from flapping the
51
+ * indicator; a service that wants to be surfaced reports `FAILED` explicitly.
51
52
  */
52
53
  export declare const aggregateConnectivity: (services: Record<string, ServiceConnection>) => RobotConnectivity;
53
54
  /**
@@ -66,11 +67,14 @@ export interface ConnectivityDeps {
66
67
  insecure?: boolean;
67
68
  /** The telemetry-liveness signal (from `getTelemetryLiveness`), folded in as one service. */
68
69
  telemetryLiveness$: Observable<ServiceConnection>;
70
+ /** Caller-injected connectivity signals, keyed by service id, folded in on equal footing. */
71
+ extraServices?: Record<string, Observable<ServiceConnection>>;
69
72
  }
70
73
  /**
71
74
  * Emits the robot's {@link RobotConnectivityStatus} — the per-service breakdown
72
- * plus the aggregate verdict — for the configured `expectedServices`. HTTP
73
- * services are polled; `telemetry-liveness` uses the supplied observable; any
74
- * unrecognised service id resolves to `UNKNOWN`.
75
+ * plus the aggregate verdict — for the configured `expectedServices`, plus any
76
+ * caller-injected `extraServices` (folded in on equal footing). HTTP services
77
+ * are polled; `telemetry-liveness` and injected services use their supplied
78
+ * observable; any unrecognised service id resolves to `UNKNOWN`.
75
79
  */
76
80
  export declare const getConnectivityChanges: (deps: ConnectivityDeps, projectId: string, callsign: string, expectedServices?: string[], pollMs?: number) => Observable<RobotConnectivityStatus>;
@@ -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
@@ -79,13 +79,13 @@ export class StreamRegister {
79
79
  return;
80
80
  // Wait for init to settle before tearing down, so init can never open resources on an already
81
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(() => {
82
+ const evictIfUnheld = () => {
85
83
  if (this.entries.get(identifier) === entry && entry.refCount <= 0) {
86
84
  this.evict(identifier, entry.stream);
87
85
  }
88
- })
86
+ };
87
+ void entry.ready
88
+ .then(() => evictIfUnheld(), () => evictIfUnheld())
89
89
  .catch((e) => this.logger.error(`Failed to evict stream. identifier: ${identifier}`, e));
90
90
  }
91
91
  evict(identifier, stream) {
@@ -26,7 +26,8 @@ export class CallerStreamAbstract {
26
26
  this.token = token;
27
27
  }
28
28
  stopStream() {
29
- // do nothing for now
29
+ this.statusStream$.next(SubscriberStatusEnum.STOPPED);
30
+ this.statusStream$.complete();
30
31
  }
31
32
  invokeRequest(params) {
32
33
  this.logger.info('invokeRequest', params);
@@ -28,7 +28,8 @@ export class CommandStreamAbstract {
28
28
  this.token = token;
29
29
  }
30
30
  stopStream() {
31
- // do nothing for now
31
+ this.statusStream$.next(SubscriberStatusEnum.STOPPED);
32
+ this.statusStream$.complete();
32
33
  }
33
34
  invokeRequest(params) {
34
35
  if (params.timeoutMs === undefined || params.timeoutMs === null) {
@@ -25,7 +25,8 @@ export class ControlStreamAbstract {
25
25
  this.token = token;
26
26
  }
27
27
  stopStream() {
28
- // do nothing for now
28
+ this.statusStream$.next(SubscriberStatusEnum.STOPPED);
29
+ this.statusStream$.complete();
29
30
  }
30
31
  takeActionWithAck(action, params, ackTimeoutInSeconds = 15) {
31
32
  return new Observable((emitter) => {
@@ -24,7 +24,8 @@ export class FileAccessorStreamAbstract {
24
24
  this.token = token;
25
25
  }
26
26
  stopStream() {
27
- // do nothing for now
27
+ this.statusStream$.next(SubscriberStatusEnum.STOPPED);
28
+ this.statusStream$.complete();
28
29
  }
29
30
  async serverReqOp(params) {
30
31
  return this.serverReqOpInternal(params);
@@ -25,7 +25,8 @@ export class SearchStreamAbstract {
25
25
  this.token = token;
26
26
  }
27
27
  stopStream() {
28
- // do nothing for now
28
+ this.statusStream$.next(SubscriberStatusEnum.STOPPED);
29
+ this.statusStream$.complete();
29
30
  }
30
31
  searchStream(params) {
31
32
  const request = this.convertToQueryMessage(params.query);
@@ -21,6 +21,7 @@ export class TelemetryStream extends TelemetryStreamAbstract {
21
21
  this.client = new TelemetryGatewayClient(transport);
22
22
  }
23
23
  stopInternal() {
24
+ this.subscriberId = undefined;
24
25
  if (this.abortController) {
25
26
  this.logger.info('Starting stop stream process');
26
27
  this.abortController.abort();
@@ -1,7 +1,7 @@
1
1
  import { BehaviorSubject, Subject } from 'rxjs';
2
2
  import { CallsignsLookup, RocosTelemetryMessage, StreamOptions, SubscriberStatusEnum, } from '../../../models';
3
3
  import { GRPC_SOURCE_NOOP, GRPC_SOURCE_SUBSCRIBED } from '../../../constants/grpc';
4
- import { QueryOrPredicate, TelemetryAckStatus, TelemetryQueryRequest, TelemetryRequest, UnsubscribeOperation, } from '../../../grpc/teletubby_pb';
4
+ import { QueryOrPredicate, TelemetryAckStatus, TelemetryAction, 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';
@@ -10,6 +10,33 @@ import { arrayRemove } from '../../../helpers/arrayRemove';
10
10
  import { arrayUnique } from '../../../helpers/arrayUnique';
11
11
  import { filter } from 'rxjs/operators';
12
12
  import { getSubscriptionsDifference } from '../../../helpers/getSubscriptionsDifference';
13
+ const telemetryActionQueueStates = new WeakMap();
14
+ const resolveTelemetryActions = (actions) => {
15
+ actions.forEach((action) => action.resolve());
16
+ };
17
+ const resolveUnsentTelemetryActions = (queueState) => {
18
+ resolveTelemetryActions(queueState.pendingActions.splice(0));
19
+ };
20
+ const takeNextTelemetryBatch = (queueState) => {
21
+ const firstAction = queueState.pendingActions.shift();
22
+ if (!firstAction)
23
+ return [];
24
+ const activeBatch = [firstAction];
25
+ if (firstAction.callsignsLookup.isQuery() || firstAction.terminate)
26
+ return activeBatch;
27
+ while (queueState.pendingActions.length > 0) {
28
+ const [nextAction] = queueState.pendingActions;
29
+ if (nextAction.callsignsLookup.isQuery() ||
30
+ nextAction.terminate ||
31
+ nextAction.actionType !== firstAction.actionType ||
32
+ nextAction.subscriberId !== firstAction.subscriberId ||
33
+ nextAction.registration !== firstAction.registration) {
34
+ break;
35
+ }
36
+ activeBatch.push(queueState.pendingActions.shift());
37
+ }
38
+ return activeBatch;
39
+ };
13
40
  export class TelemetryStreamAbstract {
14
41
  constructor(config) {
15
42
  this.telemetryActionQueue = Promise.resolve();
@@ -35,6 +62,14 @@ export class TelemetryStreamAbstract {
35
62
  this.messageStream$ = new Subject();
36
63
  this.statusStream$ = new BehaviorSubject(SubscriberStatusEnum.STOPPED);
37
64
  this.heartbeat = new StreamHeartbeat(this.identifier);
65
+ telemetryActionQueueStates.set(this, {
66
+ pendingActions: [],
67
+ activeBatch: [],
68
+ registration: 0,
69
+ drainScheduled: false,
70
+ requestInFlight: false,
71
+ stopped: false,
72
+ });
38
73
  RocosStore.getChangeSubject()
39
74
  .pipe(filter((message) => {
40
75
  return message.type === 'token' && message.url === this.url;
@@ -52,6 +87,12 @@ export class TelemetryStreamAbstract {
52
87
  this.registerReceiver();
53
88
  }
54
89
  stopStream() {
90
+ const queueState = telemetryActionQueueStates.get(this);
91
+ if (queueState) {
92
+ queueState.stopped = true;
93
+ resolveUnsentTelemetryActions(queueState);
94
+ }
95
+ this.subscriberId = undefined;
55
96
  this.heartbeat?.stop();
56
97
  this.stopInternal();
57
98
  if (this.messagesTimer) {
@@ -59,6 +100,10 @@ export class TelemetryStreamAbstract {
59
100
  }
60
101
  this.subscriberStatus = SubscriberStatusEnum.STOPPED;
61
102
  this.statusStream$.next(this.subscriberStatus);
103
+ // The stream is being torn down (the registry only stops a stream when it is evicted), so
104
+ // complete the status stream: this releases every service-level forwarder subscribed to it
105
+ // rather than leaving them attached to a dead instance.
106
+ this.statusStream$.complete();
62
107
  }
63
108
  addSubscription(params) {
64
109
  this.logger.info('Adding subscriptions from stream', params.uniqueId);
@@ -80,7 +125,9 @@ export class TelemetryStreamAbstract {
80
125
  const toAdd = arrayRemove(this.sources, sourcesBefore);
81
126
  if (toAdd.length) {
82
127
  this.logger.debug('New query subscriptions added, we need to refresh sources', { toAdd });
83
- void this.enqueueTelemetryAction('subscribe', this.callsignsLookup, toAdd);
128
+ void this.enqueueTelemetryAction('subscribe', this.callsignsLookup, toAdd).catch((error) => {
129
+ this.logger.error(`Failed to subscribe: ${error}`);
130
+ });
84
131
  }
85
132
  }
86
133
  else {
@@ -91,7 +138,9 @@ export class TelemetryStreamAbstract {
91
138
  const { toAdd } = getSubscriptionsDifference(before, after);
92
139
  if (toAdd.callsigns.length && toAdd.sources.length) {
93
140
  this.logger.debug('New subscriptions added, we need to refresh sources', { toAdd });
94
- void this.enqueueTelemetryAction('subscribe', new CallsignsLookup(toAdd.callsigns), toAdd.sources);
141
+ void this.enqueueTelemetryAction('subscribe', new CallsignsLookup(toAdd.callsigns), toAdd.sources).catch((error) => {
142
+ this.logger.error(`Failed to subscribe: ${error}`);
143
+ });
95
144
  }
96
145
  }
97
146
  }
@@ -197,14 +246,22 @@ export class TelemetryStreamAbstract {
197
246
  // Subscribe message
198
247
  case GRPC_SOURCE_SUBSCRIBED: {
199
248
  const { subscriberId } = rocosMessage.payload;
249
+ const queueState = telemetryActionQueueStates.get(this);
250
+ if (queueState?.stopped)
251
+ return;
200
252
  if (this.subscriberId && subscriberId) {
201
253
  this.logger.warn(`Replacing old subscriberId (${this.subscriberId}) with a new one (${subscriberId})`);
202
254
  }
203
- // Update the subscriberId
255
+ if (queueState) {
256
+ queueState.registration++;
257
+ resolveUnsentTelemetryActions(queueState);
258
+ }
204
259
  this.subscriberId = subscriberId;
205
260
  rocosMessage.subscriberId = this.subscriberId;
206
261
  this.logger.info('onData', `subscriberId has been updated - ${subscriberId}`, { subscriberId });
207
- void this.enqueueTelemetryAction('subscribe');
262
+ void this.enqueueTelemetryAction('subscribe').catch((error) => {
263
+ this.logger.error(`Failed to subscribe: ${error}`);
264
+ });
208
265
  this.heartbeat.start(() => this.sendHeartbeat(), this.sendHeartbeatTime);
209
266
  isRobotMessage = false;
210
267
  break;
@@ -246,21 +303,69 @@ export class TelemetryStreamAbstract {
246
303
  * unsubscribe landing last strands a subscribe and nothing re-adds it.
247
304
  */
248
305
  enqueueTelemetryAction(actionType, callsignsLookup, sources, terminate) {
249
- const takeAction = () => this.takeTelemetryAction(actionType, callsignsLookup, sources, terminate);
250
- const run = this.telemetryActionQueue.then(takeAction);
251
- // The tail only signals whose turn it is, so it must never reject, otherwise one failed op
252
- // would wedge every op queued behind it. Callers still see the failure through `run`.
253
- this.telemetryActionQueue = run.then(() => undefined, () => undefined);
254
- return run;
306
+ const queueState = telemetryActionQueueStates.get(this);
307
+ if (!queueState || queueState.stopped)
308
+ return Promise.resolve();
309
+ const completion = new Promise((resolve, reject) => {
310
+ queueState.pendingActions.push({
311
+ actionType,
312
+ callsignsLookup: callsignsLookup ?? this.callsignsLookup,
313
+ sources: [...(sources ?? this.sources)],
314
+ terminate: terminate ?? false,
315
+ subscriberId: this.subscriberId,
316
+ registration: queueState.registration,
317
+ resolve,
318
+ reject,
319
+ });
320
+ });
321
+ const scheduleDrain = () => {
322
+ if (queueState.stopped ||
323
+ queueState.requestInFlight ||
324
+ queueState.drainScheduled ||
325
+ queueState.pendingActions.length === 0) {
326
+ return;
327
+ }
328
+ queueState.drainScheduled = true;
329
+ this.telemetryActionQueue = this.telemetryActionQueue.then(async () => {
330
+ queueState.drainScheduled = false;
331
+ if (queueState.stopped || queueState.requestInFlight)
332
+ return;
333
+ const activeBatch = takeNextTelemetryBatch(queueState);
334
+ const [firstAction] = activeBatch;
335
+ if (!firstAction)
336
+ return;
337
+ queueState.activeBatch = activeBatch;
338
+ queueState.requestInFlight = true;
339
+ try {
340
+ await this.takeTelemetryAction(firstAction.actionType, firstAction.callsignsLookup, firstAction.sources);
341
+ resolveTelemetryActions(activeBatch);
342
+ }
343
+ catch (error) {
344
+ activeBatch.forEach((action) => action.reject(error));
345
+ }
346
+ finally {
347
+ queueState.activeBatch = [];
348
+ queueState.requestInFlight = false;
349
+ scheduleDrain();
350
+ }
351
+ });
352
+ };
353
+ scheduleDrain();
354
+ return completion;
255
355
  }
256
- async takeTelemetryAction(actionType, callsignsLookup, sources, terminate) {
356
+ async takeTelemetryAction(actionType, callsignsLookup, sources) {
257
357
  this.logger.debug('takeTelemetryAction', actionType);
358
+ const queueState = telemetryActionQueueStates.get(this);
359
+ const activeBatch = queueState?.activeBatch ?? [];
360
+ const [firstAction] = activeBatch;
258
361
  // A config fault, not a race: this stream can never address a request.
259
362
  if (!this.projectId) {
260
363
  this.logger.warn('takeTelemetryAction', actionType, `${actionType} without a projectId - dropped`);
261
364
  return Promise.resolve();
262
365
  }
263
- if (!this.subscriberId) {
366
+ if (!firstAction?.subscriberId ||
367
+ firstAction.subscriberId !== this.subscriberId ||
368
+ firstAction.registration !== queueState?.registration) {
264
369
  // Deferred, not lost: `onData` re-asserts `this.sources` when the subscriberId arrives.
265
370
  this.logger.debug('takeTelemetryAction', actionType, `${actionType} before the receiver was registered - deferred to the post-registration re-assert`);
266
371
  return Promise.resolve();
@@ -268,20 +373,20 @@ export class TelemetryStreamAbstract {
268
373
  const actionSources = sources ?? this.sources;
269
374
  const actionCallsigns = callsignsLookup ?? this.callsignsLookup;
270
375
  if (!actionCallsigns.isQuery()) {
271
- // Compose the request message
272
376
  const req = TelemetryRequest.create({
273
- subscriberId: this.subscriberId,
274
- requestedActions: [
275
- {
276
- operation: actionType,
277
- callsigns: actionCallsigns.getCallsigns(),
278
- sources: actionSources,
279
- },
280
- ],
377
+ subscriberId: firstAction.subscriberId,
378
+ requestedActions: activeBatch.map((action) => {
379
+ const telemetryAction = TelemetryAction.create({
380
+ operation: action.actionType,
381
+ callsigns: action.callsignsLookup.getCallsigns(),
382
+ sources: action.sources,
383
+ });
384
+ if (action.actionType === 'unsubscribe' && action.terminate) {
385
+ this.addTerminateToAction(telemetryAction);
386
+ }
387
+ return telemetryAction;
388
+ }),
281
389
  });
282
- if (actionType === 'unsubscribe' && terminate) {
283
- this.addTerminateToAction(req.requestedActions[0]);
284
- }
285
390
  this.logger.debug('takeTelemetryAction', actionType, 'client.requestTelemetry', this.subscriberId);
286
391
  // Send the message to back-end.
287
392
  return this.requestTelemetry(req);
@@ -306,7 +411,7 @@ export class TelemetryStreamAbstract {
306
411
  });
307
412
  // Compose the request message
308
413
  const req = TelemetryQueryRequest.create({
309
- subscriberId: this.subscriberId,
414
+ subscriberId: firstAction.subscriberId,
310
415
  sources: actionSources,
311
416
  operation: actionType,
312
417
  callsignQuery: {
@@ -37,6 +37,7 @@ export class TelemetryStreamConnect extends TelemetryStreamAbstract {
37
37
  this.client = createClient(TelemetryGateway, transport);
38
38
  }
39
39
  stopInternal() {
40
+ this.subscriberId = undefined;
40
41
  if (this.abortController) {
41
42
  this.logger.info('Starting stop stream process');
42
43
  this.abortController.abort();
@@ -12,6 +12,7 @@ export class TelemetryStreamNode extends TelemetryStreamAbstract {
12
12
  this.credentials = config.insecure ? grpc.credentials.createInsecure() : grpc.credentials.createSsl();
13
13
  }
14
14
  stopInternal() {
15
+ this.subscriberId = undefined;
15
16
  try {
16
17
  this.logger.info('Starting stop stream process');
17
18
  this.logger.info('Stopping stream');
@@ -25,7 +25,8 @@ export class WebRTCSignallingStreamAbstract {
25
25
  this.token = token;
26
26
  }
27
27
  stopStream() {
28
- // do nothing for now
28
+ this.statusStream$.next(SubscriberStatusEnum.STOPPED);
29
+ this.statusStream$.complete();
29
30
  }
30
31
  /**
31
32
  * Connect operator
@@ -8,6 +8,6 @@ export interface Map {
8
8
  deployedCount: number;
9
9
  isUploading: boolean;
10
10
  isInCloud: boolean;
11
- hash: string;
11
+ hash?: string;
12
12
  lastModifiedTimestamp: number;
13
13
  }
@@ -41,3 +41,26 @@ export interface RobotStatus {
41
41
  connectivity: RobotConnectivityStatus;
42
42
  colour: RobotStatusColour;
43
43
  }
44
+ /**
45
+ * One contributor to a reported-status axis, from the agent's
46
+ * `robot/status/contributors` topic. `status` is the contributor's own verdict
47
+ * for that axis — the integer wire value mapped onto the enum.
48
+ */
49
+ export interface RobotStatusContributor<TStatus = RobotHealth | RobotReadiness> {
50
+ /** Identifier of the contributor, e.g. `battery`. */
51
+ id: string;
52
+ /** The agent component that raised it, e.g. `dji`. */
53
+ component: string;
54
+ /** Human-readable explanation, e.g. `battery below 20%`; empty when none was given. */
55
+ reason: string;
56
+ /** The contributor's verdict for its axis. */
57
+ status: TStatus;
58
+ }
59
+ /**
60
+ * The per-contributor breakdown behind a robot's reported health and readiness,
61
+ * from the agent's `robot/status/contributors` topic.
62
+ */
63
+ export interface RobotStatusContributors {
64
+ health: RobotStatusContributor<RobotHealth>[];
65
+ readiness: RobotStatusContributor<RobotReadiness>[];
66
+ }