Skip to content

Commit fc462d2

Browse files
Yerazeclaude
andauthored
feat(meshcore-vn): relay SendStatusReq(27) status requests to physical node (#3904) (#3991)
After a successful remote login through the MeshCore Virtual Node, the meshcore-flutter app's "status"/owner-info action sent SendStatusReq(27), which fell through to `default:` in `dispatchCommand` and returned `Err(UnsupportedCmd)` — so the action failed as if login had granted only guest access (issue #3904, second report #3975). Mirror the #3961 transaction pattern (Sent ack → async relay → correlated push): - Parse SendStatusReq `[27][pubkey:32]` (no reserved bytes, unlike SendTelemetryReq), verified against ripplebiz/MeshCore firmware. - Reply `Sent`, call `manager.requestNodeStatus(pubkey)`, then push StatusResponse(0x87) `[0x87][reserved][pubKeyPrefix:6][statusData]`, correlated by the remote's 6-byte pubkey prefix. - Re-encode the parsed `MeshCoreStatus` into the 48-byte `RepeaterStats` wire blob the app's decoder reads (meshcore.js `getStatus`). Firmware >=1.16 appends two counters (56 bytes) the companion status view does not parse, so the 48-byte prefix is exactly what the app renders. - Ungated (read-only follow-up to login, like SendTelemetryReq/SendTracePath). - On no-result / failure, emit nothing and let the app time out (matches real-node behaviour and the existing login/telemetry handlers). SendRawData(25) is intentionally NOT implemented here: firmware inspection proved it is a raw-transmit primitive (reply Ok, async uncorrelated 0x84/0x88 pushes), NOT the neighbours request the issue assumed. Neighbours actually flows via SendBinaryReq(50) -> BinaryResponse(0x8C). See PR body. Adds codec helpers `parseSendStatusReq`, `encodeStatusResponsePush`, `encodeRepeaterStatusData`, `PushCodes.StatusResponse`, and VN tests mirroring the SendTelemetryReq suite (happy path + correlation, no-result, manager-throws, bad-payload -> IllegalArg, ungated behaviour). Claude-Session: https://claude.ai/code/session_015AFBA76hsjqhsXe1BdnYub Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 1c8947c commit fc462d2

3 files changed

Lines changed: 267 additions & 1 deletion

File tree

src/server/meshcoreCompanionCodec.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ export const PushCodes = {
9292
SendConfirmed: 0x82,
9393
MsgWaiting: 0x83,
9494
LoginSuccess: 0x85,
95+
StatusResponse: 0x87,
9596
LogRxData: 0x88,
9697
TraceData: 0x89,
9798
TelemetryResponse: 0x8b,
@@ -242,6 +243,7 @@ export interface SetAdvertNameCmd { name: string; }
242243
export interface SendLoginCmd { publicKey: string; password: string; }
243244
export interface SendTracePathCmd { tag: number; auth: number; flags: number; path: Buffer; }
244245
export interface SendTelemetryReqCmd { publicKey: string; }
246+
export interface SendStatusReqCmd { publicKey: string; }
245247
export interface SetRadioParamsCmd { freq: number; bw: number; sf: number; cr: number; }
246248
export interface SetTxPowerCmd { power: number; }
247249
export interface SetAdvertLatLonCmd { lat: number; lon: number; }
@@ -306,6 +308,18 @@ export function parseSendTelemetryReq(payload: Buffer): SendTelemetryReqCmd {
306308
return { publicKey: payload.subarray(4, 36).toString('hex') };
307309
}
308310

311+
/**
312+
* SendStatusReq(27): `[code][publicKey:32]`. Unlike SendTelemetryReq there are
313+
* no reserved bytes — the firmware/meshcore.js layout is just the command byte
314+
* followed by the 32-byte target public key (see meshcore.js
315+
* `sendCommandSendStatusReq`). Returns the target public key as a lowercase hex
316+
* string for MeshCoreManager.requestNodeStatus.
317+
*/
318+
export function parseSendStatusReq(payload: Buffer): SendStatusReqCmd {
319+
if (payload.length < 1 + 32) throw new Error('SendStatusReq: short payload');
320+
return { publicKey: payload.subarray(1, 33).toString('hex') };
321+
}
322+
309323
/**
310324
* SetRadioParams(11): `[code][freq:u32LE][bw:u32LE][sf:u8][cr:u8]`.
311325
* Wire freq is kHz and bw is Hz; returned in MHz / kHz for MeshCoreManager.setRadio.
@@ -681,6 +695,72 @@ export function encodeTelemetryResponsePush(
681695
return Buffer.concat([b, Buffer.from(lppSensorData)]);
682696
}
683697

698+
/**
699+
* The 16-field repeater status blob the firmware pushes inside a StatusResponse
700+
* (see meshcore.js `getStatus` → `repeaterStats`). All fields little-endian.
701+
* Field names mirror MeshCoreManager's `MeshCoreStatus` so the manager's parsed
702+
* result can be re-serialized verbatim (the mapping is lossless — every wire
703+
* field has a `MeshCoreStatus` counterpart). Missing values encode as 0.
704+
*/
705+
export interface RepeaterStatusData {
706+
batteryMv?: number;
707+
queueLen?: number;
708+
noiseFloor?: number;
709+
lastRssi?: number;
710+
packetsRecv?: number;
711+
packetsSent?: number;
712+
airTimeSecs?: number;
713+
uptimeSecs?: number;
714+
sentFlood?: number;
715+
sentDirect?: number;
716+
recvFlood?: number;
717+
recvDirect?: number;
718+
errors?: number;
719+
lastSnr?: number;
720+
directDups?: number;
721+
floodDups?: number;
722+
}
723+
724+
/** Serialize the 48-byte repeater status blob (inverse of meshcore.js `getStatus`). */
725+
export function encodeRepeaterStatusData(s: RepeaterStatusData): Buffer {
726+
const b = Buffer.alloc(48);
727+
let o = 0;
728+
b.writeUInt16LE((s.batteryMv ?? 0) & 0xffff, o); o += 2; // batt_milli_volts
729+
b.writeUInt16LE((s.queueLen ?? 0) & 0xffff, o); o += 2; // curr_tx_queue_len
730+
b.writeInt16LE(clampInt16(s.noiseFloor ?? 0), o); o += 2; // noise_floor
731+
b.writeInt16LE(clampInt16(s.lastRssi ?? 0), o); o += 2; // last_rssi
732+
b.writeUInt32LE((s.packetsRecv ?? 0) >>> 0, o); o += 4; // n_packets_recv
733+
b.writeUInt32LE((s.packetsSent ?? 0) >>> 0, o); o += 4; // n_packets_sent
734+
b.writeUInt32LE((s.airTimeSecs ?? 0) >>> 0, o); o += 4; // total_air_time_secs
735+
b.writeUInt32LE((s.uptimeSecs ?? 0) >>> 0, o); o += 4; // total_up_time_secs
736+
b.writeUInt32LE((s.sentFlood ?? 0) >>> 0, o); o += 4; // n_sent_flood
737+
b.writeUInt32LE((s.sentDirect ?? 0) >>> 0, o); o += 4; // n_sent_direct
738+
b.writeUInt32LE((s.recvFlood ?? 0) >>> 0, o); o += 4; // n_recv_flood
739+
b.writeUInt32LE((s.recvDirect ?? 0) >>> 0, o); o += 4; // n_recv_direct
740+
b.writeUInt16LE((s.errors ?? 0) & 0xffff, o); o += 2; // err_events
741+
b.writeInt16LE(clampInt16(s.lastSnr ?? 0), o); o += 2; // last_snr
742+
b.writeUInt16LE((s.directDups ?? 0) & 0xffff, o); o += 2; // n_direct_dups
743+
b.writeUInt16LE((s.floodDups ?? 0) & 0xffff, o); // n_flood_dups (last field)
744+
return b;
745+
}
746+
747+
/**
748+
* Encode a StatusResponse(0x87) push — the repeater stats a remote node returned
749+
* for a SendStatusReq. Layout mirrors meshcore.js `onStatusResponsePush`:
750+
* `[0x87][reserved:1][pubKeyPrefix:6][statusData:rest]`. The app correlates the
751+
* push to its pending status request by the remote's 6-byte pubkey prefix.
752+
*/
753+
export function encodeStatusResponsePush(
754+
pubKeyPrefix: Buffer | Uint8Array,
755+
status: RepeaterStatusData,
756+
): Buffer {
757+
const head = Buffer.alloc(1 + 1 + 6);
758+
head[0] = PushCodes.StatusResponse;
759+
head[1] = 0; // reserved
760+
Buffer.from(pubKeyPrefix).copy(head, 2, 0, 6);
761+
return Buffer.concat([head, encodeRepeaterStatusData(status)]);
762+
}
763+
684764
/**
685765
* Encode a LogRxData(0x88) push — the node forwarding a raw received OTA packet
686766
* to the app. This is the diagnostic "packet feed" that tools like
@@ -745,6 +825,12 @@ function clampInt8(v: number): number {
745825
return Math.max(-128, Math.min(127, Math.trunc(v)));
746826
}
747827

828+
/** Clamp a number to the signed-16-bit range (for int16 wire fields). */
829+
function clampInt16(v: number): number {
830+
if (!Number.isFinite(v)) return 0;
831+
return Math.max(-32768, Math.min(32767, Math.trunc(v)));
832+
}
833+
748834
/** Convert a hex public-key string to a 32-byte buffer (tolerant of `0x`/odd input). */
749835
export function pubKeyHexToBytes(hex: string | undefined | null): Buffer {
750836
const out = Buffer.alloc(32);

src/server/meshcoreVirtualNodeServer.test.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,24 @@ class FakeManager extends EventEmitter implements MeshCoreVirtualNodeManager {
7272
loginToNodeMock = vi.fn().mockResolvedValue(true);
7373
tracePathRawMock = vi.fn().mockResolvedValue({ pathSnrs: [8, 12], lastSnr: 5.5, pathLen: 2, flags: 0 });
7474
requestRemoteTelemetryRawMock = vi.fn().mockResolvedValue(Buffer.from([0x01, 0x67, 0x00, 0xdc]));
75+
requestNodeStatusMock = vi.fn().mockResolvedValue({
76+
batteryMv: 4100,
77+
queueLen: 3,
78+
noiseFloor: -120,
79+
lastRssi: -85,
80+
packetsRecv: 1000,
81+
packetsSent: 900,
82+
airTimeSecs: 500,
83+
uptimeSecs: 86400,
84+
sentFlood: 100,
85+
sentDirect: 200,
86+
recvFlood: 300,
87+
recvDirect: 400,
88+
errors: 5,
89+
lastSnr: 24, // int16 quarter-dB raw value as the wire carries it
90+
directDups: 6,
91+
floodDups: 7,
92+
});
7593
isConnected() { return this.localNode !== null; }
7694
getLocalNode() { return this.localNode; }
7795
getContacts() { return this.contacts; }
@@ -109,6 +127,9 @@ class FakeManager extends EventEmitter implements MeshCoreVirtualNodeManager {
109127
requestRemoteTelemetryRaw(publicKey: string) {
110128
return this.requestRemoteTelemetryRawMock(publicKey) as Promise<Buffer | null>;
111129
}
130+
requestNodeStatus(publicKey: string) {
131+
return this.requestNodeStatusMock(publicKey) as Promise<Record<string, number> | null>;
132+
}
112133
emitMessage(msg: MeshCoreMessage) { this.emit('message', msg); }
113134
emitSendConfirmed(data: { ackCode: number; roundTripMs: number }) { this.emit('send_confirmed', data); }
114135
emitOtaPacket(data: { snr?: number | null; rssi?: number | null; raw_hex?: string | null }) {
@@ -890,3 +911,101 @@ describe('MeshCoreVirtualNodeServer — SendTelemetryReq relay (#3904)', () => {
890911
expect(manager.requestRemoteTelemetryRawMock).not.toHaveBeenCalled();
891912
});
892913
});
914+
915+
// SendStatusReq(27): reply Sent, then push StatusResponse(0x87) with the remote
916+
// key prefix + the 48-byte RepeaterStats blob re-encoded from the manager's
917+
// parsed status (#3904). Read-only follow-up to login → not gated.
918+
describe('MeshCoreVirtualNodeServer — SendStatusReq relay (#3904)', () => {
919+
let server: MeshCoreVirtualNodeServer;
920+
let client: TestClient;
921+
let manager: FakeManager;
922+
923+
const REMOTE_KEY = 'd2'.repeat(32);
924+
const REMOTE_KEY_BYTES = Buffer.from(REMOTE_KEY, 'hex');
925+
926+
async function startWith(allowAdminCommands: boolean): Promise<void> {
927+
manager = new FakeManager();
928+
server = new MeshCoreVirtualNodeServer({ port: 0, manager, allowAdminCommands, databaseService: CHANNELS_DB });
929+
await server.start();
930+
client = new TestClient();
931+
await client.connect(server.getListeningPort()!);
932+
}
933+
afterEach(async () => { client?.close(); await server?.stop(); });
934+
935+
// [27][publicKey:32] — no reserved bytes (unlike SendTelemetryReq).
936+
function statusFrame(publicKeyHex: string): number[] {
937+
return [CommandCodes.SendStatusReq, ...Buffer.from(publicKeyHex, 'hex')];
938+
}
939+
940+
it('replies Sent then pushes StatusResponse with key prefix + the 48-byte stats blob', async () => {
941+
await startWith(false); // ungated
942+
const frames = client.expectFrames(2);
943+
client.send(statusFrame(REMOTE_KEY));
944+
const [sent, push] = await frames;
945+
946+
expect(sent[0]).toBe(ResponseCodes.Sent);
947+
// [0x87][reserved:1][pubKeyPrefix:6][statusData:48]
948+
expect(push[0]).toBe(PushCodes.StatusResponse);
949+
expect(push[1]).toBe(0); // reserved
950+
expect(push.subarray(2, 8)).toEqual(REMOTE_KEY_BYTES.subarray(0, 6));
951+
expect(push.length).toBe(1 + 1 + 6 + 48);
952+
953+
// Spot-check the little-endian RepeaterStats layout (offsets relative to
954+
// the statusData start at byte 8).
955+
const s = push.subarray(8);
956+
expect(s.readUInt16LE(0)).toBe(4100); // batt_milli_volts
957+
expect(s.readUInt16LE(2)).toBe(3); // curr_tx_queue_len
958+
expect(s.readInt16LE(4)).toBe(-120); // noise_floor
959+
expect(s.readInt16LE(6)).toBe(-85); // last_rssi
960+
expect(s.readUInt32LE(8)).toBe(1000); // n_packets_recv
961+
expect(s.readUInt32LE(12)).toBe(900); // n_packets_sent
962+
expect(s.readUInt32LE(16)).toBe(500); // total_air_time_secs
963+
expect(s.readUInt32LE(20)).toBe(86400); // total_up_time_secs
964+
expect(s.readUInt16LE(40)).toBe(5); // err_events
965+
expect(s.readInt16LE(42)).toBe(24); // last_snr
966+
expect(s.readUInt16LE(44)).toBe(6); // n_direct_dups
967+
expect(s.readUInt16LE(46)).toBe(7); // n_flood_dups
968+
expect(manager.requestNodeStatusMock).toHaveBeenCalledWith(REMOTE_KEY);
969+
});
970+
971+
it('relays status even when allowAdminCommands is off (read-only follow-up to login)', async () => {
972+
await startWith(true);
973+
const frames = client.expectFrames(2);
974+
client.send(statusFrame(REMOTE_KEY));
975+
const [sent, push] = await frames;
976+
expect(sent[0]).toBe(ResponseCodes.Sent);
977+
expect(push[0]).toBe(PushCodes.StatusResponse);
978+
});
979+
980+
it('replies Sent but pushes nothing when the status request returns null', async () => {
981+
await startWith(false);
982+
manager.requestNodeStatusMock.mockResolvedValueOnce(null);
983+
const sent = await client.request(statusFrame(REMOTE_KEY));
984+
expect(sent[0]).toBe(ResponseCodes.Sent);
985+
const second = await Promise.race([
986+
client.expectFrames(1).then((f) => f[0]),
987+
new Promise<null>((r) => setTimeout(() => r(null), 100)),
988+
]);
989+
expect(second).toBeNull();
990+
});
991+
992+
it('replies Sent but pushes nothing when the manager throws', async () => {
993+
await startWith(false);
994+
manager.requestNodeStatusMock.mockRejectedValueOnce(new Error('bridge down'));
995+
const sent = await client.request(statusFrame(REMOTE_KEY));
996+
expect(sent[0]).toBe(ResponseCodes.Sent);
997+
const second = await Promise.race([
998+
client.expectFrames(1).then((f) => f[0]),
999+
new Promise<null>((r) => setTimeout(() => r(null), 100)),
1000+
]);
1001+
expect(second).toBeNull();
1002+
});
1003+
1004+
it('replies Err(IllegalArg) on a short SendStatusReq payload, without querying the node', async () => {
1005+
await startWith(false);
1006+
const res = await client.request([CommandCodes.SendStatusReq, 1, 2, 3]); // < 33 bytes
1007+
expect(res[0]).toBe(ResponseCodes.Err);
1008+
expect(res[1]).toBe(ErrorCodes.IllegalArg);
1009+
expect(manager.requestNodeStatusMock).not.toHaveBeenCalled();
1010+
});
1011+
});

src/server/meshcoreVirtualNodeServer.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Server, Socket } from 'net';
22
import { EventEmitter } from 'events';
33
import { logger } from '../utils/logger.js';
44
import databaseService from '../services/database.js';
5-
import type { MeshCoreNode, TelemetryMode, MeshCoreContact, MeshCoreMessage } from './meshcoreManager.js';
5+
import type { MeshCoreNode, TelemetryMode, MeshCoreContact, MeshCoreMessage, MeshCoreStatus } from './meshcoreManager.js';
66
import {
77
CommandCodes,
88
ErrorCodes,
@@ -45,6 +45,8 @@ import {
4545
parseSendLogin,
4646
parseSendTracePath,
4747
parseSendTelemetryReq,
48+
parseSendStatusReq,
49+
encodeStatusResponsePush,
4850
type ParsedCommand,
4951
} from './meshcoreCompanionCodec.js';
5052

@@ -108,6 +110,13 @@ export interface MeshCoreVirtualNodeManager {
108110
* SendTelemetryReq (issue #3904).
109111
*/
110112
requestRemoteTelemetryRaw(publicKey: string): Promise<Buffer | null>;
113+
/**
114+
* Request operational status from a remote node (repeater/room server) and
115+
* return the parsed stats, or null on failure. Used to relay the app's
116+
* SendStatusReq (issue #3904); the parsed fields are re-serialized to the
117+
* wire status blob for the StatusResponse push.
118+
*/
119+
requestNodeStatus(publicKey: string): Promise<MeshCoreStatus | null>;
111120
/** EventEmitter surface — the manager emits 'message' with a MeshCoreMessage. */
112121
on(event: 'message', listener: (msg: MeshCoreMessage) => void): unknown;
113122
off(event: 'message', listener: (msg: MeshCoreMessage) => void): unknown;
@@ -455,6 +464,12 @@ export class MeshCoreVirtualNodeServer extends EventEmitter {
455464
// Remote telemetry request (issue #3904). Read-only — not gated.
456465
void this.handleSendTelemetryReq(clientId, command);
457466
break;
467+
case CommandCodes.SendStatusReq:
468+
// Remote status/owner-info request (issue #3904). Read-only follow-up
469+
// to a login — not gated on allowAdminCommands (the real node answers
470+
// a status request based on the session, not on our admin flag).
471+
void this.handleSendStatusReq(clientId, command);
472+
break;
458473
// Config-mutating commands (issue #3904): forwarded to the real node
459474
// only when `allowAdminCommands` is enabled; otherwise the app gets an
460475
// explicit Err (UnsupportedCmd) instead of a silent hang.
@@ -585,6 +600,7 @@ export class MeshCoreVirtualNodeServer extends EventEmitter {
585600
private readonly LOGIN_EST_TIMEOUT_MS = 12000;
586601
private readonly TRACE_EST_TIMEOUT_MS = 30000;
587602
private readonly TELEMETRY_EST_TIMEOUT_MS = 30000;
603+
private readonly STATUS_EST_TIMEOUT_MS = 30000;
588604

589605
/**
590606
* SendLogin(26): authenticate the physical node to a remote node with a
@@ -692,6 +708,51 @@ export class MeshCoreVirtualNodeServer extends EventEmitter {
692708
}
693709
}
694710

711+
/**
712+
* SendStatusReq(27): request operational status (repeater stats / owner info)
713+
* from a remote node and relay it (issue #3904). The app's flow — verified
714+
* against ripplebiz/MeshCore firmware — is Sent → StatusResponse(0x87) push
715+
* correlated by the remote's 6-byte pubkey prefix, so we reply Sent, fetch the
716+
* status from the real node, then re-serialize it into the wire status blob and
717+
* push it. On failure we emit nothing and let the app time out (mirrors real-
718+
* node behaviour, where a failed status request produces no push).
719+
*
720+
* Not gated on allowAdminCommands — this is a read-only follow-up to login,
721+
* like SendTelemetryReq; the real node answers based on the session, not our
722+
* admin flag.
723+
*
724+
* Note on the status blob: `manager.requestNodeStatus` returns the parsed
725+
* `MeshCoreStatus`, which we re-encode to the 48-byte `RepeaterStats` layout
726+
* the app's decoder reads (meshcore.js `getStatus`). Firmware ≥1.16 appends
727+
* two extra counters (total_rx_air_time_secs, n_recv_errors → 56 bytes) that
728+
* the companion-protocol status view does not parse, so the 48-byte prefix is
729+
* exactly what the app renders.
730+
*/
731+
private async handleSendStatusReq(clientId: string, command: ParsedCommand): Promise<void> {
732+
let parsed;
733+
try {
734+
parsed = parseSendStatusReq(command.payload);
735+
} catch (err) {
736+
logger.warn(`[MeshCore VN ${this.sourceId}] SendStatusReq bad payload from ${clientId}: ${(err as Error).message}`);
737+
this.send(clientId, encodeErr(ErrorCodes.IllegalArg));
738+
return;
739+
}
740+
const keyShort = parsed.publicKey.substring(0, 12);
741+
this.send(clientId, encodeSent(0, 0, this.STATUS_EST_TIMEOUT_MS));
742+
try {
743+
const status = await this.options.manager.requestNodeStatus(parsed.publicKey);
744+
if (!status) {
745+
logger.info(`[MeshCore VN ${this.sourceId}] SendStatusReq to ${keyShort}… from ${clientId} got no status`);
746+
return;
747+
}
748+
const prefix = pubKeyHexToBytes(parsed.publicKey).subarray(0, 6);
749+
this.send(clientId, encodeStatusResponsePush(prefix, status));
750+
logger.info(`[MeshCore VN ${this.sourceId}] SendStatusReq to ${keyShort}… from ${clientId} → status relayed`);
751+
} catch (err) {
752+
logger.warn(`[MeshCore VN ${this.sourceId}] SendStatusReq to ${keyShort}… from ${clientId} failed: ${(err as Error).message}`);
753+
}
754+
}
755+
695756
private handleAppStart(clientId: string, command: ParsedCommand): void {
696757
const localNode = this.options.manager.getLocalNode();
697758
if (!localNode || !this.options.manager.isConnected()) {

0 commit comments

Comments
 (0)