meshcli: Meshtastic MQTT 纯 CLI 聊天客户端(数据包解码参考 meshtastic_mqtt_server)

This commit is contained in:
dsh
2026-08-20 20:55:06 +08:00
commit 7beecfc7ce
7 changed files with 1131 additions and 0 deletions
+334
View File
@@ -0,0 +1,334 @@
// meshcli 是一个纯 CLI 的 Meshtastic MQTT 聊天客户端。
// 连接 MQTT broker(如 mesh.lmve.net),订阅 msh/CN/2/e/# 频道流量,
// 解码 Meshtastic 数据包(AES-CTR 频道加密),实现频道聊天。
//
// 用法示例:
//
// meshcli -server mesh.lmve.net -user meshdev -pass large4cats \
// -node-id !1234abcd -name "CLI 聊天" -short CLI
// meshcli -send "大家好" -name "CLI 聊天"
package main
import (
"bufio"
"crypto/rand"
"encoding/binary"
"flag"
"fmt"
"os"
"os/signal"
"sort"
"strings"
"sync"
"syscall"
"time"
mqtt "github.com/eclipse/paho.mqtt.golang"
"meshcli/mesh"
)
var (
flagServer = flag.String("server", "mesh.lmve.net", "MQTT broker 地址")
flagPort = flag.Int("port", 1883, "MQTT broker 端口")
flagUser = flag.String("user", "meshdev", "MQTT 用户名")
flagPass = flag.String("pass", "large4cats", "MQTT 密码")
flagPSK = flag.String("psk", "AQ==", "频道 PSKBase64AQ== 为默认密钥)")
flagPrefix = flag.String("prefix", "msh/CN", "MQTT topic 前缀")
flagChannel = flag.String("channel", "LongFast", "频道名")
flagNodeID = flag.String("node-id", "", "本客户端节点 ID!xxxxxxxx,默认随机生成)")
flagName = flag.String("name", "MeshCLI", "节点 Long Name")
flagShort = flag.String("short", "MCLI", "节点 Short Name(≤4 字符)")
flagAnnounce = flag.Bool("announce", true, "连接后广播一次节点信息")
flagVerbose = flag.Bool("verbose", false, "显示全部数据包(位置/遥测/节点信息等)")
flagTo = flag.String("to", "", "私聊目标节点 ID(默认广播到频道)")
flagSend = flag.String("send", "", "发送一条消息后退出(单发模式)")
)
type nodeRegistry struct {
mu sync.RWMutex
nodes map[uint32]*mesh.NodeInfo
}
func (r *nodeRegistry) put(info *mesh.NodeInfo) {
r.mu.Lock()
defer r.mu.Unlock()
r.nodes[info.From] = info
}
func (r *nodeRegistry) name(nodeNum uint32) string {
r.mu.RLock()
defer r.mu.RUnlock()
if info, ok := r.nodes[nodeNum]; ok && info.LongName != "" {
return info.LongName
}
return mesh.NodeNumToID(nodeNum)
}
func (r *nodeRegistry) list() []string {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]string, 0, len(r.nodes))
for num, info := range r.nodes {
name := info.LongName
if name == "" {
name = mesh.NodeNumToID(num)
}
out = append(out, fmt.Sprintf(" %-20s %-6s %s", name, info.ShortName, mesh.NodeNumToID(num)))
}
sort.Strings(out)
return out
}
func main() {
flag.Parse()
psk, err := mesh.ExpandPSK(*flagPSK)
if err != nil {
fmt.Fprintln(os.Stderr, "PSK 无效:", err)
os.Exit(1)
}
fromNum, err := resolveNodeNum(*flagNodeID)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
toNum := mesh.NodeNumBroadcast
if *flagTo != "" {
toNum, err = mesh.ParseNodeID(*flagTo)
if err != nil {
fmt.Fprintln(os.Stderr, "-to 无效:", err)
os.Exit(1)
}
}
registry := &nodeRegistry{nodes: map[uint32]*mesh.NodeInfo{}}
// 把自己登记进去
registry.put(&mesh.NodeInfo{From: fromNum, ID: mesh.NodeNumToID(fromNum), LongName: *flagName, ShortName: *flagShort})
broker := fmt.Sprintf("tcp://%s:%d", *flagServer, *flagPort)
clientID := fmt.Sprintf("meshcli_%08x", fromNum)
opts := mqtt.NewClientOptions().
AddBroker(broker).
SetClientID(clientID).
SetUsername(*flagUser).
SetPassword(*flagPass).
SetConnectTimeout(10 * time.Second).
SetKeepAlive(30 * time.Second).
SetAutoReconnect(true).
SetOnConnectHandler(func(c mqtt.Client) {
topic := strings.Trim(*flagPrefix, "/") + "/2/e/#"
if token := c.Subscribe(topic, 0, messageHandler(psk, registry)); token.Wait() && token.Error() != nil {
fmt.Fprintln(os.Stderr, "订阅失败:", token.Error())
} else {
fmt.Printf("已连接 %s,订阅 %s,本机 %s (%s)\n", broker, topic, *flagName, mesh.NodeNumToID(fromNum))
}
if *flagAnnounce && *flagSend == "" {
announceNodeInfo(c, fromNum, psk)
}
})
client := mqtt.NewClient(opts)
if token := client.Connect(); token.Wait() && token.Error() != nil {
fmt.Fprintln(os.Stderr, "连接失败:", token.Error())
os.Exit(1)
}
defer client.Disconnect(500)
// 单发模式:发一条消息(和节点信息)后退出
if *flagSend != "" {
if *flagAnnounce {
announceNodeInfo(client, fromNum, psk)
}
if err := sendText(client, fromNum, toNum, psk, *flagSend); err != nil {
fmt.Fprintln(os.Stderr, "发送失败:", err)
os.Exit(1)
}
fmt.Printf("已发送: %s\n", *flagSend)
time.Sleep(1500 * time.Millisecond)
return
}
// 交互模式
interactive(client, fromNum, toNum, psk, registry)
}
// resolveNodeNum 解析 -node-id;未指定时生成随机节点号。
func resolveNodeNum(nodeID string) (uint32, error) {
if nodeID == "" {
var buf [4]byte
if _, err := rand.Read(buf[:]); err != nil {
return 0, fmt.Errorf("生成随机节点号失败: %w", err)
}
return binary.LittleEndian.Uint32(buf[:]), nil
}
return mesh.ParseNodeID(nodeID)
}
// sendText 构建并发布一条加密的文本消息。
func sendText(c mqtt.Client, fromNum, toNum uint32, psk []byte, text string) error {
raw, err := mesh.BuildTextServiceEnvelope(mesh.TextBuildOptions{
BuildOptions: mesh.BuildOptions{
FromNodeNum: fromNum,
ToNodeNum: toNum,
PacketID: mesh.RandomPacketID(),
ChannelID: *flagChannel,
GatewayID: mesh.NodeNumToID(fromNum),
PSK: psk,
Encrypt: true,
ViaMQTT: true,
},
Text: text,
})
if err != nil {
return err
}
topic := mqttTopic(*flagPrefix, *flagChannel, mesh.NodeNumToID(fromNum))
token := c.Publish(topic, 0, false, raw)
if token.Wait() && token.Error() != nil {
return token.Error()
}
return nil
}
// announceNodeInfo 广播本节点的 NODEINFO_APP,让网格认识我们。
func announceNodeInfo(c mqtt.Client, fromNum uint32, psk []byte) {
raw, err := mesh.BuildNodeInfoServiceEnvelope(mesh.NodeInfoBuildOptions{
BuildOptions: mesh.BuildOptions{
FromNodeNum: fromNum,
ToNodeNum: mesh.NodeNumBroadcast,
PacketID: mesh.RandomPacketID(),
ChannelID: *flagChannel,
GatewayID: mesh.NodeNumToID(fromNum),
PSK: psk,
Encrypt: true,
ViaMQTT: true,
},
NodeID: mesh.NodeNumToID(fromNum),
LongName: *flagName,
ShortName: *flagShort,
Role: 0, // CLIENT
})
if err != nil {
fmt.Fprintln(os.Stderr, "构建节点信息失败:", err)
return
}
topic := mqttTopic(*flagPrefix, *flagChannel, mesh.NodeNumToID(fromNum))
c.Publish(topic, 0, false, raw)
}
func mqttTopic(prefix, channel, nodeID string) string {
return strings.Trim(prefix, "/") + "/2/e/" + channel + "/" + nodeID
}
func messageHandler(psk []byte, registry *nodeRegistry) mqtt.MessageHandler {
return func(_ mqtt.Client, msg mqtt.Message) {
decoded, err := mesh.Decode(msg.Topic(), msg.Payload(), psk)
if err != nil {
if *flagVerbose {
fmt.Printf("[解码失败] %s: %v\n", msg.Topic(), err)
}
return
}
now := time.Now().Format("15:04:05")
switch v := decoded.(type) {
case *mesh.TextMessage:
from := registry.name(v.From)
text := v.Text
if text == "" {
text = fmt.Sprintf("[非UTF-8: %s]", v.Hex)
}
if v.To == mesh.NodeNumBroadcast {
fmt.Printf("[%s] %s: %s\n", now, from, text)
} else {
fmt.Printf("[%s] %s → %s: %s\n", now, from, mesh.NodeNumToID(v.To), text)
}
case *mesh.NodeInfo:
registry.put(v)
if *flagVerbose {
fmt.Printf("[%s] 节点信息: %-20s %-6s %s\n", now, v.LongName, v.ShortName, mesh.NodeNumToID(v.From))
}
case *mesh.Position:
if *flagVerbose && v.Latitude != nil && v.Longitude != nil {
fmt.Printf("[%s] 位置 %s: %.6f, %.6f\n", now, registry.name(v.From), *v.Latitude, *v.Longitude)
}
case *mesh.GenericPacket:
if *flagVerbose {
fmt.Printf("[%s] %s 来自 %s%d 字节)\n", now, mesh.PortnumName(v.Portnum), registry.name(v.From), v.PayloadLen)
}
}
}
}
func interactive(c mqtt.Client, fromNum, toNum uint32, psk []byte, registry *nodeRegistry) {
scanner := bufio.NewScanner(os.Stdin)
fmt.Println("输入消息发送到频道;/help 查看命令")
for {
fmt.Print("> ")
if !scanner.Scan() {
break
}
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
switch {
case line == "/quit" || line == "/exit" || line == "/q":
fmt.Println("再见")
return
case line == "/help" || line == "/?":
printHelp()
case line == "/who" || line == "/nodes":
for _, row := range registry.list() {
fmt.Println(row)
}
case strings.HasPrefix(line, "/name "):
*flagName = strings.TrimSpace(strings.TrimPrefix(line, "/name "))
registry.put(&mesh.NodeInfo{From: fromNum, ID: mesh.NodeNumToID(fromNum), LongName: *flagName, ShortName: *flagShort})
announceNodeInfo(c, fromNum, psk)
fmt.Printf("名字已更新为 %s 并重新广播\n", *flagName)
case strings.HasPrefix(line, "/to "):
parts := strings.Fields(line)
if len(parts) != 2 {
fmt.Println("用法: /to !xxxxxxxx 设置私聊目标;/to 恢复广播")
continue
}
if parts[1] == "广播" {
toNum = mesh.NodeNumBroadcast
fmt.Println("目标: 广播")
} else {
num, err := mesh.ParseNodeID(parts[1])
if err != nil {
fmt.Println("无效节点 ID:", err)
continue
}
toNum = num
fmt.Printf("目标: %s\n", mesh.NodeNumToID(num))
}
default:
if err := sendText(c, fromNum, toNum, psk, line); err != nil {
fmt.Println("发送失败:", err)
} else {
fmt.Printf("→ %s\n", line)
}
}
}
}
func printHelp() {
fmt.Println(`命令:
/help 显示帮助
/who 列出已知节点
/name <名字> 修改本机名字并重新广播
/to !xxxxxxxx 设置私聊目标节点(PSK 频道直发,需对方同频道)
/to 广播 恢复频道广播
/quit 退出`)
}
// 让 Ctrl+C 优雅退出
func init() {
signal.Ignore(syscall.SIGPIPE)
}