335 lines
9.8 KiB
Go
335 lines
9.8 KiB
Go
// 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)
|
||
}
|