Files

335 lines
9.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)
}