// 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) }