talon-agent 1.34.1 → 1.35.0
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.
- package/package.json +1 -1
- package/src/core/engine/gateway-actions/index.ts +3 -0
- package/src/core/engine/gateway-actions/mesh.ts +22 -0
- package/src/core/mesh/index.ts +27 -0
- package/src/core/mesh/registry.ts +272 -0
- package/src/core/mesh/service.ts +550 -0
- package/src/core/mesh/types.ts +137 -0
- package/src/core/tools/index.ts +2 -0
- package/src/core/tools/mesh.ts +71 -0
- package/src/core/tools/types.ts +2 -1
- package/src/frontend/native/actions.ts +17 -1
- package/src/frontend/native/index.ts +35 -0
- package/src/frontend/native/protocol.ts +30 -0
- package/src/frontend/native/server.ts +38 -0
package/package.json
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* - `skills` — markdown workflows
|
|
17
17
|
* - `plugins` — plugin hot-reload
|
|
18
18
|
* - `models` — model / backend discovery
|
|
19
|
+
* - `mesh` — companion device mesh (presence + location)
|
|
19
20
|
*/
|
|
20
21
|
|
|
21
22
|
import type { ActionResult } from "../../types.js";
|
|
@@ -30,6 +31,7 @@ import { scriptHandlers } from "./scripts.js";
|
|
|
30
31
|
import { skillHandlers } from "./skills.js";
|
|
31
32
|
import { pluginHandlers } from "./plugins.js";
|
|
32
33
|
import { modelHandlers } from "./models.js";
|
|
34
|
+
import { meshHandlers } from "./mesh.js";
|
|
33
35
|
|
|
34
36
|
// Null-prototype so a request `action` of "toString" / "constructor" / etc.
|
|
35
37
|
// can't resolve an inherited Object.prototype method — `handlers[action]` only
|
|
@@ -44,6 +46,7 @@ const handlers: SharedActionHandlers = Object.assign(Object.create(null), {
|
|
|
44
46
|
...skillHandlers,
|
|
45
47
|
...pluginHandlers,
|
|
46
48
|
...modelHandlers,
|
|
49
|
+
...meshHandlers,
|
|
47
50
|
});
|
|
48
51
|
|
|
49
52
|
export async function handleSharedAction(
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device mesh — read tools (list_devices, get_device_location,
|
|
3
|
+
* get_device_history) and command tools (ring, live status).
|
|
4
|
+
*
|
|
5
|
+
* Shared actions, so the model has full mesh access from every frontend
|
|
6
|
+
* (Telegram, Discord, Teams, terminal, native) — not just chats running
|
|
7
|
+
* through the native bridge. The mesh itself is daemon-wide state
|
|
8
|
+
* (core/mesh); the native bridge is merely the transport companions
|
|
9
|
+
* register through and commands travel over.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { getMeshService } from "../../mesh/index.js";
|
|
13
|
+
import type { SharedActionHandlers } from "./types.js";
|
|
14
|
+
|
|
15
|
+
export const meshHandlers: SharedActionHandlers = {
|
|
16
|
+
list_devices: () => getMeshService().describeDevices(),
|
|
17
|
+
get_device_location: (body) => getMeshService().locateDevice(body.device),
|
|
18
|
+
get_device_history: (body) =>
|
|
19
|
+
getMeshService().deviceHistory(body.device, body.hours),
|
|
20
|
+
ring_device: (body) => getMeshService().ringDevice(body.device, body.message),
|
|
21
|
+
get_device_status: (body) => getMeshService().getDeviceStatus(body.device),
|
|
22
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device mesh — core module barrel.
|
|
3
|
+
*
|
|
4
|
+
* Registry (persistence) + service (policy, locate fan-out, tool surface) +
|
|
5
|
+
* canonical device/location types. Transports and gateway actions import
|
|
6
|
+
* from here.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export { MeshRegistry } from "./registry.js";
|
|
10
|
+
export {
|
|
11
|
+
MeshService,
|
|
12
|
+
getMeshService,
|
|
13
|
+
setMeshService,
|
|
14
|
+
type MeshTransport,
|
|
15
|
+
type MeshServiceOptions,
|
|
16
|
+
type MeshToolResult,
|
|
17
|
+
} from "./service.js";
|
|
18
|
+
export {
|
|
19
|
+
sanitizeCapabilities,
|
|
20
|
+
toDeviceInfo,
|
|
21
|
+
toDeviceLocation,
|
|
22
|
+
type DeviceCommand,
|
|
23
|
+
type DeviceCommandResult,
|
|
24
|
+
type DeviceInfo,
|
|
25
|
+
type DeviceLocation,
|
|
26
|
+
type DevicePlatform,
|
|
27
|
+
} from "./types.js";
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MeshRegistry — persistent storage for companion devices, last-known
|
|
3
|
+
* locations, and a bounded per-device location/battery history.
|
|
4
|
+
*
|
|
5
|
+
* Pure state: validate, upsert, persist (0600 JSON sidecars under the Talon
|
|
6
|
+
* root). No transport and no waiting logic — that policy lives in
|
|
7
|
+
* MeshService, so the registry stays trivially testable.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
11
|
+
import { dirname, resolve } from "node:path";
|
|
12
|
+
import { dirs } from "../../util/paths.js";
|
|
13
|
+
import {
|
|
14
|
+
sanitizeCapabilities,
|
|
15
|
+
toDeviceInfo,
|
|
16
|
+
toDeviceLocation,
|
|
17
|
+
type DeviceInfo,
|
|
18
|
+
type DeviceLocation,
|
|
19
|
+
type DevicePlatform,
|
|
20
|
+
} from "./types.js";
|
|
21
|
+
|
|
22
|
+
const PRESENCE_TIMEOUT_MS = 90_000;
|
|
23
|
+
const DEVICE_FILE = resolve(dirs.root, "mesh-devices.json");
|
|
24
|
+
const LOCATION_FILE = resolve(dirs.root, "mesh-locations.json");
|
|
25
|
+
const HISTORY_FILE = resolve(dirs.root, "mesh-history.json");
|
|
26
|
+
/** Newest fixes kept per device (~a day of 5-minute periodic reports). */
|
|
27
|
+
const MAX_HISTORY_PER_DEVICE = 500;
|
|
28
|
+
const PLATFORMS = new Set<DevicePlatform>([
|
|
29
|
+
"android",
|
|
30
|
+
"macos",
|
|
31
|
+
"windows",
|
|
32
|
+
"linux",
|
|
33
|
+
"ios",
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
export class MeshRegistry {
|
|
37
|
+
private devices = new Map<string, DeviceInfo>();
|
|
38
|
+
private locations = new Map<string, DeviceLocation>();
|
|
39
|
+
private history = new Map<string, DeviceLocation[]>();
|
|
40
|
+
|
|
41
|
+
constructor(
|
|
42
|
+
private readonly files = {
|
|
43
|
+
devices: DEVICE_FILE,
|
|
44
|
+
locations: LOCATION_FILE,
|
|
45
|
+
history: HISTORY_FILE,
|
|
46
|
+
},
|
|
47
|
+
) {}
|
|
48
|
+
|
|
49
|
+
async load(): Promise<void> {
|
|
50
|
+
this.devices = new Map(
|
|
51
|
+
(await readArray<DeviceInfo>(this.files.devices))
|
|
52
|
+
.map((d) => sanitizeDevice(d, d.lastSeen, false))
|
|
53
|
+
.filter((d): d is DeviceInfo => d !== null)
|
|
54
|
+
.map((d) => [d.id, d]),
|
|
55
|
+
);
|
|
56
|
+
this.locations = new Map(
|
|
57
|
+
(await readArray<DeviceLocation>(this.files.locations))
|
|
58
|
+
.map(sanitizeLocation)
|
|
59
|
+
.filter((l): l is DeviceLocation => l !== null)
|
|
60
|
+
.map((l) => [l.deviceId, l]),
|
|
61
|
+
);
|
|
62
|
+
this.history = new Map();
|
|
63
|
+
for (const fix of (await readArray<DeviceLocation>(this.files.history))
|
|
64
|
+
.map(sanitizeLocation)
|
|
65
|
+
.filter((l): l is DeviceLocation => l !== null)) {
|
|
66
|
+
this.appendHistory(fix);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async register(
|
|
71
|
+
body: Record<string, unknown>,
|
|
72
|
+
now = Date.now(),
|
|
73
|
+
): Promise<DeviceInfo> {
|
|
74
|
+
const next = sanitizeDevice(body, now, true);
|
|
75
|
+
if (!next) throw new Error("Invalid device registration");
|
|
76
|
+
const prev = this.devices.get(next.id);
|
|
77
|
+
const device = toDeviceInfo(
|
|
78
|
+
{
|
|
79
|
+
...prev,
|
|
80
|
+
...next,
|
|
81
|
+
online: true,
|
|
82
|
+
lastSeen: now,
|
|
83
|
+
},
|
|
84
|
+
now,
|
|
85
|
+
);
|
|
86
|
+
this.devices.set(device.id, device);
|
|
87
|
+
await this.persistDevices();
|
|
88
|
+
return device;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async storeLocation(
|
|
92
|
+
body: Record<string, unknown>,
|
|
93
|
+
now = Date.now(),
|
|
94
|
+
): Promise<DeviceLocation> {
|
|
95
|
+
const loc = sanitizeLocation(body);
|
|
96
|
+
if (!loc) throw new Error("Invalid device location");
|
|
97
|
+
this.locations.set(loc.deviceId, loc);
|
|
98
|
+
const device = this.devices.get(loc.deviceId);
|
|
99
|
+
if (device) {
|
|
100
|
+
this.devices.set(
|
|
101
|
+
loc.deviceId,
|
|
102
|
+
toDeviceInfo(
|
|
103
|
+
{
|
|
104
|
+
...device,
|
|
105
|
+
online: true,
|
|
106
|
+
lastSeen: now,
|
|
107
|
+
...(typeof loc.batteryPct === "number"
|
|
108
|
+
? { battery: loc.batteryPct }
|
|
109
|
+
: {}),
|
|
110
|
+
},
|
|
111
|
+
now,
|
|
112
|
+
),
|
|
113
|
+
);
|
|
114
|
+
await this.persistDevices();
|
|
115
|
+
}
|
|
116
|
+
this.appendHistory(loc);
|
|
117
|
+
await this.persistLocations();
|
|
118
|
+
await this.persistHistory();
|
|
119
|
+
return loc;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Fixes for one device since `sinceTs`, oldest first. */
|
|
123
|
+
getHistory(deviceId: string, sinceTs = 0): DeviceLocation[] {
|
|
124
|
+
return (this.history.get(deviceId) ?? [])
|
|
125
|
+
.filter((l) => l.ts >= sinceTs)
|
|
126
|
+
.map(toDeviceLocation);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Append one fix to the device's rolling history (dedup on timestamp,
|
|
130
|
+
* chronological order, bounded to MAX_HISTORY_PER_DEVICE). */
|
|
131
|
+
private appendHistory(fix: DeviceLocation): void {
|
|
132
|
+
const list = this.history.get(fix.deviceId) ?? [];
|
|
133
|
+
if (list.some((l) => l.ts === fix.ts)) return;
|
|
134
|
+
list.push(toDeviceLocation(fix));
|
|
135
|
+
list.sort((a, b) => a.ts - b.ts);
|
|
136
|
+
if (list.length > MAX_HISTORY_PER_DEVICE) {
|
|
137
|
+
list.splice(0, list.length - MAX_HISTORY_PER_DEVICE);
|
|
138
|
+
}
|
|
139
|
+
this.history.set(fix.deviceId, list);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
list(now = Date.now()): {
|
|
143
|
+
devices: DeviceInfo[];
|
|
144
|
+
locations: DeviceLocation[];
|
|
145
|
+
} {
|
|
146
|
+
return {
|
|
147
|
+
devices: [...this.devices.values()]
|
|
148
|
+
.map((d) => toDeviceInfo(d, now, PRESENCE_TIMEOUT_MS))
|
|
149
|
+
.sort((a, b) => b.lastSeen - a.lastSeen),
|
|
150
|
+
locations: [...this.locations.values()]
|
|
151
|
+
.map(toDeviceLocation)
|
|
152
|
+
.sort((a, b) => b.ts - a.ts),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
getLocation(deviceId: string): DeviceLocation | undefined {
|
|
157
|
+
const loc = this.locations.get(deviceId);
|
|
158
|
+
return loc ? toDeviceLocation(loc) : undefined;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private async persistDevices(): Promise<void> {
|
|
162
|
+
await writePrivateJson(this.files.devices, [...this.devices.values()]);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private async persistLocations(): Promise<void> {
|
|
166
|
+
await writePrivateJson(this.files.locations, [...this.locations.values()]);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private async persistHistory(): Promise<void> {
|
|
170
|
+
await writePrivateJson(
|
|
171
|
+
this.files.history,
|
|
172
|
+
[...this.history.values()].flat(),
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function readArray<T>(path: string): Promise<T[]> {
|
|
178
|
+
try {
|
|
179
|
+
const raw = await readFile(path, "utf8");
|
|
180
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
181
|
+
return Array.isArray(parsed) ? (parsed as T[]) : [];
|
|
182
|
+
} catch {
|
|
183
|
+
return [];
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function writePrivateJson(path: string, value: unknown): Promise<void> {
|
|
188
|
+
await mkdir(dirname(path), { recursive: true });
|
|
189
|
+
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function sanitizeDevice(
|
|
193
|
+
body: Record<string, unknown>,
|
|
194
|
+
now: number,
|
|
195
|
+
requireCore: boolean,
|
|
196
|
+
): DeviceInfo | null {
|
|
197
|
+
const id = stringField(body.id);
|
|
198
|
+
const name = stringField(body.name);
|
|
199
|
+
const appVersion = stringField(body.appVersion);
|
|
200
|
+
const platform = stringField(body.platform) as DevicePlatform | undefined;
|
|
201
|
+
if ((requireCore || id !== undefined) && !id) return null;
|
|
202
|
+
if ((requireCore || name !== undefined) && !name) return null;
|
|
203
|
+
if ((requireCore || appVersion !== undefined) && !appVersion) return null;
|
|
204
|
+
if (
|
|
205
|
+
(requireCore || platform !== undefined) &&
|
|
206
|
+
(!platform || !PLATFORMS.has(platform))
|
|
207
|
+
) {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
return {
|
|
211
|
+
id: id ?? "",
|
|
212
|
+
name: name ?? "",
|
|
213
|
+
platform: platform ?? "linux",
|
|
214
|
+
appVersion: appVersion ?? "",
|
|
215
|
+
online: body.online === true,
|
|
216
|
+
lastSeen: numberField(body.lastSeen) ?? now,
|
|
217
|
+
...(numberField(body.battery) !== undefined
|
|
218
|
+
? { battery: numberField(body.battery) }
|
|
219
|
+
: {}),
|
|
220
|
+
...(typeof body.charging === "boolean" ? { charging: body.charging } : {}),
|
|
221
|
+
...(sanitizeCapabilities(body.capabilities)
|
|
222
|
+
? { capabilities: sanitizeCapabilities(body.capabilities) }
|
|
223
|
+
: {}),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function sanitizeLocation(
|
|
228
|
+
body: Record<string, unknown>,
|
|
229
|
+
): DeviceLocation | null {
|
|
230
|
+
const deviceId = stringField(body.deviceId);
|
|
231
|
+
const lat = numberField(body.lat);
|
|
232
|
+
const lon = numberField(body.lon);
|
|
233
|
+
const ts = numberField(body.ts);
|
|
234
|
+
if (!deviceId || !Number.isFinite(lat) || !Number.isFinite(lon) || !ts) {
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
if (lat! < -90 || lat! > 90 || lon! < -180 || lon! > 180) return null;
|
|
238
|
+
return toDeviceLocation({
|
|
239
|
+
deviceId,
|
|
240
|
+
lat: lat!,
|
|
241
|
+
lon: lon!,
|
|
242
|
+
...(numberField(body.accuracyM) !== undefined
|
|
243
|
+
? { accuracyM: numberField(body.accuracyM) }
|
|
244
|
+
: {}),
|
|
245
|
+
...(numberField(body.altitudeM) !== undefined
|
|
246
|
+
? { altitudeM: numberField(body.altitudeM) }
|
|
247
|
+
: {}),
|
|
248
|
+
...(numberField(body.speedMps) !== undefined
|
|
249
|
+
? { speedMps: numberField(body.speedMps) }
|
|
250
|
+
: {}),
|
|
251
|
+
...(numberField(body.headingDeg) !== undefined
|
|
252
|
+
? { headingDeg: numberField(body.headingDeg) }
|
|
253
|
+
: {}),
|
|
254
|
+
ts,
|
|
255
|
+
...(stringField(body.provider)
|
|
256
|
+
? { provider: stringField(body.provider) }
|
|
257
|
+
: {}),
|
|
258
|
+
...(numberField(body.batteryPct) !== undefined
|
|
259
|
+
? { batteryPct: numberField(body.batteryPct) }
|
|
260
|
+
: {}),
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function stringField(value: unknown): string | undefined {
|
|
265
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function numberField(value: unknown): number | undefined {
|
|
269
|
+
return typeof value === "number" && Number.isFinite(value)
|
|
270
|
+
? value
|
|
271
|
+
: undefined;
|
|
272
|
+
}
|
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MeshService — the daemon-wide device-mesh facade.
|
|
3
|
+
*
|
|
4
|
+
* One instance serves every consumer in the process:
|
|
5
|
+
*
|
|
6
|
+
* - Transports (the native bridge server) feed it registrations, location
|
|
7
|
+
* reports, and command results, and plug in a MeshTransport so locate
|
|
8
|
+
* requests and device commands reach connected companion apps. The
|
|
9
|
+
* service never imports a transport — dependencies point inward.
|
|
10
|
+
* - The model reads and drives it through the shared mesh gateway actions
|
|
11
|
+
* (list_devices, get_device_location, get_device_history, ring_device,
|
|
12
|
+
* get_device_status), so full mesh access works identically from
|
|
13
|
+
* Telegram, Discord, Teams, terminal, and native chats — the mesh is
|
|
14
|
+
* daemon state, not a native-frontend feature.
|
|
15
|
+
*
|
|
16
|
+
* Two request/response flows ride the same SSE-out / HTTP-POST-back loop:
|
|
17
|
+
*
|
|
18
|
+
* locate → `locate` event → device POSTs /location (legacy,
|
|
19
|
+
* kept verbatim so pre-command app builds keep working)
|
|
20
|
+
* command → `device_command` → device POSTs /devices/command-result with
|
|
21
|
+
* the command's correlation id; sendCommand resolves the
|
|
22
|
+
* pending promise or times out.
|
|
23
|
+
*
|
|
24
|
+
* With no transport registered (daemon running without the native bridge),
|
|
25
|
+
* everything degrades gracefully: locate answers from the last persisted
|
|
26
|
+
* fix immediately, and commands fail fast with a clear explanation.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { randomUUID } from "node:crypto";
|
|
30
|
+
import { MeshRegistry } from "./registry.js";
|
|
31
|
+
import type {
|
|
32
|
+
DeviceCommand,
|
|
33
|
+
DeviceCommandResult,
|
|
34
|
+
DeviceInfo,
|
|
35
|
+
DeviceLocation,
|
|
36
|
+
} from "./types.js";
|
|
37
|
+
|
|
38
|
+
/** How a transport pushes mesh traffic to connected companion devices. */
|
|
39
|
+
export type MeshTransport = {
|
|
40
|
+
/** Ask one device (or all, when undefined) for a fresh location fix. */
|
|
41
|
+
locate(deviceId?: string): void;
|
|
42
|
+
/** Deliver an on-demand command to its target device. */
|
|
43
|
+
command(command: DeviceCommand): void;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type MeshToolResult = { ok: boolean; text: string };
|
|
47
|
+
|
|
48
|
+
export type MeshServiceOptions = {
|
|
49
|
+
/** How long a locate waits for a fresh fix before last-known fallback. */
|
|
50
|
+
freshFixTimeoutMs?: number;
|
|
51
|
+
/** Upper bound between staleness re-checks while waiting. */
|
|
52
|
+
pollIntervalMs?: number;
|
|
53
|
+
/** How long a device command waits for its result before timing out. */
|
|
54
|
+
commandTimeoutMs?: number;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const DEFAULT_FRESH_FIX_TIMEOUT_MS = 8_000;
|
|
58
|
+
const DEFAULT_POLL_INTERVAL_MS = 1_000;
|
|
59
|
+
const DEFAULT_COMMAND_TIMEOUT_MS = 12_000;
|
|
60
|
+
const MOBILE_PLATFORMS = new Set(["android", "ios"]);
|
|
61
|
+
const DEFAULT_HISTORY_HOURS = 24;
|
|
62
|
+
const MAX_HISTORY_LINES = 24;
|
|
63
|
+
|
|
64
|
+
export class MeshService {
|
|
65
|
+
private readonly waiters = new Set<() => void>();
|
|
66
|
+
private readonly transports = new Set<MeshTransport>();
|
|
67
|
+
private readonly pendingCommands = new Map<
|
|
68
|
+
string,
|
|
69
|
+
(result: DeviceCommandResult) => void
|
|
70
|
+
>();
|
|
71
|
+
private readonly freshFixTimeoutMs: number;
|
|
72
|
+
private readonly pollIntervalMs: number;
|
|
73
|
+
private readonly commandTimeoutMs: number;
|
|
74
|
+
private loading: Promise<void> | null = null;
|
|
75
|
+
|
|
76
|
+
constructor(
|
|
77
|
+
private readonly registry = new MeshRegistry(),
|
|
78
|
+
options: MeshServiceOptions = {},
|
|
79
|
+
) {
|
|
80
|
+
this.freshFixTimeoutMs =
|
|
81
|
+
options.freshFixTimeoutMs ?? DEFAULT_FRESH_FIX_TIMEOUT_MS;
|
|
82
|
+
this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
83
|
+
this.commandTimeoutMs =
|
|
84
|
+
options.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Hydrate persisted devices/locations. Idempotent — safe to await from
|
|
88
|
+
* every entry point; the first caller does the read, the rest share it. */
|
|
89
|
+
load(): Promise<void> {
|
|
90
|
+
this.loading ??= this.registry.load();
|
|
91
|
+
return this.loading;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Plug a transport in. Returns an unsubscribe so the transport detaches
|
|
96
|
+
* cleanly on shutdown (no stale broadcasts into a stopped server).
|
|
97
|
+
*/
|
|
98
|
+
registerTransport(transport: MeshTransport): () => void {
|
|
99
|
+
this.transports.add(transport);
|
|
100
|
+
return () => this.transports.delete(transport);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Fan a locate request out to every transport. True when at least one
|
|
104
|
+
* transport is attached (i.e. waiting for a fresh fix can pay off). */
|
|
105
|
+
requestLocate(deviceId?: string): boolean {
|
|
106
|
+
for (const transport of this.transports) {
|
|
107
|
+
try {
|
|
108
|
+
transport.locate(deviceId);
|
|
109
|
+
} catch {
|
|
110
|
+
// One broken transport must not stop the others.
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return this.transports.size > 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async register(body: Record<string, unknown>): Promise<DeviceInfo> {
|
|
117
|
+
await this.load();
|
|
118
|
+
return this.registry.register(body);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Store a reported fix and wake anyone waiting on a fresh location. */
|
|
122
|
+
async storeLocation(body: Record<string, unknown>): Promise<DeviceLocation> {
|
|
123
|
+
await this.load();
|
|
124
|
+
const loc = await this.registry.storeLocation(body);
|
|
125
|
+
for (const notify of [...this.waiters]) notify();
|
|
126
|
+
return loc;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async list(): Promise<{
|
|
130
|
+
devices: DeviceInfo[];
|
|
131
|
+
locations: DeviceLocation[];
|
|
132
|
+
}> {
|
|
133
|
+
await this.load();
|
|
134
|
+
return this.registry.list();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async getLocation(deviceId: string): Promise<DeviceLocation | undefined> {
|
|
138
|
+
await this.load();
|
|
139
|
+
return this.registry.getLocation(deviceId);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ── Command channel ────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* A device answered a command (bridge route POST /devices/command-result).
|
|
146
|
+
* Resolves the pending sendCommand; false when nothing was waiting (late
|
|
147
|
+
* or unknown correlation id — harmless, just ignored).
|
|
148
|
+
*/
|
|
149
|
+
completeCommand(body: Record<string, unknown>): boolean {
|
|
150
|
+
const commandId = typeof body.commandId === "string" ? body.commandId : "";
|
|
151
|
+
const resolve = this.pendingCommands.get(commandId);
|
|
152
|
+
if (!resolve) return false;
|
|
153
|
+
this.pendingCommands.delete(commandId);
|
|
154
|
+
resolve({
|
|
155
|
+
commandId,
|
|
156
|
+
deviceId: typeof body.deviceId === "string" ? body.deviceId : "",
|
|
157
|
+
ok: body.ok === true,
|
|
158
|
+
...(typeof body.message === "string" && body.message.trim()
|
|
159
|
+
? { message: body.message.trim().slice(0, 2_000) }
|
|
160
|
+
: {}),
|
|
161
|
+
...(body.data &&
|
|
162
|
+
typeof body.data === "object" &&
|
|
163
|
+
!Array.isArray(body.data)
|
|
164
|
+
? { data: body.data as Record<string, unknown> }
|
|
165
|
+
: {}),
|
|
166
|
+
});
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Push one command to a device and await its result (or time out). The
|
|
172
|
+
* low-level primitive under every command tool; exposed for tests and
|
|
173
|
+
* future tools.
|
|
174
|
+
*/
|
|
175
|
+
sendCommand(
|
|
176
|
+
device: DeviceInfo,
|
|
177
|
+
name: string,
|
|
178
|
+
params: Record<string, unknown> = {},
|
|
179
|
+
): Promise<DeviceCommandResult> {
|
|
180
|
+
const command: DeviceCommand = {
|
|
181
|
+
id: randomUUID(),
|
|
182
|
+
deviceId: device.id,
|
|
183
|
+
name,
|
|
184
|
+
params,
|
|
185
|
+
};
|
|
186
|
+
return new Promise<DeviceCommandResult>((resolve) => {
|
|
187
|
+
const timer = setTimeout(() => {
|
|
188
|
+
this.pendingCommands.delete(command.id);
|
|
189
|
+
resolve({
|
|
190
|
+
commandId: command.id,
|
|
191
|
+
deviceId: device.id,
|
|
192
|
+
ok: false,
|
|
193
|
+
message: `${device.name} did not answer within ${Math.round(this.commandTimeoutMs / 1000)}s (device ${device.online ? "was online" : "appears offline"}).`,
|
|
194
|
+
});
|
|
195
|
+
}, this.commandTimeoutMs);
|
|
196
|
+
timer.unref?.();
|
|
197
|
+
this.pendingCommands.set(command.id, (result) => {
|
|
198
|
+
clearTimeout(timer);
|
|
199
|
+
resolve(result);
|
|
200
|
+
});
|
|
201
|
+
for (const transport of this.transports) {
|
|
202
|
+
try {
|
|
203
|
+
transport.command(command);
|
|
204
|
+
} catch {
|
|
205
|
+
// One broken transport must not stop the others.
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ── Model-facing tool surface ──────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
/** `list_devices`: every mesh device with presence, battery, last-known
|
|
214
|
+
* position, and capabilities — the model's full view of the mesh. */
|
|
215
|
+
async describeDevices(): Promise<MeshToolResult> {
|
|
216
|
+
const { devices } = await this.list();
|
|
217
|
+
if (devices.length === 0) {
|
|
218
|
+
return { ok: true, text: "No mesh devices have registered yet." };
|
|
219
|
+
}
|
|
220
|
+
return {
|
|
221
|
+
ok: true,
|
|
222
|
+
text: devices.map((d) => this.deviceLine(d)).join("\n"),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* `get_device_location`: resolve the target (id, name fragment, or the
|
|
228
|
+
* most recent mobile device), push a locate to connected transports, wait
|
|
229
|
+
* briefly for a fresh fix, then fall back to the last persisted location.
|
|
230
|
+
*/
|
|
231
|
+
async locateDevice(query?: unknown): Promise<MeshToolResult> {
|
|
232
|
+
await this.load();
|
|
233
|
+
const target = this.chooseDevice(query);
|
|
234
|
+
if (!target) return this.noSuchDevice(query);
|
|
235
|
+
const requestedAt = Date.now();
|
|
236
|
+
// Only wait out the fresh-fix window when a transport can actually
|
|
237
|
+
// deliver the locate — otherwise answer from persistence immediately.
|
|
238
|
+
const dispatched = this.requestLocate(target.id);
|
|
239
|
+
const fresh = dispatched
|
|
240
|
+
? await this.waitForFreshLocation(target.id, requestedAt)
|
|
241
|
+
: undefined;
|
|
242
|
+
const loc = fresh ?? this.registry.getLocation(target.id);
|
|
243
|
+
if (!loc) {
|
|
244
|
+
return {
|
|
245
|
+
ok: false,
|
|
246
|
+
text: dispatched
|
|
247
|
+
? `No location is known for ${target.name}. A locate request was sent, but no fix arrived within ${Math.round(this.freshFixTimeoutMs / 1000)}s.`
|
|
248
|
+
: `No location is known for ${target.name}, and no companion transport is connected to request one.`,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
return { ok: true, text: this.locationSummary(target, loc) };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** `ring_device`: make the device sound/vibrate so it can be found. */
|
|
255
|
+
ringDevice(query?: unknown, message?: unknown): Promise<MeshToolResult> {
|
|
256
|
+
return this.commandTool(query, "ring", {
|
|
257
|
+
...(typeof message === "string" && message.trim()
|
|
258
|
+
? { message: message.trim().slice(0, 200) }
|
|
259
|
+
: {}),
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* `get_device_history`: the device's movement + battery over a window,
|
|
265
|
+
* computed from the fixes the daemon has been receiving all along —
|
|
266
|
+
* timeline, distance traveled, and battery trend.
|
|
267
|
+
*/
|
|
268
|
+
async deviceHistory(
|
|
269
|
+
query?: unknown,
|
|
270
|
+
hours?: unknown,
|
|
271
|
+
): Promise<MeshToolResult> {
|
|
272
|
+
await this.load();
|
|
273
|
+
const target = this.chooseDevice(query);
|
|
274
|
+
if (!target) return this.noSuchDevice(query);
|
|
275
|
+
const windowHours = clampHours(hours);
|
|
276
|
+
const sinceTs = Date.now() - windowHours * 3_600_000;
|
|
277
|
+
const fixes = this.registry.getHistory(target.id, sinceTs);
|
|
278
|
+
if (fixes.length === 0) {
|
|
279
|
+
return {
|
|
280
|
+
ok: true,
|
|
281
|
+
text: `No location reports from ${target.name} in the last ${windowHours}h. Enable periodic reporting in the companion app for a movement history.`,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
return { ok: true, text: this.historySummary(target, fixes, windowHours) };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** `get_device_status`: live telemetry straight from the device. */
|
|
288
|
+
getDeviceStatus(query?: unknown): Promise<MeshToolResult> {
|
|
289
|
+
return this.commandTool(query, "status", {});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Shared command-tool flow: resolve the target, check its advertised
|
|
294
|
+
* capabilities, require a transport, send, and render the device's answer.
|
|
295
|
+
*/
|
|
296
|
+
private async commandTool(
|
|
297
|
+
query: unknown,
|
|
298
|
+
name: string,
|
|
299
|
+
params: Record<string, unknown>,
|
|
300
|
+
): Promise<MeshToolResult> {
|
|
301
|
+
await this.load();
|
|
302
|
+
const target = this.chooseDevice(query);
|
|
303
|
+
if (!target) return this.noSuchDevice(query);
|
|
304
|
+
// Devices advertise what they can do; an explicit list that lacks the
|
|
305
|
+
// command is a clean "can't" — absent list means an older app build, so
|
|
306
|
+
// attempt it and let the timeout speak.
|
|
307
|
+
if (target.capabilities && !target.capabilities.includes(name)) {
|
|
308
|
+
return {
|
|
309
|
+
ok: false,
|
|
310
|
+
text: `${target.name} does not support "${name}" (supports: ${target.capabilities.join(", ")}).`,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
if (this.transports.size === 0) {
|
|
314
|
+
return {
|
|
315
|
+
ok: false,
|
|
316
|
+
text: `No companion transport is connected, so "${name}" cannot reach ${target.name}.`,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
const result = await this.sendCommand(target, name, params);
|
|
320
|
+
return {
|
|
321
|
+
ok: result.ok,
|
|
322
|
+
text: this.commandSummary(target, name, result),
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Resolve a device by exact id, name fragment, or default (most recently
|
|
327
|
+
* seen mobile device, falling back to the most recent device overall). */
|
|
328
|
+
chooseDevice(query?: unknown): DeviceInfo | undefined {
|
|
329
|
+
const devices = this.registry.list().devices;
|
|
330
|
+
if (typeof query === "string" && query.trim()) {
|
|
331
|
+
const q = query.trim().toLowerCase();
|
|
332
|
+
return devices.find(
|
|
333
|
+
(d) => d.id.toLowerCase() === q || d.name.toLowerCase().includes(q),
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
return devices.find((d) => MOBILE_PLATFORMS.has(d.platform)) ?? devices[0];
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ── Formatting ─────────────────────────────────────────────────────────────
|
|
340
|
+
|
|
341
|
+
private noSuchDevice(query: unknown): MeshToolResult {
|
|
342
|
+
const known = this.registry.list().devices;
|
|
343
|
+
return {
|
|
344
|
+
ok: false,
|
|
345
|
+
text:
|
|
346
|
+
typeof query === "string" && query.trim() && known.length > 0
|
|
347
|
+
? `No mesh device matches "${query.trim()}". Known devices:\n${known.map((d) => this.deviceLine(d)).join("\n")}`
|
|
348
|
+
: "No mesh devices are registered.",
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
private deviceLine(device: DeviceInfo): string {
|
|
353
|
+
const parts = [
|
|
354
|
+
`${device.name} [id: ${device.id}] (${device.platform})`,
|
|
355
|
+
device.online ? "online" : "offline",
|
|
356
|
+
`last seen ${age(Date.now() - device.lastSeen)}`,
|
|
357
|
+
];
|
|
358
|
+
if (typeof device.battery === "number") {
|
|
359
|
+
parts.push(`${device.battery}%${device.charging ? " charging" : ""}`);
|
|
360
|
+
}
|
|
361
|
+
const loc = this.registry.getLocation(device.id);
|
|
362
|
+
if (loc) {
|
|
363
|
+
parts.push(
|
|
364
|
+
`at ${loc.lat.toFixed(6)},${loc.lon.toFixed(6)} (fix ${age(Date.now() - loc.ts)})`,
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
if (device.capabilities?.length) {
|
|
368
|
+
parts.push(`can: ${device.capabilities.join(", ")}`);
|
|
369
|
+
}
|
|
370
|
+
return `- ${parts.join(" · ")}`;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
private locationSummary(device: DeviceInfo, loc: DeviceLocation): string {
|
|
374
|
+
const ageText = age(Date.now() - loc.ts);
|
|
375
|
+
const accuracy =
|
|
376
|
+
typeof loc.accuracyM === "number"
|
|
377
|
+
? ` Accuracy ${Math.round(loc.accuracyM)}m.`
|
|
378
|
+
: "";
|
|
379
|
+
return [
|
|
380
|
+
`${device.name} is at ${loc.lat.toFixed(6)}, ${loc.lon.toFixed(6)}.`,
|
|
381
|
+
`${accuracy} Fix age ${ageText}.`,
|
|
382
|
+
`Reverse-geocode pair: ${loc.lat},${loc.lon}`,
|
|
383
|
+
]
|
|
384
|
+
.join(" ")
|
|
385
|
+
.replace(/\s+/g, " ")
|
|
386
|
+
.trim();
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
private commandSummary(
|
|
390
|
+
device: DeviceInfo,
|
|
391
|
+
name: string,
|
|
392
|
+
result: DeviceCommandResult,
|
|
393
|
+
): string {
|
|
394
|
+
if (!result.ok) {
|
|
395
|
+
return result.message ?? `${device.name} rejected "${name}".`;
|
|
396
|
+
}
|
|
397
|
+
const detail = result.message ? ` ${result.message}` : "";
|
|
398
|
+
switch (name) {
|
|
399
|
+
case "ring":
|
|
400
|
+
return `${device.name} is ringing.${detail}`;
|
|
401
|
+
case "status": {
|
|
402
|
+
const fields = Object.entries(result.data ?? {})
|
|
403
|
+
.filter(([, v]) => v !== null && v !== undefined && v !== "")
|
|
404
|
+
.map(([k, v]) => `${k}: ${String(v)}`);
|
|
405
|
+
return fields.length
|
|
406
|
+
? `${device.name} status — ${fields.join(" · ")}`
|
|
407
|
+
: `${device.name} answered but reported no status fields.${detail}`;
|
|
408
|
+
}
|
|
409
|
+
default:
|
|
410
|
+
return (
|
|
411
|
+
result.message ??
|
|
412
|
+
(result.data
|
|
413
|
+
? `${device.name} answered: ${JSON.stringify(result.data)}`
|
|
414
|
+
: `${device.name} completed "${name}".`)
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Render a window of fixes: headline (count, span, distance, battery
|
|
420
|
+
* trend) plus a bounded, evenly-sampled timeline oldest-first. */
|
|
421
|
+
private historySummary(
|
|
422
|
+
device: DeviceInfo,
|
|
423
|
+
fixes: DeviceLocation[],
|
|
424
|
+
windowHours: number,
|
|
425
|
+
): string {
|
|
426
|
+
let distanceM = 0;
|
|
427
|
+
for (let i = 1; i < fixes.length; i++) {
|
|
428
|
+
distanceM += haversineM(fixes[i - 1], fixes[i]);
|
|
429
|
+
}
|
|
430
|
+
const batteries = fixes
|
|
431
|
+
.map((f) => f.batteryPct)
|
|
432
|
+
.filter((b): b is number => typeof b === "number");
|
|
433
|
+
const headline = [
|
|
434
|
+
`${device.name}: ${fixes.length} fix${fixes.length === 1 ? "" : "es"} in the last ${windowHours}h`,
|
|
435
|
+
`moved ~${formatDistance(distanceM)}`,
|
|
436
|
+
...(batteries.length >= 2
|
|
437
|
+
? [`battery ${batteries[0]}% → ${batteries[batteries.length - 1]}%`]
|
|
438
|
+
: []),
|
|
439
|
+
].join(" · ");
|
|
440
|
+
const lines = sampleEvenly(fixes, MAX_HISTORY_LINES).map((f) => {
|
|
441
|
+
const parts = [
|
|
442
|
+
`${formatWhen(f.ts)} — ${f.lat.toFixed(5)},${f.lon.toFixed(5)}`,
|
|
443
|
+
];
|
|
444
|
+
if (typeof f.accuracyM === "number")
|
|
445
|
+
parts.push(`±${Math.round(f.accuracyM)}m`);
|
|
446
|
+
if (typeof f.batteryPct === "number") parts.push(`${f.batteryPct}%`);
|
|
447
|
+
return `- ${parts.join(" · ")}`;
|
|
448
|
+
});
|
|
449
|
+
const omitted = fixes.length - Math.min(fixes.length, MAX_HISTORY_LINES);
|
|
450
|
+
return [
|
|
451
|
+
headline,
|
|
452
|
+
...lines,
|
|
453
|
+
...(omitted > 0
|
|
454
|
+
? [`(${omitted} more fixes omitted; timeline sampled evenly)`]
|
|
455
|
+
: []),
|
|
456
|
+
].join("\n");
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
private async waitForFreshLocation(
|
|
460
|
+
deviceId: string,
|
|
461
|
+
requestedAt: number,
|
|
462
|
+
): Promise<DeviceLocation | undefined> {
|
|
463
|
+
const existing = this.registry.getLocation(deviceId);
|
|
464
|
+
if (existing && existing.ts >= requestedAt) return existing;
|
|
465
|
+
const deadline = Date.now() + this.freshFixTimeoutMs;
|
|
466
|
+
while (Date.now() < deadline) {
|
|
467
|
+
const remaining = deadline - Date.now();
|
|
468
|
+
await new Promise<void>((resolve) => {
|
|
469
|
+
const done = () => {
|
|
470
|
+
clearTimeout(timer);
|
|
471
|
+
this.waiters.delete(done);
|
|
472
|
+
resolve();
|
|
473
|
+
};
|
|
474
|
+
const timer = setTimeout(
|
|
475
|
+
done,
|
|
476
|
+
Math.min(remaining, this.pollIntervalMs),
|
|
477
|
+
);
|
|
478
|
+
this.waiters.add(done);
|
|
479
|
+
});
|
|
480
|
+
const loc = this.registry.getLocation(deviceId);
|
|
481
|
+
if (loc && loc.ts >= requestedAt) return loc;
|
|
482
|
+
}
|
|
483
|
+
return undefined;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** Clamp the requested history window to 1..168 hours (default 24). */
|
|
488
|
+
function clampHours(value: unknown): number {
|
|
489
|
+
const n = typeof value === "number" ? value : Number(value);
|
|
490
|
+
if (!Number.isFinite(n) || n <= 0) return DEFAULT_HISTORY_HOURS;
|
|
491
|
+
return Math.min(168, Math.max(1, Math.round(n)));
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** Great-circle distance between two fixes in meters. */
|
|
495
|
+
function haversineM(a: DeviceLocation, b: DeviceLocation): number {
|
|
496
|
+
const R = 6_371_000;
|
|
497
|
+
const rad = (deg: number) => (deg * Math.PI) / 180;
|
|
498
|
+
const dLat = rad(b.lat - a.lat);
|
|
499
|
+
const dLon = rad(b.lon - a.lon);
|
|
500
|
+
const h =
|
|
501
|
+
Math.sin(dLat / 2) ** 2 +
|
|
502
|
+
Math.cos(rad(a.lat)) * Math.cos(rad(b.lat)) * Math.sin(dLon / 2) ** 2;
|
|
503
|
+
return 2 * R * Math.asin(Math.sqrt(h));
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function formatDistance(meters: number): string {
|
|
507
|
+
if (meters < 1_000) return `${Math.round(meters)}m`;
|
|
508
|
+
return `${(meters / 1_000).toFixed(1)}km`;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** Local wall-clock stamp for history lines (date + HH:MM). */
|
|
512
|
+
function formatWhen(ts: number): string {
|
|
513
|
+
const d = new Date(ts);
|
|
514
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
515
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** Up to `max` items spread evenly across the list, endpoints included. */
|
|
519
|
+
function sampleEvenly<T>(items: T[], max: number): T[] {
|
|
520
|
+
if (items.length <= max) return items;
|
|
521
|
+
const out: T[] = [];
|
|
522
|
+
for (let i = 0; i < max; i++) {
|
|
523
|
+
out.push(items[Math.round((i * (items.length - 1)) / (max - 1))]);
|
|
524
|
+
}
|
|
525
|
+
return out;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function age(ms: number): string {
|
|
529
|
+
const sec = Math.max(0, Math.round(ms / 1000));
|
|
530
|
+
if (sec < 60) return `${sec}s ago`;
|
|
531
|
+
const min = Math.round(sec / 60);
|
|
532
|
+
if (min < 60) return `${min}m ago`;
|
|
533
|
+
const hrs = Math.round(min / 60);
|
|
534
|
+
return `${hrs}h ago`;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// ── Process-wide instance ─────────────────────────────────────────────────────
|
|
538
|
+
|
|
539
|
+
let instance: MeshService | null = null;
|
|
540
|
+
|
|
541
|
+
/** The daemon's shared mesh service (lazily created). */
|
|
542
|
+
export function getMeshService(): MeshService {
|
|
543
|
+
instance ??= new MeshService();
|
|
544
|
+
return instance;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** Swap the shared instance — composition/test seam. Pass null to reset. */
|
|
548
|
+
export function setMeshService(service: MeshService | null): void {
|
|
549
|
+
instance = service;
|
|
550
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device-mesh domain types + normalizers.
|
|
3
|
+
*
|
|
4
|
+
* The canonical shapes for companion devices and their reported locations.
|
|
5
|
+
* They live in core (not a frontend) because the mesh is daemon-wide state:
|
|
6
|
+
* devices register through the native bridge transport, but the model reads
|
|
7
|
+
* them from ANY frontend via the shared `list_devices` /
|
|
8
|
+
* `get_device_location` gateway actions. The bridge protocol re-exports
|
|
9
|
+
* these types so the wire contract is unchanged.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export type DevicePlatform = "android" | "macos" | "windows" | "linux" | "ios";
|
|
13
|
+
|
|
14
|
+
export type DeviceInfo = {
|
|
15
|
+
id: string;
|
|
16
|
+
name: string;
|
|
17
|
+
platform: DevicePlatform;
|
|
18
|
+
appVersion: string;
|
|
19
|
+
online: boolean;
|
|
20
|
+
/** Epoch milliseconds. */
|
|
21
|
+
lastSeen: number;
|
|
22
|
+
/** Battery percent, 0-100. */
|
|
23
|
+
battery?: number;
|
|
24
|
+
charging?: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Command names this device can execute (e.g. "locate", "ring",
|
|
27
|
+
* "open_url"). Reported by the device at registration; absent for app
|
|
28
|
+
* versions that predate the command channel — treat as unknown, not
|
|
29
|
+
* empty.
|
|
30
|
+
*/
|
|
31
|
+
capabilities?: string[];
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* An on-demand command pushed to one companion device over the mesh
|
|
36
|
+
* transport. The device executes it and answers with a DeviceCommandResult
|
|
37
|
+
* carrying the same `id`.
|
|
38
|
+
*/
|
|
39
|
+
export type DeviceCommand = {
|
|
40
|
+
/** Correlation id — echoed back as the result's `commandId`. */
|
|
41
|
+
id: string;
|
|
42
|
+
deviceId: string;
|
|
43
|
+
/** Command name (e.g. "ring", "open_url", "clipboard_get", "status"). */
|
|
44
|
+
name: string;
|
|
45
|
+
params: Record<string, unknown>;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** A device's answer to a DeviceCommand. */
|
|
49
|
+
export type DeviceCommandResult = {
|
|
50
|
+
commandId: string;
|
|
51
|
+
deviceId: string;
|
|
52
|
+
ok: boolean;
|
|
53
|
+
/** Human-readable outcome (shown to the model). */
|
|
54
|
+
message?: string;
|
|
55
|
+
/** Structured payload (e.g. clipboard text, status fields). */
|
|
56
|
+
data?: Record<string, unknown>;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export type DeviceLocation = {
|
|
60
|
+
deviceId: string;
|
|
61
|
+
lat: number;
|
|
62
|
+
lon: number;
|
|
63
|
+
accuracyM?: number;
|
|
64
|
+
altitudeM?: number;
|
|
65
|
+
speedMps?: number;
|
|
66
|
+
headingDeg?: number;
|
|
67
|
+
/** Epoch milliseconds. */
|
|
68
|
+
ts: number;
|
|
69
|
+
provider?: string;
|
|
70
|
+
batteryPct?: number;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export function toDeviceInfo(
|
|
74
|
+
value: DeviceInfo,
|
|
75
|
+
now = Date.now(),
|
|
76
|
+
offlineAfterMs = 90_000,
|
|
77
|
+
): DeviceInfo {
|
|
78
|
+
const battery =
|
|
79
|
+
typeof value.battery === "number"
|
|
80
|
+
? Math.max(0, Math.min(100, Math.round(value.battery)))
|
|
81
|
+
: undefined;
|
|
82
|
+
return {
|
|
83
|
+
id: value.id,
|
|
84
|
+
name: value.name,
|
|
85
|
+
platform: value.platform,
|
|
86
|
+
appVersion: value.appVersion,
|
|
87
|
+
online: now - value.lastSeen <= offlineAfterMs && value.online,
|
|
88
|
+
lastSeen: value.lastSeen,
|
|
89
|
+
...(battery !== undefined ? { battery } : {}),
|
|
90
|
+
...(typeof value.charging === "boolean"
|
|
91
|
+
? { charging: value.charging }
|
|
92
|
+
: {}),
|
|
93
|
+
...(value.capabilities?.length
|
|
94
|
+
? { capabilities: [...value.capabilities] }
|
|
95
|
+
: {}),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Normalize a device's advertised capability list: strings only, trimmed,
|
|
101
|
+
* lowercased, deduped, and bounded so a misbehaving client can't bloat the
|
|
102
|
+
* persisted registry. Undefined when nothing valid remains.
|
|
103
|
+
*/
|
|
104
|
+
export function sanitizeCapabilities(value: unknown): string[] | undefined {
|
|
105
|
+
if (!Array.isArray(value)) return undefined;
|
|
106
|
+
const seen = new Set<string>();
|
|
107
|
+
for (const item of value) {
|
|
108
|
+
if (typeof item !== "string") continue;
|
|
109
|
+
const cap = item.trim().toLowerCase().slice(0, 32);
|
|
110
|
+
if (cap) seen.add(cap);
|
|
111
|
+
if (seen.size >= 16) break;
|
|
112
|
+
}
|
|
113
|
+
return seen.size ? [...seen] : undefined;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function toDeviceLocation(value: DeviceLocation): DeviceLocation {
|
|
117
|
+
return {
|
|
118
|
+
deviceId: value.deviceId,
|
|
119
|
+
lat: value.lat,
|
|
120
|
+
lon: value.lon,
|
|
121
|
+
...(typeof value.accuracyM === "number"
|
|
122
|
+
? { accuracyM: value.accuracyM }
|
|
123
|
+
: {}),
|
|
124
|
+
...(typeof value.altitudeM === "number"
|
|
125
|
+
? { altitudeM: value.altitudeM }
|
|
126
|
+
: {}),
|
|
127
|
+
...(typeof value.speedMps === "number" ? { speedMps: value.speedMps } : {}),
|
|
128
|
+
...(typeof value.headingDeg === "number"
|
|
129
|
+
? { headingDeg: value.headingDeg }
|
|
130
|
+
: {}),
|
|
131
|
+
ts: value.ts,
|
|
132
|
+
...(value.provider ? { provider: value.provider } : {}),
|
|
133
|
+
...(typeof value.batteryPct === "number"
|
|
134
|
+
? { batteryPct: Math.max(0, Math.min(100, Math.round(value.batteryPct))) }
|
|
135
|
+
: {}),
|
|
136
|
+
};
|
|
137
|
+
}
|
package/src/core/tools/index.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { skillTools } from "./skills.js";
|
|
|
21
21
|
import { webTools } from "./web.js";
|
|
22
22
|
import { adminTools } from "./admin.js";
|
|
23
23
|
import { modelTools } from "./models.js";
|
|
24
|
+
import { meshTools } from "./mesh.js";
|
|
24
25
|
|
|
25
26
|
/** All built-in tool definitions. */
|
|
26
27
|
export const ALL_TOOLS: readonly ToolDefinition[] = [
|
|
@@ -38,6 +39,7 @@ export const ALL_TOOLS: readonly ToolDefinition[] = [
|
|
|
38
39
|
...webTools,
|
|
39
40
|
...adminTools,
|
|
40
41
|
...modelTools,
|
|
42
|
+
...meshTools,
|
|
41
43
|
];
|
|
42
44
|
|
|
43
45
|
/**
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { ToolDefinition } from "./types.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Device-mesh tools. Deliberately NOT frontend-restricted: the mesh is
|
|
6
|
+
* daemon-wide state served by shared gateway actions, so the model can see,
|
|
7
|
+
* locate, and command companion devices from any chat surface.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Shared device-target parameter: id or name fragment; default = mobile. */
|
|
11
|
+
const deviceParam = z
|
|
12
|
+
.string()
|
|
13
|
+
.optional()
|
|
14
|
+
.describe(
|
|
15
|
+
"Device id or part of the device name (see list_devices). Defaults to the most recently seen mobile mesh device.",
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
export const meshTools: ToolDefinition[] = [
|
|
19
|
+
{
|
|
20
|
+
name: "list_devices",
|
|
21
|
+
description:
|
|
22
|
+
"List all Talon companion mesh devices with id, platform, online presence, last-seen age, battery state, last-known location, and supported commands.",
|
|
23
|
+
schema: {},
|
|
24
|
+
execute: (_params, bridge) => bridge("list_devices", {}),
|
|
25
|
+
tag: "mesh",
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: "get_device_location",
|
|
29
|
+
description:
|
|
30
|
+
"Get any Talon companion device's current or last-known location. Sends an on-demand locate request first, waits briefly for a fresh GPS fix, then falls back to last-known. With no device, uses the most recently seen mobile device.",
|
|
31
|
+
schema: { device: deviceParam },
|
|
32
|
+
execute: (params, bridge) => bridge("get_device_location", params),
|
|
33
|
+
tag: "mesh",
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: "get_device_history",
|
|
37
|
+
description:
|
|
38
|
+
"Movement and battery history for a Talon companion device: a timeline of its reported locations over a window (default 24h, max 168h), with distance traveled and battery trend. Answers questions like where a device was earlier or how fast its battery is draining.",
|
|
39
|
+
schema: {
|
|
40
|
+
device: deviceParam,
|
|
41
|
+
hours: z
|
|
42
|
+
.number()
|
|
43
|
+
.optional()
|
|
44
|
+
.describe("History window in hours (1-168, default 24)."),
|
|
45
|
+
},
|
|
46
|
+
execute: (params, bridge) => bridge("get_device_history", params),
|
|
47
|
+
tag: "mesh",
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: "ring_device",
|
|
51
|
+
description:
|
|
52
|
+
"Make a Talon companion device ring/vibrate so it can be found (find-my-phone). Optionally include a short message the device may display.",
|
|
53
|
+
schema: {
|
|
54
|
+
device: deviceParam,
|
|
55
|
+
message: z
|
|
56
|
+
.string()
|
|
57
|
+
.optional()
|
|
58
|
+
.describe("Optional short note to show on the device."),
|
|
59
|
+
},
|
|
60
|
+
execute: (params, bridge) => bridge("ring_device", params),
|
|
61
|
+
tag: "mesh",
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: "get_device_status",
|
|
65
|
+
description:
|
|
66
|
+
"Get live status straight from a Talon companion device: battery and charging state, platform/OS details, app version, and mesh sharing settings.",
|
|
67
|
+
schema: { device: deviceParam },
|
|
68
|
+
execute: (params, bridge) => bridge("get_device_status", params),
|
|
69
|
+
tag: "mesh",
|
|
70
|
+
},
|
|
71
|
+
];
|
package/src/core/tools/types.ts
CHANGED
|
@@ -52,6 +52,20 @@ function toButtons(rows: unknown): ClientButton[][] | undefined {
|
|
|
52
52
|
return mapped.length ? mapped : undefined;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/** Actions this handler owns. Everything else must return null so the
|
|
56
|
+
* gateway can try plugin + shared actions (history, web, cron, mesh…) —
|
|
57
|
+
* failing early here would swallow actions that aren't native's to answer. */
|
|
58
|
+
const NATIVE_ACTIONS = new Set([
|
|
59
|
+
"send_message",
|
|
60
|
+
"send_message_with_buttons",
|
|
61
|
+
"send_photo",
|
|
62
|
+
"react",
|
|
63
|
+
"edit_message",
|
|
64
|
+
"delete_message",
|
|
65
|
+
"get_chat_info",
|
|
66
|
+
"send_chat_action",
|
|
67
|
+
]);
|
|
68
|
+
|
|
55
69
|
export function createNativeActionHandler(
|
|
56
70
|
deps: NativeActionDeps,
|
|
57
71
|
): FrontendActionHandler {
|
|
@@ -59,6 +73,7 @@ export function createNativeActionHandler(
|
|
|
59
73
|
|
|
60
74
|
return async (body, chatId): Promise<ActionResult | null> => {
|
|
61
75
|
const action = typeof body.action === "string" ? body.action : "";
|
|
76
|
+
if (!NATIVE_ACTIONS.has(action)) return null;
|
|
62
77
|
const entry = chats.byNumeric(chatId);
|
|
63
78
|
if (!entry) return { ok: false, error: "No active native chat" };
|
|
64
79
|
|
|
@@ -126,7 +141,8 @@ export function createNativeActionHandler(
|
|
|
126
141
|
return { ok: true };
|
|
127
142
|
|
|
128
143
|
default:
|
|
129
|
-
//
|
|
144
|
+
// Unreachable — NATIVE_ACTIONS gates the switch — but keeps the
|
|
145
|
+
// contract explicit if the set and the cases ever drift.
|
|
130
146
|
return null;
|
|
131
147
|
}
|
|
132
148
|
};
|
|
@@ -66,6 +66,7 @@ import { NativeChats, DEFAULT_CHAT_TITLE, type ChatEntry } from "./chats.js";
|
|
|
66
66
|
import { extractSessionName } from "../../backend/shared/session-name.js";
|
|
67
67
|
import { BridgeServer, type BridgeServerHandlers } from "./server.js";
|
|
68
68
|
import { createNativeActionHandler } from "./actions.js";
|
|
69
|
+
import { getMeshService } from "../../core/mesh/index.js";
|
|
69
70
|
import { removeBridgeDiscovery, writeBridgeDiscovery } from "./discovery.js";
|
|
70
71
|
import { readLogEntries } from "./logs.js";
|
|
71
72
|
import {
|
|
@@ -148,6 +149,13 @@ export function createNativeFrontend(
|
|
|
148
149
|
const startedAt = new Date().toISOString();
|
|
149
150
|
const botName = config.botDisplayName || "Talon";
|
|
150
151
|
const chats = new NativeChats();
|
|
152
|
+
// Daemon-wide mesh service (core/mesh). This frontend is its transport:
|
|
153
|
+
// companions register/report/answer through the bridge routes below, and
|
|
154
|
+
// the SSE transport (wired in init) pushes locate requests and device
|
|
155
|
+
// commands out. The model's mesh tools are served by the shared gateway
|
|
156
|
+
// actions, so they work from every frontend.
|
|
157
|
+
const mesh = getMeshService();
|
|
158
|
+
let unregisterMeshTransport: (() => void) | null = null;
|
|
151
159
|
|
|
152
160
|
// Monotonic message-id minter. Seeded from the wall clock so ids stay
|
|
153
161
|
// unique and ascending across restarts (history rows persist their ids).
|
|
@@ -732,6 +740,7 @@ export function createNativeFrontend(
|
|
|
732
740
|
return {
|
|
733
741
|
app: "talon-bridge",
|
|
734
742
|
protocol: BRIDGE_PROTOCOL_VERSION,
|
|
743
|
+
capabilities: ["mesh", "mesh-commands"],
|
|
735
744
|
botName,
|
|
736
745
|
backend: config.backend,
|
|
737
746
|
model: resolveModel(config.model)?.displayName ?? config.model,
|
|
@@ -1161,6 +1170,12 @@ export function createNativeFrontend(
|
|
|
1161
1170
|
readLogEntries(files.log, { limit: lines, minLevel, component }),
|
|
1162
1171
|
liveTurnEvents,
|
|
1163
1172
|
mediaPath: (id) => media.get(id) ?? null,
|
|
1173
|
+
// Mesh routes are thin transport shims over the shared core service —
|
|
1174
|
+
// storeLocation wakes any pending fresh-fix waiters inside the service.
|
|
1175
|
+
registerDevice: (body) => mesh.register(body),
|
|
1176
|
+
storeLocation: (body) => mesh.storeLocation(body),
|
|
1177
|
+
listDevices: () => mesh.list(),
|
|
1178
|
+
completeCommand: (body) => mesh.completeCommand(body),
|
|
1164
1179
|
};
|
|
1165
1180
|
|
|
1166
1181
|
const nativeCfg = config.native ?? { port: 19880, host: "127.0.0.1" };
|
|
@@ -1202,6 +1217,24 @@ export function createNativeFrontend(
|
|
|
1202
1217
|
getBridgePort: () => gateway.getPort(),
|
|
1203
1218
|
|
|
1204
1219
|
async init() {
|
|
1220
|
+
await mesh.load();
|
|
1221
|
+
// Plug this bridge in as the mesh's transport: locates and device
|
|
1222
|
+
// commands (from ANY frontend's mesh tool calls) fan out to every
|
|
1223
|
+
// connected companion client as SSE events; each client filters by
|
|
1224
|
+
// its own device id.
|
|
1225
|
+
unregisterMeshTransport = mesh.registerTransport({
|
|
1226
|
+
locate: (deviceId) => broadcast({ kind: "locate", deviceId }),
|
|
1227
|
+
command: (command) =>
|
|
1228
|
+
broadcast({
|
|
1229
|
+
kind: "device_command",
|
|
1230
|
+
id: command.id,
|
|
1231
|
+
deviceId: command.deviceId,
|
|
1232
|
+
name: command.name,
|
|
1233
|
+
params: command.params,
|
|
1234
|
+
}),
|
|
1235
|
+
});
|
|
1236
|
+
// Mesh tool actions (list_devices / get_device_location) are shared
|
|
1237
|
+
// gateway actions now — no native-only cases here.
|
|
1205
1238
|
gateway.registerFrontendHandler(
|
|
1206
1239
|
"native",
|
|
1207
1240
|
createNativeActionHandler({
|
|
@@ -1231,6 +1264,8 @@ export function createNativeFrontend(
|
|
|
1231
1264
|
},
|
|
1232
1265
|
|
|
1233
1266
|
async stop() {
|
|
1267
|
+
unregisterMeshTransport?.();
|
|
1268
|
+
unregisterMeshTransport = null;
|
|
1234
1269
|
await removeBridgeDiscovery();
|
|
1235
1270
|
await server.stop();
|
|
1236
1271
|
await gateway.stop();
|
|
@@ -139,6 +139,8 @@ export type ClientChat = {
|
|
|
139
139
|
export type BridgeStatus = {
|
|
140
140
|
app: "talon-bridge";
|
|
141
141
|
protocol: number;
|
|
142
|
+
/** Additive bridge features supported by this daemon. */
|
|
143
|
+
capabilities?: string[];
|
|
142
144
|
botName: string;
|
|
143
145
|
backend: string;
|
|
144
146
|
/** Display name of the global default model. */
|
|
@@ -195,6 +197,18 @@ export type BackendOption = {
|
|
|
195
197
|
label: string;
|
|
196
198
|
};
|
|
197
199
|
|
|
200
|
+
// Mesh device shapes are canonical in core (the mesh is daemon-wide state,
|
|
201
|
+
// readable from every frontend); re-exported here so bridge clients keep
|
|
202
|
+
// depending on the protocol module alone.
|
|
203
|
+
export type {
|
|
204
|
+
DeviceCommand,
|
|
205
|
+
DeviceCommandResult,
|
|
206
|
+
DeviceInfo,
|
|
207
|
+
DeviceLocation,
|
|
208
|
+
DevicePlatform,
|
|
209
|
+
} from "../../core/mesh/types.js";
|
|
210
|
+
import type { DeviceCommand as MeshDeviceCommand } from "../../core/mesh/types.js";
|
|
211
|
+
|
|
198
212
|
/**
|
|
199
213
|
* Server → client events, delivered as SSE `data:` lines (one JSON object
|
|
200
214
|
* each). The client switches on `kind`. Streaming text arrives as ephemeral
|
|
@@ -233,6 +247,20 @@ export type BridgeEvent =
|
|
|
233
247
|
durationMs?: number;
|
|
234
248
|
usage?: { input: number; output: number };
|
|
235
249
|
}
|
|
250
|
+
| { kind: "locate"; deviceId?: string }
|
|
251
|
+
/**
|
|
252
|
+
* On-demand device command (ring, open_url, clipboard_*, status, …). The
|
|
253
|
+
* target device executes it and answers via POST /devices/command-result
|
|
254
|
+
* with the same `id` as `commandId`. Additive in v1 — app builds that
|
|
255
|
+
* predate the command channel simply ignore the event.
|
|
256
|
+
*/
|
|
257
|
+
| {
|
|
258
|
+
kind: "device_command";
|
|
259
|
+
id: string;
|
|
260
|
+
deviceId: string;
|
|
261
|
+
name: string;
|
|
262
|
+
params: MeshDeviceCommand["params"];
|
|
263
|
+
}
|
|
236
264
|
| { kind: "error"; chatId?: string; message: string };
|
|
237
265
|
|
|
238
266
|
// ── Mappers (Talon internals → wire types) ───────────────────────────────────
|
|
@@ -256,3 +284,5 @@ export function historyToClientMessage(
|
|
|
256
284
|
ts: m.timestamp,
|
|
257
285
|
};
|
|
258
286
|
}
|
|
287
|
+
|
|
288
|
+
export { toDeviceInfo, toDeviceLocation } from "../../core/mesh/types.js";
|
|
@@ -30,6 +30,8 @@ import {
|
|
|
30
30
|
type BridgeStatus,
|
|
31
31
|
type ClientChat,
|
|
32
32
|
type ClientMessage,
|
|
33
|
+
type DeviceInfo,
|
|
34
|
+
type DeviceLocation,
|
|
33
35
|
type LogEntry,
|
|
34
36
|
type LogLevel,
|
|
35
37
|
type ModelOption,
|
|
@@ -105,6 +107,16 @@ export type BridgeServerHandlers = {
|
|
|
105
107
|
liveTurnEvents(): BridgeEvent[];
|
|
106
108
|
/** Resolve a media id to an absolute file path (or null if unknown). */
|
|
107
109
|
mediaPath(id: string): string | null;
|
|
110
|
+
/** Register/update one mesh device. */
|
|
111
|
+
registerDevice(body: Record<string, unknown>): Promise<DeviceInfo>;
|
|
112
|
+
/** Store the last-known location for one mesh device. */
|
|
113
|
+
storeLocation(body: Record<string, unknown>): Promise<DeviceLocation>;
|
|
114
|
+
/** List mesh devices and their last-known locations. */
|
|
115
|
+
listDevices():
|
|
116
|
+
| { devices: DeviceInfo[]; locations: DeviceLocation[] }
|
|
117
|
+
| Promise<{ devices: DeviceInfo[]; locations: DeviceLocation[] }>;
|
|
118
|
+
/** A device answered a device_command; true when a call was waiting. */
|
|
119
|
+
completeCommand(body: Record<string, unknown>): boolean;
|
|
108
120
|
};
|
|
109
121
|
|
|
110
122
|
const SSE_PING_MS = 25_000;
|
|
@@ -256,6 +268,7 @@ export class BridgeServer {
|
|
|
256
268
|
backend: s.backend,
|
|
257
269
|
model: s.model,
|
|
258
270
|
activeChats: s.activeChats,
|
|
271
|
+
capabilities: ["mesh", "mesh-commands"],
|
|
259
272
|
});
|
|
260
273
|
}
|
|
261
274
|
|
|
@@ -356,6 +369,31 @@ export class BridgeServer {
|
|
|
356
369
|
return this.json(res, 202, { ok: true });
|
|
357
370
|
}
|
|
358
371
|
|
|
372
|
+
if (method === "POST" && path === "/devices/register") {
|
|
373
|
+
const body = await this.readJson(req);
|
|
374
|
+
const device = await this.handlers.registerDevice(body);
|
|
375
|
+
return this.json(res, 200, { ok: true, deviceId: device.id });
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (method === "POST" && path === "/location") {
|
|
379
|
+
const body = await this.readJson(req);
|
|
380
|
+
await this.handlers.storeLocation(body);
|
|
381
|
+
return this.json(res, 200, { ok: true });
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (method === "GET" && path === "/devices") {
|
|
385
|
+
return this.json(res, 200, await this.handlers.listDevices());
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (method === "POST" && path === "/devices/command-result") {
|
|
389
|
+
const body = await this.readJson(req);
|
|
390
|
+
// ok:false for a late/unknown correlation id — not an HTTP error,
|
|
391
|
+
// the device's POST was well-formed; nothing was waiting anymore.
|
|
392
|
+
return this.json(res, 200, {
|
|
393
|
+
ok: this.handlers.completeCommand(body),
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
359
397
|
if (method === "POST" && path === "/upload") {
|
|
360
398
|
const filename = url.searchParams.get("filename") ?? "upload";
|
|
361
399
|
const contentType =
|