Skip to content
Merged
8 changes: 8 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
"prettier-plugin-jsdoc": "^1.1.1",
"typedoc": "^0.27.9",
"typescript": "^5.3.3",
"uuid-tool": "^2.0.3",
"vite": "^5.0.10",
"vitest": "^1.1.0",
"yaml": "^2.3.3"
Expand Down
2 changes: 2 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,8 @@ export {
*/
export * as visionApi from './gen/service/vision/v1/vision_pb';

export * from './services/world-state-store';

export {
GenericClient as GenericServiceClient,
type Generic as GenericService,
Expand Down
12 changes: 12 additions & 0 deletions src/robot/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import type { Robot } from './robot';
import SessionManager from './session-manager';
import { MLModelService } from '../gen/service/mlmodel/v1/mlmodel_connect';
import type { AccessToken, Credential } from '../main';
import { WorldStateStoreService } from '../gen/service/worldstatestore/v1/world_state_store_connect';
import { assertExists } from '../assert';

interface ICEServer {
Expand Down Expand Up @@ -238,6 +239,10 @@ export class RobotClient extends EventDispatcher implements Robot {

private slamServiceClient: Client<typeof SLAMService> | undefined;

private worldStateStoreServiceClient:
| Client<typeof WorldStateStoreService>
| undefined;

private currentRetryAttempt = 0;

constructor(
Expand Down Expand Up @@ -478,6 +483,13 @@ export class RobotClient extends EventDispatcher implements Robot {
return this.slamServiceClient;
}

get worldStateStoreService() {
this.worldStateStoreServiceClient ??= this.createServiceClient(
WorldStateStoreService
);
return this.worldStateStoreServiceClient;
}

createServiceClient<T extends ServiceType>(svcType: T): Client<T> {
assertExists(this.clientTransport, RobotClient.notConnectedYetStr);
return createClient(svcType, this.clientTransport);
Expand Down
132 changes: 132 additions & 0 deletions src/services/world-state-store/client.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// @vitest-environment happy-dom

import { createClient, createRouterTransport } from '@connectrpc/connect';
import { Struct } from '@bufbuild/protobuf';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { WorldStateStoreService } from '../../gen/service/worldstatestore/v1/world_state_store_connect';
import {
GetTransformResponse,
ListUUIDsResponse,
StreamTransformChangesResponse,
} from '../../gen/service/worldstatestore/v1/world_state_store_pb';
import { RobotClient } from '../../robot';
import { WorldStateStoreClient } from './client';
import { TransformChangeType } from '../../gen/service/worldstatestore/v1/world_state_store_pb';
import { Transform, PoseInFrame, Pose } from '../../gen/common/v1/common_pb';
import { transformWithUUID, uuidToString } from './world-state-store';

vi.mock('../../robot');

const worldStateStoreClientName = 'test-world-state-store';

let worldStateStore: WorldStateStoreClient;

const mockUuids = [new Uint8Array([1, 2, 3, 4]), new Uint8Array([5, 6, 7, 8])];
const mockTransform = new Transform({
referenceFrame: 'test-frame',
poseInObserverFrame: new PoseInFrame({
referenceFrame: 'observer-frame',
pose: new Pose({
x: 10,
y: 20,
z: 30,
oX: 0,
oY: 0,
oZ: 1,
theta: 90,
}),
}),
uuid: mockUuids[0],
});

describe('WorldStateStoreClient Tests', () => {
beforeEach(() => {
const mockTransport = createRouterTransport(({ service }) => {
service(WorldStateStoreService, {
listUUIDs: () => new ListUUIDsResponse({ uuids: mockUuids }),
getTransform: () =>
new GetTransformResponse({ transform: mockTransform }),
streamTransformChanges: async function* mockStream() {
// Add await to satisfy linter
await Promise.resolve();
yield new StreamTransformChangesResponse({
changeType: TransformChangeType.ADDED,
transform: mockTransform,
});
yield new StreamTransformChangesResponse({
changeType: TransformChangeType.UPDATED,
transform: mockTransform,
updatedFields: { paths: ['pose_in_observer_frame'] },
});
},
doCommand: () => ({ result: Struct.fromJson({ success: true }) }),
});
});

RobotClient.prototype.createServiceClient = vi
.fn()
.mockImplementation(() =>
createClient(WorldStateStoreService, mockTransport)
);
worldStateStore = new WorldStateStoreClient(
new RobotClient('host'),
worldStateStoreClientName
);
});

describe('listUUIDs', () => {
it('returns all transform UUIDs', async () => {
const expected = mockUuids.map((uuid) => uuidToString(uuid));

await expect(worldStateStore.listUUIDs()).resolves.toStrictEqual(
expected
);
});
});

describe('getTransform', () => {
it('returns a transform by UUID', async () => {
const uuid = '01020304';
const expected = mockTransform;

await expect(worldStateStore.getTransform(uuid)).resolves.toStrictEqual({
...expected,
uuidString: uuid,
});
});
});

describe('streamTransformChanges', () => {
it('streams transform changes', async () => {
const stream = worldStateStore.streamTransformChanges();
const results = [];

for await (const result of stream) {
results.push(result);
}

expect(results).toHaveLength(2);
expect(results[0]).toEqual({
changeType: TransformChangeType.ADDED,
transform: transformWithUUID(mockTransform),
updatedFields: undefined,
});
expect(results[1]).toEqual({
changeType: TransformChangeType.UPDATED,
transform: transformWithUUID(mockTransform),
updatedFields: { paths: ['pose_in_observer_frame'] },
});
});
});

describe('doCommand', () => {
it('executes arbitrary commands', async () => {
const command = Struct.fromJson({ test: 'value' });
const expected = { success: true };

await expect(worldStateStore.doCommand(command)).resolves.toStrictEqual(
expected
);
});
});
});
103 changes: 103 additions & 0 deletions src/services/world-state-store/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { Struct, type JsonValue } from '@bufbuild/protobuf';
import type { CallOptions, Client } from '@connectrpc/connect';
import { WorldStateStoreService } from '../../gen/service/worldstatestore/v1/world_state_store_connect';
import {
GetTransformRequest,
ListUUIDsRequest,
StreamTransformChangesRequest,
} from '../../gen/service/worldstatestore/v1/world_state_store_pb';
import type { RobotClient } from '../../robot';
import type { Options } from '../../types';
import { doCommandFromClient } from '../../utils';
import type { WorldStateStore } from './world-state-store';
import {
transformWithUUID,
uuidFromString,
uuidToString,
} from './world-state-store';
import type { TransformChangeEvent } from './types';

/**
* A gRPC-web client for a WorldStateStore service.
*
* @group Clients
*/
export class WorldStateStoreClient implements WorldStateStore {
private client: Client<typeof WorldStateStoreService>;
public readonly name: string;
private readonly options: Options;
public callOptions: CallOptions = { headers: {} as Record<string, string> };

constructor(client: RobotClient, name: string, options: Options = {}) {
this.client = client.createServiceClient(WorldStateStoreService);
this.name = name;
this.options = options;
}

async listUUIDs(extra = {}, callOptions = this.callOptions) {
const request = new ListUUIDsRequest({
name: this.name,
extra: Struct.fromJson(extra),
});

this.options.requestLogger?.(request);

const response = await this.client.listUUIDs(request, callOptions);
return response.uuids.map((uuid) => uuidToString(uuid));
}

async getTransform(uuid: string, extra = {}, callOptions = this.callOptions) {
const request = new GetTransformRequest({
name: this.name,
uuid: uuidFromString(uuid),
extra: Struct.fromJson(extra),
});

this.options.requestLogger?.(request);

const response = await this.client.getTransform(request, callOptions);
if (!response.transform) {
throw new Error('No transform returned from server');
}

return transformWithUUID(response.transform);
}

async *streamTransformChanges(
extra = {},
callOptions = this.callOptions
): AsyncGenerator<TransformChangeEvent, void> {
const request = new StreamTransformChangesRequest({
name: this.name,
extra: Struct.fromJson(extra),
});

this.options.requestLogger?.(request);

const stream = this.client.streamTransformChanges(request, callOptions);

for await (const response of stream) {
if (!response.transform) {
continue;
}

yield {
...response,
transform: transformWithUUID(response.transform),
};
}
}

async doCommand(
command: Struct,
callOptions = this.callOptions
): Promise<JsonValue> {
return doCommandFromClient(
this.client.doCommand,
this.name,
command,
this.options,
callOptions
);
}
}
6 changes: 6 additions & 0 deletions src/services/world-state-store/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export { WorldStateStoreClient } from './client';
export type { WorldStateStore } from './world-state-store';
export { transformWithUUID } from './world-state-store';
export type { TransformChangeEvent, TransformWithUUID } from './types';

export { TransformChangeType } from '../../gen/service/worldstatestore/v1/world_state_store_pb';
14 changes: 14 additions & 0 deletions src/services/world-state-store/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { PlainMessage } from '@bufbuild/protobuf';
import type { Transform } from '../../gen/common/v1/common_pb';
import type { StreamTransformChangesResponse } from '../../gen/service/worldstatestore/v1/world_state_store_pb';

export interface TransformWithUUID extends PlainMessage<Transform> {
uuidString: string;
}

export type TransformChangeEvent = Omit<
PlainMessage<StreamTransformChangesResponse>,
'transform'
> & {
transform: TransformWithUUID | undefined;
};
Loading