Skip to content
Merged
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
2 changes: 1 addition & 1 deletion docs/meshcore-meshtastic-parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ In **MeshCore** mode only, [`ConnectionPanel.tsx`](../src/renderer/components/Co
| Preset | Broker host | Port | Notes |
| --------------- | ------------------------------------------------------ | ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| LetsMesh | `mqtt-us-v1.letsmesh.net` or `mqtt-eu-v1.letsmesh.net` | 443 | **Default for new users.** **WebSocket** (`wss`). Topic prefix `meshcore/test`. JWT auth; see [Authentication](#meshcore-mqtt-authentication) below. Optional **Packet logger** publishes to `meshcore/packets`. See [`letsmesh-mqtt-auth.md`](letsmesh-mqtt-auth.md). |
| MeshMapper | `mqtt.meshmapper.cc` | 443 | **WebSocket** (`wss`). Topic prefix `meshcore/test`. |
| MeshMapper | `mqtt.meshmapper.net` | 443 | **WebSocket** (`wss`). Topic prefix `meshcore/test`. |
| Colorado Mesh | `mqtt.meshcore.coloradomesh.org` | 443 | **Colorado residents only.** **WebSocket** (`wss`). Topic prefix `meshcore/DEN`. Confirm on select; one-time stay-or-switch gate for existing Colorado users. JWT device-signing auth. |
| Ripple Networks | `mqtt.ripplenetworks.com.au` | 8883 | TLS; preset fills default shared credentials and **insecure TLS** for self-signed / non–public CA chains. Topic prefix `meshcore`. |
| Custom | (user) | — | No automatic changes; use for private brokers |
Expand Down
19 changes: 18 additions & 1 deletion docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -1399,9 +1399,26 @@ Mis-filed messages also occur when `channelNameToIndex` is stale or incomplete:
1. Update to a build that prefers the **topic channel name** for MQTT text ingest (sampled log `mqtt-channel-topic-mismatch:*` when topic index disagrees with packet channel).
2. Connect the radio so channel keys and slot indexes sync to MQTT (`mqtt:updateChannelKeys` in logs after configure).
3. **MQTT-only (no radio):** add `ChannelName@index=base64` lines in Connection → Channel PSKs (e.g. `LongFast@1=AQ==` for Colorado-mesh slot-1 public). The Connection panel shows an inline hint when no radio is configured and no `@index` lines are present.
4. On **Export for Developer** / **Copy Debug Snapshot**, check `meshtastic.channelPills`, `meshtastic.channelConfigsSummary`, and `meshtastic.mqttChannelKeyEntryCount` — slot 1 with empty name and `isDefaultPublicPsk: true` is the common Colorado-mesh layout.
4. On **Export for Developer** / **Copy Debug Snapshot**, check `meshtastic.channelPills`, `meshtastic.channelConfigsSummary`, `meshtastic.mqttChannelKeyEntryCount`, and `meshtastic.mqttChannelNameToIndex` (main-process topic→slot map; e.g. `{ "LongFast": 1 }` for Colorado-style public on slot 1). Slot 1 with empty name and `isDefaultPublicPsk: true` is the common Colorado-mesh layout.
5. When reporting, note whether mis-filed messages are **MQTT-only**, **RF-only**, or **both**, and attach a Radio tab screenshot of channel names + slot indices.

### MeshCore-flashed radio still “Just now” on Meshtastic Nodes

**Symptoms**

- After flashing a radio from Meshtastic to MeshCore, the old Meshtastic NodeDB row (often short name / MAC-derived `!xxxxxxxx`) stays **online** / **Just now** on the Meshtastic tab while that hardware is the connected MeshCore BLE radio.
- Hops often show **0**; MQTT column may be `-` (RF-style refresh).

**Cause**

Meshtastic node numbers are frequently the lower 32 bits of the BLE MAC. With both a Meshtastic radio and that MeshCore radio on the same band, Blck (Meshtastic) can still “hear” MeshCore TX and bump `last_heard` on the **existing** stale NodeDB row (raw packet SNR path / MQTT minimal updates) without a real Meshtastic NodeInfo.

**Fix**

1. Update to a build that suppresses Meshtastic `last_heard` bumps for node IDs matching the **connected MeshCore BLE MAC**.
2. Until then: delete the ghost node on Meshtastic Nodes (it may return while both radios are on-air on older builds).
3. Confirm MeshCore Connection is BLE to that peripheral; Diagnostics foreign-LoRa is separate from the Nodes list.

### Phantom chat unread on channels not on the radio

**Symptoms**
Expand Down
1 change: 1 addition & 0 deletions src/main/index.ipc-security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,7 @@ describe('privileged IPC sender validation (source contract)', () => {
'log:getPath',
'log:getRecentLines',
'mqtt:getCachedNodes',
'mqtt:getChannelNameToIndex',
'mqtt:getClientId',
'storage:isAvailable',
'support:exportBundle',
Expand Down
12 changes: 12 additions & 0 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3261,6 +3261,18 @@ ipcMain.handle('mqtt:getCachedNodes', (event) => {
throw err;
}
});
ipcMain.handle('mqtt:getChannelNameToIndex', (event) => {
assertIpcSender(event, 'mqtt:getChannelNameToIndex');
try {
return mqttManager.getChannelNameToIndex();
} catch (err) {
console.error(
'[IPC] mqtt:getChannelNameToIndex failed:',
sanitizeLogMessage(err instanceof Error ? err.message : String(err)),
);
throw err;
}
});
ipcMain.handle('mqtt:publishNodeInfo', (event, args) => {
assertIpcSender(event, 'mqtt:publishNodeInfo');
try {
Expand Down
143 changes: 135 additions & 8 deletions src/main/mqtt-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1263,6 +1263,22 @@ describe('connect — channelPsks parsing', () => {
});

describe('updateChannelKeys', () => {
/** Test-only surface for private MQTTManager members used by channel-map regressions. */
interface MqttManagerChannelTestAccess {
_doConnect: () => void;
channelNameToIndex: Map<string, number>;
channelKeysByName: Map<string, Buffer>;
onMessage: (topic: string, payload: Buffer) => void;
}

function mqttChannelTestAccess(manager: MQTTManager): MqttManagerChannelTestAccess {
return manager as unknown as MqttManagerChannelTestAccess;
}

function stubMqttConnect(manager: MQTTManager): void {
mqttChannelTestAccess(manager)._doConnect = () => {};
}

it('registers radio channel keys for decrypt and publish', () => {
const manager = new MQTTManager();
(manager as any)._doConnect = () => {};
Expand Down Expand Up @@ -1347,7 +1363,7 @@ describe('updateChannelKeys', () => {

it('stores LongFast index mapping from default public radio sync', () => {
const manager = new MQTTManager();
(manager as any)._doConnect = () => {};
stubMqttConnect(manager);
manager.connect({
server: 'localhost',
port: 1883,
Expand All @@ -1359,13 +1375,125 @@ describe('updateChannelKeys', () => {

manager.updateChannelKeys([{ name: 'LongFast', pskBase64: 'AQ==', index: 1 }]);

const nameToIndex: Map<string, number> = (manager as any).channelNameToIndex;
expect(nameToIndex.get('LongFast')).toBe(1);
expect(mqttChannelTestAccess(manager).channelNameToIndex.get('LongFast')).toBe(1);
expect(manager.getChannelNameToIndex()).toEqual({ LongFast: 1 });
});

it('Nathan/Colorado: radio LongFast@1 overrides manual LongFast@0 for topic attribution', () => {
const manager = new MQTTManager();
const access = mqttChannelTestAccess(manager);
stubMqttConnect(manager);
manager.connect({
server: 'localhost',
port: 1883,
username: '',
password: '',
topicPrefix: 'msh/US/CO/',
autoLaunch: false,
channelPsks: ['LongFast@0=AQ=='],
});

expect(access.channelNameToIndex.get('LongFast')).toBe(0);

manager.updateChannelKeys([{ name: 'LongFast', pskBase64: 'AQ==', index: 1 }]);

expect(manager.getChannelNameToIndex().LongFast).toBe(1);
// Manual default-public PSK preserved when radio also pushes AQ==
expect(access.channelKeysByName.get('LongFast')?.equals(DEFAULT_PSK)).toBe(true);
});

it('Nathan/Colorado: inbound LongFast topic + packet channel 0 attributes to slot 1 after radio sync overrides @0', () => {
const manager = new MQTTManager();
const access = mqttChannelTestAccess(manager);
stubMqttConnect(manager);
manager.connect({
server: 'localhost',
port: 1883,
username: '',
password: '',
topicPrefix: 'msh/US/CO/',
autoLaunch: false,
channelPsks: ['LongFast@0=AQ=='],
});
manager.updateChannelKeys([{ name: 'LongFast', pskBase64: 'AQ==', index: 1 }]);

const nodeId = 0x095cf12b;
const packetId = 0x00000099;
const dataBytes = toBinary(
DataSchema,
create(DataSchema, {
portnum: PortNum.TEXT_MESSAGE_APP,
payload: new TextEncoder().encode('Good morning everyone!'),
}),
);
const payload = buildEnvelope({
nodeId,
packetId,
dataBytes,
psk: DEFAULT_PSK,
channelName: 'LongFast',
channel: 0,
});

const messages: unknown[] = [];
manager.on('message', (m) => messages.push(m));
access.onMessage('msh/US/CO/2/e/LongFast/!095cf12b', payload);

expect(messages).toHaveLength(1);
expect((messages[0] as { channel: number }).channel).toBe(1);
});

it('Nathan/Colorado: JSON LongFast with manual @0 then radio @1 attributes to slot 1', () => {
const manager = new MQTTManager();
const access = mqttChannelTestAccess(manager);
stubMqttConnect(manager);
manager.connect({
server: 'localhost',
port: 1883,
username: '',
password: '',
topicPrefix: 'msh/US/CO/',
autoLaunch: false,
channelPsks: ['LongFast@0=AQ=='],
});
manager.updateChannelKeys([{ name: 'LongFast', pskBase64: 'AQ==', index: 1 }]);

const nodeId = 0xaabbccdd;
const json = {
type: 'text',
from: nodeId,
channel: 0,
text: 'json public chat',
};

const messages: unknown[] = [];
manager.on('message', (m) => messages.push(m));
access.onMessage('msh/US/CO/2/json/LongFast/!aabbccdd', Buffer.from(JSON.stringify(json)));

expect(messages).toHaveLength(1);
expect((messages[0] as { channel: number }).channel).toBe(1);
});

it('keeps LongFast@1 when manual and radio both say slot 1', () => {
const manager = new MQTTManager();
stubMqttConnect(manager);
manager.connect({
server: 'localhost',
port: 1883,
username: '',
password: '',
topicPrefix: 'msh/',
autoLaunch: false,
channelPsks: ['LongFast@1=AQ=='],
});
manager.updateChannelKeys([{ name: 'LongFast', pskBase64: 'AQ==', index: 1 }]);
expect(manager.getChannelNameToIndex().LongFast).toBe(1);
});

it('preserves manual Garber PSK when radio sync pushes a different key', () => {
const manager = new MQTTManager();
(manager as any)._doConnect = () => {};
const access = mqttChannelTestAccess(manager);
stubMqttConnect(manager);
const customGarber = Buffer.alloc(32, 0x11);
const radioGarber = Buffer.alloc(32, 0x22);

Expand All @@ -1383,9 +1511,8 @@ describe('updateChannelKeys', () => {
{ name: 'Garber', pskBase64: radioGarber.toString('base64'), index: 2 },
]);

const byName: Map<string, Buffer> = (manager as any).channelKeysByName;
expect(byName.get('Garber')?.equals(customGarber)).toBe(true);
expect(byName.get('Garber')?.equals(radioGarber)).toBe(false);
expect(access.channelKeysByName.get('Garber')?.equals(customGarber)).toBe(true);
expect(access.channelKeysByName.get('Garber')?.equals(radioGarber)).toBe(false);

const nodeId = 0x11223344;
const packetId = 0x00000041;
Expand All @@ -1406,7 +1533,7 @@ describe('updateChannelKeys', () => {

const messages: unknown[] = [];
manager.on('message', (m) => messages.push(m));
(manager as any).onMessage('msh/US/2/e/Garber/!11223344', payload);
access.onMessage('msh/US/2/e/Garber/!11223344', payload);

expect(messages).toHaveLength(1);
expect((messages[0] as { payload: string }).payload).toBe('manual garber key');
Expand Down
26 changes: 15 additions & 11 deletions src/main/mqtt-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,9 +360,9 @@ export class MQTTManager extends EventEmitter {
const idx = entry.index >>> 0;
if (idx <= 7) {
radioTopicIndices.set(name, idx);
if (!this.manualChannelKeyNames.has(name)) {
this.channelNameToIndex.set(name, idx);
}
// Radio local slot is source of truth for topic→index attribution (even when a
// manual LongFast@0= line exists — Colorado / non-primary public layouts).
this.channelNameToIndex.set(name, idx);
}
}
if (this.manualChannelKeyNames.has(name)) continue;
Expand All @@ -378,15 +378,10 @@ export class MQTTManager extends EventEmitter {
this.radioChannelKeyNames.add(name);
}
this.applyManualChannelPskLines(this.manualChannelPskLines);
// Manual lines may reset LongFast→0 (bare LongFast= or LongFast@0=). Re-apply radio
// topic indices so local RF layout wins for inbound MQTT channel attribution.
for (const [name, idx] of radioTopicIndices) {
if (!this.manualChannelKeyNames.has(name)) continue;
const manualHasExplicitIndex = this.manualChannelPskLines.some((line) => {
const parsed = parseChannelPskLine(line);
return parsed?.name === name && parsed.index !== undefined;
});
if (!manualHasExplicitIndex) {
this.channelNameToIndex.set(name, idx);
}
this.channelNameToIndex.set(name, idx);
}
this.rebuildAllDecryptKeys();
}
Expand Down Expand Up @@ -1188,6 +1183,15 @@ export class MQTTManager extends EventEmitter {
return this.status;
}

/** Sanitized topic channel name → local slot (0–7) for debug triage — no PSKs. */
getChannelNameToIndex(): Record<string, number> {
const out: Record<string, number> = {};
for (const [name, idx] of this.channelNameToIndex) {
out[name] = idx;
}
return out;
}

getClientId(): string {
return this.clientId;
}
Expand Down
1 change: 1 addition & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
getClientId: (protocol?: MeshProtocol): Promise<string> =>
ipcRenderer.invoke('mqtt:getClientId', protocol),
getCachedNodes: () => ipcRenderer.invoke('mqtt:getCachedNodes'),
getChannelNameToIndex: () => ipcRenderer.invoke('mqtt:getChannelNameToIndex'),
updateChannelKeys: (args: { entries: { name: string; pskBase64: string; index?: number }[] }) =>
ipcRenderer.invoke('mqtt:updateChannelKeys', args),
updateTopicPrefix: (args: { topicPrefix: string }) =>
Expand Down
63 changes: 63 additions & 0 deletions src/renderer/lib/connectedMeshcoreBleMac.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest';

import {
readMeshcoreWebBluetoothDeviceId,
resolveConnectedMeshcoreBleIdentity,
} from './connectedMeshcoreBleMac';

describe('resolveConnectedMeshcoreBleIdentity', () => {
it('prefers explicit blePeripheralId over Web Bluetooth and last-id fallbacks', () => {
expect(
resolveConnectedMeshcoreBleIdentity({
blePeripheralId: 'aa:bb:cc:dd:ee:ff',
webBluetoothDeviceId: 'web-bt-uuid',
fallbackLastBlePeripheralId: 'stored-id',
}),
).toBe('aa:bb:cc:dd:ee:ff');
});

it('uses Web Bluetooth device id when peripheral id is missing (Linux)', () => {
expect(
resolveConnectedMeshcoreBleIdentity({
blePeripheralId: undefined,
webBluetoothDeviceId: ' web-bt-uuid ',
fallbackLastBlePeripheralId: 'stored-id',
}),
).toBe('web-bt-uuid');
});

it('falls back to last remembered BLE id', () => {
expect(
resolveConnectedMeshcoreBleIdentity({
blePeripheralId: '',
webBluetoothDeviceId: null,
fallbackLastBlePeripheralId: 'stored-mac',
}),
).toBe('stored-mac');
});

it('returns null when no candidates are usable', () => {
expect(
resolveConnectedMeshcoreBleIdentity({
blePeripheralId: ' ',
webBluetoothDeviceId: undefined,
fallbackLastBlePeripheralId: null,
}),
).toBeNull();
});
});

describe('readMeshcoreWebBluetoothDeviceId', () => {
it('reads getWebBluetoothDeviceId from duck-typed connections', () => {
expect(
readMeshcoreWebBluetoothDeviceId({
getWebBluetoothDeviceId: () => 'linux-device-id',
}),
).toBe('linux-device-id');
});

it('returns null for non-Web-Bluetooth handles', () => {
expect(readMeshcoreWebBluetoothDeviceId({})).toBeNull();
expect(readMeshcoreWebBluetoothDeviceId(null)).toBeNull();
});
});
Loading