Skip to content

Commit 33226ec

Browse files
Yerazeclaude
andauthored
feat(meshcore-vn): relay GetNeighbours via SendBinaryReq(50) to physical node (#3904) (#3993)
Final gap of issue #3904: after login through the MeshCore Virtual Node, the meshcore-flutter app's "neighbours" action fell through to `default:` in `dispatchCommand` and returned Err(UnsupportedCmd). Corrects the record: neighbours is NOT SendRawData(25) (the issue's inference). It is SendBinaryReq(50) — a GENERIC binary-request command dispatched on an inner sub-type — with sub-type GetNeighbours(0x06), answered by an async BinaryResponse(0x8C) push. Verified against the vendored reference lib `@liamcottle/meshcore.js` (`getNeighbours`, `sendCommandSendBinaryReq`, `onBinaryResponsePush`, `sendBinaryRequest`), the SAME companion protocol meshcore-flutter speaks. Confirmed protocol: - App→VN: `[50][targetPubkey:32][reqData]`; for neighbours `reqData = [0x06][request_version=0][count][offset:u16LE][order_by]` `[pubkey_prefix_len][random_tag:u32LE]`. - VN→App: async BinaryResponse(0x8C) `[0x8C][reserved:1][tag:u32LE]` `[totalCount:u16LE][resultsCount:u16LE]` then resultsCount × `[prefix:pubkey_prefix_len][heardSecondsAgo:u32LE][snr:int8=snr*4]`. - Tag correlation: the client matches the push by `BinaryResponse.tag == Sent.expectedAckCrc` (NOT the request's random_tag), so we echo the request's random_tag into BOTH the Sent's expectedAckCrc and the BinaryResponse tag. Implementation (mirrors the #3961/#3991 Sent-ack → async-push template): - `dispatchCommand` → `handleSendBinaryReq`: parse envelope, dispatch on the inner sub-type. GetNeighbours(0x06) implemented; any other sub-type gets an explicit Err(UnsupportedCmd) so future ones are obvious, not silently dropped. - `handleGetNeighboursReq`: reply Sent (tag echoed), call `manager.getNeighbours(publicKey, {count, offset, orderBy})`, push a BinaryResponse re-serialized with the requested pubkey_prefix_len stride and snr as round(snr*4) int8. - Ungated by allowAdminCommands — read-only follow-up to login, like SendTelemetryReq/SendStatusReq (the real node answers based on the session). - Graceful: bad envelope/inner blob → Err(IllegalArg); null result (non-repeater / no session / disconnected) → empty BinaryResponse (client tolerates it); manager throw → log and emit nothing (app times out, matching siblings). Codec: adds `parseSendBinaryReq`, `parseGetNeighboursReq`, `encodeBinaryResponsePush`, `encodeNeighboursPayload`, `PushCodes.BinaryResponse = 0x8C`, and `BinaryRequestTypes`. VALIDATED END-TO-END against a live repeater: drove the real meshcore.js client over TCP against the VN on :5000, handshook, and called `getNeighbours('661b0674…')` — the VN relayed 16-total/10-returned real neighbours from the physical node, tag-correlated (Sent expectedAckCrc == BinaryResponse tag). Tests mirror the sibling VN suites. Claude-Session: https://claude.ai/code/session_015AFBA76hsjqhsXe1BdnYub Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent ec8316a commit 33226ec

3 files changed

Lines changed: 414 additions & 0 deletions

File tree

src/server/meshcoreCompanionCodec.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,17 @@ export const PushCodes = {
9696
LogRxData: 0x88,
9797
TraceData: 0x89,
9898
TelemetryResponse: 0x8b,
99+
BinaryResponse: 0x8c,
100+
} as const;
101+
102+
/**
103+
* Inner sub-type of a SendBinaryReq(50) generic binary request (mirrors
104+
* meshcore.js `Constants.BinaryRequestTypes`, which mirror the firmware
105+
* `REQ_TYPE_*` defines). `req_data[0]` selects the request.
106+
*/
107+
export const BinaryRequestTypes = {
108+
GetTelemetryData: 0x03, // REQ_TYPE_GET_TELEMETRY_DATA
109+
GetNeighbours: 0x06, // REQ_TYPE_GET_NEIGHBOURS
99110
} as const;
100111

101112
/** Error codes carried by an Err(1) response. */
@@ -244,6 +255,29 @@ export interface SendLoginCmd { publicKey: string; password: string; }
244255
export interface SendTracePathCmd { tag: number; auth: number; flags: number; path: Buffer; }
245256
export interface SendTelemetryReqCmd { publicKey: string; }
246257
export interface SendStatusReqCmd { publicKey: string; }
258+
/**
259+
* A decoded SendBinaryReq(50): the target public key plus the inner request
260+
* blob. `reqType` is `reqData[0]` (a `BinaryRequestTypes` value); the caller
261+
* dispatches on it and re-parses `reqData` with the matching sub-parser.
262+
*/
263+
export interface SendBinaryReqCmd { publicKey: string; reqType: number; reqData: Buffer; }
264+
/** Parsed GetNeighbours(0x06) request parameters (inner sub-type of SendBinaryReq). */
265+
export interface GetNeighboursReq {
266+
count: number;
267+
offset: number;
268+
orderBy: number;
269+
pubkeyPrefixLen: number;
270+
/** The app's random blob (`req_data[7..10]`); echoed back as the correlation tag. */
271+
tag: number;
272+
}
273+
/** One neighbour entry for the GetNeighbours response payload. */
274+
export interface NeighbourEntry {
275+
/** Public-key prefix as a lowercase hex string (may be longer than requested). */
276+
publicKeyPrefix: string;
277+
heardSecondsAgo: number;
278+
/** SNR in dB (already /4, as MeshCoreManager returns it). */
279+
snr: number;
280+
}
247281
export interface SetRadioParamsCmd { freq: number; bw: number; sf: number; cr: number; }
248282
export interface SetTxPowerCmd { power: number; }
249283
export interface SetAdvertLatLonCmd { lat: number; lon: number; }
@@ -320,6 +354,44 @@ export function parseSendStatusReq(payload: Buffer): SendStatusReqCmd {
320354
return { publicKey: payload.subarray(1, 33).toString('hex') };
321355
}
322356

357+
/**
358+
* SendBinaryReq(50): `[code][targetPublicKey:32][reqData…]` (see meshcore.js
359+
* `sendCommandSendBinaryReq`). A generic binary-request envelope; the inner
360+
* `reqData[0]` byte selects the request (a `BinaryRequestTypes` value). Returns
361+
* the target public key (lowercase hex), the sub-type, and the full inner blob
362+
* for the matching sub-parser. Requires at least one inner byte so `reqType` is
363+
* always defined.
364+
*/
365+
export function parseSendBinaryReq(payload: Buffer): SendBinaryReqCmd {
366+
if (payload.length < 1 + 32 + 1) throw new Error('SendBinaryReq: short payload');
367+
const reqData = Buffer.from(payload.subarray(33));
368+
return {
369+
publicKey: payload.subarray(1, 33).toString('hex'),
370+
reqType: reqData[0],
371+
reqData,
372+
};
373+
}
374+
375+
/**
376+
* Parse the GetNeighbours(0x06) inner request blob (mirrors meshcore.js
377+
* `getNeighbours`):
378+
* `[type:u8][request_version:u8][count:u8][offset:u16LE][order_by:u8]`
379+
* `[pubkey_prefix_len:u8][random_tag:u32LE]` — 11 bytes.
380+
* `random_tag` is the app's correlation blob (echoed back as the BinaryResponse
381+
* tag). Throws on a short buffer so the dispatcher can reply Err(IllegalArg).
382+
*/
383+
export function parseGetNeighboursReq(reqData: Buffer): GetNeighboursReq {
384+
if (reqData.length < 1 + 1 + 1 + 2 + 1 + 1 + 4) throw new Error('GetNeighbours: short payload');
385+
return {
386+
// reqData[0] = type, reqData[1] = request_version (ignored)
387+
count: reqData[2],
388+
offset: reqData.readUInt16LE(3),
389+
orderBy: reqData[5],
390+
pubkeyPrefixLen: reqData[6],
391+
tag: reqData.readUInt32LE(7),
392+
};
393+
}
394+
323395
/**
324396
* SetRadioParams(11): `[code][freq:u32LE][bw:u32LE][sf:u8][cr:u8]`.
325397
* Wire freq is kHz and bw is Hz; returned in MHz / kHz for MeshCoreManager.setRadio.
@@ -761,6 +833,58 @@ export function encodeStatusResponsePush(
761833
return Buffer.concat([head, encodeRepeaterStatusData(status)]);
762834
}
763835

836+
/**
837+
* Encode a BinaryResponse(0x8C) push — the reply to a SendBinaryReq the app
838+
* initiated. Layout mirrors meshcore.js `onBinaryResponsePush`:
839+
* `[0x8C][reserved:1][tag:u32LE][responseData:rest]`.
840+
*
841+
* The client correlates this push to its pending request by comparing `tag` to
842+
* the `expectedAckCrc` it received in the preceding Sent(6) response (see
843+
* meshcore.js `sendBinaryRequest`), so the caller MUST have echoed this same
844+
* `tag` value into that Sent frame.
845+
*/
846+
export function encodeBinaryResponsePush(tag: number, responseData: Buffer | Uint8Array): Buffer {
847+
const head = Buffer.alloc(1 + 1 + 4);
848+
head[0] = PushCodes.BinaryResponse;
849+
head[1] = 0; // reserved
850+
head.writeUInt32LE(tag >>> 0, 2);
851+
return Buffer.concat([head, Buffer.from(responseData)]);
852+
}
853+
854+
/**
855+
* Serialize the GetNeighbours(0x06) response payload (inverse of meshcore.js
856+
* `getNeighbours`'s response decode):
857+
* `[totalCount:u16LE][resultsCount:u16LE]` then `resultsCount` ×
858+
* `[prefix:pubkeyPrefixLen][heardSecondsAgo:u32LE][snr:int8]`.
859+
*
860+
* - `pubkeyPrefixLen` sets the per-entry prefix STRIDE and MUST equal the value
861+
* the app sent in its request (it slices exactly that many bytes back out).
862+
* Each entry's hex prefix is truncated/zero-padded to that length; the manager
863+
* fetches an 8-byte prefix, so requests for a longer prefix are zero-padded.
864+
* - `snr` is a dB value; the wire carries `snr×4` (int8), matching how the app
865+
* divides the byte by 4 on decode.
866+
*/
867+
export function encodeNeighboursPayload(
868+
total: number,
869+
neighbours: NeighbourEntry[],
870+
pubkeyPrefixLen: number,
871+
): Buffer {
872+
const stride = Math.max(0, pubkeyPrefixLen);
873+
const b = Buffer.alloc(2 + 2 + neighbours.length * (stride + 4 + 1));
874+
let o = 0;
875+
b.writeUInt16LE(total & 0xffff, o); o += 2;
876+
b.writeUInt16LE(neighbours.length & 0xffff, o); o += 2;
877+
for (const n of neighbours) {
878+
const prefixBytes = pubKeyHexToBytes(n.publicKeyPrefix); // hex → bytes (zero-fills short/undefined)
879+
const prefix = Buffer.alloc(stride); // zero-padded to the requested stride
880+
prefixBytes.copy(prefix, 0, 0, Math.min(stride, prefixBytes.length));
881+
prefix.copy(b, o); o += stride;
882+
b.writeUInt32LE((n.heardSecondsAgo ?? 0) >>> 0, o); o += 4;
883+
b.writeInt8(clampInt8(Math.round((n.snr ?? 0) * 4)), o); o += 1;
884+
}
885+
return b;
886+
}
887+
764888
/**
765889
* Encode a LogRxData(0x88) push — the node forwarding a raw received OTA packet
766890
* to the app. This is the diagnostic "packet feed" that tools like

src/server/meshcoreVirtualNodeServer.test.ts

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
ResponseCodes,
99
ErrorCodes,
1010
PushCodes,
11+
BinaryRequestTypes,
1112
FRAME_APP_TO_NODE,
1213
FRAME_NODE_TO_APP,
1314
SUPPORTED_COMPANION_PROTOCOL_VERSION,
@@ -130,6 +131,19 @@ class FakeManager extends EventEmitter implements MeshCoreVirtualNodeManager {
130131
requestNodeStatus(publicKey: string) {
131132
return this.requestNodeStatusMock(publicKey) as Promise<Record<string, number> | null>;
132133
}
134+
getNeighboursMock = vi.fn().mockResolvedValue({
135+
total: 3,
136+
neighbours: [
137+
{ publicKeyPrefix: 'aabbccddeeff0011', heardSecondsAgo: 42, snr: 5.25 },
138+
{ publicKeyPrefix: '1122334455667788', heardSecondsAgo: 3600, snr: -2.5 },
139+
],
140+
});
141+
getNeighbours(publicKey: string, opts?: { count?: number; offset?: number; orderBy?: number }) {
142+
return this.getNeighboursMock(publicKey, opts) as Promise<{
143+
total: number;
144+
neighbours: { publicKeyPrefix: string; heardSecondsAgo: number; snr: number }[];
145+
} | null>;
146+
}
133147
emitMessage(msg: MeshCoreMessage) { this.emit('message', msg); }
134148
emitSendConfirmed(data: { ackCode: number; roundTripMs: number }) { this.emit('send_confirmed', data); }
135149
emitOtaPacket(data: { snr?: number | null; rssi?: number | null; raw_hex?: string | null }) {
@@ -1009,3 +1023,168 @@ describe('MeshCoreVirtualNodeServer — SendStatusReq relay (#3904)', () => {
10091023
expect(manager.requestNodeStatusMock).not.toHaveBeenCalled();
10101024
});
10111025
});
1026+
1027+
// SendBinaryReq(50)/GetNeighbours(0x06): reply Sent (with the request's
1028+
// random_tag echoed as expectedAckCrc), then push BinaryResponse(0x8C) carrying
1029+
// the re-serialized neighbour list correlated by the same tag (#3904 final gap).
1030+
// The neighbours protocol is SendBinaryReq(50)/GetNeighbours(0x06), NOT
1031+
// SendRawData(25) as the issue originally inferred.
1032+
describe('MeshCoreVirtualNodeServer — SendBinaryReq/GetNeighbours relay (#3904)', () => {
1033+
let server: MeshCoreVirtualNodeServer;
1034+
let client: TestClient;
1035+
let manager: FakeManager;
1036+
1037+
const REMOTE_KEY = 'e5'.repeat(32);
1038+
const REMOTE_KEY_BYTES = Buffer.from(REMOTE_KEY, 'hex');
1039+
const TAG = 0x11223344;
1040+
1041+
async function startWith(allowAdminCommands: boolean): Promise<void> {
1042+
manager = new FakeManager();
1043+
server = new MeshCoreVirtualNodeServer({ port: 0, manager, allowAdminCommands, databaseService: CHANNELS_DB });
1044+
await server.start();
1045+
client = new TestClient();
1046+
await client.connect(server.getListeningPort()!);
1047+
}
1048+
afterEach(async () => { client?.close(); await server?.stop(); });
1049+
1050+
// [50][pubkey:32][0x06][version:0][count][offset:u16LE][orderBy][prefixLen][tag:u32LE]
1051+
function neighboursFrame(
1052+
publicKeyHex: string,
1053+
opts: { count?: number; offset?: number; orderBy?: number; prefixLen?: number; tag?: number } = {},
1054+
): number[] {
1055+
const { count = 10, offset = 0, orderBy = 0, prefixLen = 8, tag = TAG } = opts;
1056+
const req = Buffer.alloc(11);
1057+
req[0] = BinaryRequestTypes.GetNeighbours;
1058+
req[1] = 0; // request_version
1059+
req[2] = count;
1060+
req.writeUInt16LE(offset, 3);
1061+
req[5] = orderBy;
1062+
req[6] = prefixLen;
1063+
req.writeUInt32LE(tag >>> 0, 7);
1064+
return [CommandCodes.SendBinaryReq, ...Buffer.from(publicKeyHex, 'hex'), ...req];
1065+
}
1066+
1067+
it('replies Sent (tag echoed) then pushes a tag-correlated BinaryResponse with the neighbour list', async () => {
1068+
await startWith(false); // ungated
1069+
const frames = client.expectFrames(2);
1070+
client.send(neighboursFrame(REMOTE_KEY, { count: 5, offset: 2, orderBy: 1, prefixLen: 8 }));
1071+
const [sent, push] = await frames;
1072+
1073+
// Sent(6): [code][result:i8][expectedAckCrc:u32LE][estTimeout:u32LE]
1074+
expect(sent[0]).toBe(ResponseCodes.Sent);
1075+
expect(sent.readUInt32LE(2)).toBe(TAG); // expectedAckCrc echoes the request tag
1076+
1077+
// BinaryResponse(0x8C): [code][reserved:1][tag:u32LE][total:u16LE][count:u16LE][entries…]
1078+
expect(push[0]).toBe(PushCodes.BinaryResponse);
1079+
expect(push[1]).toBe(0); // reserved
1080+
expect(push.readUInt32LE(2)).toBe(TAG); // tag correlates to the Sent's expectedAckCrc
1081+
const body = push.subarray(6);
1082+
expect(body.readUInt16LE(0)).toBe(3); // totalCount (from manager.total)
1083+
expect(body.readUInt16LE(2)).toBe(2); // resultsCount (neighbours.length)
1084+
1085+
// stride = prefixLen(8) + heard(4) + snr(1) = 13 bytes per entry
1086+
const e0 = body.subarray(4, 4 + 13);
1087+
expect(e0.subarray(0, 8)).toEqual(Buffer.from('aabbccddeeff0011', 'hex'));
1088+
expect(e0.readUInt32LE(8)).toBe(42); // heardSecondsAgo
1089+
expect(e0.readInt8(12)).toBe(21); // snr 5.25 dB → round(5.25*4)=21
1090+
1091+
const e1 = body.subarray(4 + 13, 4 + 26);
1092+
expect(e1.subarray(0, 8)).toEqual(Buffer.from('1122334455667788', 'hex'));
1093+
expect(e1.readUInt32LE(8)).toBe(3600);
1094+
expect(e1.readInt8(12)).toBe(-10); // snr -2.5 dB → round(-2.5*4)=-10
1095+
1096+
expect(push.length).toBe(6 + 4 + 2 * 13);
1097+
expect(manager.getNeighboursMock).toHaveBeenCalledWith(REMOTE_KEY, { count: 5, offset: 2, orderBy: 1 });
1098+
});
1099+
1100+
it('respects the requested pubkey_prefix_len for the entry stride', async () => {
1101+
await startWith(false);
1102+
const frames = client.expectFrames(2);
1103+
client.send(neighboursFrame(REMOTE_KEY, { prefixLen: 6 }));
1104+
const [, push] = await frames;
1105+
const body = push.subarray(6);
1106+
expect(body.readUInt16LE(2)).toBe(2); // 2 entries
1107+
// stride now 6 + 4 + 1 = 11 bytes/entry
1108+
expect(push.length).toBe(6 + 4 + 2 * 11);
1109+
const e0 = body.subarray(4, 4 + 11);
1110+
expect(e0.subarray(0, 6)).toEqual(Buffer.from('aabbccddeeff', 'hex')); // first 6 prefix bytes
1111+
expect(e0.readUInt32LE(6)).toBe(42);
1112+
expect(e0.readInt8(10)).toBe(21);
1113+
});
1114+
1115+
it('relays neighbours even when allowAdminCommands is off (read-only follow-up to login)', async () => {
1116+
await startWith(true);
1117+
const frames = client.expectFrames(2);
1118+
client.send(neighboursFrame(REMOTE_KEY));
1119+
const [sent, push] = await frames;
1120+
expect(sent[0]).toBe(ResponseCodes.Sent);
1121+
expect(push[0]).toBe(PushCodes.BinaryResponse);
1122+
});
1123+
1124+
it('pushes an empty BinaryResponse (total=0/count=0) when there are no neighbours', async () => {
1125+
await startWith(false);
1126+
manager.getNeighboursMock.mockResolvedValueOnce({ total: 0, neighbours: [] });
1127+
const frames = client.expectFrames(2);
1128+
client.send(neighboursFrame(REMOTE_KEY));
1129+
const [sent, push] = await frames;
1130+
expect(sent[0]).toBe(ResponseCodes.Sent);
1131+
expect(push[0]).toBe(PushCodes.BinaryResponse);
1132+
expect(push.readUInt32LE(2)).toBe(TAG);
1133+
const body = push.subarray(6);
1134+
expect(body.readUInt16LE(0)).toBe(0); // total
1135+
expect(body.readUInt16LE(2)).toBe(0); // count
1136+
expect(push.length).toBe(6 + 4); // header + [total][count] only
1137+
});
1138+
1139+
it('pushes an empty BinaryResponse when the manager returns null (non-repeater / disconnected)', async () => {
1140+
await startWith(false);
1141+
manager.getNeighboursMock.mockResolvedValueOnce(null);
1142+
const frames = client.expectFrames(2);
1143+
client.send(neighboursFrame(REMOTE_KEY));
1144+
const [sent, push] = await frames;
1145+
expect(sent[0]).toBe(ResponseCodes.Sent);
1146+
expect(push[0]).toBe(PushCodes.BinaryResponse);
1147+
const body = push.subarray(6);
1148+
expect(body.readUInt16LE(0)).toBe(0);
1149+
expect(body.readUInt16LE(2)).toBe(0);
1150+
});
1151+
1152+
it('replies Sent but pushes nothing when the manager throws', async () => {
1153+
await startWith(false);
1154+
manager.getNeighboursMock.mockRejectedValueOnce(new Error('bridge down'));
1155+
const sent = await client.request(neighboursFrame(REMOTE_KEY));
1156+
expect(sent[0]).toBe(ResponseCodes.Sent);
1157+
const second = await Promise.race([
1158+
client.expectFrames(1).then((f) => f[0]),
1159+
new Promise<null>((r) => setTimeout(() => r(null), 100)),
1160+
]);
1161+
expect(second).toBeNull();
1162+
});
1163+
1164+
it('replies Err(IllegalArg) on a short SendBinaryReq envelope, without querying the node', async () => {
1165+
await startWith(false);
1166+
// [50][pubkey:32] with no inner req_data byte → too short.
1167+
const res = await client.request([CommandCodes.SendBinaryReq, ...REMOTE_KEY_BYTES]);
1168+
expect(res[0]).toBe(ResponseCodes.Err);
1169+
expect(res[1]).toBe(ErrorCodes.IllegalArg);
1170+
expect(manager.getNeighboursMock).not.toHaveBeenCalled();
1171+
});
1172+
1173+
it('replies Err(IllegalArg) on a short GetNeighbours inner blob, without querying the node', async () => {
1174+
await startWith(false);
1175+
// Valid envelope + sub-type but truncated params (< 11 inner bytes).
1176+
const res = await client.request([CommandCodes.SendBinaryReq, ...REMOTE_KEY_BYTES, BinaryRequestTypes.GetNeighbours, 0, 5]);
1177+
expect(res[0]).toBe(ResponseCodes.Err);
1178+
expect(res[1]).toBe(ErrorCodes.IllegalArg);
1179+
expect(manager.getNeighboursMock).not.toHaveBeenCalled();
1180+
});
1181+
1182+
it('replies Err(UnsupportedCmd) for an unknown inner sub-type, without querying the node', async () => {
1183+
await startWith(false);
1184+
// Envelope + an unimplemented sub-type (0x03 GetTelemetryData is not handled here).
1185+
const res = await client.request([CommandCodes.SendBinaryReq, ...REMOTE_KEY_BYTES, BinaryRequestTypes.GetTelemetryData, 0, 0]);
1186+
expect(res[0]).toBe(ResponseCodes.Err);
1187+
expect(res[1]).toBe(ErrorCodes.UnsupportedCmd);
1188+
expect(manager.getNeighboursMock).not.toHaveBeenCalled();
1189+
});
1190+
});

0 commit comments

Comments
 (0)