@dronedeploy/rocos-js-sdk 4.1.0 → 4.2.1

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.
@@ -1,4 +1,4 @@
1
- import { API_GRAPHS_MAPS_COPY_URL, API_GRAPHS_MAPS_DEPLOYED_URL, API_GRAPHS_MAPS_DEPLOY_URL, API_GRAPHS_MAPS_GEOJSON_URL, API_GRAPHS_MAPS_MERGE_URL, API_GRAPHS_MAPS_NEW_URL, API_GRAPHS_MAPS_URL, API_GRAPHS_MAP_CONTENT_URL, API_GRAPHS_MAP_EDGES_ADD_TRANSFORM_URL, API_GRAPHS_MAP_EDGES_ADD_URL, API_GRAPHS_MAP_EDGE_URL, API_GRAPHS_MAP_ID_URL, API_GRAPHS_MAP_METADATA_URL, API_GRAPHS_MAP_NODES_ADD_TRANSFORMABLE_URL, API_GRAPHS_MAP_NODES_ADD_URL, API_GRAPHS_MAP_NODE_URL, API_GRAPHS_OBSERVATIONS_URL, API_GRAPHS_OBSERVATION_KEYS_URL, API_GRAPHS_PANORAMA, API_GRAPHS_PANORAMAS_URL, API_GRAPHS_PANORAMA_OBSERVATIONS_URL, } from '../constants/api';
1
+ import { API_GRAPHS_MAPS_COPY_URL, API_GRAPHS_MAPS_DEPLOYED_URL, API_GRAPHS_MAPS_DEPLOY_URL, API_GRAPHS_MAPS_GEOJSON_URL, API_GRAPHS_MAPS_MERGE_URL, API_GRAPHS_MAPS_NEW_URL, API_GRAPHS_MAPS_URL, API_GRAPHS_MAP_CONTENT_URL, API_GRAPHS_MAP_EDGES_ADD_TRANSFORM_URL, API_GRAPHS_MAP_EDGES_ADD_URL, API_GRAPHS_MAP_EDGE_URL, API_GRAPHS_MAP_ID_URL, API_GRAPHS_MAP_METADATA_URL, API_GRAPHS_MAP_NODES_ADD_TRANSFORMABLE_URL, API_GRAPHS_MAP_NODES_ADD_URL, API_GRAPHS_MAP_NODE_URL, API_GRAPHS_MAP_PATHS_URL, API_GRAPHS_MAP_PATH_APPEND_TRANSFORMABLE_URL, API_GRAPHS_MAP_PATH_APPEND_URL, API_GRAPHS_MAP_PATH_URL, API_GRAPHS_OBSERVATIONS_URL, API_GRAPHS_OBSERVATION_KEYS_URL, API_GRAPHS_PANORAMA, API_GRAPHS_PANORAMAS_URL, API_GRAPHS_PANORAMA_OBSERVATIONS_URL, } from '../constants/api';
2
2
  import { RocosError, errorCodes } from '../models';
3
3
  import { BaseServiceAbstract } from './BaseServiceAbstract';
4
4
  import { RocosLogger } from '../logger/RocosLogger';
@@ -451,4 +451,144 @@ export class MapService extends BaseServiceAbstract {
451
451
  deleteEdge(projectId, mapId, edgeId) {
452
452
  return this.callDelete(formatServiceUrl(API_GRAPHS_MAP_EDGE_URL, { url: this.config.url, projectId, mapId, edgeId }, this.config.insecure), 'Failed to delete edge.');
453
453
  }
454
+ /**
455
+ * List the paths (polylines/polygons) in a map. A path is a sub-graph following the
456
+ * `go-common/graph/paths` convention shared with the rocos-agent's map component; this
457
+ * mirrors the agent's `paths/list` service, returning the parent node ids of every path
458
+ * that survives the filters.
459
+ *
460
+ * @param projectId The ID of the project the map belongs to.
461
+ * @param mapId The ID of the map to list paths from.
462
+ * @param filter Optional closed-flag and parent-node-type filters. The type filter goes
463
+ * over the wire as one comma-separated query parameter, so types containing a comma
464
+ * cannot be expressed (see {@link ListPathsFilter.types}).
465
+ * @returns The path ids (= parent node ids), sorted.
466
+ */
467
+ async listPaths(projectId, mapId, filter) {
468
+ const queryParams = {};
469
+ if (filter?.closed !== undefined)
470
+ queryParams.closed = filter.closed.toString();
471
+ if (filter?.types?.length)
472
+ queryParams.types = filter.types.join(',');
473
+ const resp = await this.callGet(formatServiceUrl(API_GRAPHS_MAP_PATHS_URL, { url: this.config.url, projectId, mapId }, this.config.insecure), 'Failed to list paths.', queryParams);
474
+ return resp.paths;
475
+ }
476
+ /**
477
+ * Get one path: its closed flag, full parent node, and ordered member node ids — the same
478
+ * shape as the agent's `paths/get` response. An existing node with no path structure is
479
+ * reported as an empty open path; a missing node is 404; stored structure violating the
480
+ * path convention (e.g. multiple start edges) is 422 (repair it with {@link replacePath}).
481
+ *
482
+ * @param projectId The ID of the project the map belongs to.
483
+ * @param mapId The ID of the map the path lives in.
484
+ * @param pathId The path id (= parent node id).
485
+ */
486
+ getPath(projectId, mapId, pathId) {
487
+ return this.callGet(formatServiceUrl(API_GRAPHS_MAP_PATH_URL, { url: this.config.url, projectId, mapId, pathId }, this.config.insecure), 'Failed to get path.');
488
+ }
489
+ /**
490
+ * Append already-existing member nodes to a path, creating the path when absent —
491
+ * mirroring the agent's `paths/append`. Members must already exist (400 otherwise);
492
+ * `path.index` numbering continues from the existing member count. A `closed` value
493
+ * conflicting with a non-empty path's stored flag is rejected with 409 (unlike the agent,
494
+ * which silently ignores it) — change closed-ness with {@link replacePath}.
495
+ *
496
+ * @param projectId The ID of the project the map belongs to.
497
+ * @param mapId The ID of the map the path lives in.
498
+ * @param pathId The path id (= parent node id).
499
+ * @param body The members to append, plus optional closed flag and parent data.
500
+ */
501
+ async appendToPath(projectId, mapId, pathId, body) {
502
+ await this.callPost(formatServiceUrl(API_GRAPHS_MAP_PATH_APPEND_URL, { url: this.config.url, projectId, mapId, pathId }, this.config.insecure), body, 'Failed to append to path.');
503
+ }
504
+ /**
505
+ * Create one positioned vertex node per point and append them all to a path in one
506
+ * transaction — the cloud analog of the agent's `paths/append/currentPosition`, with the
507
+ * caller supplying positions instead of the robot's live pose. Every point must carry a
508
+ * transform (append only creates; use {@link appendToPath} for existing nodes); a point id
509
+ * that already exists is a 409 and the whole append rolls back.
510
+ *
511
+ * Wire-shape note: the server receives each point's transform as
512
+ * `{ rotation, translation }`. This method marshals the caller-facing `rot`/`pos`
513
+ * (matching the node/panorama transform naming) onto that shape before POSTing.
514
+ *
515
+ * @param projectId The ID of the project the map belongs to.
516
+ * @param mapId The ID of the map the path lives in.
517
+ * @param pathId The path id (= parent node id).
518
+ * @param body The frame, points, and optional node type / closed flag / parent data.
519
+ * @returns The created vertex node ids, in path order (server-minted UUIDs for blank ids).
520
+ */
521
+ appendTransformablePointsToPath(projectId, mapId, pathId, body) {
522
+ const wireBody = {
523
+ closed: body.closed,
524
+ data: body.data,
525
+ frame: body.frame,
526
+ nodeType: body.nodeType,
527
+ points: body.points.map((p) => this.pathPointToWire(p)),
528
+ };
529
+ return this.callPost(formatServiceUrl(API_GRAPHS_MAP_PATH_APPEND_TRANSFORMABLE_URL, { url: this.config.url, projectId, mapId, pathId }, this.config.insecure), wireBody, 'Failed to append points to path.');
530
+ }
531
+ /**
532
+ * Replace a path's entire structure in one idempotent call — the editor primitive for
533
+ * vertex move/insert/delete/reorder and closed-flag changes. See {@link ReplacePathRequest}
534
+ * for the full semantics; the essentials:
535
+ *
536
+ * - A point WITH a transform declares its position; for the path's own vertices, same id +
537
+ * new transform = a move. A transform on a node the path doesn't exclusively own is 409.
538
+ * - A point WITHOUT a transform reuses an existing node in place (400 if missing).
539
+ * - Old vertices exclusively owned by the path and not re-referenced are deleted; shared
540
+ * members are detached, never moved or deleted.
541
+ * - `parentType` must match an existing parent (409) or be omitted; it applies on create.
542
+ *
543
+ * @param projectId The ID of the project the map belongs to.
544
+ * @param mapId The ID of the map the path lives in.
545
+ * @param pathId The path id (= parent node id).
546
+ * @param body The path's desired state.
547
+ */
548
+ async replacePath(projectId, mapId, pathId, body) {
549
+ const wireBody = {
550
+ closed: body.closed,
551
+ parentType: body.parentType,
552
+ nodeType: body.nodeType,
553
+ data: body.data,
554
+ frame: body.frame,
555
+ points: body.points.map((p) => this.pathPointToWire(p)),
556
+ };
557
+ await this.callPut(formatServiceUrl(API_GRAPHS_MAP_PATH_URL, { url: this.config.url, projectId, mapId, pathId }, this.config.insecure), wireBody, 'Failed to replace path.');
558
+ }
559
+ /**
560
+ * Delete a path's structure, mirroring the agent's `paths/delete`: idempotent (a missing
561
+ * path still returns success), member nodes preserved by default, and the parent node
562
+ * itself deleted only when it is a pure `path.parent.v1` (an anchored path keeps its
563
+ * anchor node, minus the path edges).
564
+ *
565
+ * With `deleteMembers`, member nodes exclusively owned by the path (no remaining edges
566
+ * beyond their positioning `TRANSFORM`) are also removed; anything referenced elsewhere
567
+ * survives. Access rules match {@link deleteNode}: environment off-limits, dronedeploy
568
+ * admin-only, normal maps open to any caller with project access.
569
+ *
570
+ * @param projectId The ID of the project the map belongs to.
571
+ * @param mapId The ID of the map the path lives in.
572
+ * @param pathId The path id (= parent node id).
573
+ * @param opts Set `deleteMembers` to also remove exclusively-owned vertex nodes.
574
+ */
575
+ deletePath(projectId, mapId, pathId, opts) {
576
+ let url = formatServiceUrl(API_GRAPHS_MAP_PATH_URL, { url: this.config.url, projectId, mapId, pathId }, this.config.insecure);
577
+ if (opts?.deleteMembers)
578
+ url += '?deleteMembers=true';
579
+ return this.callDelete(url, 'Failed to delete path.');
580
+ }
581
+ /**
582
+ * Marshal a caller-facing {@link PathPoint} onto the server's wire shape: `rot`/`pos`
583
+ * become `rotation`/`translation`, and an absent transform stays absent (its presence
584
+ * carries intent in a replace). A missing `rot` is left for the server to default to
585
+ * identity.
586
+ */
587
+ pathPointToWire(p) {
588
+ return {
589
+ id: p.id,
590
+ data: p.data,
591
+ transform: p.transform ? { rotation: p.transform.rot, translation: p.transform.pos } : undefined,
592
+ };
593
+ }
454
594
  }
@@ -0,0 +1,65 @@
1
+ import { IConnectedCallsign, RobotConnectivity, RobotConnectivityStatus, ServiceConnection } from '../../models';
2
+ import { Observable } from 'rxjs';
3
+ /**
4
+ * Service id → public path prefix, for services that expose the standard
5
+ * `connections` endpoint. New services adopting the convention are added here.
6
+ * The `telemetry` (Teletubby) endpoint joins this once built — see EAI-718/719.
7
+ */
8
+ export declare const CONNECTION_SERVICE_PREFIXES: Record<string, string>;
9
+ /**
10
+ * The telemetry-liveness signal is derived from heartbeat freshness rather than
11
+ * an HTTP endpoint, so it is handled specially (its observable is supplied by
12
+ * the caller). See `getTelemetryLiveness`.
13
+ */
14
+ export declare const TELEMETRY_LIVENESS_SERVICE = "telemetry-liveness";
15
+ /**
16
+ * The services evaluated by default. The `telemetry` connections endpoint joins
17
+ * this list once Teletubby exposes it (follow-up).
18
+ */
19
+ export declare const DEFAULT_EXPECTED_SERVICES: string[];
20
+ /** Authenticated GET used to poll a service's `connections` endpoint. */
21
+ export type ConnectionsHttpGet = (url: string) => Promise<IConnectedCallsign>;
22
+ /**
23
+ * Builds the `connections` endpoint URL for a service — per-callsign when a
24
+ * callsign is given, otherwise the bulk per-project variant.
25
+ */
26
+ export declare const buildConnectionsUrl: (serviceId: string, baseUrl: string, projectId: string, callsign?: string, insecure?: boolean) => string;
27
+ /** Maps a `connections` endpoint response onto a {@link ServiceConnection}. */
28
+ export declare const mapConnectionStatus: (connection?: IConnectedCallsign | null) => ServiceConnection;
29
+ /**
30
+ * Aggregates per-service connection states into an overall verdict:
31
+ * - `ONLINE` — at least one service connected and none disconnected.
32
+ * - `DEGRADED` — a genuine mix: some connected, some disconnected.
33
+ * - `OFFLINE` — nothing connected.
34
+ *
35
+ * `UNKNOWN` services (e.g. not yet polled) never force `DEGRADED`, so startup
36
+ * settles on `OFFLINE` (gray) rather than a spurious flashing-red degraded state.
37
+ *
38
+ * Note: this optimism also means a service held `UNKNOWN` indefinitely (e.g. an
39
+ * endpoint whose polls keep failing) does not by itself pull the aggregate below
40
+ * `ONLINE` — a deliberate v1 tradeoff to keep transient errors from flapping the
41
+ * indicator. Surfacing a long-lived failure distinctly (likely via health rather
42
+ * than connectivity) is left as a future refinement.
43
+ */
44
+ export declare const aggregateConnectivity: (services: Record<string, ServiceConnection>) => RobotConnectivity;
45
+ /**
46
+ * Polls a single service's `connections` endpoint on an interval, emitting its
47
+ * {@link ServiceConnection} (change-only). A failed/timed-out poll yields
48
+ * `UNKNOWN` (held transitional) rather than `DISCONNECTED`, to avoid a spurious
49
+ * degraded flash on a transient error.
50
+ */
51
+ export declare const getServiceConnectionChanges: (httpGet: ConnectionsHttpGet, url: string, pollMs?: number) => Observable<ServiceConnection>;
52
+ export interface ConnectivityDeps {
53
+ httpGet: ConnectionsHttpGet;
54
+ baseUrl: string;
55
+ insecure?: boolean;
56
+ /** The telemetry-liveness signal (from `getTelemetryLiveness`), folded in as one service. */
57
+ telemetryLiveness$: Observable<ServiceConnection>;
58
+ }
59
+ /**
60
+ * Emits the robot's {@link RobotConnectivityStatus} — the per-service breakdown
61
+ * plus the aggregate verdict — for the configured `expectedServices`. HTTP
62
+ * services are polled; `telemetry-liveness` uses the supplied observable; any
63
+ * unrecognised service id resolves to `UNKNOWN`.
64
+ */
65
+ export declare const getConnectivityChanges: (deps: ConnectivityDeps, projectId: string, callsign: string, expectedServices?: string[], pollMs?: number) => Observable<RobotConnectivityStatus>;
@@ -0,0 +1,120 @@
1
+ import { API_SERVICE_CONNECTIONS_URL, API_SERVICE_CONNECTION_URL } from '../../constants/api';
2
+ import { ConnectionStatus, RobotConnectivity, ServiceConnection, } from '../../models';
3
+ import { catchError, combineLatest, distinctUntilChanged, from, map, of, startWith, switchMap, timer, } from 'rxjs';
4
+ import { formatServiceUrl } from '../../helpers/formatServiceUrl';
5
+ /**
6
+ * Service id → public path prefix, for services that expose the standard
7
+ * `connections` endpoint. New services adopting the convention are added here.
8
+ * The `telemetry` (Teletubby) endpoint joins this once built — see EAI-718/719.
9
+ */
10
+ export const CONNECTION_SERVICE_PREFIXES = {
11
+ 'robot-configs': 'robot-configs',
12
+ 'robot-services': 'robot-services',
13
+ };
14
+ /**
15
+ * The telemetry-liveness signal is derived from heartbeat freshness rather than
16
+ * an HTTP endpoint, so it is handled specially (its observable is supplied by
17
+ * the caller). See `getTelemetryLiveness`.
18
+ */
19
+ export const TELEMETRY_LIVENESS_SERVICE = 'telemetry-liveness';
20
+ /**
21
+ * The services evaluated by default. The `telemetry` connections endpoint joins
22
+ * this list once Teletubby exposes it (follow-up).
23
+ */
24
+ export const DEFAULT_EXPECTED_SERVICES = [TELEMETRY_LIVENESS_SERVICE, 'robot-configs', 'robot-services'];
25
+ const DEFAULT_CONNECTIVITY_POLL_MS = 5000;
26
+ /**
27
+ * Builds the `connections` endpoint URL for a service — per-callsign when a
28
+ * callsign is given, otherwise the bulk per-project variant.
29
+ */
30
+ export const buildConnectionsUrl = (serviceId, baseUrl, projectId, callsign, insecure) => {
31
+ const template = callsign ? API_SERVICE_CONNECTION_URL : API_SERVICE_CONNECTIONS_URL;
32
+ const params = { url: baseUrl, service: CONNECTION_SERVICE_PREFIXES[serviceId], projectId };
33
+ if (callsign)
34
+ params.callsign = callsign;
35
+ return formatServiceUrl(template, params, insecure);
36
+ };
37
+ /** Maps a `connections` endpoint response onto a {@link ServiceConnection}. */
38
+ export const mapConnectionStatus = (connection) => {
39
+ if (connection?.status === ConnectionStatus.CONNECTED)
40
+ return ServiceConnection.CONNECTED;
41
+ if (connection?.status === ConnectionStatus.DISCONNECTED)
42
+ return ServiceConnection.DISCONNECTED;
43
+ return ServiceConnection.UNKNOWN;
44
+ };
45
+ /**
46
+ * Aggregates per-service connection states into an overall verdict:
47
+ * - `ONLINE` — at least one service connected and none disconnected.
48
+ * - `DEGRADED` — a genuine mix: some connected, some disconnected.
49
+ * - `OFFLINE` — nothing connected.
50
+ *
51
+ * `UNKNOWN` services (e.g. not yet polled) never force `DEGRADED`, so startup
52
+ * settles on `OFFLINE` (gray) rather than a spurious flashing-red degraded state.
53
+ *
54
+ * Note: this optimism also means a service held `UNKNOWN` indefinitely (e.g. an
55
+ * endpoint whose polls keep failing) does not by itself pull the aggregate below
56
+ * `ONLINE` — a deliberate v1 tradeoff to keep transient errors from flapping the
57
+ * indicator. Surfacing a long-lived failure distinctly (likely via health rather
58
+ * than connectivity) is left as a future refinement.
59
+ */
60
+ export const aggregateConnectivity = (services) => {
61
+ const values = Object.values(services);
62
+ const hasConnected = values.includes(ServiceConnection.CONNECTED);
63
+ const hasDisconnected = values.includes(ServiceConnection.DISCONNECTED);
64
+ if (hasConnected && hasDisconnected)
65
+ return RobotConnectivity.DEGRADED;
66
+ if (hasConnected)
67
+ return RobotConnectivity.ONLINE;
68
+ return RobotConnectivity.OFFLINE;
69
+ };
70
+ /**
71
+ * Polls a single service's `connections` endpoint on an interval, emitting its
72
+ * {@link ServiceConnection} (change-only). A failed/timed-out poll yields
73
+ * `UNKNOWN` (held transitional) rather than `DISCONNECTED`, to avoid a spurious
74
+ * degraded flash on a transient error.
75
+ */
76
+ export const getServiceConnectionChanges = (httpGet, url, pollMs = DEFAULT_CONNECTIVITY_POLL_MS) => {
77
+ return timer(0, pollMs).pipe(switchMap(() => {
78
+ return from(httpGet(url)).pipe(map(mapConnectionStatus), catchError(() => of(ServiceConnection.UNKNOWN)));
79
+ }), startWith(ServiceConnection.UNKNOWN), distinctUntilChanged());
80
+ };
81
+ const connectivityEquals = (a, b) => {
82
+ if (a.overall !== b.overall)
83
+ return false;
84
+ const keys = Object.keys(a.services);
85
+ if (keys.length !== Object.keys(b.services).length)
86
+ return false;
87
+ return keys.every((key) => a.services[key] === b.services[key]);
88
+ };
89
+ /**
90
+ * Emits the robot's {@link RobotConnectivityStatus} — the per-service breakdown
91
+ * plus the aggregate verdict — for the configured `expectedServices`. HTTP
92
+ * services are polled; `telemetry-liveness` uses the supplied observable; any
93
+ * unrecognised service id resolves to `UNKNOWN`.
94
+ */
95
+ export const getConnectivityChanges = (deps, projectId, callsign, expectedServices = DEFAULT_EXPECTED_SERVICES, pollMs = DEFAULT_CONNECTIVITY_POLL_MS) => {
96
+ // combineLatest([]) completes without emitting; emit an explicit empty/offline status instead.
97
+ if (expectedServices.length === 0) {
98
+ return of({ overall: RobotConnectivity.OFFLINE, services: {} });
99
+ }
100
+ const perService = expectedServices.map((serviceId) => {
101
+ let source$;
102
+ if (serviceId === TELEMETRY_LIVENESS_SERVICE) {
103
+ source$ = deps.telemetryLiveness$;
104
+ }
105
+ else if (serviceId in CONNECTION_SERVICE_PREFIXES) {
106
+ const url = buildConnectionsUrl(serviceId, deps.baseUrl, projectId, callsign, deps.insecure);
107
+ source$ = getServiceConnectionChanges(deps.httpGet, url, pollMs);
108
+ }
109
+ else {
110
+ source$ = of(ServiceConnection.UNKNOWN);
111
+ }
112
+ return source$.pipe(startWith(ServiceConnection.UNKNOWN), map((connection) => ({ serviceId, connection })));
113
+ });
114
+ return combineLatest(perService).pipe(map((entries) => {
115
+ const services = {};
116
+ for (const { serviceId, connection } of entries)
117
+ services[serviceId] = connection;
118
+ return { overall: aggregateConnectivity(services), services };
119
+ }), distinctUntilChanged(connectivityEquals));
120
+ };
@@ -0,0 +1,38 @@
1
+ import { Observable } from 'rxjs';
2
+ import { RobotHealth, RobotReadiness, ServiceConnection } from '../../models';
3
+ import { TelemetryService } from '../TelemetryService';
4
+ /** The new agent topic carrying health + readiness (replaces the heartbeat). */
5
+ export declare const ROBOT_STATUS_SOURCE = "/diagnostics/robot/status";
6
+ /** The legacy heartbeat topic — liveness only, no status payload. */
7
+ export declare const LEGACY_HEARTBEAT_SOURCE = "/rocos/agent/telemetry/heartbeat";
8
+ export interface ReportedRobotStatus {
9
+ health: RobotHealth;
10
+ readiness: RobotReadiness;
11
+ }
12
+ /**
13
+ * Maps a `robot/status` wire value (integer, or its string equivalent) to a
14
+ * {@link RobotHealth}. Anything unrecognised — including `0` / `'unknown'` —
15
+ * resolves to {@link RobotHealth.UNKNOWN}.
16
+ */
17
+ export declare const parseRobotHealth: (value: unknown) => RobotHealth;
18
+ /** As {@link parseRobotHealth}, for {@link RobotReadiness}. */
19
+ export declare const parseRobotReadiness: (value: unknown) => RobotReadiness;
20
+ /**
21
+ * Emits the robot's reported health/readiness, subscribing to the new
22
+ * `robot/status` topic and the legacy heartbeat concurrently.
23
+ *
24
+ * The new topic takes precedence: once any `robot/status` message has been seen,
25
+ * legacy heartbeats no longer affect the reported value. While only the legacy
26
+ * heartbeat is present, a live heartbeat is reported as `NORMAL`/`IDLE` — which
27
+ * preserves today's "online ⇒ green" behaviour for agents not yet emitting the
28
+ * new topic.
29
+ */
30
+ export declare const getReportedStatusChanges: (telemetry: TelemetryService, projectId: string, callsign: string) => Observable<ReportedRobotStatus>;
31
+ /**
32
+ * Emits the `telemetry-liveness` connectivity signal: whether a heartbeat (from
33
+ * either the new `robot/status` topic or the legacy heartbeat) has been received
34
+ * within `heartbeatTimeoutMs`. Mirrors {@link TelemetryService.monitorTelemetryWithTimeout}
35
+ * but spans both topics and maps onto {@link ServiceConnection} so it can be
36
+ * folded into connectivity aggregation as just another service entry.
37
+ */
38
+ export declare const getTelemetryLiveness: (telemetry: TelemetryService, projectId: string, callsign: string, heartbeatTimeoutMs?: number, intervalMs?: number) => Observable<ServiceConnection>;
@@ -0,0 +1,102 @@
1
+ import { catchError, combineLatest, distinctUntilChanged, filter, interval, map, merge, of, scan, startWith, } from 'rxjs';
2
+ import { RobotHealth, RobotReadiness, ServiceConnection } from '../../models';
3
+ /** The new agent topic carrying health + readiness (replaces the heartbeat). */
4
+ export const ROBOT_STATUS_SOURCE = '/diagnostics/robot/status';
5
+ /** The legacy heartbeat topic — liveness only, no status payload. */
6
+ export const LEGACY_HEARTBEAT_SOURCE = '/rocos/agent/telemetry/heartbeat';
7
+ const DEFAULT_HEARTBEAT_TIMEOUT_MS = 5000;
8
+ const DEFAULT_INTERVAL_MS = 2000;
9
+ /**
10
+ * Maps a `robot/status` wire value (integer, or its string equivalent) to a
11
+ * {@link RobotHealth}. Anything unrecognised — including `0` / `'unknown'` —
12
+ * resolves to {@link RobotHealth.UNKNOWN}.
13
+ */
14
+ export const parseRobotHealth = (value) => {
15
+ switch (value) {
16
+ case 1:
17
+ case '1':
18
+ case 'normal':
19
+ return RobotHealth.NORMAL;
20
+ case 2:
21
+ case '2':
22
+ case 'abnormal':
23
+ return RobotHealth.ABNORMAL;
24
+ case 3:
25
+ case '3':
26
+ case 'critical':
27
+ return RobotHealth.CRITICAL;
28
+ default:
29
+ return RobotHealth.UNKNOWN;
30
+ }
31
+ };
32
+ /** As {@link parseRobotHealth}, for {@link RobotReadiness}. */
33
+ export const parseRobotReadiness = (value) => {
34
+ switch (value) {
35
+ case 1:
36
+ case '1':
37
+ case 'idle':
38
+ return RobotReadiness.IDLE;
39
+ case 2:
40
+ case '2':
41
+ case 'busy':
42
+ return RobotReadiness.BUSY;
43
+ default:
44
+ return RobotReadiness.UNKNOWN;
45
+ }
46
+ };
47
+ /**
48
+ * Emits the robot's reported health/readiness, subscribing to the new
49
+ * `robot/status` topic and the legacy heartbeat concurrently.
50
+ *
51
+ * The new topic takes precedence: once any `robot/status` message has been seen,
52
+ * legacy heartbeats no longer affect the reported value. While only the legacy
53
+ * heartbeat is present, a live heartbeat is reported as `NORMAL`/`IDLE` — which
54
+ * preserves today's "online ⇒ green" behaviour for agents not yet emitting the
55
+ * new topic.
56
+ */
57
+ export const getReportedStatusChanges = (telemetry, projectId, callsign) => {
58
+ const new$ = telemetry
59
+ .subscribe({ projectId, callsigns: [callsign], sources: [ROBOT_STATUS_SOURCE] })
60
+ .pipe(map((message) => ({
61
+ from: 'new',
62
+ status: {
63
+ health: parseRobotHealth(message.payload?.health),
64
+ readiness: parseRobotReadiness(message.payload?.readiness),
65
+ },
66
+ })));
67
+ const legacy$ = telemetry.subscribe({ projectId, callsigns: [callsign], sources: [LEGACY_HEARTBEAT_SOURCE] }).pipe(map(() => ({
68
+ from: 'legacy',
69
+ status: { health: RobotHealth.NORMAL, readiness: RobotReadiness.IDLE },
70
+ })));
71
+ return merge(new$, legacy$).pipe(scan((acc, event) => {
72
+ if (event.from === 'new')
73
+ return { seenNew: true, status: event.status };
74
+ // Legacy heartbeat: only honoured until the new topic has been seen.
75
+ return acc.seenNew ? acc : { seenNew: false, status: event.status };
76
+ }, { seenNew: false, status: undefined }), map((acc) => acc.status), filter((status) => status !== undefined), distinctUntilChanged((a, b) => a.health === b.health && a.readiness === b.readiness),
77
+ // A telemetry error would otherwise terminate this stream permanently; fall back to
78
+ // UNKNOWN/UNKNOWN (matching getTelemetryLiveness) so subscribers get an honest value.
79
+ catchError(() => of({ health: RobotHealth.UNKNOWN, readiness: RobotReadiness.UNKNOWN })));
80
+ };
81
+ /**
82
+ * Emits the `telemetry-liveness` connectivity signal: whether a heartbeat (from
83
+ * either the new `robot/status` topic or the legacy heartbeat) has been received
84
+ * within `heartbeatTimeoutMs`. Mirrors {@link TelemetryService.monitorTelemetryWithTimeout}
85
+ * but spans both topics and maps onto {@link ServiceConnection} so it can be
86
+ * folded into connectivity aggregation as just another service entry.
87
+ */
88
+ export const getTelemetryLiveness = (telemetry, projectId, callsign, heartbeatTimeoutMs = DEFAULT_HEARTBEAT_TIMEOUT_MS, intervalMs = DEFAULT_INTERVAL_MS) => {
89
+ const startedAt = Date.now();
90
+ const lastMessageAt$ = telemetry
91
+ .subscribe({ projectId, callsigns: [callsign], sources: [ROBOT_STATUS_SOURCE, LEGACY_HEARTBEAT_SOURCE] })
92
+ .pipe(map(() => Date.now()));
93
+ return combineLatest([lastMessageAt$.pipe(startWith(startedAt)), interval(intervalMs)]).pipe(map(([lastMessageAt]) => {
94
+ const now = Date.now();
95
+ // No message yet, but still within the initial grace period.
96
+ if (lastMessageAt === startedAt && now - startedAt <= heartbeatTimeoutMs)
97
+ return ServiceConnection.UNKNOWN;
98
+ if (now - lastMessageAt > heartbeatTimeoutMs)
99
+ return ServiceConnection.DISCONNECTED;
100
+ return ServiceConnection.CONNECTED;
101
+ }), startWith(ServiceConnection.UNKNOWN), distinctUntilChanged(), catchError(() => of(ServiceConnection.UNKNOWN)));
102
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dronedeploy/rocos-js-sdk",
3
- "version": "4.1.0",
3
+ "version": "4.2.1",
4
4
  "description": "Javascript SDK for rocos",
5
5
  "main": "cjs/index.js",
6
6
  "module": "esm/index.js",