Telemetry stream: GET /api/telemetry + MCP get_telemetry (#16)
CI / build + unit (push) Successful in 21s

This commit was merged in pull request #16.
This commit is contained in:
2026-09-24 23:56:11 +02:00
parent ba5e60daf0
commit 0571c7095e
11 changed files with 286 additions and 6 deletions
+116
View File
@@ -10,6 +10,7 @@ import {
moduleConfigSectionFrame,
nodeInfoFrame,
startBridge,
telemetryPacketFrame,
waitFor,
} from './fake-bridge.js';
import { MeshService } from './mesh.service.js';
@@ -560,6 +561,121 @@ describe('MeshService message history', () => {
});
});
describe('MeshService telemetry', () => {
it('captures device metrics and environment variants with from/kind/data', async () => {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = String(bridge.port);
const svc = new MeshService();
try {
svc.onModuleInit();
await waitFor(
() => bridge.sockets.length === 1 && svc.status().connected,
);
expect(svc.telemetry()).toEqual([]); // nothing before the first packet
bridge.sockets[0].write(
telemetryPacketFrame(KAMIL_NUM, {
case: 'deviceMetrics',
value: { batteryLevel: 87, voltage: 4.1, uptimeSeconds: 3600 },
}),
);
bridge.sockets[0].write(
telemetryPacketFrame(987654321, {
case: 'environmentMetrics',
value: { temperature: 21.5, relativeHumidity: 48 },
}),
);
await waitFor(() => svc.telemetry().length === 2);
expect(svc.telemetry()).toEqual([
{
from: 987654321, // newest first
at: expect.any(Number),
kind: 'environmentMetrics',
// the deprecated repeated one_wire_temperature field decodes as an empty list even when unset
data: {
temperature: 21.5,
relativeHumidity: 48,
oneWireTemperature: [],
},
},
{
from: KAMIL_NUM,
at: expect.any(Number),
kind: 'deviceMetrics',
// proto float is IEEE-754 single precision — 4.1 widens to this exact double
data: {
batteryLevel: 87,
voltage: 4.099999904632568,
uptimeSeconds: 3600,
},
},
]);
} finally {
svc.onModuleDestroy();
bridge.close();
}
});
it('caps telemetry at 100 records, dropping the oldest', async () => {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = String(bridge.port);
const svc = new MeshService();
try {
svc.onModuleInit();
await waitFor(
() => bridge.sockets.length === 1 && svc.status().connected,
);
for (let i = 0; i < 105; i++)
bridge.sockets[0].write(
telemetryPacketFrame(42, {
case: 'deviceMetrics',
value: { batteryLevel: i },
}),
);
await waitFor(() => svc.telemetry().length === 100, 10000);
expect(svc.telemetry()[0]?.data).toEqual({ batteryLevel: 104 }); // newest first
expect(svc.telemetry()[99]?.data).toEqual({ batteryLevel: 5 }); // i=0..4 dropped
} finally {
svc.onModuleDestroy();
bridge.close();
}
});
it('resets telemetry on reconnect (no stale data)', async () => {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = String(bridge.port);
process.env.MESH_RECONNECT_MS = '50';
const svc = new MeshService();
try {
svc.onModuleInit();
await waitFor(
() => bridge.sockets.length === 1 && svc.status().connected,
);
bridge.sockets[0].write(
telemetryPacketFrame(KAMIL_NUM, { case: 'deviceMetrics', value: {} }),
);
await waitFor(() => svc.telemetry().length === 1);
// drop the connection; bot reconnects and must start with an empty history
bridge.sockets[0].destroy();
await waitFor(() => bridge.sockets.length >= 2 && svc.status().connected);
await waitFor(() => svc.telemetry().length === 0);
} finally {
delete process.env.MESH_RECONNECT_MS;
svc.onModuleDestroy();
bridge.close();
}
});
});
describe('MeshService resolveDest', () => {
it('resolves broadcast sentinels, numbers and names to node numbers', async () => {
const bridge = await startBridge();