From 54c9b733d8712c745e35b3d6cb8b0e7fe986b940 Mon Sep 17 00:00:00 2001 From: 1Croydan1 <1Croydan1@users.noreply.github.com> Date: Thu, 4 Jun 2026 22:03:19 +0300 Subject: [PATCH 1/2] fix: align MQTT JSON transport with the Meshtastic protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MQTT transport's JSON did not match the Meshtastic JSON format it documents (default broker mqtt.meshtastic.org / msh/REGION/2/json), so as shipped it could neither receive text from the broker nor have its downlinks transmitted by a gateway. Inbound (msh/REGION/2/json/...): - received text packets have type "text", not "sendtext" ("sendtext" is the downlink verb and never appears on uplink), so the filter dropped every real message - attribute the message to `from` (the originating node), not `sender` (the gateway that uploaded it to MQTT) — otherwise every sender in a multi-node mesh collapses to the gateway - `sender` is a node ID, not a display name, so stop surfacing it as one Downlink (publish to msh/REGION/2/json/mqtt/): - `payload` must be a plain string, not { text } - use `from` (numeric gateway node ID) and `channel` (index), not `sender` / `channel_name` - publish to the documented ".../2/json/mqtt/" topic - thread the numeric channelIndex through (mirroring the serial transport) instead of parsing it out of the channel name Verified end-to-end against a real Meshtastic gateway: an injected uplink is now received and answered, and the reply downlink {"from":,"type":"sendtext","payload":"42","channel":1} is accepted by the gateway and transmitted on the mesh. Ref: https://meshtastic.org/docs/software/integrations/mqtt/ Co-Authored-By: Claude Opus 4.8 (1M context) --- src/monitor.ts | 11 +++--- src/mqtt-client.ts | 84 +++++++++++++++++++++++++++++----------------- src/send.ts | 6 ++-- 3 files changed, 63 insertions(+), 38 deletions(-) diff --git a/src/monitor.ts b/src/monitor.ts index 9d12c32..6ae5865 100644 --- a/src/monitor.ts +++ b/src/monitor.ts @@ -289,8 +289,11 @@ async function monitorMqtt(params: { if (!mqttClient) { return; } - const channelName = message.isGroup ? message.channelName : undefined; - await mqttClient.sendText(text, message.isGroup ? undefined : target, channelName); + await mqttClient.sendText( + text, + message.isGroup ? undefined : target, + message.isGroup ? message.channelIndex : undefined, + ); opts.statusSink?.({ lastOutboundAt: Date.now() }); core.channel.activity.record({ channel: "meshtastic", @@ -304,8 +307,8 @@ async function monitorMqtt(params: { }); // Register active send function for `openclaw message send`. - setActiveMqttSend((text, destination, channelName) => - mqttClient ? mqttClient.sendText(text, destination, channelName) : Promise.resolve(), + setActiveMqttSend((text, destination, channelIndex) => + mqttClient ? mqttClient.sendText(text, destination, channelIndex) : Promise.resolve(), ); logger.info( diff --git a/src/mqtt-client.ts b/src/mqtt-client.ts index 56154d4..0913ebf 100644 --- a/src/mqtt-client.ts +++ b/src/mqtt-client.ts @@ -1,15 +1,17 @@ import mqtt from "mqtt"; -import { nodeNumToHex } from "./normalize.js"; +import { hexToNodeNum, looksLikeMeshtasticNodeId, nodeNumToHex } from "./normalize.js"; import type { MeshtasticMqttConfig } from "./types.js"; -/** Derive a publish topic from a subscribe topic. - * Standard pattern: "msh/REGION/NUM/json/#" → "msh/REGION/NUM/json/mqtt". - * If the topic has no wildcard suffix, appends "/mqtt" as the publish leaf. */ +/** Derive the JSON downlink topic from a subscribe topic. + * Standard pattern: "msh/REGION/NUM/json/#" → "msh/REGION/NUM/json/mqtt/". + * Meshtastic firmware accepts JSON downlinks on the ".../2/json/mqtt/" topic; + * a channel named "mqtt" (with downlink enabled) must exist on the gateway. + * See https://meshtastic.org/docs/software/integrations/mqtt/ */ function derivePublishTopic(subscribeTopic: string): string { if (subscribeTopic.endsWith("/#")) { - return subscribeTopic.slice(0, -2) + "/mqtt"; + return subscribeTopic.slice(0, -2) + "/mqtt/"; } - return subscribeTopic + "/mqtt"; + return subscribeTopic + "/mqtt/"; } export type MeshtasticMqttTextEvent = { @@ -32,13 +34,15 @@ export type MeshtasticMqttClientOptions = { }; export type MeshtasticMqttClient = { - sendText: (text: string, destination?: string, channelName?: string) => Promise; + sendText: (text: string, destination?: string, channelIndex?: number) => Promise; close: () => void; }; /** - * Meshtastic MQTT JSON message format. - * Messages on the JSON topic contain: sender, from, type, payload, channel. + * Meshtastic MQTT JSON *uplink* message (received from the broker). + * Text packets arrive as { type: "text", payload: { text }, from, to, channel, + * sender }, where `from` is the originating node and `sender` is the gateway + * node that published the packet to MQTT. */ type MqttJsonMessage = { sender?: string; @@ -50,6 +54,19 @@ type MqttJsonMessage = { channel_name?: string; }; +/** + * Meshtastic MQTT JSON *downlink* message (published to ".../2/json/mqtt/"). + * `payload` is a plain string and `from` is the numeric node ID of the gateway + * that will transmit the message. `channel` (index) and `to` are optional. + */ +type MqttJsonDownlink = { + from: number; + type: "sendtext"; + payload: string; + channel?: number; + to?: number; +}; + /** Connect to a Meshtastic mesh via MQTT broker. */ export async function connectMeshtasticMqtt( options: MeshtasticMqttClientOptions, @@ -109,16 +126,18 @@ export async function connectMeshtasticMqtt( return; } - // Only handle text messages. - if (msg.type !== "sendtext" || !msg.payload?.text) { + // Only handle text messages. Received text packets use type "text" + // ("sendtext" is the downlink verb and never appears on uplink). + if (msg.type !== "text" || !msg.payload?.text) { return; } - // Skip own messages. - const senderNodeId = msg.sender - ? msg.sender.toLowerCase() - : msg.from - ? nodeNumToHex(msg.from) + // Identify the originating node. `from` is the actual author; `sender` is + // only the gateway that uploaded the packet to MQTT, so prefer `from`. + const senderNodeId = msg.from !== undefined + ? nodeNumToHex(msg.from) + : msg.sender + ? msg.sender.toLowerCase() : undefined; if (!senderNodeId) { return; @@ -134,13 +153,10 @@ export async function connectMeshtasticMqtt( && msg.to !== 0xffffffff && nodeNumToHex(msg.to).toLowerCase() === myNodeId; - const senderName = msg.sender && msg.sender !== senderNodeId - ? msg.sender - : undefined; - + // The JSON envelope carries no display name (`sender` is the gateway node + // ID, not a name), so leave senderName unset and let the node ID stand in. const event: MeshtasticMqttTextEvent = { senderNodeId: senderNodeId.startsWith("!") ? senderNodeId : `!${senderNodeId}`, - senderName, text: msg.payload.text, channelIndex: msg.channel ?? 0, channelName: msg.channel_name, @@ -166,18 +182,24 @@ export async function connectMeshtasticMqtt( } return { - sendText: async (text, destination, channelName) => { - const outboundTopic = channelName - ? publishTopic.replace(/\/[^/]*$/, `/${channelName}`) - : publishTopic; - const message: MqttJsonMessage = { - sender: myNodeId ?? options.myNodeId, + sendText: async (text, destination, channelIndex) => { + // Standard Meshtastic JSON downlink: publish to ".../2/json/mqtt/" with + // { from, type: "sendtext", payload: , channel?, to? }. `payload` + // is a plain string (not an object) and `from` is the numeric node ID of + // the gateway that transmits. The gateway needs a channel named "mqtt" + // with downlink enabled and JSON output on. + // https://meshtastic.org/docs/software/integrations/mqtt/ + const fromNum = myNodeId ? hexToNodeNum(myNodeId) : 0; + const message: MqttJsonDownlink = { + from: fromNum, type: "sendtext", - payload: { text }, - ...(destination ? { to: Number.parseInt(destination.replace("!", ""), 16) } : {}), - ...(channelName ? { channel_name: channelName } : {}), + payload: text, + ...(channelIndex !== undefined ? { channel: channelIndex } : {}), + ...(destination && looksLikeMeshtasticNodeId(destination) + ? { to: hexToNodeNum(destination) } + : {}), }; - client.publish(outboundTopic, JSON.stringify(message)); + client.publish(publishTopic, JSON.stringify(message)); }, close: () => { client.end(true); diff --git a/src/send.ts b/src/send.ts index 181790a..9eadb7a 100644 --- a/src/send.ts +++ b/src/send.ts @@ -23,7 +23,7 @@ let activeSerialSend: | ((text: string, destination?: number, channelIndex?: number) => Promise) | null = null; let activeMqttSend: - | ((text: string, destination?: string, channelName?: string) => Promise) + | ((text: string, destination?: string, channelIndex?: number) => Promise) | null = null; export function setActiveSerialSend( @@ -33,7 +33,7 @@ export function setActiveSerialSend( } export function setActiveMqttSend( - fn: ((text: string, destination?: string, channelName?: string) => Promise) | null, + fn: ((text: string, destination?: string, channelIndex?: number) => Promise) | null, ) { activeMqttSend = fn; } @@ -79,7 +79,7 @@ export async function sendMessageMeshtastic( if (transport === "mqtt") { if (activeMqttSend) { - await activeMqttSend(stripped, target, opts.channelName); + await activeMqttSend(stripped, target, opts.channelIndex); } else { throw new Error("No active MQTT connection. Run 'openclaw gateway start' to connect."); } From 8164a9583689357051d9a787d44fd0d23bb9066a Mon Sep 17 00:00:00 2001 From: 1Croydan1 <1Croydan1@users.noreply.github.com> Date: Thu, 4 Jun 2026 22:45:16 +0300 Subject: [PATCH 2/2] refactor(send): drop the dead channelName option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review (PR #18): once the send path standardized on numeric channelIndex, SendMeshtasticOptions.channelName was no longer read anywhere — both the MQTT and serial branches use channelIndex. Leaving it invited silent channel-selection drops when only channelName was set. Remove it together with the now-redundant deliverMeshtasticReply plumbing in inbound.ts. The inbound message's own channelName (group routing) is unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/inbound.ts | 3 --- src/send.ts | 1 - 2 files changed, 4 deletions(-) diff --git a/src/inbound.ts b/src/inbound.ts index c885ebe..6e76915 100644 --- a/src/inbound.ts +++ b/src/inbound.ts @@ -82,7 +82,6 @@ async function deliverMeshtasticReply(params: { target: string; accountId: string; channelIndex?: number; - channelName?: string; chunkLimit?: number; sendReply?: (target: string, text: string) => Promise; statusSink?: (patch: { lastOutboundAt?: number }) => void; @@ -104,7 +103,6 @@ async function deliverMeshtasticReply(params: { await sendMessageMeshtastic(params.target, chunk, { accountId: params.accountId, channelIndex: params.channelIndex, - channelName: params.channelName, }); } // Small delay between chunks to avoid overwhelming the radio queue. @@ -383,7 +381,6 @@ export async function handleMeshtasticInbound(params: { target: peerId, accountId: account.accountId, channelIndex: message.isGroup ? message.channelIndex : undefined, - channelName: message.isGroup ? message.channelName : undefined, chunkLimit: account.config.textChunkLimit, sendReply: params.sendReply, statusSink, diff --git a/src/send.ts b/src/send.ts index 9eadb7a..4b9e047 100644 --- a/src/send.ts +++ b/src/send.ts @@ -10,7 +10,6 @@ import type { CoreConfig } from "./types.js"; type SendMeshtasticOptions = { accountId?: string; channelIndex?: number; - channelName?: string; }; export type SendMeshtasticResult = {