diff --git a/README.md b/README.md index dae73f3..30e067c 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,49 @@ ![](https://affirmatech.com/meshsense.png) -MeshSense directly connects to your Meshtastic node via Bluetooth or WiFi and continuously provides all the information you need to assess the health of your network. For more detailed information, take a peek at our [Frequently Asked Questions](https://affirmatech.com/meshsense/faq) or [Bluetooth Tips](https://affirmatech.com/meshsense/bluetooth). +MeshSense directly connects to your Meshtastic node via Bluetooth, WiFi, or Serial/USB and continuously provides all the information you need to assess the health of your network. For more detailed information, take a peek at our [Frequently Asked Questions](https://affirmatech.com/meshsense/faq) or [Bluetooth Tips](https://affirmatech.com/meshsense/bluetooth). + +## Docker + +A pre-built Docker image is available on GitHub Container Registry: + +```sh +docker pull ghcr.io/outrun207/meshsense:latest +``` + +### Running the container + +```sh +docker run -d --name meshsense \ + --privileged \ + --network host \ + -v /var/run/dbus:/var/run/dbus \ + -v meshsense-data:/root/.local/share/meshsense \ + -e ACCESS_KEY=yourSecretKey \ + -e ADDRESS=/dev/ttyACM0 \ + --restart unless-stopped \ + ghcr.io/outrun207/meshsense:latest +``` + +- `ADDRESS` — Set to a serial port path (e.g. `/dev/ttyACM0`) or IP address for auto-connect on startup +- `ACCESS_KEY` — Required for remote (non-localhost) access to send messages and manage the device +- `--privileged` — Required for Bluetooth and serial port access +- `--network host` — Required for mDNS and WebSocket connectivity + +The web UI will be available at `http://:5920`. + +### Building the Docker image locally + +```sh +# Build the UI and API +cd ui && npm install && npm run build && cd .. +cd api && npm install && npm run build && cd .. + +# Build the Docker image (run on target platform or use buildx) +docker build -t meshsense . +``` + +**Note:** The `serialport` native bindings and `simpleble.node` must match the target platform architecture. When building on ARM64 (e.g. Raspberry Pi), the correct bindings will be installed automatically. ## Headless Usage diff --git a/api/src/index.ts b/api/src/index.ts index 2d79146..81ae9b7 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -3,6 +3,7 @@ import './lib/persistence' import { app, createRoutes, finalize, server } from './lib/server' import './meshtastic' import { connect, disconnect, deleteNodes, requestPosition, send, traceRoute, setPosition, deviceConfig } from './meshtastic' +import { listSerialPorts } from './lib/serial' import { address, apiPort, currentTime, apiHostname, accessKey, autoConnectOnStartup, meshSenseNewsDate, allowRemoteMessaging } from './vars' import { hostname } from 'os' import intercept from 'intercept-stdout' @@ -98,6 +99,11 @@ createRoutes((app) => { return res.json(deviceConfig) }) + app.get('/serialPorts', async (req, res) => { + let ports = await listSerialPorts() + return res.json(ports) + }) + app.post('/position', async (req, res) => { if (!isAuthorized(req)) return res.sendStatus(403) console.log('[express]', '/position', req.body) @@ -135,5 +141,8 @@ createRoutes((app) => { checkForNews() + // Scan for serial ports + listSerialPorts() + if ((process.env.ADDRESS || autoConnectOnStartup.value) && address.value) connect(address.value) }) diff --git a/api/src/lib/serial.ts b/api/src/lib/serial.ts new file mode 100644 index 0000000..89d5767 --- /dev/null +++ b/api/src/lib/serial.ts @@ -0,0 +1,25 @@ +import { SerialPort } from 'serialport' +import { State } from './state' + +export let serialPorts = new State<{ path: string; manufacturer?: string; vendorId?: string; productId?: string }[]>('serialPorts', []) + +/** Scan for available serial ports that might be Meshtastic devices */ +export async function listSerialPorts() { + try { + const ports = await SerialPort.list() + // Filter for likely Meshtastic devices (USB serial adapters) + const filtered = ports + .filter((p) => p.path.includes('ttyACM') || p.path.includes('ttyUSB') || p.path.includes('cu.usbmodem') || p.path.includes('cu.SLAB')) + .map((p) => ({ path: p.path, manufacturer: p.manufacturer, vendorId: p.vendorId, productId: p.productId })) + serialPorts.set(filtered) + return filtered + } catch (e) { + console.error('[serial] Error listing ports:', e) + return [] + } +} + +/** Check if a string looks like a serial port path */ +export function isSerialPath(address: string): boolean { + return address.startsWith('/dev/tty') || address.startsWith('COM') || address.startsWith('/dev/cu.') +} diff --git a/api/src/lib/serialConnection.ts b/api/src/lib/serialConnection.ts new file mode 100644 index 0000000..4fe2c8f --- /dev/null +++ b/api/src/lib/serialConnection.ts @@ -0,0 +1,164 @@ +import { SerialPort } from 'serialport' +import { MeshDevice, Types } from '../../meshtastic-js/dist' + +/** + * Node.js serial connection for Meshtastic devices. + * Uses the `serialport` npm package instead of the Web Serial API. + */ +export class NodeSerialConnection extends MeshDevice { + public connType: any = 'serial' + protected portId: string = '' + + private port: SerialPort | undefined + private byteBuffer = new Uint8Array([]) + private heartbeatInterval?: ReturnType + + constructor(configId?: number) { + super(configId) + } + + /** List available serial ports */ + static async listPorts() { + return SerialPort.list() + } + + /** Connect to a serial port by path */ + async connect(params: any) { + const path = params.path || params.address + const baudRate = params.baudRate || 115200 + + this.updateDeviceStatus(Types.DeviceStatusEnum.DeviceConnecting) + this.portId = path + + this.port = new SerialPort({ path, baudRate, autoOpen: false }) + + return new Promise((resolve, reject) => { + this.port!.open((err) => { + if (err) { + console.error('[serial] Failed to open port:', err.message) + this.updateDeviceStatus(Types.DeviceStatusEnum.DeviceDisconnected) + reject(err) + return + } + + console.log('[serial] Port opened:', path) + + this.port!.on('data', (chunk: Buffer) => { + this.processIncomingData(new Uint8Array(chunk)) + }) + + this.port!.on('close', () => { + console.log('[serial] Port closed') + this.updateDeviceStatus(Types.DeviceStatusEnum.DeviceDisconnected) + this.complete() + }) + + this.port!.on('error', (err) => { + console.error('[serial] Port error:', err.message) + }) + + this.updateDeviceStatus(Types.DeviceStatusEnum.DeviceConnected) + + setTimeout(() => { + this.configure().catch(() => {}) + }, 1000) + + this.heartbeatInterval = setInterval(() => { + this.heartbeat().catch((err) => { + console.error('[serial] Heartbeat error', err) + }) + }, 60 * 1000) + + resolve() + }) + }) + } + + /** Parse incoming serial data using the Meshtastic framing protocol */ + private processIncomingData(chunk: Uint8Array) { + this.byteBuffer = new Uint8Array([...this.byteBuffer, ...chunk]) + + let processingExhausted = false + while (this.byteBuffer.length !== 0 && !processingExhausted) { + const framingIndex = this.byteBuffer.findIndex((byte) => byte === 0x94) + if (framingIndex === -1) { + this.byteBuffer = new Uint8Array([]) + break + } + + const framingByte2 = this.byteBuffer[framingIndex + 1] + if (framingByte2 === 0xc3) { + if (framingIndex > 0) { + this.byteBuffer = this.byteBuffer.subarray(framingIndex) + } + + const msb = this.byteBuffer[2] + const lsb = this.byteBuffer[3] + + if (msb !== undefined && lsb !== undefined && this.byteBuffer.length >= 4 + (msb << 8) + lsb) { + const packetLen = (msb << 8) + lsb + const packet = this.byteBuffer.subarray(4, 4 + packetLen) + + const malformedIndex = packet.findIndex((byte) => byte === 0x94) + if (malformedIndex !== -1 && packet[malformedIndex + 1] === 0xc3) { + this.byteBuffer = this.byteBuffer.subarray(malformedIndex) + } else { + this.byteBuffer = this.byteBuffer.subarray(4 + packetLen) + this.handleFromRadio(packet) + } + } else { + processingExhausted = true + } + } else { + this.byteBuffer = this.byteBuffer.subarray(framingIndex + 1) + } + } + } + + /** Send protobuf data to the radio with serial framing */ + protected async writeToRadio(data: Uint8Array): Promise { + if (!this.port?.isOpen) return + + const frame = new Uint8Array([0x94, 0xc3, (data.length >> 8) & 0xff, data.length & 0xff, ...data]) + + return new Promise((resolve, reject) => { + this.port!.write(frame, (err) => { + if (err) reject(err) + else { + this.port!.drain((err) => { + if (err) reject(err) + else resolve() + }) + } + }) + }) + } + + async disconnect() { + if (this.heartbeatInterval) { + clearInterval(this.heartbeatInterval) + this.heartbeatInterval = undefined + } + + if (this.port?.isOpen) { + return new Promise((resolve) => { + this.port!.close(() => { + this.updateDeviceStatus(Types.DeviceStatusEnum.DeviceDisconnected) + this.complete() + resolve() + }) + }) + } + + this.updateDeviceStatus(Types.DeviceStatusEnum.DeviceDisconnected) + this.complete() + } + + async reconnect() { + await this.connect({ path: this.portId }) + } + + async ping(): Promise { + return this.port?.isOpen ?? false + } +} diff --git a/api/src/meshtastic.ts b/api/src/meshtastic.ts index aa8fc0b..461cb6b 100644 --- a/api/src/meshtastic.ts +++ b/api/src/meshtastic.ts @@ -4,6 +4,8 @@ // import { HttpConnection, BleConnection } from '@meshtastic/js' import { HttpConnection, BleConnection, Protobuf } from '../meshtastic-js/dist' +import { NodeSerialConnection } from './lib/serialConnection' +import { isSerialPath, listSerialPorts } from './lib/serial' import { Channel, MeshPacket, @@ -17,6 +19,7 @@ import { enableTLS, lastFromRadio, meshMapForwarding, + messageHistory, messagePrefix, messageSuffix, myNodeMetadata, @@ -36,7 +39,7 @@ import { State } from './lib/state' let routeCache: State> -let connection: HttpConnection | BleConnection +let connection: HttpConnection | BleConnection | NodeSerialConnection let connectionIntended = false // address.subscribe(connect) @@ -221,6 +224,9 @@ export async function connect(address?: string) { /** If device never showed up, bail */ if (!bluetoothDevices[address]) return stopScanning() + } else if (isSerialPath(address)) { + /** Serial Device */ + connection = new NodeSerialConnection() } else { /** HTTP Endpoint */ connection = new HttpConnection() @@ -230,6 +236,11 @@ export async function connect(address?: string) { channels.set([]) updateTimeout() + // Load persisted message history into packets + if (messageHistory.value?.length) { + for (let msg of messageHistory.value) packets.upsert(msg) + } + // DeviceRestarting = 1, // DeviceDisconnected = 2, // DeviceConnecting = 3, @@ -325,12 +336,14 @@ export async function connect(address?: string) { } }) + /** TEXT_MESSAGE_APP */ /** TEXT_MESSAGE_APP */ connection.events.onMessagePacket.subscribe((e) => { let message = copy(e) message.show = true let packet: MeshPacket packet = packets.upsert({ id: message.id, message }) + messageHistory.upsert(packet) let node = getNodeById(packet.from) if (packet?.viaMqtt === false) sendToMeshMap({ num: message.from }, node, packet) }) @@ -481,10 +494,12 @@ export async function connect(address?: string) { }) // Attempt to connect to the specified MeshTastic Node - console.log('[meshtastic] Connecting to Node', address, connection instanceof BleConnection ? 'via Bluetooth' : 'via IP') + console.log('[meshtastic] Connecting to Node', address, connection instanceof BleConnection ? 'via Bluetooth' : connection instanceof NodeSerialConnection ? 'via Serial' : 'via IP') if (connection instanceof BleConnection) { // console.log(bluetoothDevices[address]) await connection.connect({ device: bluetoothDevices[address] }) + } else if (connection instanceof NodeSerialConnection) { + await connection.connect({ path: address }) } else { process.env['NODE_TLS_REJECT_UNAUTHORIZED'] = '0' await connection.connect({ address, fetchInterval: 2000, tls: enableTLS.value }) diff --git a/api/src/vars.ts b/api/src/vars.ts index 3a839dc..5b6ffd1 100644 --- a/api/src/vars.ts +++ b/api/src/vars.ts @@ -7,6 +7,12 @@ export let connectionStatus = new State<'connected' | 'connecting' | 'disconnect export let lastFromRadio = new State('lastFromRadio', undefined, { hideLog: true }) export let channels = new State('channels', [], { primaryKey: 'index', hideLog: true }) export let packets = new State('packets', [], { hideLog: true }) +export let messageHistory = new State('messageHistory', [], { persist: true, primaryKey: 'id', hideLog: true }) + +// Keep message history capped at 200 +messageHistory.subscribe(() => { + while (messageHistory.value?.length > 200) messageHistory.shift() +}) export let nodes = new State('nodes', [], { primaryKey: 'num', hideLog: true }) export let currentTime = new State('currentTime', Date.now(), { hideLog: true }) export let myNodeNum = new State('myNodeNum') diff --git a/ui/src/App.svelte b/ui/src/App.svelte index 8f746b8..f744016 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -8,6 +8,7 @@ import Map, { expandedMap } from './Map.svelte' import OpenLayersMap from './lib/OpenLayersMap.svelte' import Bluetooth from './Bluetooth.svelte' + import Serial from './Serial.svelte' import Message from './Message.svelte' import { allowRemoteMessaging, connectionStatus, version } from 'api/src/vars' import UpdateStatus from './lib/UpdateStatus.svelte' @@ -51,6 +52,7 @@
{/if} + {#if window.location.hostname == 'localhost' || $hasAccess || $allowRemoteMessaging} @@ -64,8 +66,8 @@
Welcome to MeshSense!
-
Available bluetooth devices will appear on the left
-
If your device is on the network, enter it's IP address in the Device IP field and click Connect.
+
Available bluetooth and serial devices will appear on the left
+
If your device is on the network, enter it's IP address in the Device IP field and click Connect. For serial/USB, enter the port path (e.g. /dev/ttyACM0).
{#if !$hasAccess}
If you do not see the option to connect, you can get access by connecting via localhost or by setting your Access and User key.
diff --git a/ui/src/Channels.svelte b/ui/src/Channels.svelte index db7920e..e2bf0fa 100644 --- a/ui/src/Channels.svelte +++ b/ui/src/Channels.svelte @@ -15,7 +15,7 @@ class="btn w-32 min-h-8 text-sm {channelIndex == selectedChannelIndex ? 'outline outline-1 outline-blue-500' : '-hue-rotate-60 saturate-50'} {channel.role == 0 ? '!saturate-0' : ''}" - on:click={() => (selectedChannelIndex = channelIndex)}>{channelIndex} {channel.settings?.name ? `- ${channel?.settings.name}` : ''} (selectedChannelIndex = channelIndex)}>{channel.settings?.name || (channel.role == 1 ? 'Primary' : `Channel ${channelIndex}`)} {/each}
diff --git a/ui/src/Message.svelte b/ui/src/Message.svelte index 6f6c5c6..a631140 100644 --- a/ui/src/Message.svelte +++ b/ui/src/Message.svelte @@ -48,7 +48,7 @@ {#each $channels as channel} {#if channel.role != 'DISABLED'} - + {/if} {/each} diff --git a/ui/src/Serial.svelte b/ui/src/Serial.svelte new file mode 100644 index 0000000..56db8e0 --- /dev/null +++ b/ui/src/Serial.svelte @@ -0,0 +1,41 @@ + + +{#if $connectionStatus == 'disconnected' && $hasAccess} + +
+ {#if serialPorts.length == 0} +

No serial devices detected

+ {/if} + {#each serialPorts as port} + + {/each} + +
+
+{/if}