meshcli: Meshtastic MQTT 纯 CLI 聊天客户端(数据包解码参考 meshtastic_mqtt_server)
This commit is contained in:
@@ -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==", "频道 PSK(Base64,AQ== 为默认密钥)")
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user