This commit is contained in:
Michael Lynch 2026-01-26 15:24:00 +01:00 committed by GitHub
commit 90eda01174
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 333 additions and 18 deletions

14
.github/workflows/test.yml vendored Normal file
View file

@ -0,0 +1,14 @@
name: Run tests
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm test

View file

@ -5,7 +5,7 @@
"main": "src/index.js",
"type": "module",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
"test": "node --test test/"
},
"author": "Liam Cottle <liam@liamcottle.com>",
"license": "MIT",

View file

@ -1,18 +1,9 @@
import BufferReader from "./buffer_reader.js";
import BufferWriter from "./buffer_writer.js";
import Constants from "./constants.js";
class Advert {
static ADV_TYPE_NONE = 0;
static ADV_TYPE_CHAT = 1;
static ADV_TYPE_REPEATER = 2;
static ADV_TYPE_ROOM = 3;
static ADV_LATLON_MASK = 0x10;
static ADV_BATTERY_MASK = 0x20;
static ADV_TEMPERATURE_MASK = 0x40;
static ADV_NAME_MASK = 0x80;
constructor(publicKey, timestamp, signature, appData) {
this.publicKey = publicKey;
this.timestamp = timestamp;
@ -45,10 +36,10 @@ class Advert {
getTypeString() {
const type = this.getType();
if(type === Advert.ADV_TYPE_NONE) return "NONE";
if(type === Advert.ADV_TYPE_CHAT) return "CHAT";
if(type === Advert.ADV_TYPE_REPEATER) return "REPEATER";
if(type === Advert.ADV_TYPE_ROOM) return "ROOM";
if(type === Constants.AdvType.None) return "NONE";
if(type === Constants.AdvType.Chat) return "CHAT";
if(type === Constants.AdvType.Repeater) return "REPEATER";
if(type === Constants.AdvType.Room) return "ROOM";
return null;
}
@ -76,14 +67,14 @@ class Advert {
// parse lat lon
var lat = null;
var lon = null;
if(flags & Advert.ADV_LATLON_MASK){
if(flags & Constants.AdvertFlags.LatLon){
lat = bufferReader.readInt32LE();
lon = bufferReader.readInt32LE();
}
// parse name (remainder of app data)
var name = null;
if(flags & Advert.ADV_NAME_MASK){
if(flags & Constants.AdvertFlags.Name){
name = bufferReader.readString();
}

View file

@ -42,6 +42,14 @@ class BufferWriter {
this.writeBytes(bytes);
}
// not little-endian because a single byte has no ordering
writeInt8(num) {
const bytes = new Uint8Array(1);
const view = new DataView(bytes.buffer);
view.setInt8(0, num);
this.writeBytes(bytes);
}
writeString(string) {
this.writeBytes(new TextEncoder().encode(string));
}

View file

@ -106,7 +106,7 @@ class Connection extends EventEmitter {
data.writeBytes(publicKey);
data.writeByte(type);
data.writeByte(flags);
data.writeByte(outPathLen); // todo writeInt8
data.writeInt8(outPathLen);
data.writeBytes(outPath); // 64 bytes
data.writeCString(advName, 32); // 32 bytes
data.writeUInt32LE(lastAdvert);

View file

@ -126,6 +126,13 @@ class Constants {
GetNeighbours: 0x06, // #define REQ_TYPE_GET_NEIGHBOURS 0x06
}
static AdvertFlags = {
LatLon: 0x10,
Battery: 0x20,
Temperature: 0x40,
Name: 0x80,
}
}
export default Constants;

295
test/protocol.test.js Normal file
View file

@ -0,0 +1,295 @@
import { describe, it } from 'node:test';
import assert from 'node:assert';
import Connection from '../src/connection/connection.js';
import BufferWriter from '../src/buffer_writer.js';
import Constants from '../src/constants.js';
// Helper to wait for an event from the connection
function waitForEvent(conn, eventCode) {
return new Promise((resolve) => {
conn.on(eventCode, (data) => {
resolve(data);
});
});
}
describe('SelfInfo Response Parsing', () => {
it('should parse SelfInfo with correct field order', async () => {
// Build a mock SelfInfo response
const writer = new BufferWriter();
writer.writeByte(Constants.ResponseCodes.SelfInfo);
writer.writeByte(Constants.AdvType.Chat); // type
writer.writeByte(20); // txPower
writer.writeByte(30); // maxTxPower
writer.writeBytes(new Uint8Array(32).fill(0xAB)); // publicKey
writer.writeInt32LE(12345678); // advLat
writer.writeInt32LE(-87654321); // advLon
writer.writeBytes(new Uint8Array(3)); // reserved
writer.writeByte(1); // manualAddContacts
writer.writeUInt32LE(915000); // radioFreq
writer.writeUInt32LE(125); // radioBw
writer.writeByte(10); // radioSf
writer.writeByte(5); // radioCr
writer.writeString('TestNode'); // name
const conn = new Connection();
const resultPromise = waitForEvent(conn, Constants.ResponseCodes.SelfInfo);
conn.onFrameReceived(writer.toBytes());
const result = await resultPromise;
assert.strictEqual(result.type, 1);
assert.strictEqual(result.txPower, 20);
assert.strictEqual(result.maxTxPower, 30);
assert.deepStrictEqual(result.publicKey, new Uint8Array(32).fill(0xAB));
assert.strictEqual(result.advLat, 12345678);
assert.strictEqual(result.advLon, -87654321);
assert.strictEqual(result.manualAddContacts, 1);
assert.strictEqual(result.radioFreq, 915000);
assert.strictEqual(result.radioBw, 125);
assert.strictEqual(result.radioSf, 10);
assert.strictEqual(result.radioCr, 5);
assert.strictEqual(result.name, 'TestNode');
});
});
describe('Contact Response Parsing', () => {
it('should parse Contact with correct field order starting with publicKey', async () => {
// Create a specific outPath with known values for the first 3 bytes (matching outPathLen)
const outPath = new Uint8Array(64).fill(0x00);
outPath[0] = 0xAA; // First hop
outPath[1] = 0xBB; // Second hop
outPath[2] = 0xCC; // Third hop
const writer = new BufferWriter();
writer.writeByte(Constants.ResponseCodes.Contact);
writer.writeBytes(new Uint8Array(32).fill(0xCD)); // publicKey
writer.writeByte(Constants.AdvType.Repeater); // type
writer.writeByte(0x01); // flags
writer.writeInt8(3); // outPathLen
writer.writeBytes(outPath); // outPath (fixed 64 bytes)
writer.writeCString('James Example', 32); // advName (32 bytes C-string)
writer.writeUInt32LE(1704067200); // lastAdvert (timestamp)
writer.writeUInt32LE(40000000); // advLat
writer.writeUInt32LE(-74000000 >>> 0); // advLon (as unsigned)
writer.writeUInt32LE(1704153600); // lastMod
const conn = new Connection();
const resultPromise = waitForEvent(conn, Constants.ResponseCodes.Contact);
conn.onFrameReceived(writer.toBytes());
const result = await resultPromise;
assert.deepStrictEqual(result.publicKey, new Uint8Array(32).fill(0xCD));
assert.strictEqual(result.type, 2);
assert.strictEqual(result.flags, 0x01);
assert.strictEqual(result.outPathLen, 3);
assert.strictEqual(result.outPath.length, 64);
// Verify the actual path bytes match what was written
assert.strictEqual(result.outPath[0], 0xAA);
assert.strictEqual(result.outPath[1], 0xBB);
assert.strictEqual(result.outPath[2], 0xCC);
// Note: bytes beyond outPathLen are undefined and should not be asserted
assert.strictEqual(result.advName, 'James Example');
assert.strictEqual(result.lastAdvert, 1704067200);
});
it('should parse Contact with empty outPath (direct connection)', async () => {
const writer = new BufferWriter();
writer.writeByte(Constants.ResponseCodes.Contact);
writer.writeBytes(new Uint8Array(32).fill(0xEE)); // publicKey
writer.writeByte(Constants.AdvType.Chat); // type
writer.writeByte(0x00); // flags
writer.writeInt8(0); // outPathLen = 0 (direct connection)
writer.writeBytes(new Uint8Array(64).fill(0x00)); // outPath (all zeros)
writer.writeCString('Direct Contact', 32); // advName
writer.writeUInt32LE(1704067200); // lastAdvert
writer.writeUInt32LE(0); // advLat
writer.writeUInt32LE(0); // advLon
writer.writeUInt32LE(1704153600); // lastMod
const conn = new Connection();
const resultPromise = waitForEvent(conn, Constants.ResponseCodes.Contact);
conn.onFrameReceived(writer.toBytes());
const result = await resultPromise;
assert.strictEqual(result.outPathLen, 0);
assert.strictEqual(result.outPath.length, 64);
// Note: when outPathLen is 0, all bytes in outPath are undefined and should not be asserted
});
it('should parse Contact with longer multi-hop outPath', async () => {
// Create a path with 6 hops
const outPath = new Uint8Array(64).fill(0x00);
const pathHops = [0x11, 0x22, 0x33, 0x44, 0x55, 0x66];
for (let i = 0; i < pathHops.length; i++) {
outPath[i] = pathHops[i];
}
const writer = new BufferWriter();
writer.writeByte(Constants.ResponseCodes.Contact);
writer.writeBytes(new Uint8Array(32).fill(0xAB)); // publicKey
writer.writeByte(Constants.AdvType.Repeater); // type
writer.writeByte(0x03); // flags
writer.writeInt8(6); // outPathLen = 6 hops
writer.writeBytes(outPath); // outPath
writer.writeCString('Multi-hop Node', 32); // advName
writer.writeUInt32LE(1704067200); // lastAdvert
writer.writeUInt32LE(40000000); // advLat
writer.writeUInt32LE(0); // advLon
writer.writeUInt32LE(1704153600); // lastMod
const conn = new Connection();
const resultPromise = waitForEvent(conn, Constants.ResponseCodes.Contact);
conn.onFrameReceived(writer.toBytes());
const result = await resultPromise;
assert.strictEqual(result.outPathLen, 6);
assert.strictEqual(result.outPath.length, 64);
// Verify all path hops are correctly parsed
for (let i = 0; i < pathHops.length; i++) {
assert.strictEqual(result.outPath[i], pathHops[i], `outPath[${i}] should be 0x${pathHops[i].toString(16)}`);
}
// Note: bytes beyond outPathLen are undefined and should not be asserted
});
});
describe('LogRxData Push Parsing', () => {
it('should parse SNR and RSSI from beginning of payload', async () => {
const writer = new BufferWriter();
writer.writeByte(Constants.PushCodes.LogRxData);
writer.writeInt8(40); // snr * 4 = 10.0
writer.writeInt8(-90); // rssi
writer.writeBytes([0xDE, 0xAD, 0xBE, 0xEF]); // raw payload
const conn = new Connection();
const resultPromise = waitForEvent(conn, Constants.PushCodes.LogRxData);
conn.onFrameReceived(writer.toBytes());
const result = await resultPromise;
assert.strictEqual(result.lastSnr, 10.0); // 40/4
assert.strictEqual(result.lastRssi, -90);
assert.deepStrictEqual(result.raw, new Uint8Array([0xDE, 0xAD, 0xBE, 0xEF]));
});
it('should handle negative SNR values', async () => {
const writer = new BufferWriter();
writer.writeByte(Constants.PushCodes.LogRxData);
writer.writeInt8(-20); // snr * 4 = -5.0
writer.writeInt8(-110); // rssi
writer.writeBytes([0x01, 0x02]); // raw payload
const conn = new Connection();
const resultPromise = waitForEvent(conn, Constants.PushCodes.LogRxData);
conn.onFrameReceived(writer.toBytes());
const result = await resultPromise;
assert.strictEqual(result.lastSnr, -5.0); // -20/4
assert.strictEqual(result.lastRssi, -110);
});
});
describe('SendConfirmed Push Parsing', () => {
it('should parse ackCode as 4 bytes (uint32)', async () => {
const writer = new BufferWriter();
writer.writeByte(Constants.PushCodes.SendConfirmed);
writer.writeUInt32LE(0xDEADBEEF); // ackCode (4 bytes)
writer.writeUInt32LE(1500); // roundTrip (4 bytes)
const conn = new Connection();
const resultPromise = waitForEvent(conn, Constants.PushCodes.SendConfirmed);
conn.onFrameReceived(writer.toBytes());
const result = await resultPromise;
assert.strictEqual(result.ackCode, 0xDEADBEEF);
assert.strictEqual(result.roundTrip, 1500);
});
});
describe('RawData Push Parsing', () => {
it('should parse SNR, RSSI, reserved byte, then payload', async () => {
const writer = new BufferWriter();
writer.writeByte(Constants.PushCodes.RawData);
writer.writeInt8(24); // snr * 4 = 6.0
writer.writeInt8(-85); // rssi
writer.writeByte(0x00); // reserved
writer.writeBytes([0xCA, 0xFE, 0xBA, 0xBE]); // payload
const conn = new Connection();
const resultPromise = waitForEvent(conn, Constants.PushCodes.RawData);
conn.onFrameReceived(writer.toBytes());
const result = await resultPromise;
assert.strictEqual(result.lastSnr, 6.0); // 24/4
assert.strictEqual(result.lastRssi, -85);
assert.deepStrictEqual(result.payload, new Uint8Array([0xCA, 0xFE, 0xBA, 0xBE]));
});
});
describe('NewAdvert Push Parsing', () => {
it('should parse NewAdvert with lastMod field', async () => {
const writer = new BufferWriter();
writer.writeByte(Constants.PushCodes.NewAdvert);
writer.writeBytes(new Uint8Array(32).fill(0xEF)); // publicKey
writer.writeByte(Constants.AdvType.Chat); // type
writer.writeByte(0x02); // flags
writer.writeInt8(2); // outPathLen
writer.writeBytes(new Uint8Array(64).fill(0x00)); // outPath (fixed 64 bytes)
writer.writeCString('NewNode', 32); // advName (32 bytes C-string)
writer.writeUInt32LE(1704067200); // lastAdvert
writer.writeUInt32LE(40000000); // advLat
writer.writeUInt32LE(0); // advLon
writer.writeUInt32LE(1704153600); // lastMod
const conn = new Connection();
const resultPromise = waitForEvent(conn, Constants.PushCodes.NewAdvert);
conn.onFrameReceived(writer.toBytes());
const result = await resultPromise;
assert.deepStrictEqual(result.publicKey, new Uint8Array(32).fill(0xEF));
assert.strictEqual(result.type, 1);
assert.strictEqual(result.flags, 0x02);
assert.strictEqual(result.outPathLen, 2);
assert.strictEqual(result.outPath.length, 64);
assert.strictEqual(result.advName, 'NewNode');
assert.strictEqual(result.lastAdvert, 1704067200);
assert.strictEqual(result.lastMod, 1704153600);
});
});
describe('Sent Response Parsing', () => {
it('should parse Sent response with result, expectedAckCrc, and estTimeout', async () => {
const writer = new BufferWriter();
writer.writeByte(Constants.ResponseCodes.Sent);
writer.writeInt8(0); // result
writer.writeUInt32LE(0x12345678); // expectedAckCrc
writer.writeUInt32LE(5000); // estTimeout
const conn = new Connection();
const resultPromise = waitForEvent(conn, Constants.ResponseCodes.Sent);
conn.onFrameReceived(writer.toBytes());
const result = await resultPromise;
assert.strictEqual(result.result, 0);
assert.strictEqual(result.expectedAckCrc, 0x12345678);
assert.strictEqual(result.estTimeout, 5000);
});
});