Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 78 additions & 1 deletion packages/lplex/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,15 @@ import { HttpError } from "./errors.js";
import { Session } from "./session.js";
import { parseSSE } from "./sse.js";
import type {
DecodedDeviceValues,
Device,
DeviceValues,
Event,
Filter,
Frame,
HealthStatus,
QueryParams,
ReplicationStatus,
SendParams,
SessionConfig,
SessionInfo,
Expand Down Expand Up @@ -83,6 +88,24 @@ export class Client {
return parseSSE(resp.body);
}

/** Fetch the last-seen decoded values for each (device, PGN) pair. */
async decodedValues(
filter?: Filter,
signal?: AbortSignal,
): Promise<DecodedDeviceValues[]> {
let url = `${this.#baseURL}/values/decoded`;
const qs = filterToQueryString(filter);
if (qs) url += `?${qs}`;
const resp = await this.#fetch(url, { signal });

if (!resp.ok) {
const body = await resp.text();
throw new HttpError("GET", url, resp.status, body);
}

return resp.json() as Promise<DecodedDeviceValues[]>;
}

/** Transmit a CAN frame through the server. */
async send(params: SendParams, signal?: AbortSignal): Promise<void> {
const url = `${this.#baseURL}/send`;
Expand All @@ -99,6 +122,53 @@ export class Client {
}
}

/**
* Send an ISO Request (PGN 59904) and wait for the response frame.
* Returns the response frame, or throws HttpError with status 504 on timeout.
*/
async query(params: QueryParams, signal?: AbortSignal): Promise<Frame> {
const url = `${this.#baseURL}/query`;
const resp = await this.#fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params),
signal,
});

if (!resp.ok) {
const body = await resp.text();
throw new HttpError("POST", url, resp.status, body);
}

return resp.json() as Promise<Frame>;
}

/** Check server health. */
async health(signal?: AbortSignal): Promise<HealthStatus> {
const url = `${this.#baseURL}/healthz`;
const resp = await this.#fetch(url, { signal });

if (!resp.ok) {
const body = await resp.text();
throw new HttpError("GET", url, resp.status, body);
}

return resp.json() as Promise<HealthStatus>;
}

/** Fetch boat-side replication status (only available when replication is configured). */
async replicationStatus(signal?: AbortSignal): Promise<ReplicationStatus> {
const url = `${this.#baseURL}/replication/status`;
const resp = await this.#fetch(url, { signal });

if (!resp.ok) {
const body = await resp.text();
throw new HttpError("GET", url, resp.status, body);
}

return resp.json() as Promise<ReplicationStatus>;
}

/** Create or reconnect a buffered session on the server. */
async createSession(
config: SessionConfig,
Expand Down Expand Up @@ -133,9 +203,11 @@ export class Client {
function filterIsEmpty(f: Filter): boolean {
return (
!f.pgn?.length &&
!f.exclude_pgn?.length &&
!f.manufacturer?.length &&
!f.instance?.length &&
!f.name?.length
!f.name?.length &&
!f.exclude_name?.length
);
}

Expand All @@ -144,17 +216,22 @@ function filterToQueryString(f?: Filter): string {

const params = new URLSearchParams();
for (const p of f.pgn ?? []) params.append("pgn", p.toString());
for (const p of f.exclude_pgn ?? [])
params.append("exclude_pgn", p.toString());
for (const m of f.manufacturer ?? []) params.append("manufacturer", m);
for (const i of f.instance ?? []) params.append("instance", i.toString());
for (const n of f.name ?? []) params.append("name", n);
for (const n of f.exclude_name ?? []) params.append("exclude_name", n);
return params.toString();
}

function filterToJSON(f: Filter): Record<string, unknown> {
const m: Record<string, unknown> = {};
if (f.pgn?.length) m.pgn = f.pgn;
if (f.exclude_pgn?.length) m.exclude_pgn = f.exclude_pgn;
if (f.manufacturer?.length) m.manufacturer = f.manufacturer;
if (f.instance?.length) m.instance = f.instance;
if (f.name?.length) m.name = f.name;
if (f.exclude_name?.length) m.exclude_name = f.exclude_name;
return m;
}
5 changes: 5 additions & 0 deletions packages/lplex/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ export type {
Filter,
PGNValue,
DeviceValues,
DecodedPGNValue,
DecodedDeviceValues,
QueryParams,
HealthStatus,
ReplicationStatus,
SessionConfig,
SessionInfo,
SendParams,
Expand Down
44 changes: 44 additions & 0 deletions packages/lplex/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,11 @@ export type Event =
*/
export interface Filter {
pgn?: number[];
exclude_pgn?: number[];
manufacturer?: string[];
instance?: number[];
name?: string[];
exclude_name?: string[];
}

/** Configuration for creating a buffered session. */
Expand Down Expand Up @@ -89,6 +91,48 @@ export interface DeviceValues {
values: PGNValue[];
}

/** A single PGN's decoded value for a device. */
export interface DecodedPGNValue {
pgn: number;
ts: string;
data: string;
seq: number;
decoded: Record<string, unknown>;
}

/** Decoded values grouped by device. */
export interface DecodedDeviceValues {
name: string;
src: number;
manufacturer?: string;
model_id?: string;
values: DecodedPGNValue[];
}

/** Parameters for an ISO Request query (POST /query). */
export interface QueryParams {
pgn: number;
dst: number;
timeout?: string;
}

/** Health check response from GET /healthz. */
export interface HealthStatus {
status: string;
}

/** Boat-side replication status from GET /replication/status. */
export interface ReplicationStatus {
connected: boolean;
instance_id: string;
local_head_seq: number;
cloud_cursor: number;
holes: SeqRange[];
live_lag: number;
backfill_remaining_seqs: number;
last_ack: string;
}

// --- Cloud types ---

/** Summary of a cloud instance, returned by GET /instances. */
Expand Down
Loading