Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<host-ip>: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

Expand Down
9 changes: 9 additions & 0 deletions api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -135,5 +141,8 @@ createRoutes((app) => {

checkForNews()

// Scan for serial ports
listSerialPorts()

if ((process.env.ADDRESS || autoConnectOnStartup.value) && address.value) connect(address.value)
})
25 changes: 25 additions & 0 deletions api/src/lib/serial.ts
Original file line number Diff line number Diff line change
@@ -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.')
}
164 changes: 164 additions & 0 deletions api/src/lib/serialConnection.ts
Original file line number Diff line number Diff line change
@@ -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<typeof setInterval>

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<void>((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<void> {
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<void>((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<boolean> {
return this.port?.isOpen ?? false
}
}
19 changes: 17 additions & 2 deletions api/src/meshtastic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -17,6 +19,7 @@ import {
enableTLS,
lastFromRadio,
meshMapForwarding,
messageHistory,
messagePrefix,
messageSuffix,
myNodeMetadata,
Expand All @@ -36,7 +39,7 @@ import { State } from './lib/state'

let routeCache: State<Record<number, number[]>>

let connection: HttpConnection | BleConnection
let connection: HttpConnection | BleConnection | NodeSerialConnection
let connectionIntended = false
// address.subscribe(connect)

Expand Down Expand Up @@ -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()
Expand All @@ -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,
Expand Down Expand Up @@ -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)
})
Expand Down Expand Up @@ -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 })
Expand Down
6 changes: 6 additions & 0 deletions api/src/vars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Channel[]>('channels', [], { primaryKey: 'index', hideLog: true })
export let packets = new State<MeshPacket[]>('packets', [], { hideLog: true })
export let messageHistory = new State<MeshPacket[]>('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<NodeInfo[]>('nodes', [], { primaryKey: 'num', hideLog: true })
export let currentTime = new State<number>('currentTime', Date.now(), { hideLog: true })
export let myNodeNum = new State<number>('myNodeNum')
Expand Down
6 changes: 4 additions & 2 deletions ui/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -51,6 +52,7 @@
<Address class="shrink-0" />
{/if}
<Bluetooth class="shrink-0" />
<Serial class="shrink-0" />
<!-- <Channels class="shrink-0" /> -->
<Nodes {ol} class="grow" />
{#if window.location.hostname == 'localhost' || $hasAccess || $allowRemoteMessaging}
Expand All @@ -64,8 +66,8 @@
<div class="grid items-center px-5 m-auto">
<div class="text-3xl font-bold text-white">Welcome to MeshSense!</div>
<div class="max-w-md mt-5 flex flex-col gap-4">
<div>Available bluetooth devices will appear on the left</div>
<div>If your device is on the network, enter it's IP address in the Device IP field and click Connect.</div>
<div>Available bluetooth and serial devices will appear on the left</div>
<div>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).</div>

{#if !$hasAccess}
<div>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.</div>
Expand Down
2 changes: 1 addition & 1 deletion ui/src/Channels.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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}` : ''}</button
on:click={() => (selectedChannelIndex = channelIndex)}>{channel.settings?.name || (channel.role == 1 ? 'Primary' : `Channel ${channelIndex}`)}</button
>
{/each}
</div>
Expand Down
Loading