From 30c918a09adb700065d0c743d3aa884997c4c534 Mon Sep 17 00:00:00 2001 From: liamcottle Date: Tue, 11 Feb 2025 07:27:31 +1300 Subject: [PATCH] initial serial client implementation for meshcore device --- index.html | 122 +++++++++++++++++ package.json | 3 +- src/buffer_reader.js | 53 ++++++++ src/buffer_writer.js | 44 +++++++ src/constants.js | 51 ++++++++ src/device.js | 302 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 574 insertions(+), 1 deletion(-) create mode 100644 index.html create mode 100644 src/buffer_reader.js create mode 100644 src/buffer_writer.js create mode 100644 src/constants.js create mode 100644 src/device.js diff --git a/index.html b/index.html new file mode 100644 index 0000000..780a415 --- /dev/null +++ b/index.html @@ -0,0 +1,122 @@ + + + + + + + MeshCore + + + + + +
+ + +
+
+
MeshCore Client
+
Developed by Liam Cottle
+
+
+ +
+ +
+ + + + + + + + + +
+ +
+ +
+ + + + + \ No newline at end of file diff --git a/package.json b/package.json index 9d92b50..3ce5a94 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,8 @@ "name": "@liamcottle/meshcore.js", "version": "0.0.1", "description": "", - "main": "index.js", + "main": "device.js", + "type": "module", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, diff --git a/src/buffer_reader.js b/src/buffer_reader.js new file mode 100644 index 0000000..ef638d6 --- /dev/null +++ b/src/buffer_reader.js @@ -0,0 +1,53 @@ +class BufferReader { + + constructor(data) { + this.pointer = 0; + this.buffer = new Uint8Array(data); + } + + readByte() { + return this.readBytes(1)[0]; + } + + readBytes(count) { + const data = this.buffer.slice(this.pointer, this.pointer + count); + this.pointer += count; + return data; + } + + readString() { + const remainingBytesCount = this.buffer.length - this.pointer; + const remainingBytes = this.readBytes(remainingBytesCount); + return new TextDecoder().decode(remainingBytes); + } + + readCString(maxLength) { + const value = []; + const bytes = this.readBytes(maxLength); + for(const byte of bytes){ + + // if we find a null terminator character, we have reached the end of the cstring + if(byte === 0){ + return new TextDecoder().decode(new Uint8Array(value)); + } + + value.push(byte); + + } + } + + readUInt16LE() { + const bytes = this.readBytes(2); + const view = new DataView(bytes.buffer); + return view.getUint16(0, true); + } + + readUInt32LE() { + const bytes = this.readBytes(4); + const view = new DataView(bytes.buffer); + return view.getUint32(0, true); + } + +} + +export default BufferReader; diff --git a/src/buffer_writer.js b/src/buffer_writer.js new file mode 100644 index 0000000..bbef00b --- /dev/null +++ b/src/buffer_writer.js @@ -0,0 +1,44 @@ +class BufferWriter { + + constructor() { + this.buffer = []; + } + + toBytes() { + return new Uint8Array(this.buffer); + } + + writeBytes(bytes) { + this.buffer = [ + ...this.buffer, + ...bytes, + ]; + } + + writeByte(byte) { + this.writeBytes([ + byte, + ]); + } + + writeUInt16LE(num) { + const bytes = new Uint8Array(2); + const view = new DataView(bytes.buffer); + view.setUint16(0, num, true); + this.writeBytes(bytes); + } + + writeUInt32LE(num) { + const bytes = new Uint8Array(4); + const view = new DataView(bytes.buffer); + view.setUint32(0, num, true); + this.writeBytes(bytes); + } + + writeString(string) { + this.writeBytes(new TextEncoder().encode(string)); + } + +} + +export default BufferWriter; diff --git a/src/constants.js b/src/constants.js new file mode 100644 index 0000000..b2b6c07 --- /dev/null +++ b/src/constants.js @@ -0,0 +1,51 @@ +class Constants { + + static SerialFrameTypes = { + Incoming: 0x3e, // ">" + Outgoing: 0x3c, // "<" + }; + + static CommandCodes = { + AppStart: 1, // done + SendTxtMsg: 2, + SendChannelTxtMsg: 3, + GetContacts: 4, // done + GetDeviceTime: 5, // done + SetDeviceTime: 6, // done + SendSelfAdvert: 7, // done + SetAdvertName: 8, // done + AddUpdateContact: 9, + SyncNextMessage: 10, // done + SetRadioParams: 11, + SetTxPower: 12, + } + + static ResponseCodes = { + Ok: 0, + Err: 1, + ContactsStart: 2, // done + Contact: 3, // done + EndOfContacts: 4, // done + SelfInfo: 5, // done + Sent: 6, + ContactMsgRecv: 7, // done + ChannelMsgRecv: 8, + CurrTime: 9, // done + NoMoreMessages: 10, + } + + static PushCodes = { + Advert: 0x80, + PathUpdated: 0x81, + SendConfirmed: 0x82, + MsgWaiting: 0x83, + } + + static SelfAdvertTypes = { + ZeroHop: 0, + Flood: 1, + } + +} + +export default Constants; diff --git a/src/device.js b/src/device.js new file mode 100644 index 0000000..8b1ee65 --- /dev/null +++ b/src/device.js @@ -0,0 +1,302 @@ +import BufferWriter from "./buffer_writer.js"; +import BufferReader from "./buffer_reader.js"; +import Constants from "./constants.js"; + +class Device { + + constructor(serialPort) { + this.serialPort = serialPort; + this.reader = serialPort.readable.getReader(); + this.writable = serialPort.writable; + this.readBuffer = []; + this.readLoop(); + } + + static async fromSerialPort(serialPort) { + + // open port + await serialPort.open({ + baudRate: 115200, + }); + + return new Device(serialPort); + + } + + async close() { + + // release reader lock + try { + this.reader.releaseLock(); + } catch(e) { + // console.log("failed to release lock on serial port readable, ignoring...", e); + } + + // close serial port + try { + await this.serialPort.close(); + } catch(e) { + // console.log("failed to close serial port, ignoring...", e); + } + + } + + async write(bytes) { + const writer = this.writable.getWriter(); + try { + await writer.write(new Uint8Array(bytes)); + } finally { + writer.releaseLock(); + } + } + + async writeFrame(frameType, frameData) { + + // create frame + const frame = new BufferWriter(); + + // add frame header + frame.writeByte(frameType); + frame.writeUInt16LE(frameData.length); + + // add frame data + frame.writeBytes(frameData); + + // write frame to device + await this.write(frame.toBytes()); + + } + + async sendToRadioFrame(data) { + // write "app to radio" frame 0x3c "<" + await this.writeFrame(0x3c, data); + } + + async sendCommandAppStart() { + const data = new BufferWriter(); + data.writeByte(Constants.CommandCodes.AppStart); + data.writeByte(1); // appVer + data.writeBytes(new Uint8Array(6)); // reserved + data.writeString("test"); // appName + await this.sendToRadioFrame(data.toBytes()); + } + + async sendCommandGetContacts(since) { + const data = new BufferWriter(); + data.writeByte(Constants.CommandCodes.GetContacts); + if(since){ + data.writeUInt32LE(since); + } + await this.sendToRadioFrame(data.toBytes()); + } + + async sendCommandGetDeviceTime() { + const data = new BufferWriter(); + data.writeByte(Constants.CommandCodes.GetDeviceTime); + await this.sendToRadioFrame(data.toBytes()); + } + + async sendCommandSetDeviceTime(epochSecs) { + const data = new BufferWriter(); + data.writeByte(Constants.CommandCodes.SetDeviceTime); + data.writeUInt32LE(epochSecs); + await this.sendToRadioFrame(data.toBytes()); + } + + async sendCommandSendSelfAdvert(type) { + const data = new BufferWriter(); + data.writeByte(Constants.CommandCodes.SendSelfAdvert); + data.writeByte(type); + await this.sendToRadioFrame(data.toBytes()); + } + + async sendCommandSetAdvertName(name) { + const data = new BufferWriter(); + data.writeByte(Constants.CommandCodes.SetAdvertName); + data.writeString(name); + await this.sendToRadioFrame(data.toBytes()); + } + + async sendCommandSyncNextMessage() { + const data = new BufferWriter(); + data.writeByte(Constants.CommandCodes.SyncNextMessage); + await this.sendToRadioFrame(data.toBytes()); + } + + async readLoop() { + try { + while(true){ + + // read bytes until reader indicates it's done + const { value, done } = await this.reader.read(); + if(done){ + break; + } + + // append received bytes to read buffer + this.readBuffer = [ + ...this.readBuffer, + ...value, + ]; + + // process read buffer while there is enough bytes for a frame header + // 3 bytes frame header = (1 byte frame type) + (2 bytes frame length as unsigned 16-bit little endian) + const frameHeaderLength = 3; + while(this.readBuffer.length >= frameHeaderLength){ + try { + + // extract frame header + const frameHeader = new BufferReader(this.readBuffer.slice(0, frameHeaderLength)); + + // ensure frame type supported + const frameType = frameHeader.readByte(); + if(frameType !== Constants.SerialFrameTypes.Incoming && frameType !== Constants.SerialFrameTypes.Outgoing){ + // unexpected byte, lets skip it and try again + this.readBuffer = this.readBuffer.slice(1); + continue; + } + + // ensure frame length valid + const frameLength = frameHeader.readUInt16LE(); + if(!frameLength){ + // unexpected byte, lets skip it and try again + this.readBuffer = this.readBuffer.slice(1); + continue; + } + + // check if we have received enough bytes for this frame, otherwise wait until more bytes received + const requiredLength = frameHeaderLength + frameLength; + if(this.readBuffer.length < requiredLength){ + break; + } + + // get frame data, and remove it and its frame header from the read buffer + const frameData = this.readBuffer.slice(frameHeaderLength, requiredLength); + this.readBuffer = this.readBuffer.slice(requiredLength); + + // handle received frame + this.onFrameReceived(frameData); + + } catch(e) { + console.error("Failed to process frame", e); + break; + } + } + + } + } catch(error) { + + // ignore error if reader was released + if(error instanceof TypeError){ + return; + } + + console.error('Error reading from serial port: ', error); + + } finally { + this.reader.releaseLock(); + } + } + + onFrameReceived(frame) { + + // console.log("onFrameReceived", frame); + + const bufferReader = new BufferReader(frame); + const responseCode = bufferReader.readByte(); + + if(responseCode === Constants.ResponseCodes.SelfInfo){ + this.onSelfInfoResponse(bufferReader); + } else if(responseCode === Constants.ResponseCodes.CurrTime){ + this.onCurrTimeResponse(bufferReader); + } else if(responseCode === Constants.ResponseCodes.ContactMsgRecv){ + this.onContactMsgRecvResponse(bufferReader); + } else if(responseCode === Constants.ResponseCodes.ContactsStart){ + this.onContactsStartResponse(bufferReader); + } else if(responseCode === Constants.ResponseCodes.Contact){ + this.onContactResponse(bufferReader); + } else if(responseCode === Constants.ResponseCodes.EndOfContacts){ + this.onEndOfContactsResponse(bufferReader); + } else if(responseCode === Constants.PushCodes.Advert){ + this.onAdvertPush(bufferReader); + } else if(responseCode === Constants.PushCodes.MsgWaiting){ + this.onMsgWaitingPush(bufferReader); + } else { + console.log("unhandled frame", frame); + } + + } + + onAdvertPush(bufferReader) { + console.log("onAdvertPush", { + publicKey: bufferReader.readBytes(32), + }); + } + + onMsgWaitingPush(bufferReader) { + console.log("onMsgWaitingPush", { + + }); + } + + onContactsStartResponse(bufferReader) { + console.log("onContactsStartResponse", { + count: bufferReader.readUInt32LE(), + }); + } + + onContactResponse(bufferReader) { + console.log("onContactResponse", { + publicKey: bufferReader.readBytes(32), + type: bufferReader.readByte(), + flags: bufferReader.readByte(), + outPathLen: bufferReader.readByte(), + outPath: bufferReader.readBytes(64), + advName: bufferReader.readCString(32), + lastAdvert: bufferReader.readUInt32LE(), + advLat: bufferReader.readUInt32LE(), + advLon: bufferReader.readUInt32LE(), + lastMod: bufferReader.readUInt32LE(), + }); + } + + onEndOfContactsResponse(bufferReader) { + console.log("onEndOfContactsResponse", { + mostRecentLastmod: bufferReader.readUInt32LE(), + }); + } + + onSelfInfoResponse(bufferReader) { + console.log("onSelfInfoResponse", { + type: bufferReader.readByte(), + txPower: bufferReader.readByte(), + maxTxPower: bufferReader.readByte(), + publicKey: bufferReader.readBytes(32), + deviceLoc: bufferReader.readBytes(12), + radioFreq: bufferReader.readUInt32LE(), + radioBw: bufferReader.readUInt32LE(), + radioSf: bufferReader.readByte(), + radioCr: bufferReader.readByte(), + name: bufferReader.readString(), + }); + } + + onCurrTimeResponse(bufferReader) { + console.log("onCurrTimeResponse", { + epochSecs: bufferReader.readUInt32LE(), + }); + } + + onContactMsgRecvResponse(bufferReader) { + console.log("onContactMsgRecvResponse", { + pubKeyPrefix: bufferReader.readBytes(6), + pathLen: bufferReader.readByte(), + txtType: bufferReader.readByte(), + senderTimestamp: bufferReader.readUInt32LE(), + text: bufferReader.readString(), + }); + } + +} + +export default Device;