From 6623537d2a062492f9abc739816bce2e0cdb45b3 Mon Sep 17 00:00:00 2001 From: Timendus Date: Thu, 24 Oct 2024 23:37:22 +0200 Subject: [PATCH 01/87] Made a start at rewriting the thing in Go --- go/Makefile | 10 ++ go/go.mod | 14 +++ go/go.sum | 22 +++++ go/main.go | 63 +++++++++++++ go/meshtastic/channel.go | 28 ++++++ go/meshtastic/connected_node.go | 149 ++++++++++++++++++++++++++++++ go/meshtastic/device_metrics.go | 24 +++++ go/meshtastic/helpers.go | 11 +++ go/meshtastic/node.go | 81 ++++++++++++++++ go/meshtastic/node_list.go | 38 ++++++++ go/meshtastic/position.go | 39 ++++++++ go/meshtastic/stream_interface.go | 122 ++++++++++++++++++++++++ go/meshtastic/time.go | 40 ++++++++ 13 files changed, 641 insertions(+) create mode 100644 go/Makefile create mode 100644 go/go.mod create mode 100644 go/go.sum create mode 100644 go/main.go create mode 100644 go/meshtastic/channel.go create mode 100644 go/meshtastic/connected_node.go create mode 100644 go/meshtastic/device_metrics.go create mode 100644 go/meshtastic/helpers.go create mode 100644 go/meshtastic/node.go create mode 100644 go/meshtastic/node_list.go create mode 100644 go/meshtastic/position.go create mode 100644 go/meshtastic/stream_interface.go create mode 100644 go/meshtastic/time.go diff --git a/go/Makefile b/go/Makefile new file mode 100644 index 0000000..2cb3807 --- /dev/null +++ b/go/Makefile @@ -0,0 +1,10 @@ +SHELL := /bin/bash + +all: run + +install-protobuf-compiler: + @sudo dnf install protobuf-compiler + @go install google.golang.org/protobuf/cmd/protoc-gen-go@latest + +run: + @go run *.go diff --git a/go/go.mod b/go/go.mod new file mode 100644 index 0000000..bb451ca --- /dev/null +++ b/go/go.mod @@ -0,0 +1,14 @@ +module github.com/timendus/meshbot + +go 1.22.7 + +require ( + buf.build/gen/go/meshtastic/protobufs/protocolbuffers/go v1.35.1-20241006120827-cc36fd21e859.1 + go.bug.st/serial v1.6.2 + google.golang.org/protobuf v1.35.1 +) + +require ( + github.com/creack/goselect v0.1.2 // indirect + golang.org/x/sys v0.0.0-20220829200755-d48e67d00261 // indirect +) diff --git a/go/go.sum b/go/go.sum new file mode 100644 index 0000000..9caed0c --- /dev/null +++ b/go/go.sum @@ -0,0 +1,22 @@ +buf.build/gen/go/meshtastic/protobufs/protocolbuffers/go v1.35.1-20241006120827-cc36fd21e859.1 h1:jVWv67MPDtbIuA86+CvVbKd3plzTxA1a6RWeN+C2qdM= +buf.build/gen/go/meshtastic/protobufs/protocolbuffers/go v1.35.1-20241006120827-cc36fd21e859.1/go.mod h1:4j54QYpOxc7iCSXqucVz/TXhq+MCRZ0Xs4uEKjxZyt0= +github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0= +github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY= +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +go.bug.st/serial v1.6.2 h1:kn9LRX3sdm+WxWKufMlIRndwGfPWsH1/9lCWXQCasq8= +go.bug.st/serial v1.6.2/go.mod h1:UABfsluHAiaNI+La2iESysd9Vetq7VRdpxvjx7CmmOE= +golang.org/x/sys v0.0.0-20220829200755-d48e67d00261 h1:v6hYoSR9T5oet+pMXwUWkbiVqx/63mlHjefrHmxwfeY= +golang.org/x/sys v0.0.0-20220829200755-d48e67d00261/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= +google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/go/main.go b/go/main.go new file mode 100644 index 0000000..f621e39 --- /dev/null +++ b/go/main.go @@ -0,0 +1,63 @@ +package main + +// https://meshtastic.org/docs/development/device/client-api/ +// https://buf.build/meshtastic/protobufs/docs/main:meshtastic#meshtastic.ToRadio + +import ( + "log" + "net" + "time" + + "github.com/timendus/meshbot/meshtastic" + "go.bug.st/serial" +) + +func main() { + log.Println("Starting Meshed Potatoes!") + + // Attempt to auto-detect Meshtestic device on a serial port. Otherwise, + // connect over TCP. + + var node *meshtastic.ConnectedNode + + ports, err := serial.GetPortsList() + if err != nil { + log.Fatal(err) + } + + if len(ports) > 0 { + log.Printf("Found %d serial ports:\n", len(ports)) + for i, port := range ports { + log.Printf(" [%d] %s\n", i, port) + } + log.Println("Defaulting to port: " + ports[0]) + + serialPort, err := serial.Open(ports[0], &serial.Mode{ + BaudRate: 115200, + }) + if err != nil { + log.Fatal(err) + } + + node, err = meshtastic.NewConnectedNode(serialPort, "serial-log.txt") + if err != nil { + log.Fatal(err) + } + } else { + tcpPort, err := net.Dial("tcp", "meshtastic.local:4403") + if err != nil { + log.Fatal(err) + } + + node, err = meshtastic.NewConnectedNode(tcpPort, "tcp-log.txt") + if err != nil { + log.Fatal(err) + } + } + + defer node.Close() + + for { + time.Sleep(100 * time.Millisecond) + } +} diff --git a/go/meshtastic/channel.go b/go/meshtastic/channel.go new file mode 100644 index 0000000..a5b1ee1 --- /dev/null +++ b/go/meshtastic/channel.go @@ -0,0 +1,28 @@ +package meshtastic + +import ( + "fmt" + + "buf.build/gen/go/meshtastic/protobufs/protocolbuffers/go/meshtastic" +) + +type channel struct { + id int32 + name string + passkey []byte +} + +func NewChannel(unit *meshtastic.Channel) channel { + if unit == nil { + return channel{} + } + return channel{ + id: unit.Index, + name: unit.GetSettings().Name, + passkey: unit.GetSettings().Psk, + } +} + +func (c channel) String() string { + return fmt.Sprintf("[%d] %s", c.id, c.name) +} diff --git a/go/meshtastic/connected_node.go b/go/meshtastic/connected_node.go new file mode 100644 index 0000000..756b8e8 --- /dev/null +++ b/go/meshtastic/connected_node.go @@ -0,0 +1,149 @@ +package meshtastic + +import ( + "io" + "log" + "os" + "time" + + "buf.build/gen/go/meshtastic/protobufs/protocolbuffers/go/meshtastic" +) + +type ConnectedNode struct { + stream io.ReadWriteCloser + logFile io.WriteCloser + firmwareVersion string + channels []channel + node node +} + +func NewConnectedNode(stream io.ReadWriteCloser, debugFile string) (*ConnectedNode, error) { + // Create a debug file for this device + debugSink, err := os.Create(debugFile) + if err != nil { + return nil, err + } + + // Create the new connected node + newNode := ConnectedNode{ + stream: stream, + logFile: debugSink, + node: node{ + shortName: "UNKN", + longName: "Unknown node", + id: 0, + nodeList: NewNodeList(), + }, + } + + // Spin up a goroutine to read messages from the device + go newNode.ReadMessages(stream, debugSink) + + // Wake the device + if err := wakeDevice(stream); err != nil { + return nil, err + } + + // Tell the device that we can speak ProtoBuf + if err := writeMessage(stream, &meshtastic.ToRadio{ + PayloadVariant: &meshtastic.ToRadio_WantConfigId{ + WantConfigId: 1, + }, + }); err != nil { + return nil, err + } + + return &newNode, nil +} + +func (n *ConnectedNode) Close() error { + err := n.stream.Close() + if err != nil { + return err + } + return n.logFile.Close() +} + +func (n *ConnectedNode) String() string { + color := "92" + return n.node.String(&color) +} + +func (n *ConnectedNode) SendMessage(message meshtastic.ToRadio_Packet) error { + if err := writeMessage(n.stream, &meshtastic.ToRadio{ + PayloadVariant: &message, + }); err != nil { + return err + } + return nil +} + +func (n *ConnectedNode) ReadMessages(stream io.ReadWriteCloser, debugSink *os.File) error { + for { + packet, err := readMessage(stream, debugSink) + if err != nil { + debugSink.WriteString("Error: " + err.Error() + "\n") + } + + switch packet.PayloadVariant.(type) { + case *meshtastic.FromRadio_ConfigCompleteId: + log.Println("Loaded all device information") + log.Println("This is me: " + n.String()) + log.Println("Node list: \n" + n.node.nodeList.String()) + log.Println("Channel list:") + for _, channel := range n.channels { + log.Println(" " + channel.String()) + } + case *meshtastic.FromRadio_MyInfo: + n.node.id = packet.GetMyInfo().MyNodeNum + case *meshtastic.FromRadio_Metadata: + n.firmwareVersion = packet.GetMetadata().FirmwareVersion + case *meshtastic.FromRadio_NodeInfo: + nodeInfo := packet.GetNodeInfo() + + if nodeInfo.User.Id == n.node.IDExpression() { + n.node.shortName = nodeInfo.User.LongName + n.node.longName = nodeInfo.User.ShortName + break + } + + var hopsAway uint32 = 0 + if nodeInfo.HopsAway != nil { + hopsAway = *nodeInfo.HopsAway + } + + relevantNode, exists := n.node.nodeList.nodes[nodeInfo.Num] + if !exists { + relevantNode = node{ + nodeList: nodeList{nodes: make(map[uint32]node)}, + } + } + + relevantNode.id = nodeInfo.Num + relevantNode.shortName = nodeInfo.User.ShortName + relevantNode.longName = nodeInfo.User.LongName + relevantNode.macAddr = nodeInfo.User.Macaddr + relevantNode.hwModel = nodeInfo.User.HwModel + relevantNode.role = nodeInfo.User.Role + relevantNode.snr = nodeInfo.Snr + relevantNode.lastHeard = time.Unix(int64(nodeInfo.LastHeard), 0) + relevantNode.hopsAway = hopsAway + relevantNode.isLicensed = nodeInfo.User.IsLicensed + relevantNode.position = NewPosition(nodeInfo.Position) + relevantNode.deviceMetrics = NewDeviceMetrics(nodeInfo.DeviceMetrics) + n.node.nodeList.nodes[nodeInfo.Num] = relevantNode + case *meshtastic.FromRadio_Channel: + n.channels = append(n.channels, NewChannel(packet.GetChannel())) + case *meshtastic.FromRadio_Config: + // log.Println("Ignoring configuration packet (for now)") + case *meshtastic.FromRadio_ModuleConfig: + // log.Println("Ignoring module config packet (for now)") + case *meshtastic.FromRadio_FileInfo: + // Silently ignore file info packets + case *meshtastic.FromRadio_Packet: + log.Println("Got packet: " + packet.String()) + default: + log.Println("Unhandled message: " + packet.String()) + } + } +} diff --git a/go/meshtastic/device_metrics.go b/go/meshtastic/device_metrics.go new file mode 100644 index 0000000..da71551 --- /dev/null +++ b/go/meshtastic/device_metrics.go @@ -0,0 +1,24 @@ +package meshtastic + +import "buf.build/gen/go/meshtastic/protobufs/protocolbuffers/go/meshtastic" + +type deviceMetrics struct { + batteryLevel *uint32 + voltage *float32 + channelUtilization *float32 + airUtilizationTx *float32 + uptime *uint32 +} + +func NewDeviceMetrics(metrics *meshtastic.DeviceMetrics) *deviceMetrics { + if metrics == nil { + return nil + } + return &deviceMetrics{ + batteryLevel: metrics.BatteryLevel, + voltage: metrics.Voltage, + channelUtilization: metrics.ChannelUtilization, + airUtilizationTx: metrics.AirUtilTx, + uptime: metrics.UptimeSeconds, + } +} diff --git a/go/meshtastic/helpers.go b/go/meshtastic/helpers.go new file mode 100644 index 0000000..41fa543 --- /dev/null +++ b/go/meshtastic/helpers.go @@ -0,0 +1,11 @@ +package meshtastic + +func pluralize(word string, count int) string { + if count == 1 { + return word + } + if word == "it" { + return "them" + } + return word + "s" +} diff --git a/go/meshtastic/node.go b/go/meshtastic/node.go new file mode 100644 index 0000000..c94f572 --- /dev/null +++ b/go/meshtastic/node.go @@ -0,0 +1,81 @@ +package meshtastic + +import ( + "fmt" + "time" + "unicode/utf8" + + "buf.build/gen/go/meshtastic/protobufs/protocolbuffers/go/meshtastic" +) + +type node struct { + shortName string + longName string + id uint32 + macAddr []byte + hwModel meshtastic.HardwareModel + role meshtastic.Config_DeviceConfig_Role + snr float32 + lastHeard time.Time + hopsAway uint32 + nodeList nodeList + position *position + isLicensed bool + deviceMetrics *deviceMetrics +} + +func (n *node) String(color ...*string) string { + var col string + if len(color) > 0 && color[0] != nil { + col = *color[0] + } else if n.hopsAway == 0 { + col = "96" + } else { + col = "94" + } + + var shortName string + if len(n.shortName) == 4 && utf8.RuneCountInString(n.shortName) == 1 { + // Short name is an emoji + shortName = fmt.Sprintf(" %s ", n.shortName) + } else { + shortName = fmt.Sprintf("%-4s", n.shortName) + } + + return fmt.Sprintf( + "\033[%sm[%s] %s (%s)]\033[0m", + col, + shortName, + n.longName, + n.IDExpression(), + ) +} + +func (n *node) VerboseString() string { + hardware := n.hwModel.String() + role := n.role.String() + + snr := "" + if n.snr != 0 { + snr = fmt.Sprintf(", SNR %.2f", n.snr) + } + + hopsAway := "" + if n.hopsAway > 0 { + hopsAway = fmt.Sprintf(", %d %s away", n.hopsAway, pluralize("hop", int(n.hopsAway))) + } + + return fmt.Sprintf( + "%s \033[90m(%s, %s, last heard %s ago%s%s)\033[0m", + n.String(), + hardware, + role, + timeAgo(n.lastHeard), + snr, + hopsAway, + ) +} + +func (n *node) IDExpression() string { + return fmt.Sprintf("!%x", n.id) +} diff --git a/go/meshtastic/node_list.go b/go/meshtastic/node_list.go new file mode 100644 index 0000000..5ec9b73 --- /dev/null +++ b/go/meshtastic/node_list.go @@ -0,0 +1,38 @@ +package meshtastic + +import ( + "cmp" + "slices" +) + +type nodeList struct { + nodes map[uint32]node +} + +func NewNodeList() nodeList { + return nodeList{ + nodes: make(map[uint32]node), + } +} + +func (n *nodeList) String() string { + nodes := "" + for _, node := range n.sortedNodes() { + nodes += node.VerboseString() + "\n" + } + return nodes +} + +func (n *nodeList) sortedNodes() []node { + nodes := make([]node, 0, len(n.nodes)) + for _, node := range n.nodes { + nodes = append(nodes, node) + } + slices.SortFunc(nodes, func(a, b node) int { + return cmp.Or( + cmp.Compare(a.hopsAway, b.hopsAway), + -cmp.Compare(a.lastHeard.Unix(), b.lastHeard.Unix()), + ) + }) + return nodes +} diff --git a/go/meshtastic/position.go b/go/meshtastic/position.go new file mode 100644 index 0000000..7a55a7b --- /dev/null +++ b/go/meshtastic/position.go @@ -0,0 +1,39 @@ +package meshtastic + +import ( + "math" + + "buf.build/gen/go/meshtastic/protobufs/protocolbuffers/go/meshtastic" +) + +type position struct { + latitude float32 + longitude float32 + altitude int32 +} + +func NewPosition(pos *meshtastic.Position) *position { + if pos == nil { + return nil + } + var latI float64 = 0 + var lonI float64 = 0 + var alt int32 = 0 + if pos.LatitudeI != nil { + latI = float64(*pos.LatitudeI) + } + if pos.LongitudeI != nil { + lonI = float64(*pos.LongitudeI) + } + if pos.Altitude != nil { + alt = *pos.Altitude + } + if latI == 0 && lonI == 0 && alt == 0 { + return nil + } + return &position{ + latitude: float32(latI / math.Pow(10, 7)), + longitude: float32(lonI / math.Pow(10, 7)), + altitude: alt, + } +} diff --git a/go/meshtastic/stream_interface.go b/go/meshtastic/stream_interface.go new file mode 100644 index 0000000..663a69f --- /dev/null +++ b/go/meshtastic/stream_interface.go @@ -0,0 +1,122 @@ +package meshtastic + +import ( + "errors" + "io" + "log" + "time" + + "buf.build/gen/go/meshtastic/protobufs/protocolbuffers/go/meshtastic" + "google.golang.org/protobuf/proto" +) + +const ( + START1 = 0x94 + START2 = 0xC3 + MAX_SIZE = 512 + DEBUGGING = false +) + +func wakeDevice(writer io.ReadWriteCloser) error { + // Comments copied from Python implementation + // https://github.com/meshtastic/python/blob/0bb4b31b6a147134c57fb720492c8719c037d195/meshtastic/stream_interface.py#L55-L75 + + // Send some bogus UART characters to force a sleeping device to wake, and + // if the reading statemachine was parsing a bad packet make sure + // we write enough start bytes to force it to resync (we don't use START1 + // because we want to ensure it is looking for START1) + bytes := make([]byte, 32) + _, err := writer.Write(bytes) + if err != nil { + return err + } + + // wait 100ms to give device time to start running + time.Sleep(100 * time.Millisecond) + return nil +} + +func writeMessage(writer io.ReadWriteCloser, message *meshtastic.ToRadio) error { + if DEBUGGING { + log.Println("\033[90mSending: " + message.String() + "\033[0m") + } + + bytes, err := proto.Marshal(message) + if err != nil { + return err + } + + header := [4]byte{START1, START2, byte(len(bytes) >> 8), byte(len(bytes) & 0xFF)} + _, err = writer.Write(header[:]) + if err != nil { + return err + } + + _, err = writer.Write(bytes) + if err != nil { + return err + } + return nil +} + +func readMessage(reader io.ReadWriteCloser, debugSink io.Writer) (*meshtastic.FromRadio, error) { + buffer := make([]byte, 1) + protobuffer := make([]byte, 0) + status := 0 + length := 0 + for { + n, err := reader.Read(buffer) + if err != nil { + return nil, err + } + if n == 0 { + return nil, errors.New("unexpected end of file") + } + + switch status { + case 0: + if buffer[0] == START1 { + status = 1 + } else { + // Handle any other bytes as text debug output + debugSink.Write(buffer) + } + case 1: + if buffer[0] == START2 { + status = 2 + } else { + status = 0 + debugSink.Write(buffer) + } + case 2: + length = int(buffer[0]) << 8 + status = 3 + case 3: + length |= int(buffer[0]) + if length > MAX_SIZE { + log.Printf("Invalid packet size: %d\n", length) + msb := make([]byte, 1) + msb[0] = byte(length >> 8) + debugSink.Write(msb) + debugSink.Write(buffer) + status = 0 + } else { + status = 4 + } + default: + if length > 0 { + protobuffer = append(protobuffer, buffer[0]) + length-- + if length == 0 { + status = 0 + result := meshtastic.FromRadio{} + proto.Unmarshal(protobuffer, &result) + if DEBUGGING { + log.Println("\033[90mReceived: " + result.String() + "\033[0m") + } + return &result, nil + } + } + } + } +} diff --git a/go/meshtastic/time.go b/go/meshtastic/time.go new file mode 100644 index 0000000..712709d --- /dev/null +++ b/go/meshtastic/time.go @@ -0,0 +1,40 @@ +package meshtastic + +import ( + "fmt" + "math" + "time" +) + +func timeAgo(timestamp time.Time) string { + seconds := int(time.Since(timestamp).Seconds()) + + if seconds == 1 { + return "one second" + } + if seconds < 60 { + return fmt.Sprintf("%d seconds", seconds) + } + + minutes := int(math.Floor(float64(seconds) / 60)) + if minutes == 1 { + return "one minute" + } + if minutes < 60 { + return fmt.Sprintf("%d minutes", minutes) + } + + hours := int(math.Floor(float64(minutes) / 60)) + if hours == 1 { + return "one hour" + } + if hours < 24 { + return fmt.Sprintf("%d hours", hours) + } + + days := int(math.Floor(float64(hours) / 24)) + if days == 1 { + return "one day" + } + return fmt.Sprintf("%d days", days) +} From 3963cb9fbeac87ffcde906162d847579daa87d02 Mon Sep 17 00:00:00 2001 From: Timendus Date: Fri, 25 Oct 2024 00:10:04 +0200 Subject: [PATCH 02/87] Refactor receiving messages and get rid of debug files --- go/main.go | 4 +- go/meshtastic/connected_node.go | 28 ++++------- go/meshtastic/stream_interface.go | 80 ++++++++++++++++++------------- 3 files changed, 56 insertions(+), 56 deletions(-) diff --git a/go/main.go b/go/main.go index f621e39..f312a2c 100644 --- a/go/main.go +++ b/go/main.go @@ -39,7 +39,7 @@ func main() { log.Fatal(err) } - node, err = meshtastic.NewConnectedNode(serialPort, "serial-log.txt") + node, err = meshtastic.NewConnectedNode(serialPort) if err != nil { log.Fatal(err) } @@ -49,7 +49,7 @@ func main() { log.Fatal(err) } - node, err = meshtastic.NewConnectedNode(tcpPort, "tcp-log.txt") + node, err = meshtastic.NewConnectedNode(tcpPort) if err != nil { log.Fatal(err) } diff --git a/go/meshtastic/connected_node.go b/go/meshtastic/connected_node.go index 756b8e8..9e7d3a4 100644 --- a/go/meshtastic/connected_node.go +++ b/go/meshtastic/connected_node.go @@ -3,7 +3,6 @@ package meshtastic import ( "io" "log" - "os" "time" "buf.build/gen/go/meshtastic/protobufs/protocolbuffers/go/meshtastic" @@ -11,23 +10,15 @@ import ( type ConnectedNode struct { stream io.ReadWriteCloser - logFile io.WriteCloser firmwareVersion string channels []channel node node } -func NewConnectedNode(stream io.ReadWriteCloser, debugFile string) (*ConnectedNode, error) { - // Create a debug file for this device - debugSink, err := os.Create(debugFile) - if err != nil { - return nil, err - } - +func NewConnectedNode(stream io.ReadWriteCloser) (*ConnectedNode, error) { // Create the new connected node newNode := ConnectedNode{ - stream: stream, - logFile: debugSink, + stream: stream, node: node{ shortName: "UNKN", longName: "Unknown node", @@ -37,7 +28,7 @@ func NewConnectedNode(stream io.ReadWriteCloser, debugFile string) (*ConnectedNo } // Spin up a goroutine to read messages from the device - go newNode.ReadMessages(stream, debugSink) + go newNode.ReadMessages(stream) // Wake the device if err := wakeDevice(stream); err != nil { @@ -57,11 +48,7 @@ func NewConnectedNode(stream io.ReadWriteCloser, debugFile string) (*ConnectedNo } func (n *ConnectedNode) Close() error { - err := n.stream.Close() - if err != nil { - return err - } - return n.logFile.Close() + return n.stream.Close() } func (n *ConnectedNode) String() string { @@ -78,11 +65,12 @@ func (n *ConnectedNode) SendMessage(message meshtastic.ToRadio_Packet) error { return nil } -func (n *ConnectedNode) ReadMessages(stream io.ReadWriteCloser, debugSink *os.File) error { +func (n *ConnectedNode) ReadMessages(stream io.ReadWriteCloser) error { for { - packet, err := readMessage(stream, debugSink) + packet, err := readMessage(stream) if err != nil { - debugSink.WriteString("Error: " + err.Error() + "\n") + log.Println("Error: " + err.Error()) + continue } switch packet.PayloadVariant.(type) { diff --git a/go/meshtastic/stream_interface.go b/go/meshtastic/stream_interface.go index 663a69f..3690f12 100644 --- a/go/meshtastic/stream_interface.go +++ b/go/meshtastic/stream_interface.go @@ -2,6 +2,7 @@ package meshtastic import ( "errors" + "fmt" "io" "log" "time" @@ -17,7 +18,7 @@ const ( DEBUGGING = false ) -func wakeDevice(writer io.ReadWriteCloser) error { +func wakeDevice(writer io.Writer) error { // Comments copied from Python implementation // https://github.com/meshtastic/python/blob/0bb4b31b6a147134c57fb720492c8719c037d195/meshtastic/stream_interface.py#L55-L75 @@ -36,7 +37,7 @@ func wakeDevice(writer io.ReadWriteCloser) error { return nil } -func writeMessage(writer io.ReadWriteCloser, message *meshtastic.ToRadio) error { +func writeMessage(writer io.Writer, message *meshtastic.ToRadio) error { if DEBUGGING { log.Println("\033[90mSending: " + message.String() + "\033[0m") } @@ -59,11 +60,12 @@ func writeMessage(writer io.ReadWriteCloser, message *meshtastic.ToRadio) error return nil } -func readMessage(reader io.ReadWriteCloser, debugSink io.Writer) (*meshtastic.FromRadio, error) { +func readMessage(reader io.Reader) (*meshtastic.FromRadio, error) { buffer := make([]byte, 1) - protobuffer := make([]byte, 0) - status := 0 + state := 0 length := 0 + +searching: for { n, err := reader.Read(buffer) if err != nil { @@ -73,50 +75,60 @@ func readMessage(reader io.ReadWriteCloser, debugSink io.Writer) (*meshtastic.Fr return nil, errors.New("unexpected end of file") } - switch status { + switch state { case 0: if buffer[0] == START1 { - status = 1 - } else { + state = 1 + } else if DEBUGGING { // Handle any other bytes as text debug output - debugSink.Write(buffer) + fmt.Print(buffer) } case 1: if buffer[0] == START2 { - status = 2 + state = 2 } else { - status = 0 - debugSink.Write(buffer) + state = 0 + if DEBUGGING { + fmt.Print([]byte{START1}) + fmt.Print(buffer) + } } case 2: length = int(buffer[0]) << 8 - status = 3 + state = 3 case 3: - length |= int(buffer[0]) + length |= int(buffer[0]) & 0xFF if length > MAX_SIZE { log.Printf("Invalid packet size: %d\n", length) - msb := make([]byte, 1) - msb[0] = byte(length >> 8) - debugSink.Write(msb) - debugSink.Write(buffer) - status = 0 - } else { - status = 4 - } - default: - if length > 0 { - protobuffer = append(protobuffer, buffer[0]) - length-- - if length == 0 { - status = 0 - result := meshtastic.FromRadio{} - proto.Unmarshal(protobuffer, &result) - if DEBUGGING { - log.Println("\033[90mReceived: " + result.String() + "\033[0m") - } - return &result, nil + if DEBUGGING { + fmt.Print([]byte{START1, START2, byte(length >> 8)}) + fmt.Print(buffer) } + state = 0 + } else if length == 0 { + state = 0 + } else { + break searching } } } + + protobuffer := make([]byte, length) + n, err := reader.Read(protobuffer) + if err != nil { + return nil, err + } + if n != length { + return nil, errors.New("unexpected end of file") + } + + result := meshtastic.FromRadio{} + err = proto.Unmarshal(protobuffer, &result) + if err != nil { + return nil, err + } + if DEBUGGING { + log.Println("\033[90mReceived: " + result.String() + "\033[0m") + } + return &result, nil } From 13fa7477e001fadc0ce1094119e307b282486eee Mon Sep 17 00:00:00 2001 From: Timendus Date: Mon, 18 Nov 2024 10:44:40 +0100 Subject: [PATCH 03/87] Refactor stuff a bit and start parsing incoming mesh packets --- go/Makefile | 4 + go/go.mod | 6 +- go/go.sum | 12 +- go/main.go | 21 +++- go/meshtastic/connected_node.go | 195 ++++++++++++++++++++++---------- go/meshtastic/node.go | 84 ++++++++++---- go/meshtastic/node_list.go | 14 +-- 7 files changed, 236 insertions(+), 100 deletions(-) diff --git a/go/Makefile b/go/Makefile index 2cb3807..19ef9a0 100644 --- a/go/Makefile +++ b/go/Makefile @@ -2,6 +2,10 @@ SHELL := /bin/bash all: run +update: + @go get -u + @go mod tidy + install-protobuf-compiler: @sudo dnf install protobuf-compiler @go install google.golang.org/protobuf/cmd/protoc-gen-go@latest diff --git a/go/go.mod b/go/go.mod index bb451ca..7177cee 100644 --- a/go/go.mod +++ b/go/go.mod @@ -3,12 +3,12 @@ module github.com/timendus/meshbot go 1.22.7 require ( - buf.build/gen/go/meshtastic/protobufs/protocolbuffers/go v1.35.1-20241006120827-cc36fd21e859.1 + buf.build/gen/go/meshtastic/protobufs/protocolbuffers/go v1.35.2-20241006120827-cc36fd21e859.1 go.bug.st/serial v1.6.2 - google.golang.org/protobuf v1.35.1 + google.golang.org/protobuf v1.35.2 ) require ( github.com/creack/goselect v0.1.2 // indirect - golang.org/x/sys v0.0.0-20220829200755-d48e67d00261 // indirect + golang.org/x/sys v0.27.0 // indirect ) diff --git a/go/go.sum b/go/go.sum index 9caed0c..59463ec 100644 --- a/go/go.sum +++ b/go/go.sum @@ -1,5 +1,5 @@ -buf.build/gen/go/meshtastic/protobufs/protocolbuffers/go v1.35.1-20241006120827-cc36fd21e859.1 h1:jVWv67MPDtbIuA86+CvVbKd3plzTxA1a6RWeN+C2qdM= -buf.build/gen/go/meshtastic/protobufs/protocolbuffers/go v1.35.1-20241006120827-cc36fd21e859.1/go.mod h1:4j54QYpOxc7iCSXqucVz/TXhq+MCRZ0Xs4uEKjxZyt0= +buf.build/gen/go/meshtastic/protobufs/protocolbuffers/go v1.35.2-20241006120827-cc36fd21e859.1 h1:arn+/xFe4UCiBWK/wjrTI59R9a9t6ZIqLG/vFDxU5Zo= +buf.build/gen/go/meshtastic/protobufs/protocolbuffers/go v1.35.2-20241006120827-cc36fd21e859.1/go.mod h1:ZkaTWUand3LqOJGrTfoO7CeV8WkIuFWi8+cRNfOkaQU= github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0= github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY= github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= @@ -12,11 +12,11 @@ github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5Cc github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= go.bug.st/serial v1.6.2 h1:kn9LRX3sdm+WxWKufMlIRndwGfPWsH1/9lCWXQCasq8= go.bug.st/serial v1.6.2/go.mod h1:UABfsluHAiaNI+La2iESysd9Vetq7VRdpxvjx7CmmOE= -golang.org/x/sys v0.0.0-20220829200755-d48e67d00261 h1:v6hYoSR9T5oet+pMXwUWkbiVqx/63mlHjefrHmxwfeY= -golang.org/x/sys v0.0.0-20220829200755-d48e67d00261/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= +golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= -google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io= +google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/go/main.go b/go/main.go index f312a2c..aefceb9 100644 --- a/go/main.go +++ b/go/main.go @@ -4,6 +4,7 @@ package main // https://buf.build/meshtastic/protobufs/docs/main:meshtastic#meshtastic.ToRadio import ( + "fmt" "log" "net" "time" @@ -39,17 +40,17 @@ func main() { log.Fatal(err) } - node, err = meshtastic.NewConnectedNode(serialPort) + node, err = meshtastic.NewConnectedNode(serialPort, connected, message) if err != nil { log.Fatal(err) } } else { - tcpPort, err := net.Dial("tcp", "meshtastic.local:4403") + tcpPort, err := net.Dial("tcp", "meshtastic.thuis:4403") if err != nil { log.Fatal(err) } - node, err = meshtastic.NewConnectedNode(tcpPort) + node, err = meshtastic.NewConnectedNode(tcpPort, connected, message) if err != nil { log.Fatal(err) } @@ -61,3 +62,17 @@ func main() { time.Sleep(100 * time.Millisecond) } } + +func connected(node meshtastic.ConnectedNode) { + fmt.Println("Connected to a node!") + log.Println("This is me: " + node.String()) + log.Println("Node list: \n" + node.Node.NodeList.String()) + log.Println("Channel list:") + for _, channel := range node.Channels { + log.Println(" " + channel.String()) + } +} + +func message(message meshtastic.Message) { + fmt.Println(message.String()) +} diff --git a/go/meshtastic/connected_node.go b/go/meshtastic/connected_node.go index 9e7d3a4..76548d0 100644 --- a/go/meshtastic/connected_node.go +++ b/go/meshtastic/connected_node.go @@ -1,29 +1,34 @@ package meshtastic import ( + "fmt" "io" "log" - "time" "buf.build/gen/go/meshtastic/protobufs/protocolbuffers/go/meshtastic" + "google.golang.org/protobuf/proto" ) type ConnectedNode struct { - stream io.ReadWriteCloser - firmwareVersion string - channels []channel - node node + stream io.ReadWriteCloser + connectedCallback func(ConnectedNode) + messageCallback func(Message) + FirmwareVersion string + Channels []channel + Node Node } -func NewConnectedNode(stream io.ReadWriteCloser) (*ConnectedNode, error) { +func NewConnectedNode(stream io.ReadWriteCloser, connected func(ConnectedNode), message func(Message)) (*ConnectedNode, error) { // Create the new connected node newNode := ConnectedNode{ - stream: stream, - node: node{ - shortName: "UNKN", - longName: "Unknown node", + stream: stream, + connectedCallback: connected, + messageCallback: message, + Node: Node{ + ShortName: "UNKN", + LongName: "Unknown node", id: 0, - nodeList: NewNodeList(), + NodeList: NewNodeList(), }, } @@ -53,7 +58,7 @@ func (n *ConnectedNode) Close() error { func (n *ConnectedNode) String() string { color := "92" - return n.node.String(&color) + return n.Node.String(&color) } func (n *ConnectedNode) SendMessage(message meshtastic.ToRadio_Packet) error { @@ -65,63 +70,29 @@ func (n *ConnectedNode) SendMessage(message meshtastic.ToRadio_Packet) error { return nil } -func (n *ConnectedNode) ReadMessages(stream io.ReadWriteCloser) error { +func (n *ConnectedNode) ReadMessages(stream io.ReadCloser) error { for { packet, err := readMessage(stream) if err != nil { log.Println("Error: " + err.Error()) + if err == io.EOF { + log.Println("EOF probably means the device has disconnected. Stopping execution.") + return n.Close() + } continue } switch packet.PayloadVariant.(type) { case *meshtastic.FromRadio_ConfigCompleteId: - log.Println("Loaded all device information") - log.Println("This is me: " + n.String()) - log.Println("Node list: \n" + n.node.nodeList.String()) - log.Println("Channel list:") - for _, channel := range n.channels { - log.Println(" " + channel.String()) - } + n.connectedCallback(*n) case *meshtastic.FromRadio_MyInfo: - n.node.id = packet.GetMyInfo().MyNodeNum + n.Node.id = packet.GetMyInfo().MyNodeNum case *meshtastic.FromRadio_Metadata: - n.firmwareVersion = packet.GetMetadata().FirmwareVersion + n.FirmwareVersion = packet.GetMetadata().FirmwareVersion case *meshtastic.FromRadio_NodeInfo: - nodeInfo := packet.GetNodeInfo() - - if nodeInfo.User.Id == n.node.IDExpression() { - n.node.shortName = nodeInfo.User.LongName - n.node.longName = nodeInfo.User.ShortName - break - } - - var hopsAway uint32 = 0 - if nodeInfo.HopsAway != nil { - hopsAway = *nodeInfo.HopsAway - } - - relevantNode, exists := n.node.nodeList.nodes[nodeInfo.Num] - if !exists { - relevantNode = node{ - nodeList: nodeList{nodes: make(map[uint32]node)}, - } - } - - relevantNode.id = nodeInfo.Num - relevantNode.shortName = nodeInfo.User.ShortName - relevantNode.longName = nodeInfo.User.LongName - relevantNode.macAddr = nodeInfo.User.Macaddr - relevantNode.hwModel = nodeInfo.User.HwModel - relevantNode.role = nodeInfo.User.Role - relevantNode.snr = nodeInfo.Snr - relevantNode.lastHeard = time.Unix(int64(nodeInfo.LastHeard), 0) - relevantNode.hopsAway = hopsAway - relevantNode.isLicensed = nodeInfo.User.IsLicensed - relevantNode.position = NewPosition(nodeInfo.Position) - relevantNode.deviceMetrics = NewDeviceMetrics(nodeInfo.DeviceMetrics) - n.node.nodeList.nodes[nodeInfo.Num] = relevantNode + n.parseNodeInfo(packet.GetNodeInfo()) case *meshtastic.FromRadio_Channel: - n.channels = append(n.channels, NewChannel(packet.GetChannel())) + n.Channels = append(n.Channels, NewChannel(packet.GetChannel())) case *meshtastic.FromRadio_Config: // log.Println("Ignoring configuration packet (for now)") case *meshtastic.FromRadio_ModuleConfig: @@ -129,9 +100,117 @@ func (n *ConnectedNode) ReadMessages(stream io.ReadWriteCloser) error { case *meshtastic.FromRadio_FileInfo: // Silently ignore file info packets case *meshtastic.FromRadio_Packet: - log.Println("Got packet: " + packet.String()) + n.parseMeshPacket(packet.GetPacket()) + default: + log.Println("Unhandled message:" + packet.String()) + } + } +} + +func (n *ConnectedNode) parseNodeInfo(nodeInfo *meshtastic.NodeInfo) { + // Does this pertain to the connected node? + if nodeInfo.User != nil && nodeInfo.User.Id == n.Node.IDExpression() { + n.Node.ShortName = nodeInfo.User.ShortName + n.Node.LongName = nodeInfo.User.LongName + return + } + + // Otherwise, create or update a neighbouring node + relevantNode, exists := n.Node.NodeList.nodes[nodeInfo.Num] + if !exists { + n.Node.NodeList.nodes[nodeInfo.Num] = *NewNode(nodeInfo) + } else { + relevantNode.Update(nodeInfo) + } +} + +func (n *ConnectedNode) parseMeshPacket(meshPacket *meshtastic.MeshPacket) { + // Ignore broken, encrypted or empty packets + if meshPacket == nil || meshPacket.GetDecoded() == nil || meshPacket.GetDecoded().GetPayload() == nil { + return + } + + payload := meshPacket.GetDecoded().GetPayload() + + directionString := "" + fromNode, ok := n.Node.NodeList.nodes[meshPacket.From] + if ok { + directionString += fromNode.String() + } else { + directionString += "Unknown node (" + fmt.Sprintf("!%x", meshPacket.From) + ")" + } + toNode, ok := n.Node.NodeList.nodes[meshPacket.To] + if ok { + directionString += " -> " + toNode.String() + } else { + directionString += " -> Unknown node (" + fmt.Sprintf("!%x", meshPacket.To) + ")" + } + + switch meshPacket.GetDecoded().Portnum { + case meshtastic.PortNum_NODEINFO_APP: + // Update our node list with this new information + // NOTE: is this needed? Or does the node also send a NodeInfo packet..? + // Looks like we will have to do this ourselves + + // We need to decode this crap somehow..? + // log.Println("Node info: " + string(payload)) + + // result := meshtastic.NodeInfo{} + result := meshtastic.NodeInfo{} + err := proto.Unmarshal(payload, &result) + if err != nil { + log.Println("Error unmarshalling NodeInfo mesh packet: " + err.Error()) + } + + log.Println("Got Node Info:", result.String()) + + // relevantNode, exists := n.Node.NodeList.nodes[nodeInfo.Num] + // if !exists { + // relevantNode = Node{ + // NodeList: nodeList{nodes: make(map[uint32]Node)}, + // } + // } + // n.Node.NodeList.nodes[nodeInfo.Num] = relevantNode + case meshtastic.PortNum_TELEMETRY_APP: + result := meshtastic.Telemetry{} + // result := meshtastic.DeviceMetrics{} + err := proto.Unmarshal(payload, &result) + if err != nil { + log.Println("Error unmarshalling Telemetry mesh packet: " + err.Error()) + } + switch result.Variant.(type) { + case *meshtastic.Telemetry_DeviceMetrics: + log.Println(directionString, "Device metrics:", result.String()) + case *meshtastic.Telemetry_EnvironmentMetrics: + log.Println(directionString, "Enviroment metrics:", result.String()) + case *meshtastic.Telemetry_HealthMetrics: + log.Println(directionString, "Health metrics:", result.String()) + case *meshtastic.Telemetry_AirQualityMetrics: + log.Println(directionString, "Air quality: metrics", result.String()) + case *meshtastic.Telemetry_PowerMetrics: + log.Println(directionString, "Power metrics:", result.String()) + case *meshtastic.Telemetry_LocalStats: + log.Println(directionString, "Local stats:", result.String()) default: - log.Println("Unhandled message: " + packet.String()) + log.Println(directionString, "Unknown telemetry variant:", result.String()) + } + case meshtastic.PortNum_POSITION_APP: + result := meshtastic.Position{} + err := proto.Unmarshal(payload, &result) + if err != nil { + log.Println("Error unmarshalling Position mesh packet: " + err.Error()) + } + log.Println(directionString, "Position: ", result.String()) + case meshtastic.PortNum_NEIGHBORINFO_APP: + result := meshtastic.NeighborInfo{} + err := proto.Unmarshal(payload, &result) + if err != nil { + log.Println("Error unmarshalling NeighborInfo mesh packet: " + err.Error()) } + log.Println(directionString, "NeighborInfo:", result.String()) + case meshtastic.PortNum_TEXT_MESSAGE_APP: + log.Println(directionString, string(payload)) + default: + log.Println(directionString, "Unhandled mesh packet:"+meshPacket.String()) } } diff --git a/go/meshtastic/node.go b/go/meshtastic/node.go index c94f572..b8f8b07 100644 --- a/go/meshtastic/node.go +++ b/go/meshtastic/node.go @@ -8,52 +8,90 @@ import ( "buf.build/gen/go/meshtastic/protobufs/protocolbuffers/go/meshtastic" ) -type node struct { - shortName string - longName string +type Node struct { + ShortName string + LongName string id uint32 macAddr []byte - hwModel meshtastic.HardwareModel - role meshtastic.Config_DeviceConfig_Role + HwModel meshtastic.HardwareModel + Role meshtastic.Config_DeviceConfig_Role snr float32 - lastHeard time.Time - hopsAway uint32 - nodeList nodeList - position *position - isLicensed bool + LastHeard time.Time + HopsAway uint32 + NodeList nodeList + Position *position + IsLicensed bool deviceMetrics *deviceMetrics } -func (n *node) String(color ...*string) string { +func NewNode(info *meshtastic.NodeInfo) *Node { + node := Node{ + id: info.Num, + HopsAway: 0, + ShortName: "UNKN", + LongName: "Unknown node", + HwModel: meshtastic.HardwareModel_UNSET, + IsLicensed: false, + } + + node.Update(info) + return &node +} + +func (n *Node) Update(info *meshtastic.NodeInfo) { + if info == nil || info.Num != n.id { + return + } + + n.snr = info.Snr + n.LastHeard = time.Unix(int64(info.LastHeard), 0) + n.Position = NewPosition(info.Position) + n.deviceMetrics = NewDeviceMetrics(info.DeviceMetrics) + + if info.HopsAway != nil { + n.HopsAway = *info.HopsAway + } + + if info.User != nil { + n.ShortName = info.User.ShortName + n.LongName = info.User.LongName + n.macAddr = info.User.Macaddr + n.HwModel = info.User.HwModel + n.Role = info.User.Role + n.IsLicensed = info.User.IsLicensed + } +} + +func (n *Node) String(color ...*string) string { var col string if len(color) > 0 && color[0] != nil { col = *color[0] - } else if n.hopsAway == 0 { + } else if n.HopsAway == 0 { col = "96" } else { col = "94" } var shortName string - if len(n.shortName) == 4 && utf8.RuneCountInString(n.shortName) == 1 { + if len(n.ShortName) == 4 && utf8.RuneCountInString(n.ShortName) == 1 { // Short name is an emoji - shortName = fmt.Sprintf(" %s ", n.shortName) + shortName = fmt.Sprintf(" %s ", n.ShortName) } else { - shortName = fmt.Sprintf("%-4s", n.shortName) + shortName = fmt.Sprintf("%-4s", n.ShortName) } return fmt.Sprintf( "\033[%sm[%s] %s (%s)]\033[0m", col, shortName, - n.longName, + n.LongName, n.IDExpression(), ) } -func (n *node) VerboseString() string { - hardware := n.hwModel.String() - role := n.role.String() +func (n *Node) VerboseString() string { + hardware := n.HwModel.String() + role := n.Role.String() snr := "" if n.snr != 0 { @@ -61,8 +99,8 @@ func (n *node) VerboseString() string { } hopsAway := "" - if n.hopsAway > 0 { - hopsAway = fmt.Sprintf(", %d %s away", n.hopsAway, pluralize("hop", int(n.hopsAway))) + if n.HopsAway > 0 { + hopsAway = fmt.Sprintf(", %d %s away", n.HopsAway, pluralize("hop", int(n.HopsAway))) } return fmt.Sprintf( @@ -70,12 +108,12 @@ func (n *node) VerboseString() string { n.String(), hardware, role, - timeAgo(n.lastHeard), + timeAgo(n.LastHeard), snr, hopsAway, ) } -func (n *node) IDExpression() string { +func (n *Node) IDExpression() string { return fmt.Sprintf("!%x", n.id) } diff --git a/go/meshtastic/node_list.go b/go/meshtastic/node_list.go index 5ec9b73..85bd58b 100644 --- a/go/meshtastic/node_list.go +++ b/go/meshtastic/node_list.go @@ -6,12 +6,12 @@ import ( ) type nodeList struct { - nodes map[uint32]node + nodes map[uint32]Node } func NewNodeList() nodeList { return nodeList{ - nodes: make(map[uint32]node), + nodes: make(map[uint32]Node), } } @@ -23,15 +23,15 @@ func (n *nodeList) String() string { return nodes } -func (n *nodeList) sortedNodes() []node { - nodes := make([]node, 0, len(n.nodes)) +func (n *nodeList) sortedNodes() []Node { + nodes := make([]Node, 0, len(n.nodes)) for _, node := range n.nodes { nodes = append(nodes, node) } - slices.SortFunc(nodes, func(a, b node) int { + slices.SortFunc(nodes, func(a, b Node) int { return cmp.Or( - cmp.Compare(a.hopsAway, b.hopsAway), - -cmp.Compare(a.lastHeard.Unix(), b.lastHeard.Unix()), + cmp.Compare(a.HopsAway, b.HopsAway), + -cmp.Compare(a.LastHeard.Unix(), b.LastHeard.Unix()), ) }) return nodes From 4eeb834352e4eee9714d339f3be1eecc0dd710f2 Mon Sep 17 00:00:00 2001 From: Timendus Date: Mon, 18 Nov 2024 16:56:02 +0100 Subject: [PATCH 04/87] Decipher Mesh packets into Messages and lots of cleanup --- go/meshtastic/connected_node.go | 99 +++++++++++++++++---------------- go/meshtastic/device_metrics.go | 24 -------- go/meshtastic/message.go | 89 +++++++++++++++++++++++++++++ go/meshtastic/node.go | 37 +++++++----- go/meshtastic/node_list.go | 29 ++++++++-- 5 files changed, 189 insertions(+), 89 deletions(-) delete mode 100644 go/meshtastic/device_metrics.go create mode 100644 go/meshtastic/message.go diff --git a/go/meshtastic/connected_node.go b/go/meshtastic/connected_node.go index 76548d0..22a0771 100644 --- a/go/meshtastic/connected_node.go +++ b/go/meshtastic/connected_node.go @@ -1,9 +1,9 @@ package meshtastic import ( - "fmt" "io" "log" + "time" "buf.build/gen/go/meshtastic/protobufs/protocolbuffers/go/meshtastic" "google.golang.org/protobuf/proto" @@ -15,7 +15,7 @@ type ConnectedNode struct { messageCallback func(Message) FirmwareVersion string Channels []channel - Node Node + Node *Node } func NewConnectedNode(stream io.ReadWriteCloser, connected func(ConnectedNode), message func(Message)) (*ConnectedNode, error) { @@ -24,10 +24,11 @@ func NewConnectedNode(stream io.ReadWriteCloser, connected func(ConnectedNode), stream: stream, connectedCallback: connected, messageCallback: message, - Node: Node{ + Node: &Node{ ShortName: "UNKN", LongName: "Unknown node", id: 0, + connected: true, NodeList: NewNodeList(), }, } @@ -57,8 +58,7 @@ func (n *ConnectedNode) Close() error { } func (n *ConnectedNode) String() string { - color := "92" - return n.Node.String(&color) + return n.Node.String() } func (n *ConnectedNode) SendMessage(message meshtastic.ToRadio_Packet) error { @@ -87,6 +87,7 @@ func (n *ConnectedNode) ReadMessages(stream io.ReadCloser) error { n.connectedCallback(*n) case *meshtastic.FromRadio_MyInfo: n.Node.id = packet.GetMyInfo().MyNodeNum + n.Node.NodeList.nodes[n.Node.id] = n.Node case *meshtastic.FromRadio_Metadata: n.FirmwareVersion = packet.GetMetadata().FirmwareVersion case *meshtastic.FromRadio_NodeInfo: @@ -108,17 +109,10 @@ func (n *ConnectedNode) ReadMessages(stream io.ReadCloser) error { } func (n *ConnectedNode) parseNodeInfo(nodeInfo *meshtastic.NodeInfo) { - // Does this pertain to the connected node? - if nodeInfo.User != nil && nodeInfo.User.Id == n.Node.IDExpression() { - n.Node.ShortName = nodeInfo.User.ShortName - n.Node.LongName = nodeInfo.User.LongName - return - } - - // Otherwise, create or update a neighbouring node + // Create or update the node that this info relates to relevantNode, exists := n.Node.NodeList.nodes[nodeInfo.Num] if !exists { - n.Node.NodeList.nodes[nodeInfo.Num] = *NewNode(nodeInfo) + n.Node.NodeList.nodes[nodeInfo.Num] = NewNode(nodeInfo) } else { relevantNode.Update(nodeInfo) } @@ -130,36 +124,39 @@ func (n *ConnectedNode) parseMeshPacket(meshPacket *meshtastic.MeshPacket) { return } - payload := meshPacket.GetDecoded().GetPayload() - - directionString := "" - fromNode, ok := n.Node.NodeList.nodes[meshPacket.From] - if ok { - directionString += fromNode.String() + var hops uint32 + if meshPacket.HopStart == 0 { + hops = 0 } else { - directionString += "Unknown node (" + fmt.Sprintf("!%x", meshPacket.From) + ")" + hops = meshPacket.HopStart - meshPacket.HopLimit } - toNode, ok := n.Node.NodeList.nodes[meshPacket.To] - if ok { - directionString += " -> " + toNode.String() - } else { - directionString += " -> Unknown node (" + fmt.Sprintf("!%x", meshPacket.To) + ")" + + payload := meshPacket.GetDecoded().GetPayload() + + fromNode := n.Node.NodeList.nodes[meshPacket.From] + fromNode.snr = meshPacket.RxSnr + fromNode.HopsAway = hops + toNode := n.Node.NodeList.nodes[meshPacket.To] + + message := Message{ + fromNode: fromNode, + toNode: toNode, + timeStamp: time.Unix(int64(meshPacket.RxTime), 0), + messageType: MESSAGE_TYPE_OTHER, + snr: meshPacket.RxSnr, + hopsAway: hops, } switch meshPacket.GetDecoded().Portnum { case meshtastic.PortNum_NODEINFO_APP: // Update our node list with this new information - // NOTE: is this needed? Or does the node also send a NodeInfo packet..? - // Looks like we will have to do this ourselves - - // We need to decode this crap somehow..? - // log.Println("Node info: " + string(payload)) // result := meshtastic.NodeInfo{} result := meshtastic.NodeInfo{} err := proto.Unmarshal(payload, &result) if err != nil { - log.Println("Error unmarshalling NodeInfo mesh packet: " + err.Error()) + log.Println("Error: Could not unmarshall NodeInfo mesh packet: " + err.Error()) + return } log.Println("Got Node Info:", result.String()) @@ -173,44 +170,52 @@ func (n *ConnectedNode) parseMeshPacket(meshPacket *meshtastic.MeshPacket) { // n.Node.NodeList.nodes[nodeInfo.Num] = relevantNode case meshtastic.PortNum_TELEMETRY_APP: result := meshtastic.Telemetry{} - // result := meshtastic.DeviceMetrics{} err := proto.Unmarshal(payload, &result) if err != nil { - log.Println("Error unmarshalling Telemetry mesh packet: " + err.Error()) + log.Println("Error: Could not unmarshall Telemetry mesh packet: " + err.Error()) + return } switch result.Variant.(type) { case *meshtastic.Telemetry_DeviceMetrics: - log.Println(directionString, "Device metrics:", result.String()) + message.messageType = MESSAGE_TYPE_TELEMETRY_DEVICE + message.deviceMetrics = result.GetDeviceMetrics() case *meshtastic.Telemetry_EnvironmentMetrics: - log.Println(directionString, "Enviroment metrics:", result.String()) + message.messageType = MESSAGE_TYPE_TELEMETRY_ENVIRONMENT case *meshtastic.Telemetry_HealthMetrics: - log.Println(directionString, "Health metrics:", result.String()) + message.messageType = MESSAGE_TYPE_TELEMETRY_HEALTH case *meshtastic.Telemetry_AirQualityMetrics: - log.Println(directionString, "Air quality: metrics", result.String()) + message.messageType = MESSAGE_TYPE_TELEMETRY_AIR_QUALITY case *meshtastic.Telemetry_PowerMetrics: - log.Println(directionString, "Power metrics:", result.String()) + message.messageType = MESSAGE_TYPE_TELEMETRY_POWER case *meshtastic.Telemetry_LocalStats: - log.Println(directionString, "Local stats:", result.String()) + message.messageType = MESSAGE_TYPE_TELEMETRY_LOCAL_STATS default: - log.Println(directionString, "Unknown telemetry variant:", result.String()) + log.Println("Warning: Unknown telemetry variant:", result.String()) } case meshtastic.PortNum_POSITION_APP: result := meshtastic.Position{} err := proto.Unmarshal(payload, &result) if err != nil { - log.Println("Error unmarshalling Position mesh packet: " + err.Error()) + log.Println("Error: Could not unmarshall Position mesh packet: " + err.Error()) + return } - log.Println(directionString, "Position: ", result.String()) + message.messageType = MESSAGE_TYPE_POSITION + message.position = NewPosition(&result) case meshtastic.PortNum_NEIGHBORINFO_APP: result := meshtastic.NeighborInfo{} err := proto.Unmarshal(payload, &result) if err != nil { - log.Println("Error unmarshalling NeighborInfo mesh packet: " + err.Error()) + log.Println("Error: Could not unmarshall NeighborInfo mesh packet: " + err.Error()) + return } - log.Println(directionString, "NeighborInfo:", result.String()) + message.messageType = MESSAGE_TYPE_NEIGHBOR_INFO + message.neighborInfo = &result case meshtastic.PortNum_TEXT_MESSAGE_APP: - log.Println(directionString, string(payload)) + message.messageType = MESSAGE_TYPE_TEXT_MESSAGE + message.text = string(payload) default: - log.Println(directionString, "Unhandled mesh packet:"+meshPacket.String()) + log.Println("Warning: Unknown mesh packet:", meshPacket.String()) } + + n.messageCallback(message) } diff --git a/go/meshtastic/device_metrics.go b/go/meshtastic/device_metrics.go deleted file mode 100644 index da71551..0000000 --- a/go/meshtastic/device_metrics.go +++ /dev/null @@ -1,24 +0,0 @@ -package meshtastic - -import "buf.build/gen/go/meshtastic/protobufs/protocolbuffers/go/meshtastic" - -type deviceMetrics struct { - batteryLevel *uint32 - voltage *float32 - channelUtilization *float32 - airUtilizationTx *float32 - uptime *uint32 -} - -func NewDeviceMetrics(metrics *meshtastic.DeviceMetrics) *deviceMetrics { - if metrics == nil { - return nil - } - return &deviceMetrics{ - batteryLevel: metrics.BatteryLevel, - voltage: metrics.Voltage, - channelUtilization: metrics.ChannelUtilization, - airUtilizationTx: metrics.AirUtilTx, - uptime: metrics.UptimeSeconds, - } -} diff --git a/go/meshtastic/message.go b/go/meshtastic/message.go new file mode 100644 index 0000000..20593db --- /dev/null +++ b/go/meshtastic/message.go @@ -0,0 +1,89 @@ +package meshtastic + +import ( + "fmt" + "time" + + "buf.build/gen/go/meshtastic/protobufs/protocolbuffers/go/meshtastic" +) + +const ( + MESSAGE_TYPE_TEXT_MESSAGE = iota + MESSAGE_TYPE_POSITION + MESSAGE_TYPE_NEIGHBOR_INFO + MESSAGE_TYPE_TELEMETRY_DEVICE + MESSAGE_TYPE_TELEMETRY_ENVIRONMENT + MESSAGE_TYPE_TELEMETRY_HEALTH + MESSAGE_TYPE_TELEMETRY_AIR_QUALITY + MESSAGE_TYPE_TELEMETRY_POWER + MESSAGE_TYPE_TELEMETRY_LOCAL_STATS + MESSAGE_TYPE_OTHER +) + +type Message struct { + fromNode *Node + toNode *Node + timeStamp time.Time + messageType int + text string + deviceMetrics *meshtastic.DeviceMetrics + neighborInfo *meshtastic.NeighborInfo + position *position + snr float32 + hopsAway uint32 +} + +func (m *Message) String() string { + direction := m.fromNode.String() + " -> " + m.toNode.String() + + var content string + if m.messageType == MESSAGE_TYPE_TEXT_MESSAGE { + content = m.text + } else { + content = "\033[1m" + m.TypeString() + " packet\033[0m" + } + + return fmt.Sprintf("%s: %s %s", direction, content, m.radioMetricsString()) +} + +func (m *Message) TypeString() string { + switch m.messageType { + case MESSAGE_TYPE_TEXT_MESSAGE: + return "text message" + case MESSAGE_TYPE_POSITION: + return "position" + case MESSAGE_TYPE_NEIGHBOR_INFO: + return "neighbor info" + case MESSAGE_TYPE_TELEMETRY_DEVICE: + return "device telemetry" + case MESSAGE_TYPE_TELEMETRY_ENVIRONMENT: + return "environment telemetry" + case MESSAGE_TYPE_TELEMETRY_HEALTH: + return "health telemetry" + case MESSAGE_TYPE_TELEMETRY_AIR_QUALITY: + return "air quality telemetry" + case MESSAGE_TYPE_TELEMETRY_POWER: + return "power telemetry" + case MESSAGE_TYPE_TELEMETRY_LOCAL_STATS: + return "local stats telemetry" + default: + return "other" + } +} + +func (m *Message) radioMetricsString() string { + if m.fromNode.connected { + return "" + } + + snr := "" + if m.snr != 0 { + snr = fmt.Sprintf("SNR %.2f, ", m.snr) + } + return fmt.Sprintf( + "\033[90m(%s%d %s away)\033[0m", + snr, + m.hopsAway, + pluralize("hop", int(m.hopsAway)), + ) +} diff --git a/go/meshtastic/node.go b/go/meshtastic/node.go index b8f8b07..3b930bc 100644 --- a/go/meshtastic/node.go +++ b/go/meshtastic/node.go @@ -19,19 +19,22 @@ type Node struct { LastHeard time.Time HopsAway uint32 NodeList nodeList - Position *position + Position []*position IsLicensed bool - deviceMetrics *deviceMetrics + deviceMetrics []*meshtastic.DeviceMetrics + connected bool } func NewNode(info *meshtastic.NodeInfo) *Node { node := Node{ - id: info.Num, - HopsAway: 0, - ShortName: "UNKN", - LongName: "Unknown node", - HwModel: meshtastic.HardwareModel_UNSET, - IsLicensed: false, + id: info.Num, + HopsAway: 0, + ShortName: "UNKN", + LongName: "Unknown node", + HwModel: meshtastic.HardwareModel_UNSET, + IsLicensed: false, + Position: make([]*position, 0), + deviceMetrics: make([]*meshtastic.DeviceMetrics, 0), } node.Update(info) @@ -45,8 +48,14 @@ func (n *Node) Update(info *meshtastic.NodeInfo) { n.snr = info.Snr n.LastHeard = time.Unix(int64(info.LastHeard), 0) - n.Position = NewPosition(info.Position) - n.deviceMetrics = NewDeviceMetrics(info.DeviceMetrics) + + if info.Position != nil { + n.Position = append(n.Position, NewPosition(info.Position)) + } + + if info.DeviceMetrics != nil { + n.deviceMetrics = append(n.deviceMetrics, info.DeviceMetrics) + } if info.HopsAway != nil { n.HopsAway = *info.HopsAway @@ -62,10 +71,12 @@ func (n *Node) Update(info *meshtastic.NodeInfo) { } } -func (n *Node) String(color ...*string) string { +func (n *Node) String() string { var col string - if len(color) > 0 && color[0] != nil { - col = *color[0] + if n.connected { + col = "92" + } else if n.id == Broadcast.id || n.id == Unknown.id { + col = "95" } else if n.HopsAway == 0 { col = "96" } else { diff --git a/go/meshtastic/node_list.go b/go/meshtastic/node_list.go index 85bd58b..abf53ca 100644 --- a/go/meshtastic/node_list.go +++ b/go/meshtastic/node_list.go @@ -6,19 +6,38 @@ import ( ) type nodeList struct { - nodes map[uint32]Node + nodes map[uint32]*Node +} + +var Broadcast = Node{ + id: 0xFFFFFFFF, + ShortName: "CAST", + LongName: "Everyone", +} + +var Unknown = Node{ + id: 0x00000000, + ShortName: "UNKN", + LongName: "Unknown", } func NewNodeList() nodeList { - return nodeList{ - nodes: make(map[uint32]Node), + list := nodeList{ + nodes: make(map[uint32]*Node), } + + list.nodes[Broadcast.id] = &Broadcast + list.nodes[Unknown.id] = &Unknown + + return list } func (n *nodeList) String() string { nodes := "" for _, node := range n.sortedNodes() { - nodes += node.VerboseString() + "\n" + if node.id != Broadcast.id && node.id != Unknown.id { + nodes += node.VerboseString() + "\n" + } } return nodes } @@ -26,7 +45,7 @@ func (n *nodeList) String() string { func (n *nodeList) sortedNodes() []Node { nodes := make([]Node, 0, len(n.nodes)) for _, node := range n.nodes { - nodes = append(nodes, node) + nodes = append(nodes, *node) } slices.SortFunc(nodes, func(a, b Node) int { return cmp.Or( From accb5f3d870956d9b8003075def43fe978207595 Mon Sep 17 00:00:00 2001 From: Timendus Date: Mon, 18 Nov 2024 18:04:48 +0100 Subject: [PATCH 05/87] Move helpers to their own package --- go/meshtastic/{helpers.go => helpers/language.go} | 4 ++-- go/meshtastic/{ => helpers}/time.go | 4 ++-- go/meshtastic/message.go | 3 ++- go/meshtastic/node.go | 5 +++-- 4 files changed, 9 insertions(+), 7 deletions(-) rename go/meshtastic/{helpers.go => helpers/language.go} (58%) rename go/meshtastic/{ => helpers}/time.go (91%) diff --git a/go/meshtastic/helpers.go b/go/meshtastic/helpers/language.go similarity index 58% rename from go/meshtastic/helpers.go rename to go/meshtastic/helpers/language.go index 41fa543..e00f80e 100644 --- a/go/meshtastic/helpers.go +++ b/go/meshtastic/helpers/language.go @@ -1,6 +1,6 @@ -package meshtastic +package helpers -func pluralize(word string, count int) string { +func Pluralize(word string, count int) string { if count == 1 { return word } diff --git a/go/meshtastic/time.go b/go/meshtastic/helpers/time.go similarity index 91% rename from go/meshtastic/time.go rename to go/meshtastic/helpers/time.go index 712709d..a10d804 100644 --- a/go/meshtastic/time.go +++ b/go/meshtastic/helpers/time.go @@ -1,4 +1,4 @@ -package meshtastic +package helpers import ( "fmt" @@ -6,7 +6,7 @@ import ( "time" ) -func timeAgo(timestamp time.Time) string { +func TimeAgo(timestamp time.Time) string { seconds := int(time.Since(timestamp).Seconds()) if seconds == 1 { diff --git a/go/meshtastic/message.go b/go/meshtastic/message.go index 20593db..1c73f13 100644 --- a/go/meshtastic/message.go +++ b/go/meshtastic/message.go @@ -5,6 +5,7 @@ import ( "time" "buf.build/gen/go/meshtastic/protobufs/protocolbuffers/go/meshtastic" + "github.com/timendus/meshbot/meshtastic/helpers" ) const ( @@ -84,6 +85,6 @@ func (m *Message) radioMetricsString() string { "\033[90m(%s%d %s away)\033[0m", snr, m.hopsAway, - pluralize("hop", int(m.hopsAway)), + helpers.Pluralize("hop", int(m.hopsAway)), ) } diff --git a/go/meshtastic/node.go b/go/meshtastic/node.go index 3b930bc..439ccf9 100644 --- a/go/meshtastic/node.go +++ b/go/meshtastic/node.go @@ -6,6 +6,7 @@ import ( "unicode/utf8" "buf.build/gen/go/meshtastic/protobufs/protocolbuffers/go/meshtastic" + "github.com/timendus/meshbot/meshtastic/helpers" ) type Node struct { @@ -111,7 +112,7 @@ func (n *Node) VerboseString() string { hopsAway := "" if n.HopsAway > 0 { - hopsAway = fmt.Sprintf(", %d %s away", n.HopsAway, pluralize("hop", int(n.HopsAway))) + hopsAway = fmt.Sprintf(", %d %s away", n.HopsAway, helpers.Pluralize("hop", int(n.HopsAway))) } return fmt.Sprintf( @@ -119,7 +120,7 @@ func (n *Node) VerboseString() string { n.String(), hardware, role, - timeAgo(n.LastHeard), + helpers.TimeAgo(n.LastHeard), snr, hopsAway, ) From 978626728075d9c1e0ee049c8d462ff63e793b17 Mon Sep 17 00:00:00 2001 From: Timendus Date: Mon, 18 Nov 2024 18:10:24 +0100 Subject: [PATCH 06/87] Move the callbacks to a little pubsub system --- go/main.go | 9 ++++++--- go/meshtastic/connected_node.go | 25 +++++++++++++------------ go/meshtastic/pubsub.go | 22 ++++++++++++++++++++++ 3 files changed, 41 insertions(+), 15 deletions(-) create mode 100644 go/meshtastic/pubsub.go diff --git a/go/main.go b/go/main.go index aefceb9..b107d1e 100644 --- a/go/main.go +++ b/go/main.go @@ -16,6 +16,9 @@ import ( func main() { log.Println("Starting Meshed Potatoes!") + meshtastic.MessageEvents.Subscribe("all-messages", message) + meshtastic.NodeEvents.Subscribe("node-connected", connected) + // Attempt to auto-detect Meshtestic device on a serial port. Otherwise, // connect over TCP. @@ -40,7 +43,7 @@ func main() { log.Fatal(err) } - node, err = meshtastic.NewConnectedNode(serialPort, connected, message) + node, err = meshtastic.NewConnectedNode(serialPort) if err != nil { log.Fatal(err) } @@ -50,7 +53,7 @@ func main() { log.Fatal(err) } - node, err = meshtastic.NewConnectedNode(tcpPort, connected, message) + node, err = meshtastic.NewConnectedNode(tcpPort) if err != nil { log.Fatal(err) } @@ -64,7 +67,7 @@ func main() { } func connected(node meshtastic.ConnectedNode) { - fmt.Println("Connected to a node!") + log.Println("Connected to a node!") log.Println("This is me: " + node.String()) log.Println("Node list: \n" + node.Node.NodeList.String()) log.Println("Channel list:") diff --git a/go/meshtastic/connected_node.go b/go/meshtastic/connected_node.go index 22a0771..db500d5 100644 --- a/go/meshtastic/connected_node.go +++ b/go/meshtastic/connected_node.go @@ -10,20 +10,18 @@ import ( ) type ConnectedNode struct { - stream io.ReadWriteCloser - connectedCallback func(ConnectedNode) - messageCallback func(Message) - FirmwareVersion string - Channels []channel - Node *Node + stream io.ReadWriteCloser + Connected bool + FirmwareVersion string + Channels []channel + Node *Node } -func NewConnectedNode(stream io.ReadWriteCloser, connected func(ConnectedNode), message func(Message)) (*ConnectedNode, error) { +func NewConnectedNode(stream io.ReadWriteCloser) (*ConnectedNode, error) { // Create the new connected node newNode := ConnectedNode{ - stream: stream, - connectedCallback: connected, - messageCallback: message, + stream: stream, + Connected: false, Node: &Node{ ShortName: "UNKN", LongName: "Unknown node", @@ -54,6 +52,8 @@ func NewConnectedNode(stream io.ReadWriteCloser, connected func(ConnectedNode), } func (n *ConnectedNode) Close() error { + n.Connected = false + NodeEvents.publish("node-disconnected", *n) return n.stream.Close() } @@ -84,7 +84,8 @@ func (n *ConnectedNode) ReadMessages(stream io.ReadCloser) error { switch packet.PayloadVariant.(type) { case *meshtastic.FromRadio_ConfigCompleteId: - n.connectedCallback(*n) + n.Connected = true + NodeEvents.publish("node-connected", *n) case *meshtastic.FromRadio_MyInfo: n.Node.id = packet.GetMyInfo().MyNodeNum n.Node.NodeList.nodes[n.Node.id] = n.Node @@ -217,5 +218,5 @@ func (n *ConnectedNode) parseMeshPacket(meshPacket *meshtastic.MeshPacket) { log.Println("Warning: Unknown mesh packet:", meshPacket.String()) } - n.messageCallback(message) + MessageEvents.publish("all-messages", message) } diff --git a/go/meshtastic/pubsub.go b/go/meshtastic/pubsub.go new file mode 100644 index 0000000..8e4c356 --- /dev/null +++ b/go/meshtastic/pubsub.go @@ -0,0 +1,22 @@ +package meshtastic + +type EventBody interface { + Message | Node | ConnectedNode +} + +type pubSub[T EventBody] struct { + subscriptions map[string][]func(T) +} + +func (ps *pubSub[T]) Subscribe(topic string, function func(T)) { + ps.subscriptions[topic] = append(ps.subscriptions[topic], function) +} + +func (ps *pubSub[T]) publish(topic string, msg T) { + for _, function := range ps.subscriptions[topic] { + go function(msg) + } +} + +var NodeEvents = pubSub[ConnectedNode]{make(map[string][]func(ConnectedNode))} +var MessageEvents = pubSub[Message]{make(map[string][]func(Message))} From c2a9d1e62c589e4a0679925110ff7c16c530ec55 Mon Sep 17 00:00:00 2001 From: Timendus Date: Mon, 18 Nov 2024 21:02:19 +0100 Subject: [PATCH 07/87] Fixing visibility of fields outside of package and made a start at sending messages --- go/main.go | 4 ++ go/meshtastic/connected_node.go | 86 ++++++++++++++++----------------- go/meshtastic/message.go | 71 +++++++++++++++++++-------- go/meshtastic/node.go | 30 ++++++------ go/meshtastic/node_list.go | 10 ++-- 5 files changed, 116 insertions(+), 85 deletions(-) diff --git a/go/main.go b/go/main.go index b107d1e..aec0903 100644 --- a/go/main.go +++ b/go/main.go @@ -78,4 +78,8 @@ func connected(node meshtastic.ConnectedNode) { func message(message meshtastic.Message) { fmt.Println(message.String()) + + if message.ToNode.Id != meshtastic.Broadcast.Id { + message.Reply("Hello, world!") + } } diff --git a/go/meshtastic/connected_node.go b/go/meshtastic/connected_node.go index db500d5..d0008e4 100644 --- a/go/meshtastic/connected_node.go +++ b/go/meshtastic/connected_node.go @@ -25,8 +25,8 @@ func NewConnectedNode(stream io.ReadWriteCloser) (*ConnectedNode, error) { Node: &Node{ ShortName: "UNKN", LongName: "Unknown node", - id: 0, - connected: true, + Id: 0, + Connected: true, NodeList: NewNodeList(), }, } @@ -87,22 +87,21 @@ func (n *ConnectedNode) ReadMessages(stream io.ReadCloser) error { n.Connected = true NodeEvents.publish("node-connected", *n) case *meshtastic.FromRadio_MyInfo: - n.Node.id = packet.GetMyInfo().MyNodeNum - n.Node.NodeList.nodes[n.Node.id] = n.Node + n.Node.Id = packet.GetMyInfo().MyNodeNum + n.Node.NodeList.nodes[n.Node.Id] = n.Node case *meshtastic.FromRadio_Metadata: n.FirmwareVersion = packet.GetMetadata().FirmwareVersion case *meshtastic.FromRadio_NodeInfo: n.parseNodeInfo(packet.GetNodeInfo()) case *meshtastic.FromRadio_Channel: n.Channels = append(n.Channels, NewChannel(packet.GetChannel())) + case *meshtastic.FromRadio_Packet: + n.parseMeshPacket(packet.GetPacket()) case *meshtastic.FromRadio_Config: - // log.Println("Ignoring configuration packet (for now)") case *meshtastic.FromRadio_ModuleConfig: - // log.Println("Ignoring module config packet (for now)") case *meshtastic.FromRadio_FileInfo: - // Silently ignore file info packets - case *meshtastic.FromRadio_Packet: - n.parseMeshPacket(packet.GetPacket()) + case *meshtastic.FromRadio_QueueStatus: + // Silently ignore these packets default: log.Println("Unhandled message:" + packet.String()) } @@ -134,41 +133,36 @@ func (n *ConnectedNode) parseMeshPacket(meshPacket *meshtastic.MeshPacket) { payload := meshPacket.GetDecoded().GetPayload() + toNode := n.Node.NodeList.nodes[meshPacket.To] fromNode := n.Node.NodeList.nodes[meshPacket.From] - fromNode.snr = meshPacket.RxSnr + fromNode.Snr = meshPacket.RxSnr fromNode.HopsAway = hops - toNode := n.Node.NodeList.nodes[meshPacket.To] message := Message{ - fromNode: fromNode, - toNode: toNode, - timeStamp: time.Unix(int64(meshPacket.RxTime), 0), - messageType: MESSAGE_TYPE_OTHER, - snr: meshPacket.RxSnr, - hopsAway: hops, + FromNode: fromNode, + ToNode: toNode, + ReceivingNode: n, + Timestamp: time.Unix(int64(meshPacket.RxTime), 0), + MessageType: MESSAGE_TYPE_OTHER, + Snr: meshPacket.RxSnr, + HopsAway: hops, } switch meshPacket.GetDecoded().Portnum { case meshtastic.PortNum_NODEINFO_APP: - // Update our node list with this new information - - // result := meshtastic.NodeInfo{} - result := meshtastic.NodeInfo{} + result := meshtastic.User{} err := proto.Unmarshal(payload, &result) if err != nil { - log.Println("Error: Could not unmarshall NodeInfo mesh packet: " + err.Error()) + log.Println("Error: Could not unmarshall NodeInfo User mesh packet: " + err.Error()) return } - log.Println("Got Node Info:", result.String()) - // relevantNode, exists := n.Node.NodeList.nodes[nodeInfo.Num] - // if !exists { - // relevantNode = Node{ - // NodeList: nodeList{nodes: make(map[uint32]Node)}, - // } - // } - // n.Node.NodeList.nodes[nodeInfo.Num] = relevantNode + fromNode.ShortName = result.ShortName + fromNode.LongName = result.LongName + fromNode.HwModel = result.HwModel + fromNode.Role = result.Role + fromNode.IsLicensed = result.IsLicensed case meshtastic.PortNum_TELEMETRY_APP: result := meshtastic.Telemetry{} err := proto.Unmarshal(payload, &result) @@ -178,18 +172,18 @@ func (n *ConnectedNode) parseMeshPacket(meshPacket *meshtastic.MeshPacket) { } switch result.Variant.(type) { case *meshtastic.Telemetry_DeviceMetrics: - message.messageType = MESSAGE_TYPE_TELEMETRY_DEVICE - message.deviceMetrics = result.GetDeviceMetrics() + message.MessageType = MESSAGE_TYPE_TELEMETRY_DEVICE + message.DeviceMetrics = result.GetDeviceMetrics() case *meshtastic.Telemetry_EnvironmentMetrics: - message.messageType = MESSAGE_TYPE_TELEMETRY_ENVIRONMENT + message.MessageType = MESSAGE_TYPE_TELEMETRY_ENVIRONMENT case *meshtastic.Telemetry_HealthMetrics: - message.messageType = MESSAGE_TYPE_TELEMETRY_HEALTH + message.MessageType = MESSAGE_TYPE_TELEMETRY_HEALTH case *meshtastic.Telemetry_AirQualityMetrics: - message.messageType = MESSAGE_TYPE_TELEMETRY_AIR_QUALITY + message.MessageType = MESSAGE_TYPE_TELEMETRY_AIR_QUALITY case *meshtastic.Telemetry_PowerMetrics: - message.messageType = MESSAGE_TYPE_TELEMETRY_POWER + message.MessageType = MESSAGE_TYPE_TELEMETRY_POWER case *meshtastic.Telemetry_LocalStats: - message.messageType = MESSAGE_TYPE_TELEMETRY_LOCAL_STATS + message.MessageType = MESSAGE_TYPE_TELEMETRY_LOCAL_STATS default: log.Println("Warning: Unknown telemetry variant:", result.String()) } @@ -200,8 +194,9 @@ func (n *ConnectedNode) parseMeshPacket(meshPacket *meshtastic.MeshPacket) { log.Println("Error: Could not unmarshall Position mesh packet: " + err.Error()) return } - message.messageType = MESSAGE_TYPE_POSITION - message.position = NewPosition(&result) + message.MessageType = MESSAGE_TYPE_POSITION + message.Position = NewPosition(&result) + fromNode.Position = append(fromNode.Position, message.Position) case meshtastic.PortNum_NEIGHBORINFO_APP: result := meshtastic.NeighborInfo{} err := proto.Unmarshal(payload, &result) @@ -209,11 +204,16 @@ func (n *ConnectedNode) parseMeshPacket(meshPacket *meshtastic.MeshPacket) { log.Println("Error: Could not unmarshall NeighborInfo mesh packet: " + err.Error()) return } - message.messageType = MESSAGE_TYPE_NEIGHBOR_INFO - message.neighborInfo = &result + message.MessageType = MESSAGE_TYPE_NEIGHBOR_INFO + message.NeighborInfo = &result case meshtastic.PortNum_TEXT_MESSAGE_APP: - message.messageType = MESSAGE_TYPE_TEXT_MESSAGE - message.text = string(payload) + message.MessageType = MESSAGE_TYPE_TEXT_MESSAGE + message.Text = string(payload) + case meshtastic.PortNum_ROUTING_APP: + if meshPacket.GetDecoded() != nil { + log.Println("Ack for message with ID", meshPacket.GetDecoded().RequestId, "from", fromNode.String()) + } + return default: log.Println("Warning: Unknown mesh packet:", meshPacket.String()) } diff --git a/go/meshtastic/message.go b/go/meshtastic/message.go index 1c73f13..9751ce7 100644 --- a/go/meshtastic/message.go +++ b/go/meshtastic/message.go @@ -2,6 +2,8 @@ package meshtastic import ( "fmt" + "log" + "math/rand/v2" "time" "buf.build/gen/go/meshtastic/protobufs/protocolbuffers/go/meshtastic" @@ -22,33 +24,60 @@ const ( ) type Message struct { - fromNode *Node - toNode *Node - timeStamp time.Time - messageType int - text string - deviceMetrics *meshtastic.DeviceMetrics - neighborInfo *meshtastic.NeighborInfo - position *position - snr float32 - hopsAway uint32 + FromNode *Node + ToNode *Node + ReceivingNode *ConnectedNode + Timestamp time.Time + MessageType int + Text string + DeviceMetrics *meshtastic.DeviceMetrics + NeighborInfo *meshtastic.NeighborInfo + Position *position + Snr float32 + HopsAway uint32 +} + +func (m *Message) Reply(message string) { + id := rand.Uint32() + log.Println("Sending message with ID", id) + m.ReceivingNode.SendMessage(meshtastic.ToRadio_Packet{ + Packet: &meshtastic.MeshPacket{ + Id: id, + To: m.FromNode.Id, + From: m.ToNode.Id, + HopLimit: 3, + WantAck: true, + Priority: meshtastic.MeshPacket_Priority(meshtastic.MeshPacket_Priority_value["RELIABLE"]), + + // PkiEncrypted: true, + // PublicKey: []byte{1, 2, 3}, + // Channel: 0, + + PayloadVariant: &meshtastic.MeshPacket_Decoded{ + Decoded: &meshtastic.Data{ + Portnum: meshtastic.PortNum_TEXT_MESSAGE_APP, + Payload: []byte(message), + }, + }, + }, + }) } func (m *Message) String() string { - direction := m.fromNode.String() + " -> " + m.toNode.String() + direction := m.FromNode.String() + " -> " + m.ToNode.String() var content string - if m.messageType == MESSAGE_TYPE_TEXT_MESSAGE { - content = m.text + if m.MessageType == MESSAGE_TYPE_TEXT_MESSAGE { + content = m.Text } else { - content = "\033[1m" + m.TypeString() + " packet\033[0m" + content = "\033[1m" + m.typeString() + " packet\033[0m" } return fmt.Sprintf("%s: %s %s", direction, content, m.radioMetricsString()) } -func (m *Message) TypeString() string { - switch m.messageType { +func (m *Message) typeString() string { + switch m.MessageType { case MESSAGE_TYPE_TEXT_MESSAGE: return "text message" case MESSAGE_TYPE_POSITION: @@ -73,18 +102,18 @@ func (m *Message) TypeString() string { } func (m *Message) radioMetricsString() string { - if m.fromNode.connected { + if m.FromNode.Connected { return "" } snr := "" - if m.snr != 0 { - snr = fmt.Sprintf("SNR %.2f, ", m.snr) + if m.Snr != 0 { + snr = fmt.Sprintf("SNR %.2f, ", m.Snr) } return fmt.Sprintf( "\033[90m(%s%d %s away)\033[0m", snr, - m.hopsAway, - helpers.Pluralize("hop", int(m.hopsAway)), + m.HopsAway, + helpers.Pluralize("hop", int(m.HopsAway)), ) } diff --git a/go/meshtastic/node.go b/go/meshtastic/node.go index 439ccf9..827421f 100644 --- a/go/meshtastic/node.go +++ b/go/meshtastic/node.go @@ -12,30 +12,29 @@ import ( type Node struct { ShortName string LongName string - id uint32 - macAddr []byte + Id uint32 HwModel meshtastic.HardwareModel Role meshtastic.Config_DeviceConfig_Role - snr float32 + Snr float32 LastHeard time.Time HopsAway uint32 NodeList nodeList Position []*position IsLicensed bool - deviceMetrics []*meshtastic.DeviceMetrics - connected bool + DeviceMetrics []*meshtastic.DeviceMetrics + Connected bool } func NewNode(info *meshtastic.NodeInfo) *Node { node := Node{ - id: info.Num, + Id: info.Num, HopsAway: 0, ShortName: "UNKN", LongName: "Unknown node", HwModel: meshtastic.HardwareModel_UNSET, IsLicensed: false, Position: make([]*position, 0), - deviceMetrics: make([]*meshtastic.DeviceMetrics, 0), + DeviceMetrics: make([]*meshtastic.DeviceMetrics, 0), } node.Update(info) @@ -43,11 +42,11 @@ func NewNode(info *meshtastic.NodeInfo) *Node { } func (n *Node) Update(info *meshtastic.NodeInfo) { - if info == nil || info.Num != n.id { + if info == nil || info.Num != n.Id { return } - n.snr = info.Snr + n.Snr = info.Snr n.LastHeard = time.Unix(int64(info.LastHeard), 0) if info.Position != nil { @@ -55,7 +54,7 @@ func (n *Node) Update(info *meshtastic.NodeInfo) { } if info.DeviceMetrics != nil { - n.deviceMetrics = append(n.deviceMetrics, info.DeviceMetrics) + n.DeviceMetrics = append(n.DeviceMetrics, info.DeviceMetrics) } if info.HopsAway != nil { @@ -65,7 +64,6 @@ func (n *Node) Update(info *meshtastic.NodeInfo) { if info.User != nil { n.ShortName = info.User.ShortName n.LongName = info.User.LongName - n.macAddr = info.User.Macaddr n.HwModel = info.User.HwModel n.Role = info.User.Role n.IsLicensed = info.User.IsLicensed @@ -74,9 +72,9 @@ func (n *Node) Update(info *meshtastic.NodeInfo) { func (n *Node) String() string { var col string - if n.connected { + if n.Connected { col = "92" - } else if n.id == Broadcast.id || n.id == Unknown.id { + } else if n.Id == Broadcast.Id || n.Id == Unknown.Id { col = "95" } else if n.HopsAway == 0 { col = "96" @@ -106,8 +104,8 @@ func (n *Node) VerboseString() string { role := n.Role.String() snr := "" - if n.snr != 0 { - snr = fmt.Sprintf(", SNR %.2f", n.snr) + if n.Snr != 0 { + snr = fmt.Sprintf(", SNR %.2f", n.Snr) } hopsAway := "" @@ -127,5 +125,5 @@ func (n *Node) VerboseString() string { } func (n *Node) IDExpression() string { - return fmt.Sprintf("!%x", n.id) + return fmt.Sprintf("!%x", n.Id) } diff --git a/go/meshtastic/node_list.go b/go/meshtastic/node_list.go index abf53ca..09acb85 100644 --- a/go/meshtastic/node_list.go +++ b/go/meshtastic/node_list.go @@ -10,13 +10,13 @@ type nodeList struct { } var Broadcast = Node{ - id: 0xFFFFFFFF, + Id: 0xFFFFFFFF, ShortName: "CAST", LongName: "Everyone", } var Unknown = Node{ - id: 0x00000000, + Id: 0x00000000, ShortName: "UNKN", LongName: "Unknown", } @@ -26,8 +26,8 @@ func NewNodeList() nodeList { nodes: make(map[uint32]*Node), } - list.nodes[Broadcast.id] = &Broadcast - list.nodes[Unknown.id] = &Unknown + list.nodes[Broadcast.Id] = &Broadcast + list.nodes[Unknown.Id] = &Unknown return list } @@ -35,7 +35,7 @@ func NewNodeList() nodeList { func (n *nodeList) String() string { nodes := "" for _, node := range n.sortedNodes() { - if node.id != Broadcast.id && node.id != Unknown.id { + if node.Id != Broadcast.Id && node.Id != Unknown.Id { nodes += node.VerboseString() + "\n" } } From 114e2036ceb987b167c85b4e0532e642690093e9 Mon Sep 17 00:00:00 2001 From: Timendus Date: Mon, 18 Nov 2024 21:40:12 +0100 Subject: [PATCH 08/87] Keep a record of incoming messages in the node that sent them --- go/meshtastic/connected_node.go | 24 ++++++++++--- go/meshtastic/message.go | 24 ++++++++----- go/meshtastic/node.go | 64 +++++++++++++++++++++------------ 3 files changed, 77 insertions(+), 35 deletions(-) diff --git a/go/meshtastic/connected_node.go b/go/meshtastic/connected_node.go index d0008e4..29e8781 100644 --- a/go/meshtastic/connected_node.go +++ b/go/meshtastic/connected_node.go @@ -135,8 +135,13 @@ func (n *ConnectedNode) parseMeshPacket(meshPacket *meshtastic.MeshPacket) { toNode := n.Node.NodeList.nodes[meshPacket.To] fromNode := n.Node.NodeList.nodes[meshPacket.From] - fromNode.Snr = meshPacket.RxSnr fromNode.HopsAway = hops + if hops == 0 { + // Assumption: the packet RxSnr is the signal quality of the received + // packet, which may have hopped through other nodes. So only update + // this node's SNR if we haven't hopped yet. + fromNode.Snr = meshPacket.RxSnr + } message := Message{ FromNode: fromNode, @@ -156,13 +161,14 @@ func (n *ConnectedNode) parseMeshPacket(meshPacket *meshtastic.MeshPacket) { log.Println("Error: Could not unmarshall NodeInfo User mesh packet: " + err.Error()) return } - log.Println("Got Node Info:", result.String()) - fromNode.ShortName = result.ShortName fromNode.LongName = result.LongName fromNode.HwModel = result.HwModel fromNode.Role = result.Role fromNode.IsLicensed = result.IsLicensed + fromNode.PublicKey = result.PublicKey + message.MessageType = MESSAGE_TYPE_NODE_INFO + case meshtastic.PortNum_TELEMETRY_APP: result := meshtastic.Telemetry{} err := proto.Unmarshal(payload, &result) @@ -176,17 +182,23 @@ func (n *ConnectedNode) parseMeshPacket(meshPacket *meshtastic.MeshPacket) { message.DeviceMetrics = result.GetDeviceMetrics() case *meshtastic.Telemetry_EnvironmentMetrics: message.MessageType = MESSAGE_TYPE_TELEMETRY_ENVIRONMENT + message.EnvironmentMetrics = result.GetEnvironmentMetrics() case *meshtastic.Telemetry_HealthMetrics: message.MessageType = MESSAGE_TYPE_TELEMETRY_HEALTH + message.HealthMetrics = result.GetHealthMetrics() case *meshtastic.Telemetry_AirQualityMetrics: message.MessageType = MESSAGE_TYPE_TELEMETRY_AIR_QUALITY + message.AirQualityMetrics = result.GetAirQualityMetrics() case *meshtastic.Telemetry_PowerMetrics: message.MessageType = MESSAGE_TYPE_TELEMETRY_POWER + message.PowerMetrics = result.GetPowerMetrics() case *meshtastic.Telemetry_LocalStats: message.MessageType = MESSAGE_TYPE_TELEMETRY_LOCAL_STATS + message.LocalStats = result.GetLocalStats() default: log.Println("Warning: Unknown telemetry variant:", result.String()) } + case meshtastic.PortNum_POSITION_APP: result := meshtastic.Position{} err := proto.Unmarshal(payload, &result) @@ -196,7 +208,7 @@ func (n *ConnectedNode) parseMeshPacket(meshPacket *meshtastic.MeshPacket) { } message.MessageType = MESSAGE_TYPE_POSITION message.Position = NewPosition(&result) - fromNode.Position = append(fromNode.Position, message.Position) + case meshtastic.PortNum_NEIGHBORINFO_APP: result := meshtastic.NeighborInfo{} err := proto.Unmarshal(payload, &result) @@ -206,17 +218,21 @@ func (n *ConnectedNode) parseMeshPacket(meshPacket *meshtastic.MeshPacket) { } message.MessageType = MESSAGE_TYPE_NEIGHBOR_INFO message.NeighborInfo = &result + case meshtastic.PortNum_TEXT_MESSAGE_APP: message.MessageType = MESSAGE_TYPE_TEXT_MESSAGE message.Text = string(payload) + case meshtastic.PortNum_ROUTING_APP: if meshPacket.GetDecoded() != nil { log.Println("Ack for message with ID", meshPacket.GetDecoded().RequestId, "from", fromNode.String()) } return + default: log.Println("Warning: Unknown mesh packet:", meshPacket.String()) } + fromNode.ReceivedMessages = append(fromNode.ReceivedMessages, &message) MessageEvents.publish("all-messages", message) } diff --git a/go/meshtastic/message.go b/go/meshtastic/message.go index 9751ce7..a9b9f82 100644 --- a/go/meshtastic/message.go +++ b/go/meshtastic/message.go @@ -12,6 +12,7 @@ import ( const ( MESSAGE_TYPE_TEXT_MESSAGE = iota + MESSAGE_TYPE_NODE_INFO MESSAGE_TYPE_POSITION MESSAGE_TYPE_NEIGHBOR_INFO MESSAGE_TYPE_TELEMETRY_DEVICE @@ -27,14 +28,21 @@ type Message struct { FromNode *Node ToNode *Node ReceivingNode *ConnectedNode - Timestamp time.Time - MessageType int - Text string - DeviceMetrics *meshtastic.DeviceMetrics - NeighborInfo *meshtastic.NeighborInfo - Position *position - Snr float32 - HopsAway uint32 + + Timestamp time.Time + Snr float32 + HopsAway uint32 + + MessageType int + Text string + DeviceMetrics *meshtastic.DeviceMetrics + EnvironmentMetrics *meshtastic.EnvironmentMetrics + HealthMetrics *meshtastic.HealthMetrics + AirQualityMetrics *meshtastic.AirQualityMetrics + PowerMetrics *meshtastic.PowerMetrics + LocalStats *meshtastic.LocalStats + NeighborInfo *meshtastic.NeighborInfo + Position *position } func (m *Message) Reply(message string) { diff --git a/go/meshtastic/node.go b/go/meshtastic/node.go index 827421f..17a4a7e 100644 --- a/go/meshtastic/node.go +++ b/go/meshtastic/node.go @@ -10,31 +10,30 @@ import ( ) type Node struct { - ShortName string - LongName string - Id uint32 - HwModel meshtastic.HardwareModel - Role meshtastic.Config_DeviceConfig_Role - Snr float32 - LastHeard time.Time - HopsAway uint32 - NodeList nodeList - Position []*position - IsLicensed bool - DeviceMetrics []*meshtastic.DeviceMetrics - Connected bool + ShortName string + LongName string + Id uint32 + HwModel meshtastic.HardwareModel + Role meshtastic.Config_DeviceConfig_Role + Snr float32 + LastHeard time.Time + HopsAway uint32 + NodeList nodeList + IsLicensed bool + ReceivedMessages []*Message + Connected bool + PublicKey []byte } func NewNode(info *meshtastic.NodeInfo) *Node { node := Node{ - Id: info.Num, - HopsAway: 0, - ShortName: "UNKN", - LongName: "Unknown node", - HwModel: meshtastic.HardwareModel_UNSET, - IsLicensed: false, - Position: make([]*position, 0), - DeviceMetrics: make([]*meshtastic.DeviceMetrics, 0), + Id: info.Num, + HopsAway: 0, + ShortName: "UNKN", + LongName: "Unknown node", + HwModel: meshtastic.HardwareModel_UNSET, + IsLicensed: false, + ReceivedMessages: make([]*Message, 0), } node.Update(info) @@ -50,11 +49,29 @@ func (n *Node) Update(info *meshtastic.NodeInfo) { n.LastHeard = time.Unix(int64(info.LastHeard), 0) if info.Position != nil { - n.Position = append(n.Position, NewPosition(info.Position)) + // TODO: move this to connected_node, I think. Because we don't have the + // receiving node here. Does that even matter..? + n.ReceivedMessages = append(n.ReceivedMessages, &Message{ + FromNode: n, + ToNode: &Broadcast, + ReceivingNode: nil, + Timestamp: time.Unix(int64(info.LastHeard), 0), + MessageType: MESSAGE_TYPE_POSITION, + Position: NewPosition(info.Position), + }) } if info.DeviceMetrics != nil { - n.DeviceMetrics = append(n.DeviceMetrics, info.DeviceMetrics) + // TODO: move this to connected_node, I think. Because we don't have the + // receiving node here. Does that even matter..? + n.ReceivedMessages = append(n.ReceivedMessages, &Message{ + FromNode: n, + ToNode: &Broadcast, + ReceivingNode: nil, + Timestamp: time.Unix(int64(info.LastHeard), 0), + MessageType: MESSAGE_TYPE_TELEMETRY_DEVICE, + DeviceMetrics: info.DeviceMetrics, + }) } if info.HopsAway != nil { @@ -67,6 +84,7 @@ func (n *Node) Update(info *meshtastic.NodeInfo) { n.HwModel = info.User.HwModel n.Role = info.User.Role n.IsLicensed = info.User.IsLicensed + n.PublicKey = info.User.PublicKey } } From 18ca6ab49068186e2d30b34d45ec964a1d8fbe34 Mon Sep 17 00:00:00 2001 From: Timendus Date: Wed, 20 Nov 2024 15:50:55 +0100 Subject: [PATCH 09/87] Add more events to subscribe to --- go/main.go | 9 +++++++-- go/meshtastic/connected_node.go | 22 +++++++++++++++++----- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/go/main.go b/go/main.go index aec0903..3993a40 100644 --- a/go/main.go +++ b/go/main.go @@ -16,8 +16,9 @@ import ( func main() { log.Println("Starting Meshed Potatoes!") - meshtastic.MessageEvents.Subscribe("all-messages", message) - meshtastic.NodeEvents.Subscribe("node-connected", connected) + meshtastic.MessageEvents.Subscribe("any", message) + meshtastic.NodeEvents.Subscribe("connected", connected) + meshtastic.NodeEvents.Subscribe("disconnected", disconnected) // Attempt to auto-detect Meshtestic device on a serial port. Otherwise, // connect over TCP. @@ -76,6 +77,10 @@ func connected(node meshtastic.ConnectedNode) { } } +func disconnected(node meshtastic.ConnectedNode) { + log.Println("Disconnected from the node. Maybe some retry-logic here?") +} + func message(message meshtastic.Message) { fmt.Println(message.String()) diff --git a/go/meshtastic/connected_node.go b/go/meshtastic/connected_node.go index 29e8781..f586551 100644 --- a/go/meshtastic/connected_node.go +++ b/go/meshtastic/connected_node.go @@ -53,7 +53,7 @@ func NewConnectedNode(stream io.ReadWriteCloser) (*ConnectedNode, error) { func (n *ConnectedNode) Close() error { n.Connected = false - NodeEvents.publish("node-disconnected", *n) + NodeEvents.publish("disconnected", *n) return n.stream.Close() } @@ -85,7 +85,7 @@ func (n *ConnectedNode) ReadMessages(stream io.ReadCloser) error { switch packet.PayloadVariant.(type) { case *meshtastic.FromRadio_ConfigCompleteId: n.Connected = true - NodeEvents.publish("node-connected", *n) + NodeEvents.publish("connected", *n) case *meshtastic.FromRadio_MyInfo: n.Node.Id = packet.GetMyInfo().MyNodeNum n.Node.NodeList.nodes[n.Node.Id] = n.Node @@ -153,6 +153,8 @@ func (n *ConnectedNode) parseMeshPacket(meshPacket *meshtastic.MeshPacket) { HopsAway: hops, } + fromNode.ReceivedMessages = append(fromNode.ReceivedMessages, &message) + switch meshPacket.GetDecoded().Portnum { case meshtastic.PortNum_NODEINFO_APP: result := meshtastic.User{} @@ -168,6 +170,7 @@ func (n *ConnectedNode) parseMeshPacket(meshPacket *meshtastic.MeshPacket) { fromNode.IsLicensed = result.IsLicensed fromNode.PublicKey = result.PublicKey message.MessageType = MESSAGE_TYPE_NODE_INFO + MessageEvents.publish("node info", message) case meshtastic.PortNum_TELEMETRY_APP: result := meshtastic.Telemetry{} @@ -180,24 +183,31 @@ func (n *ConnectedNode) parseMeshPacket(meshPacket *meshtastic.MeshPacket) { case *meshtastic.Telemetry_DeviceMetrics: message.MessageType = MESSAGE_TYPE_TELEMETRY_DEVICE message.DeviceMetrics = result.GetDeviceMetrics() + MessageEvents.publish("device telemetry", message) case *meshtastic.Telemetry_EnvironmentMetrics: message.MessageType = MESSAGE_TYPE_TELEMETRY_ENVIRONMENT message.EnvironmentMetrics = result.GetEnvironmentMetrics() + MessageEvents.publish("environment telemetry", message) case *meshtastic.Telemetry_HealthMetrics: message.MessageType = MESSAGE_TYPE_TELEMETRY_HEALTH message.HealthMetrics = result.GetHealthMetrics() + MessageEvents.publish("health telemetry", message) case *meshtastic.Telemetry_AirQualityMetrics: message.MessageType = MESSAGE_TYPE_TELEMETRY_AIR_QUALITY message.AirQualityMetrics = result.GetAirQualityMetrics() + MessageEvents.publish("air quality telemetry", message) case *meshtastic.Telemetry_PowerMetrics: message.MessageType = MESSAGE_TYPE_TELEMETRY_POWER message.PowerMetrics = result.GetPowerMetrics() + MessageEvents.publish("power telemetry", message) case *meshtastic.Telemetry_LocalStats: message.MessageType = MESSAGE_TYPE_TELEMETRY_LOCAL_STATS message.LocalStats = result.GetLocalStats() + MessageEvents.publish("local stats telemetry", message) default: log.Println("Warning: Unknown telemetry variant:", result.String()) } + MessageEvents.publish("telemetry", message) case meshtastic.PortNum_POSITION_APP: result := meshtastic.Position{} @@ -208,6 +218,7 @@ func (n *ConnectedNode) parseMeshPacket(meshPacket *meshtastic.MeshPacket) { } message.MessageType = MESSAGE_TYPE_POSITION message.Position = NewPosition(&result) + MessageEvents.publish("position", message) case meshtastic.PortNum_NEIGHBORINFO_APP: result := meshtastic.NeighborInfo{} @@ -218,21 +229,22 @@ func (n *ConnectedNode) parseMeshPacket(meshPacket *meshtastic.MeshPacket) { } message.MessageType = MESSAGE_TYPE_NEIGHBOR_INFO message.NeighborInfo = &result + MessageEvents.publish("neighbor info", message) case meshtastic.PortNum_TEXT_MESSAGE_APP: message.MessageType = MESSAGE_TYPE_TEXT_MESSAGE message.Text = string(payload) + MessageEvents.publish("text message", message) case meshtastic.PortNum_ROUTING_APP: if meshPacket.GetDecoded() != nil { log.Println("Ack for message with ID", meshPacket.GetDecoded().RequestId, "from", fromNode.String()) } - return + MessageEvents.publish("routing", message) default: log.Println("Warning: Unknown mesh packet:", meshPacket.String()) } - fromNode.ReceivedMessages = append(fromNode.ReceivedMessages, &message) - MessageEvents.publish("all-messages", message) + MessageEvents.publish("any", message) } From cb0c50f7d1f96a04b0eaf5143d390419e703e075 Mon Sep 17 00:00:00 2001 From: Timendus Date: Wed, 20 Nov 2024 16:04:58 +0100 Subject: [PATCH 10/87] Implement blocking send --- go/main.go | 9 +++++++-- go/meshtastic/connected_node.go | 8 +++++++- go/meshtastic/message.go | 15 ++++++++++++--- 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/go/main.go b/go/main.go index 3993a40..b6cb03b 100644 --- a/go/main.go +++ b/go/main.go @@ -17,6 +17,7 @@ func main() { log.Println("Starting Meshed Potatoes!") meshtastic.MessageEvents.Subscribe("any", message) + meshtastic.MessageEvents.Subscribe("text message", textMessage) meshtastic.NodeEvents.Subscribe("connected", connected) meshtastic.NodeEvents.Subscribe("disconnected", disconnected) @@ -83,8 +84,12 @@ func disconnected(node meshtastic.ConnectedNode) { func message(message meshtastic.Message) { fmt.Println(message.String()) +} - if message.ToNode.Id != meshtastic.Broadcast.Id { - message.Reply("Hello, world!") +func textMessage(message meshtastic.Message) { + if message.ToNode.Id != meshtastic.Broadcast.Id && message.FromNode.Id == 0x56598860 { + log.Println("Sending message and waiting...") + <-message.ReplyBlocking("Hello, world!") + log.Println("Process unblocked!") } } diff --git a/go/meshtastic/connected_node.go b/go/meshtastic/connected_node.go index f586551..a8ab3a4 100644 --- a/go/meshtastic/connected_node.go +++ b/go/meshtastic/connected_node.go @@ -15,6 +15,7 @@ type ConnectedNode struct { FirmwareVersion string Channels []channel Node *Node + Acks map[uint32]chan bool } func NewConnectedNode(stream io.ReadWriteCloser) (*ConnectedNode, error) { @@ -22,6 +23,7 @@ func NewConnectedNode(stream io.ReadWriteCloser) (*ConnectedNode, error) { newNode := ConnectedNode{ stream: stream, Connected: false, + Acks: make(map[uint32]chan bool), Node: &Node{ ShortName: "UNKN", LongName: "Unknown node", @@ -238,8 +240,12 @@ func (n *ConnectedNode) parseMeshPacket(meshPacket *meshtastic.MeshPacket) { case meshtastic.PortNum_ROUTING_APP: if meshPacket.GetDecoded() != nil { - log.Println("Ack for message with ID", meshPacket.GetDecoded().RequestId, "from", fromNode.String()) + messageId := meshPacket.GetDecoded().RequestId + if n.Acks[messageId] != nil { + n.Acks[messageId] <- true + } } + message.MessageType = MESSAGE_TYPE_ROUTING MessageEvents.publish("routing", message) default: diff --git a/go/meshtastic/message.go b/go/meshtastic/message.go index a9b9f82..3b86bb7 100644 --- a/go/meshtastic/message.go +++ b/go/meshtastic/message.go @@ -2,7 +2,6 @@ package meshtastic import ( "fmt" - "log" "math/rand/v2" "time" @@ -15,6 +14,7 @@ const ( MESSAGE_TYPE_NODE_INFO MESSAGE_TYPE_POSITION MESSAGE_TYPE_NEIGHBOR_INFO + MESSAGE_TYPE_ROUTING MESSAGE_TYPE_TELEMETRY_DEVICE MESSAGE_TYPE_TELEMETRY_ENVIRONMENT MESSAGE_TYPE_TELEMETRY_HEALTH @@ -45,9 +45,8 @@ type Message struct { Position *position } -func (m *Message) Reply(message string) { +func (m *Message) Reply(message string) uint32 { id := rand.Uint32() - log.Println("Sending message with ID", id) m.ReceivingNode.SendMessage(meshtastic.ToRadio_Packet{ Packet: &meshtastic.MeshPacket{ Id: id, @@ -69,6 +68,14 @@ func (m *Message) Reply(message string) { }, }, }) + return id +} + +func (m *Message) ReplyBlocking(message string) chan bool { + ch := make(chan bool) + id := m.Reply(message) + m.ReceivingNode.Acks[id] = ch + return ch } func (m *Message) String() string { @@ -92,6 +99,8 @@ func (m *Message) typeString() string { return "position" case MESSAGE_TYPE_NEIGHBOR_INFO: return "neighbor info" + case MESSAGE_TYPE_ROUTING: + return "routing" case MESSAGE_TYPE_TELEMETRY_DEVICE: return "device telemetry" case MESSAGE_TYPE_TELEMETRY_ENVIRONMENT: From c6d33241d8d253bc445a5b3105c78a3f3c313d06 Mon Sep 17 00:00:00 2001 From: Timendus Date: Wed, 20 Nov 2024 16:18:48 +0100 Subject: [PATCH 11/87] Nodelist should be a property of a connected node. And fix a bug. --- go/main.go | 2 +- go/meshtastic/connected_node.go | 23 +++++++++++++++++------ go/meshtastic/node.go | 1 - 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/go/main.go b/go/main.go index b6cb03b..ce2664a 100644 --- a/go/main.go +++ b/go/main.go @@ -71,7 +71,7 @@ func main() { func connected(node meshtastic.ConnectedNode) { log.Println("Connected to a node!") log.Println("This is me: " + node.String()) - log.Println("Node list: \n" + node.Node.NodeList.String()) + log.Println("Node list: \n" + node.NodeList.String()) log.Println("Channel list:") for _, channel := range node.Channels { log.Println(" " + channel.String()) diff --git a/go/meshtastic/connected_node.go b/go/meshtastic/connected_node.go index a8ab3a4..feeeeab 100644 --- a/go/meshtastic/connected_node.go +++ b/go/meshtastic/connected_node.go @@ -15,6 +15,7 @@ type ConnectedNode struct { FirmwareVersion string Channels []channel Node *Node + NodeList nodeList Acks map[uint32]chan bool } @@ -23,13 +24,13 @@ func NewConnectedNode(stream io.ReadWriteCloser) (*ConnectedNode, error) { newNode := ConnectedNode{ stream: stream, Connected: false, + NodeList: NewNodeList(), Acks: make(map[uint32]chan bool), Node: &Node{ ShortName: "UNKN", LongName: "Unknown node", Id: 0, Connected: true, - NodeList: NewNodeList(), }, } @@ -90,7 +91,7 @@ func (n *ConnectedNode) ReadMessages(stream io.ReadCloser) error { NodeEvents.publish("connected", *n) case *meshtastic.FromRadio_MyInfo: n.Node.Id = packet.GetMyInfo().MyNodeNum - n.Node.NodeList.nodes[n.Node.Id] = n.Node + n.NodeList.nodes[n.Node.Id] = n.Node case *meshtastic.FromRadio_Metadata: n.FirmwareVersion = packet.GetMetadata().FirmwareVersion case *meshtastic.FromRadio_NodeInfo: @@ -112,9 +113,9 @@ func (n *ConnectedNode) ReadMessages(stream io.ReadCloser) error { func (n *ConnectedNode) parseNodeInfo(nodeInfo *meshtastic.NodeInfo) { // Create or update the node that this info relates to - relevantNode, exists := n.Node.NodeList.nodes[nodeInfo.Num] + relevantNode, exists := n.NodeList.nodes[nodeInfo.Num] if !exists { - n.Node.NodeList.nodes[nodeInfo.Num] = NewNode(nodeInfo) + n.NodeList.nodes[nodeInfo.Num] = NewNode(nodeInfo) } else { relevantNode.Update(nodeInfo) } @@ -135,8 +136,18 @@ func (n *ConnectedNode) parseMeshPacket(meshPacket *meshtastic.MeshPacket) { payload := meshPacket.GetDecoded().GetPayload() - toNode := n.Node.NodeList.nodes[meshPacket.To] - fromNode := n.Node.NodeList.nodes[meshPacket.From] + toNode := n.NodeList.nodes[meshPacket.To] + fromNode := n.NodeList.nodes[meshPacket.From] + + if fromNode == nil { + // If the sending node is not in our node list yet, just add it. + fromNode = NewNode(&meshtastic.NodeInfo{ + Num: meshPacket.From, + LastHeard: meshPacket.RxTime, + }) + n.NodeList.nodes[meshPacket.From] = fromNode + } + fromNode.HopsAway = hops if hops == 0 { // Assumption: the packet RxSnr is the signal quality of the received diff --git a/go/meshtastic/node.go b/go/meshtastic/node.go index 17a4a7e..bf4fee3 100644 --- a/go/meshtastic/node.go +++ b/go/meshtastic/node.go @@ -18,7 +18,6 @@ type Node struct { Snr float32 LastHeard time.Time HopsAway uint32 - NodeList nodeList IsLicensed bool ReceivedMessages []*Message Connected bool From d77bdff15189d2b4b9f55a9e14fe8552989857ce Mon Sep 17 00:00:00 2001 From: Timendus Date: Thu, 21 Nov 2024 00:12:13 +0100 Subject: [PATCH 12/87] Store a node's most recent neighbours on the node for easy access --- go/meshtastic/connected_node.go | 8 +++-- go/meshtastic/helpers/assertions.go | 7 +++++ go/meshtastic/neighbor.go | 49 +++++++++++++++++++++++++++++ go/meshtastic/node.go | 2 ++ 4 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 go/meshtastic/helpers/assertions.go create mode 100644 go/meshtastic/neighbor.go diff --git a/go/meshtastic/connected_node.go b/go/meshtastic/connected_node.go index feeeeab..1858576 100644 --- a/go/meshtastic/connected_node.go +++ b/go/meshtastic/connected_node.go @@ -3,9 +3,11 @@ package meshtastic import ( "io" "log" + "strconv" "time" "buf.build/gen/go/meshtastic/protobufs/protocolbuffers/go/meshtastic" + "github.com/timendus/meshbot/meshtastic/helpers" "google.golang.org/protobuf/proto" ) @@ -35,7 +37,7 @@ func NewConnectedNode(stream io.ReadWriteCloser) (*ConnectedNode, error) { } // Spin up a goroutine to read messages from the device - go newNode.ReadMessages(stream) + go newNode.readMessages(stream) // Wake the device if err := wakeDevice(stream); err != nil { @@ -73,7 +75,7 @@ func (n *ConnectedNode) SendMessage(message meshtastic.ToRadio_Packet) error { return nil } -func (n *ConnectedNode) ReadMessages(stream io.ReadCloser) error { +func (n *ConnectedNode) readMessages(stream io.ReadCloser) error { for { packet, err := readMessage(stream) if err != nil { @@ -242,6 +244,8 @@ func (n *ConnectedNode) parseMeshPacket(meshPacket *meshtastic.MeshPacket) { } message.MessageType = MESSAGE_TYPE_NEIGHBOR_INFO message.NeighborInfo = &result + helpers.Assert(result.NodeId == meshPacket.From, "I don't understand this format well enough: received "+message.String()+" but it has NodeId "+strconv.Itoa(int(result.NodeId))) + fromNode.Neighbors = NewNeighbourList(&n.NodeList, meshPacket.RxTime, result.Neighbors) MessageEvents.publish("neighbor info", message) case meshtastic.PortNum_TEXT_MESSAGE_APP: diff --git a/go/meshtastic/helpers/assertions.go b/go/meshtastic/helpers/assertions.go new file mode 100644 index 0000000..f72904a --- /dev/null +++ b/go/meshtastic/helpers/assertions.go @@ -0,0 +1,7 @@ +package helpers + +func Assert(condition bool, message string) { + if !condition { + panic(message) + } +} diff --git a/go/meshtastic/neighbor.go b/go/meshtastic/neighbor.go new file mode 100644 index 0000000..43b5d4a --- /dev/null +++ b/go/meshtastic/neighbor.go @@ -0,0 +1,49 @@ +package meshtastic + +import ( + "fmt" + "time" + + "buf.build/gen/go/meshtastic/protobufs/protocolbuffers/go/meshtastic" + "github.com/timendus/meshbot/meshtastic/helpers" +) + +type Neighbor struct { + Node *Node + Snr float32 + LastReported time.Time +} + +func (n *Neighbor) String() string { + return fmt.Sprintf("%s \033[90m(last reported %s ago, SNR %.2f)\033[0m", n.Node.String(), helpers.TimeAgo(n.LastReported), n.Snr) +} + +type NeighborList []Neighbor + +func NewNeighbourList(nodelist *nodeList, timestamp uint32, neighbors []*meshtastic.Neighbor) NeighborList { + properTimestamp := time.Unix(int64(timestamp), 0) + neighbourList := make([]Neighbor, 0) + for _, neighbor := range neighbors { + node := nodelist.nodes[neighbor.NodeId] + if node == nil { + node = NewNode(&meshtastic.NodeInfo{ + Num: neighbor.NodeId, + }) + nodelist.nodes[neighbor.NodeId] = node + } + neighbourList = append(neighbourList, Neighbor{ + Node: node, + Snr: neighbor.Snr, + LastReported: properTimestamp, + }) + } + return neighbourList +} + +func (nl NeighborList) String() string { + nodes := "" + for _, node := range nl { + nodes += " - " + node.String() + "\n" + } + return nodes +} diff --git a/go/meshtastic/node.go b/go/meshtastic/node.go index bf4fee3..8c2be81 100644 --- a/go/meshtastic/node.go +++ b/go/meshtastic/node.go @@ -22,6 +22,7 @@ type Node struct { ReceivedMessages []*Message Connected bool PublicKey []byte + Neighbors NeighborList } func NewNode(info *meshtastic.NodeInfo) *Node { @@ -33,6 +34,7 @@ func NewNode(info *meshtastic.NodeInfo) *Node { HwModel: meshtastic.HardwareModel_UNSET, IsLicensed: false, ReceivedMessages: make([]*Message, 0), + Neighbors: make(NeighborList, 0), } node.Update(info) From aca15e9beb47a07366ea2b3e2c5d4b758018ee9b Mon Sep 17 00:00:00 2001 From: Timendus Date: Thu, 21 Nov 2024 00:13:24 +0100 Subject: [PATCH 13/87] Symplify message types and add traceroute type --- go/meshtastic/connected_node.go | 4 +++ go/meshtastic/message.go | 56 +++++++++------------------------ 2 files changed, 19 insertions(+), 41 deletions(-) diff --git a/go/meshtastic/connected_node.go b/go/meshtastic/connected_node.go index 1858576..f9f8e97 100644 --- a/go/meshtastic/connected_node.go +++ b/go/meshtastic/connected_node.go @@ -263,6 +263,10 @@ func (n *ConnectedNode) parseMeshPacket(meshPacket *meshtastic.MeshPacket) { message.MessageType = MESSAGE_TYPE_ROUTING MessageEvents.publish("routing", message) + case meshtastic.PortNum_TRACEROUTE_APP: + message.MessageType = MESSAGE_TYPE_TRACEROUTE + MessageEvents.publish("traceroute", message) + default: log.Println("Warning: Unknown mesh packet:", meshPacket.String()) } diff --git a/go/meshtastic/message.go b/go/meshtastic/message.go index 3b86bb7..6655665 100644 --- a/go/meshtastic/message.go +++ b/go/meshtastic/message.go @@ -10,18 +10,19 @@ import ( ) const ( - MESSAGE_TYPE_TEXT_MESSAGE = iota - MESSAGE_TYPE_NODE_INFO - MESSAGE_TYPE_POSITION - MESSAGE_TYPE_NEIGHBOR_INFO - MESSAGE_TYPE_ROUTING - MESSAGE_TYPE_TELEMETRY_DEVICE - MESSAGE_TYPE_TELEMETRY_ENVIRONMENT - MESSAGE_TYPE_TELEMETRY_HEALTH - MESSAGE_TYPE_TELEMETRY_AIR_QUALITY - MESSAGE_TYPE_TELEMETRY_POWER - MESSAGE_TYPE_TELEMETRY_LOCAL_STATS - MESSAGE_TYPE_OTHER + MESSAGE_TYPE_TEXT_MESSAGE = "text message" + MESSAGE_TYPE_NODE_INFO = "node info" + MESSAGE_TYPE_POSITION = "position" + MESSAGE_TYPE_NEIGHBOR_INFO = "neighbor info" + MESSAGE_TYPE_ROUTING = "routing" + MESSAGE_TYPE_TRACEROUTE = "traceroute" + MESSAGE_TYPE_TELEMETRY_DEVICE = "device telemetry" + MESSAGE_TYPE_TELEMETRY_ENVIRONMENT = "environment telemetry" + MESSAGE_TYPE_TELEMETRY_HEALTH = "health telemetry" + MESSAGE_TYPE_TELEMETRY_AIR_QUALITY = "air quality telemetry" + MESSAGE_TYPE_TELEMETRY_POWER = "power telemetry" + MESSAGE_TYPE_TELEMETRY_LOCAL_STATS = "local stats telemetry" + MESSAGE_TYPE_OTHER = "other" ) type Message struct { @@ -33,7 +34,7 @@ type Message struct { Snr float32 HopsAway uint32 - MessageType int + MessageType string Text string DeviceMetrics *meshtastic.DeviceMetrics EnvironmentMetrics *meshtastic.EnvironmentMetrics @@ -85,39 +86,12 @@ func (m *Message) String() string { if m.MessageType == MESSAGE_TYPE_TEXT_MESSAGE { content = m.Text } else { - content = "\033[1m" + m.typeString() + " packet\033[0m" + content = "\033[1m" + m.MessageType + " packet\033[0m" } return fmt.Sprintf("%s: %s %s", direction, content, m.radioMetricsString()) } -func (m *Message) typeString() string { - switch m.MessageType { - case MESSAGE_TYPE_TEXT_MESSAGE: - return "text message" - case MESSAGE_TYPE_POSITION: - return "position" - case MESSAGE_TYPE_NEIGHBOR_INFO: - return "neighbor info" - case MESSAGE_TYPE_ROUTING: - return "routing" - case MESSAGE_TYPE_TELEMETRY_DEVICE: - return "device telemetry" - case MESSAGE_TYPE_TELEMETRY_ENVIRONMENT: - return "environment telemetry" - case MESSAGE_TYPE_TELEMETRY_HEALTH: - return "health telemetry" - case MESSAGE_TYPE_TELEMETRY_AIR_QUALITY: - return "air quality telemetry" - case MESSAGE_TYPE_TELEMETRY_POWER: - return "power telemetry" - case MESSAGE_TYPE_TELEMETRY_LOCAL_STATS: - return "local stats telemetry" - default: - return "other" - } -} - func (m *Message) radioMetricsString() string { if m.FromNode.Connected { return "" From f343d698e1f0ab145469afb69d9af91d025b079f Mon Sep 17 00:00:00 2001 From: Timendus Date: Thu, 21 Nov 2024 00:32:46 +0100 Subject: [PATCH 14/87] Properly output neighbour list --- go/meshtastic/message.go | 4 ++++ go/meshtastic/neighbor.go | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/go/meshtastic/message.go b/go/meshtastic/message.go index 6655665..249da70 100644 --- a/go/meshtastic/message.go +++ b/go/meshtastic/message.go @@ -82,6 +82,10 @@ func (m *Message) ReplyBlocking(message string) chan bool { func (m *Message) String() string { direction := m.FromNode.String() + " -> " + m.ToNode.String() + if m.MessageType == MESSAGE_TYPE_NEIGHBOR_INFO { + return fmt.Sprintf("%s: \033[1mNeighbor list:\033[0m %s %s", direction, m.radioMetricsString(), m.FromNode.Neighbors.String()) + } + var content string if m.MessageType == MESSAGE_TYPE_TEXT_MESSAGE { content = m.Text diff --git a/go/meshtastic/neighbor.go b/go/meshtastic/neighbor.go index 43b5d4a..21e4b61 100644 --- a/go/meshtastic/neighbor.go +++ b/go/meshtastic/neighbor.go @@ -43,7 +43,7 @@ func NewNeighbourList(nodelist *nodeList, timestamp uint32, neighbors []*meshtas func (nl NeighborList) String() string { nodes := "" for _, node := range nl { - nodes += " - " + node.String() + "\n" + nodes += "\n - " + node.String() } return nodes } From 6a3d2d7acecac5a690a6d909c00b599ed58a6a54 Mon Sep 17 00:00:00 2001 From: Timendus Date: Thu, 21 Nov 2024 00:39:18 +0100 Subject: [PATCH 15/87] Properly finish blocking reply with timeout (could still have race conditions, but we'll see) --- go/main.go | 8 ++++++-- go/meshtastic/connected_node.go | 1 + go/meshtastic/message.go | 7 ++++++- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/go/main.go b/go/main.go index ce2664a..ac55fed 100644 --- a/go/main.go +++ b/go/main.go @@ -89,7 +89,11 @@ func message(message meshtastic.Message) { func textMessage(message meshtastic.Message) { if message.ToNode.Id != meshtastic.Broadcast.Id && message.FromNode.Id == 0x56598860 { log.Println("Sending message and waiting...") - <-message.ReplyBlocking("Hello, world!") - log.Println("Process unblocked!") + delivered := <-message.ReplyBlocking("Hello, world!", 10*time.Second) + if delivered { + log.Println("Message delivered!") + } else { + log.Println("No delivery confirmation received within 10 seconds :(") + } } } diff --git a/go/meshtastic/connected_node.go b/go/meshtastic/connected_node.go index f9f8e97..b01b845 100644 --- a/go/meshtastic/connected_node.go +++ b/go/meshtastic/connected_node.go @@ -258,6 +258,7 @@ func (n *ConnectedNode) parseMeshPacket(meshPacket *meshtastic.MeshPacket) { messageId := meshPacket.GetDecoded().RequestId if n.Acks[messageId] != nil { n.Acks[messageId] <- true + delete(n.Acks, messageId) } } message.MessageType = MESSAGE_TYPE_ROUTING diff --git a/go/meshtastic/message.go b/go/meshtastic/message.go index 249da70..0265c48 100644 --- a/go/meshtastic/message.go +++ b/go/meshtastic/message.go @@ -72,10 +72,15 @@ func (m *Message) Reply(message string) uint32 { return id } -func (m *Message) ReplyBlocking(message string) chan bool { +func (m *Message) ReplyBlocking(message string, timeout time.Duration) chan bool { ch := make(chan bool) id := m.Reply(message) m.ReceivingNode.Acks[id] = ch + go func() { + time.Sleep(timeout) + ch <- false + delete(m.ReceivingNode.Acks, id) + }() return ch } From 41543bb63d9211837c215c33654eab2e6ba75e31 Mon Sep 17 00:00:00 2001 From: Timendus Date: Thu, 21 Nov 2024 01:15:19 +0100 Subject: [PATCH 16/87] Rename meshtastic -> meshwrapper to be consistent with Python code and differentiate from the protobuf package --- go/main.go | 26 +++++++++---------- go/{meshtastic => meshwrapper}/channel.go | 2 +- .../connected_node.go | 4 +-- .../helpers/assertions.go | 0 .../helpers/language.go | 0 .../helpers/time.go | 0 go/{meshtastic => meshwrapper}/message.go | 4 +-- go/{meshtastic => meshwrapper}/neighbor.go | 4 +-- go/{meshtastic => meshwrapper}/node.go | 4 +-- go/{meshtastic => meshwrapper}/node_list.go | 2 +- go/{meshtastic => meshwrapper}/position.go | 2 +- go/{meshtastic => meshwrapper}/pubsub.go | 2 +- .../stream_interface.go | 2 +- 13 files changed, 26 insertions(+), 26 deletions(-) rename go/{meshtastic => meshwrapper}/channel.go (95%) rename go/{meshtastic => meshwrapper}/connected_node.go (99%) rename go/{meshtastic => meshwrapper}/helpers/assertions.go (100%) rename go/{meshtastic => meshwrapper}/helpers/language.go (100%) rename go/{meshtastic => meshwrapper}/helpers/time.go (100%) rename go/{meshtastic => meshwrapper}/message.go (97%) rename go/{meshtastic => meshwrapper}/neighbor.go (94%) rename go/{meshtastic => meshwrapper}/node.go (97%) rename go/{meshtastic => meshwrapper}/node_list.go (98%) rename go/{meshtastic => meshwrapper}/position.go (97%) rename go/{meshtastic => meshwrapper}/pubsub.go (96%) rename go/{meshtastic => meshwrapper}/stream_interface.go (99%) diff --git a/go/main.go b/go/main.go index ac55fed..5471136 100644 --- a/go/main.go +++ b/go/main.go @@ -9,22 +9,22 @@ import ( "net" "time" - "github.com/timendus/meshbot/meshtastic" + m "github.com/timendus/meshbot/meshwrapper" "go.bug.st/serial" ) func main() { log.Println("Starting Meshed Potatoes!") - meshtastic.MessageEvents.Subscribe("any", message) - meshtastic.MessageEvents.Subscribe("text message", textMessage) - meshtastic.NodeEvents.Subscribe("connected", connected) - meshtastic.NodeEvents.Subscribe("disconnected", disconnected) + m.MessageEvents.Subscribe("any", message) + m.MessageEvents.Subscribe("text message", textMessage) + m.NodeEvents.Subscribe("connected", connected) + m.NodeEvents.Subscribe("disconnected", disconnected) // Attempt to auto-detect Meshtestic device on a serial port. Otherwise, // connect over TCP. - var node *meshtastic.ConnectedNode + var node *m.ConnectedNode ports, err := serial.GetPortsList() if err != nil { @@ -45,7 +45,7 @@ func main() { log.Fatal(err) } - node, err = meshtastic.NewConnectedNode(serialPort) + node, err = m.NewConnectedNode(serialPort) if err != nil { log.Fatal(err) } @@ -55,7 +55,7 @@ func main() { log.Fatal(err) } - node, err = meshtastic.NewConnectedNode(tcpPort) + node, err = m.NewConnectedNode(tcpPort) if err != nil { log.Fatal(err) } @@ -68,7 +68,7 @@ func main() { } } -func connected(node meshtastic.ConnectedNode) { +func connected(node m.ConnectedNode) { log.Println("Connected to a node!") log.Println("This is me: " + node.String()) log.Println("Node list: \n" + node.NodeList.String()) @@ -78,16 +78,16 @@ func connected(node meshtastic.ConnectedNode) { } } -func disconnected(node meshtastic.ConnectedNode) { +func disconnected(node m.ConnectedNode) { log.Println("Disconnected from the node. Maybe some retry-logic here?") } -func message(message meshtastic.Message) { +func message(message m.Message) { fmt.Println(message.String()) } -func textMessage(message meshtastic.Message) { - if message.ToNode.Id != meshtastic.Broadcast.Id && message.FromNode.Id == 0x56598860 { +func textMessage(message m.Message) { + if message.ToNode.Id != m.Broadcast.Id && message.FromNode.Id == 0x56598860 { log.Println("Sending message and waiting...") delivered := <-message.ReplyBlocking("Hello, world!", 10*time.Second) if delivered { diff --git a/go/meshtastic/channel.go b/go/meshwrapper/channel.go similarity index 95% rename from go/meshtastic/channel.go rename to go/meshwrapper/channel.go index a5b1ee1..bd5f037 100644 --- a/go/meshtastic/channel.go +++ b/go/meshwrapper/channel.go @@ -1,4 +1,4 @@ -package meshtastic +package meshwrapper import ( "fmt" diff --git a/go/meshtastic/connected_node.go b/go/meshwrapper/connected_node.go similarity index 99% rename from go/meshtastic/connected_node.go rename to go/meshwrapper/connected_node.go index b01b845..ecabda0 100644 --- a/go/meshtastic/connected_node.go +++ b/go/meshwrapper/connected_node.go @@ -1,4 +1,4 @@ -package meshtastic +package meshwrapper import ( "io" @@ -7,7 +7,7 @@ import ( "time" "buf.build/gen/go/meshtastic/protobufs/protocolbuffers/go/meshtastic" - "github.com/timendus/meshbot/meshtastic/helpers" + "github.com/timendus/meshbot/meshwrapper/helpers" "google.golang.org/protobuf/proto" ) diff --git a/go/meshtastic/helpers/assertions.go b/go/meshwrapper/helpers/assertions.go similarity index 100% rename from go/meshtastic/helpers/assertions.go rename to go/meshwrapper/helpers/assertions.go diff --git a/go/meshtastic/helpers/language.go b/go/meshwrapper/helpers/language.go similarity index 100% rename from go/meshtastic/helpers/language.go rename to go/meshwrapper/helpers/language.go diff --git a/go/meshtastic/helpers/time.go b/go/meshwrapper/helpers/time.go similarity index 100% rename from go/meshtastic/helpers/time.go rename to go/meshwrapper/helpers/time.go diff --git a/go/meshtastic/message.go b/go/meshwrapper/message.go similarity index 97% rename from go/meshtastic/message.go rename to go/meshwrapper/message.go index 0265c48..e75cf4f 100644 --- a/go/meshtastic/message.go +++ b/go/meshwrapper/message.go @@ -1,4 +1,4 @@ -package meshtastic +package meshwrapper import ( "fmt" @@ -6,7 +6,7 @@ import ( "time" "buf.build/gen/go/meshtastic/protobufs/protocolbuffers/go/meshtastic" - "github.com/timendus/meshbot/meshtastic/helpers" + "github.com/timendus/meshbot/meshwrapper/helpers" ) const ( diff --git a/go/meshtastic/neighbor.go b/go/meshwrapper/neighbor.go similarity index 94% rename from go/meshtastic/neighbor.go rename to go/meshwrapper/neighbor.go index 21e4b61..19dbafe 100644 --- a/go/meshtastic/neighbor.go +++ b/go/meshwrapper/neighbor.go @@ -1,11 +1,11 @@ -package meshtastic +package meshwrapper import ( "fmt" "time" "buf.build/gen/go/meshtastic/protobufs/protocolbuffers/go/meshtastic" - "github.com/timendus/meshbot/meshtastic/helpers" + "github.com/timendus/meshbot/meshwrapper/helpers" ) type Neighbor struct { diff --git a/go/meshtastic/node.go b/go/meshwrapper/node.go similarity index 97% rename from go/meshtastic/node.go rename to go/meshwrapper/node.go index 8c2be81..fd6fad4 100644 --- a/go/meshtastic/node.go +++ b/go/meshwrapper/node.go @@ -1,4 +1,4 @@ -package meshtastic +package meshwrapper import ( "fmt" @@ -6,7 +6,7 @@ import ( "unicode/utf8" "buf.build/gen/go/meshtastic/protobufs/protocolbuffers/go/meshtastic" - "github.com/timendus/meshbot/meshtastic/helpers" + "github.com/timendus/meshbot/meshwrapper/helpers" ) type Node struct { diff --git a/go/meshtastic/node_list.go b/go/meshwrapper/node_list.go similarity index 98% rename from go/meshtastic/node_list.go rename to go/meshwrapper/node_list.go index 09acb85..dac8be1 100644 --- a/go/meshtastic/node_list.go +++ b/go/meshwrapper/node_list.go @@ -1,4 +1,4 @@ -package meshtastic +package meshwrapper import ( "cmp" diff --git a/go/meshtastic/position.go b/go/meshwrapper/position.go similarity index 97% rename from go/meshtastic/position.go rename to go/meshwrapper/position.go index 7a55a7b..0ec4c3d 100644 --- a/go/meshtastic/position.go +++ b/go/meshwrapper/position.go @@ -1,4 +1,4 @@ -package meshtastic +package meshwrapper import ( "math" diff --git a/go/meshtastic/pubsub.go b/go/meshwrapper/pubsub.go similarity index 96% rename from go/meshtastic/pubsub.go rename to go/meshwrapper/pubsub.go index 8e4c356..83041f7 100644 --- a/go/meshtastic/pubsub.go +++ b/go/meshwrapper/pubsub.go @@ -1,4 +1,4 @@ -package meshtastic +package meshwrapper type EventBody interface { Message | Node | ConnectedNode diff --git a/go/meshtastic/stream_interface.go b/go/meshwrapper/stream_interface.go similarity index 99% rename from go/meshtastic/stream_interface.go rename to go/meshwrapper/stream_interface.go index 3690f12..592bbb5 100644 --- a/go/meshtastic/stream_interface.go +++ b/go/meshwrapper/stream_interface.go @@ -1,4 +1,4 @@ -package meshtastic +package meshwrapper import ( "errors" From 08216f681d09d80d76c74d5787f10f931af5aa07 Mon Sep 17 00:00:00 2001 From: Timendus Date: Thu, 21 Nov 2024 01:28:56 +0100 Subject: [PATCH 17/87] Don't use strings for events, because strings aren't checked at compile time --- go/main.go | 8 ++++---- go/meshwrapper/connected_node.go | 32 +++++++++++++++---------------- go/meshwrapper/pubsub.go | 33 +++++++++++++++++++++++++++----- 3 files changed, 48 insertions(+), 25 deletions(-) diff --git a/go/main.go b/go/main.go index 5471136..5419ce0 100644 --- a/go/main.go +++ b/go/main.go @@ -16,10 +16,10 @@ import ( func main() { log.Println("Starting Meshed Potatoes!") - m.MessageEvents.Subscribe("any", message) - m.MessageEvents.Subscribe("text message", textMessage) - m.NodeEvents.Subscribe("connected", connected) - m.NodeEvents.Subscribe("disconnected", disconnected) + m.MessageEvents.Subscribe(m.AnyMessageEvent, message) + m.MessageEvents.Subscribe(m.TextMessageEvent, textMessage) + m.ConnectionEvents.Subscribe(m.ConnectedEvent, connected) + m.ConnectionEvents.Subscribe(m.DisconnectedEvent, disconnected) // Attempt to auto-detect Meshtestic device on a serial port. Otherwise, // connect over TCP. diff --git a/go/meshwrapper/connected_node.go b/go/meshwrapper/connected_node.go index ecabda0..dc03182 100644 --- a/go/meshwrapper/connected_node.go +++ b/go/meshwrapper/connected_node.go @@ -58,7 +58,7 @@ func NewConnectedNode(stream io.ReadWriteCloser) (*ConnectedNode, error) { func (n *ConnectedNode) Close() error { n.Connected = false - NodeEvents.publish("disconnected", *n) + ConnectionEvents.publish(DisconnectedEvent, *n) return n.stream.Close() } @@ -90,7 +90,7 @@ func (n *ConnectedNode) readMessages(stream io.ReadCloser) error { switch packet.PayloadVariant.(type) { case *meshtastic.FromRadio_ConfigCompleteId: n.Connected = true - NodeEvents.publish("connected", *n) + ConnectionEvents.publish(ConnectedEvent, *n) case *meshtastic.FromRadio_MyInfo: n.Node.Id = packet.GetMyInfo().MyNodeNum n.NodeList.nodes[n.Node.Id] = n.Node @@ -185,7 +185,7 @@ func (n *ConnectedNode) parseMeshPacket(meshPacket *meshtastic.MeshPacket) { fromNode.IsLicensed = result.IsLicensed fromNode.PublicKey = result.PublicKey message.MessageType = MESSAGE_TYPE_NODE_INFO - MessageEvents.publish("node info", message) + MessageEvents.publish(NodeInfoEvent, message) case meshtastic.PortNum_TELEMETRY_APP: result := meshtastic.Telemetry{} @@ -198,31 +198,31 @@ func (n *ConnectedNode) parseMeshPacket(meshPacket *meshtastic.MeshPacket) { case *meshtastic.Telemetry_DeviceMetrics: message.MessageType = MESSAGE_TYPE_TELEMETRY_DEVICE message.DeviceMetrics = result.GetDeviceMetrics() - MessageEvents.publish("device telemetry", message) + MessageEvents.publish(DeviceTelemetryEvent, message) case *meshtastic.Telemetry_EnvironmentMetrics: message.MessageType = MESSAGE_TYPE_TELEMETRY_ENVIRONMENT message.EnvironmentMetrics = result.GetEnvironmentMetrics() - MessageEvents.publish("environment telemetry", message) + MessageEvents.publish(EnvironmentTelemetryEvent, message) case *meshtastic.Telemetry_HealthMetrics: message.MessageType = MESSAGE_TYPE_TELEMETRY_HEALTH message.HealthMetrics = result.GetHealthMetrics() - MessageEvents.publish("health telemetry", message) + MessageEvents.publish(HealthTelemetryEvent, message) case *meshtastic.Telemetry_AirQualityMetrics: message.MessageType = MESSAGE_TYPE_TELEMETRY_AIR_QUALITY message.AirQualityMetrics = result.GetAirQualityMetrics() - MessageEvents.publish("air quality telemetry", message) + MessageEvents.publish(AirQualityTelemetryEvent, message) case *meshtastic.Telemetry_PowerMetrics: message.MessageType = MESSAGE_TYPE_TELEMETRY_POWER message.PowerMetrics = result.GetPowerMetrics() - MessageEvents.publish("power telemetry", message) + MessageEvents.publish(PowerTelemetryEvent, message) case *meshtastic.Telemetry_LocalStats: message.MessageType = MESSAGE_TYPE_TELEMETRY_LOCAL_STATS message.LocalStats = result.GetLocalStats() - MessageEvents.publish("local stats telemetry", message) + MessageEvents.publish(LocalStatsTelemetryEvent, message) default: log.Println("Warning: Unknown telemetry variant:", result.String()) } - MessageEvents.publish("telemetry", message) + MessageEvents.publish(TelemetryEvent, message) case meshtastic.PortNum_POSITION_APP: result := meshtastic.Position{} @@ -233,7 +233,7 @@ func (n *ConnectedNode) parseMeshPacket(meshPacket *meshtastic.MeshPacket) { } message.MessageType = MESSAGE_TYPE_POSITION message.Position = NewPosition(&result) - MessageEvents.publish("position", message) + MessageEvents.publish(PositionEvent, message) case meshtastic.PortNum_NEIGHBORINFO_APP: result := meshtastic.NeighborInfo{} @@ -246,12 +246,12 @@ func (n *ConnectedNode) parseMeshPacket(meshPacket *meshtastic.MeshPacket) { message.NeighborInfo = &result helpers.Assert(result.NodeId == meshPacket.From, "I don't understand this format well enough: received "+message.String()+" but it has NodeId "+strconv.Itoa(int(result.NodeId))) fromNode.Neighbors = NewNeighbourList(&n.NodeList, meshPacket.RxTime, result.Neighbors) - MessageEvents.publish("neighbor info", message) + MessageEvents.publish(NeighborInfoEvent, message) case meshtastic.PortNum_TEXT_MESSAGE_APP: message.MessageType = MESSAGE_TYPE_TEXT_MESSAGE message.Text = string(payload) - MessageEvents.publish("text message", message) + MessageEvents.publish(TextMessageEvent, message) case meshtastic.PortNum_ROUTING_APP: if meshPacket.GetDecoded() != nil { @@ -262,15 +262,15 @@ func (n *ConnectedNode) parseMeshPacket(meshPacket *meshtastic.MeshPacket) { } } message.MessageType = MESSAGE_TYPE_ROUTING - MessageEvents.publish("routing", message) + MessageEvents.publish(RoutingEvent, message) case meshtastic.PortNum_TRACEROUTE_APP: message.MessageType = MESSAGE_TYPE_TRACEROUTE - MessageEvents.publish("traceroute", message) + MessageEvents.publish(TraceRouteEvent, message) default: log.Println("Warning: Unknown mesh packet:", meshPacket.String()) } - MessageEvents.publish("any", message) + MessageEvents.publish(AnyMessageEvent, message) } diff --git a/go/meshwrapper/pubsub.go b/go/meshwrapper/pubsub.go index 83041f7..de39f7c 100644 --- a/go/meshwrapper/pubsub.go +++ b/go/meshwrapper/pubsub.go @@ -4,19 +4,42 @@ type EventBody interface { Message | Node | ConnectedNode } +type Event int + +const ( + ConnectedEvent Event = iota + DisconnectedEvent + + AnyMessageEvent + TextMessageEvent + NodeInfoEvent + PositionEvent + TelemetryEvent + NeighborInfoEvent + RoutingEvent + TraceRouteEvent + DeviceTelemetryEvent + EnvironmentTelemetryEvent + HealthTelemetryEvent + AirQualityTelemetryEvent + PowerTelemetryEvent + LocalStatsTelemetryEvent +) + type pubSub[T EventBody] struct { - subscriptions map[string][]func(T) + subscriptions map[Event][]func(T) } -func (ps *pubSub[T]) Subscribe(topic string, function func(T)) { +func (ps *pubSub[T]) Subscribe(topic Event, function func(T)) { ps.subscriptions[topic] = append(ps.subscriptions[topic], function) } -func (ps *pubSub[T]) publish(topic string, msg T) { +func (ps *pubSub[T]) publish(topic Event, msg T) { for _, function := range ps.subscriptions[topic] { go function(msg) } } -var NodeEvents = pubSub[ConnectedNode]{make(map[string][]func(ConnectedNode))} -var MessageEvents = pubSub[Message]{make(map[string][]func(Message))} +var ConnectionEvents = pubSub[ConnectedNode]{make(map[Event][]func(ConnectedNode))} +var MessageEvents = pubSub[Message]{make(map[Event][]func(Message))} +var NodeEvents = pubSub[Node]{make(map[Event][]func(Node))} From 0f99cfafba40eb2f0572dfe224bb76fd5b8e09a3 Mon Sep 17 00:00:00 2001 From: Timendus Date: Thu, 21 Nov 2024 01:29:09 +0100 Subject: [PATCH 18/87] No need to abort if we can still connect over TCP --- go/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go/main.go b/go/main.go index 5419ce0..3650b31 100644 --- a/go/main.go +++ b/go/main.go @@ -28,7 +28,7 @@ func main() { ports, err := serial.GetPortsList() if err != nil { - log.Fatal(err) + log.Println(err) } if len(ports) > 0 { From d94dc0da35c103708f3a81226f74248abae3c3cd Mon Sep 17 00:00:00 2001 From: Timendus Date: Thu, 21 Nov 2024 01:36:32 +0100 Subject: [PATCH 19/87] ...provided we don't mess this up --- go/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go/main.go b/go/main.go index 3650b31..37ba898 100644 --- a/go/main.go +++ b/go/main.go @@ -31,7 +31,7 @@ func main() { log.Println(err) } - if len(ports) > 0 { + if err == nil && len(ports) > 0 { log.Printf("Found %d serial ports:\n", len(ports)) for i, port := range ports { log.Printf(" [%d] %s\n", i, port) From a1d8950aa1b58e252a519f79ff96b5b20b73f9f1 Mon Sep 17 00:00:00 2001 From: Timendus Date: Mon, 25 Nov 2024 23:38:39 +0100 Subject: [PATCH 20/87] A little cleanup and making things more robust --- go/meshwrapper/message.go | 35 ++++++++++++++++++++++++++++++----- go/meshwrapper/pubsub.go | 10 ++++++---- 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/go/meshwrapper/message.go b/go/meshwrapper/message.go index e75cf4f..182adf6 100644 --- a/go/meshwrapper/message.go +++ b/go/meshwrapper/message.go @@ -23,6 +23,8 @@ const ( MESSAGE_TYPE_TELEMETRY_POWER = "power telemetry" MESSAGE_TYPE_TELEMETRY_LOCAL_STATS = "local stats telemetry" MESSAGE_TYPE_OTHER = "other" + + DEFAULT_BLOCKING_MESSAGE_TIMEOUT = 30 * time.Second ) type Message struct { @@ -48,6 +50,9 @@ type Message struct { func (m *Message) Reply(message string) uint32 { id := rand.Uint32() + if m.ReceivingNode == nil { + return id + } m.ReceivingNode.SendMessage(meshtastic.ToRadio_Packet{ Packet: &meshtastic.MeshPacket{ Id: id, @@ -72,12 +77,18 @@ func (m *Message) Reply(message string) uint32 { return id } -func (m *Message) ReplyBlocking(message string, timeout time.Duration) chan bool { +func (m *Message) ReplyBlocking(message string, timeout ...time.Duration) chan bool { + if m.ReceivingNode == nil { + return nil + } + if len(timeout) == 0 { + timeout = []time.Duration{DEFAULT_BLOCKING_MESSAGE_TIMEOUT} + } ch := make(chan bool) id := m.Reply(message) m.ReceivingNode.Acks[id] = ch go func() { - time.Sleep(timeout) + time.Sleep(timeout[0]) ch <- false delete(m.ReceivingNode.Acks, id) }() @@ -85,10 +96,24 @@ func (m *Message) ReplyBlocking(message string, timeout time.Duration) chan bool } func (m *Message) String() string { - direction := m.FromNode.String() + " -> " + m.ToNode.String() + direction := "" + if m.FromNode != nil { + direction += m.FromNode.String() + } else { + direction += "No node" + } + if m.ToNode != nil { + direction += " -> " + m.ToNode.String() + } else { + direction += " -> No node" + } if m.MessageType == MESSAGE_TYPE_NEIGHBOR_INFO { - return fmt.Sprintf("%s: \033[1mNeighbor list:\033[0m %s %s", direction, m.radioMetricsString(), m.FromNode.Neighbors.String()) + neighbours := "unknown" + if m.FromNode != nil { + neighbours = m.FromNode.Neighbors.String() + } + return fmt.Sprintf("%s: \033[1mNeighbor list:\033[0m %s %s", direction, m.radioMetricsString(), neighbours) } var content string @@ -102,7 +127,7 @@ func (m *Message) String() string { } func (m *Message) radioMetricsString() string { - if m.FromNode.Connected { + if m.FromNode != nil && m.FromNode.Connected { return "" } diff --git a/go/meshwrapper/pubsub.go b/go/meshwrapper/pubsub.go index de39f7c..8dba8d5 100644 --- a/go/meshwrapper/pubsub.go +++ b/go/meshwrapper/pubsub.go @@ -1,15 +1,13 @@ package meshwrapper -type EventBody interface { - Message | Node | ConnectedNode -} - type Event int const ( + // Connection events ConnectedEvent Event = iota DisconnectedEvent + // Message events AnyMessageEvent TextMessageEvent NodeInfoEvent @@ -26,6 +24,10 @@ const ( LocalStatsTelemetryEvent ) +type EventBody interface { + Message | Node | ConnectedNode +} + type pubSub[T EventBody] struct { subscriptions map[Event][]func(T) } From 10424c91a9b9eb2f46cdcdbff00d6b0eaeff3ec1 Mon Sep 17 00:00:00 2001 From: Timendus Date: Mon, 25 Nov 2024 23:42:58 +0100 Subject: [PATCH 21/87] Made a start at supporting Lua scripts for the bot plugins --- go/go.mod | 1 + go/go.sum | 2 + go/meshbot/lua_plugins.go | 218 ++++++++++++++++++++++++++++++++++++++ go/plugins/about.lua | 42 ++++++++ 4 files changed, 263 insertions(+) create mode 100644 go/meshbot/lua_plugins.go create mode 100644 go/plugins/about.lua diff --git a/go/go.mod b/go/go.mod index 7177cee..c621fb3 100644 --- a/go/go.mod +++ b/go/go.mod @@ -4,6 +4,7 @@ go 1.22.7 require ( buf.build/gen/go/meshtastic/protobufs/protocolbuffers/go v1.35.2-20241006120827-cc36fd21e859.1 + github.com/yuin/gopher-lua v1.1.1 go.bug.st/serial v1.6.2 google.golang.org/protobuf v1.35.2 ) diff --git a/go/go.sum b/go/go.sum index 59463ec..e2452da 100644 --- a/go/go.sum +++ b/go/go.sum @@ -10,6 +10,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= go.bug.st/serial v1.6.2 h1:kn9LRX3sdm+WxWKufMlIRndwGfPWsH1/9lCWXQCasq8= go.bug.st/serial v1.6.2/go.mod h1:UABfsluHAiaNI+La2iESysd9Vetq7VRdpxvjx7CmmOE= golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= diff --git a/go/meshbot/lua_plugins.go b/go/meshbot/lua_plugins.go new file mode 100644 index 0000000..dfbe9ec --- /dev/null +++ b/go/meshbot/lua_plugins.go @@ -0,0 +1,218 @@ +package main + +import ( + "context" + "fmt" + "time" + + "github.com/timendus/meshbot/meshwrapper" + lua "github.com/yuin/gopher-lua" +) + +type plugin struct { + Name string + Description string + version string + Commands []command +} + +type command struct { + State State + Command []string + Prefix []string + Description string + Private bool + Channel bool + Function *lua.LFunction +} + +type loadedPlugin struct { + Definition *plugin + State *lua.LState +} + +type State string + +const luaMessageTypeName = "message" + +func main() { + // Load plugin + plugin := LoadPlugin("plugins/about.lua") + defer plugin.State.Close() + + // Execute the first command from the plugin and print the result + state, err := Execute(plugin.Definition.Commands[0], &meshwrapper.Message{ + MessageType: meshwrapper.MESSAGE_TYPE_TEXT_MESSAGE, + Text: "Hello World!", + }, plugin.State) + if err != nil { + // Don't panic here, just provide feedback + panic(err) + } + fmt.Println(state) +} + +func LoadPlugin(filename string) loadedPlugin { + L := createLuaVM() + if err := L.DoFile(filename); err != nil { + panic(err) + } + definition := L.GetGlobal("plugin").(*lua.LTable) + return loadedPlugin{newPlugin(definition), L} +} + +func Execute(command command, message *meshwrapper.Message, L *lua.LState) (State, error) { + mud := L.NewUserData() + mud.Value = message + L.SetMetatable(mud, L.GetTypeMetatable(luaMessageTypeName)) + err := L.CallByParam(lua.P{ + Fn: command.Function, + NRet: 1, + Protect: true, + }, mud) + if err != nil { + return "ERROR", err + } + ret := L.Get(-1) + L.Pop(1) + return State(ret.String()), nil +} + +func newPlugin(definition *lua.LTable) *plugin { + plugin := plugin{ + Name: definition.RawGetString("name").String(), + Description: definition.RawGetString("description").String(), + version: definition.RawGetString("version").String(), + Commands: make([]command, 0), + } + + commands := definition.RawGetString("commands") + if commands, ok := commands.(*lua.LTable); ok { + commands.ForEach(func(k, v lua.LValue) { + command := newCommand(v.(*lua.LTable)) + plugin.Commands = append(plugin.Commands, command) + }) + } + + return &plugin +} + +func newCommand(definition *lua.LTable) command { + commands := definition.RawGetString("command") + commandList := make([]string, 0) + if commands, ok := commands.(*lua.LTable); ok { + commands.ForEach(func(k, v lua.LValue) { + commandList = append(commandList, v.String()) + }) + } + if command, ok := commands.(lua.LString); ok { + commandList = append(commandList, command.String()) + } + // TODO: fix this, commands can be catch all, how? + if command, ok := commands.(lua.LNumber); ok { + commandList = append(commandList, command.String()) + } + + prefixes := definition.RawGetString("prefix") + prefixList := make([]string, 0) + if prefixes, ok := prefixes.(*lua.LTable); ok { + prefixes.ForEach(func(k, v lua.LValue) { + prefixList = append(prefixList, v.String()) + }) + } + if prefix, ok := prefixes.(lua.LString); ok { + prefixList = append(prefixList, prefix.String()) + } + + state := definition.RawGetString("state").String() + if state == "nil" { + state = "MAIN" + } + + command := command{ + State: State(state), + Command: commandList, + Prefix: prefixList, + Description: definition.RawGetString("description").String(), + Private: lua.LVAsBool(definition.RawGetString("private")), + Channel: lua.LVAsBool(definition.RawGetString("channel")), + Function: definition.RawGetString("func").(*lua.LFunction), + } + + return command +} + +func createLuaVM() *lua.LState { + // Initialize a bare-bones Lua VM + L := lua.NewState(lua.Options{SkipOpenLibs: true}) + lua.OpenBase(L) + + // Make some properties of the bot available to Lua + bot := L.NewTable() + L.SetGlobal("bot", bot) + bot.RawSetString("CATCH_ALL_TEXT", lua.LNumber(meshwrapper.TextMessageEvent)) + bot.RawSetString("CATCH_ALL_EVENTS", lua.LNumber(meshwrapper.AnyMessageEvent)) + botMT := L.NewTable() + botMT.RawSetString("__tostring", L.NewFunction(func(L *lua.LState) int { + L.Push(lua.LString("Hello, world!")) + return 1 + })) + L.SetMetatable(bot, botMT) + + // This is pretty crude, but it provides a way to save some data from the + // Lua scripts, that we can actually persist and make thread safe in the + // future. + L.SetContext(context.WithValue(context.Background(), "storage", make(map[string]string))) + memory := L.NewTable() + memory.RawSetString("write", L.NewFunction(func(L *lua.LState) int { + ctx := L.Context() + key := L.CheckString(1) + value := L.CheckString(2) + ctx.Value("storage").(map[string]string)[key] = value + return 0 + })) + memory.RawSetString("read", L.NewFunction(func(L *lua.LState) int { + ctx := L.Context() + key := L.CheckString(1) + value := ctx.Value("storage").(map[string]string)[key] + L.Push(lua.LString(value)) + return 1 + })) + bot.RawSetString("memory", memory) + + // Register the Message usertype + mt := L.NewTypeMetatable(luaMessageTypeName) + L.SetGlobal(luaMessageTypeName, mt) + L.SetField(mt, "__index", L.SetFuncs(L.NewTable(), messageMethods)) + + return L +} + +var messageMethods = map[string]lua.LGFunction{ + "reply": messageReply, + "replyBlocking": messageReplyBlocking, +} + +// Checks whether the first lua argument is a *LUserData with *Message and returns this *Message +func checkMessage(L *lua.LState) *meshwrapper.Message { + ud := L.CheckUserData(1) + if v, ok := ud.Value.(*meshwrapper.Message); ok { + return v + } + L.ArgError(1, "message expected") + return nil +} + +func messageReply(L *lua.LState) int { + message := checkMessage(L) + message.Reply(L.CheckString(2)) + return 0 +} + +func messageReplyBlocking(L *lua.LState) int { + message := checkMessage(L) + timeout := time.Second * time.Duration(L.OptInt(3, int(meshwrapper.DEFAULT_BLOCKING_MESSAGE_TIMEOUT))) + delivered := <-message.ReplyBlocking(L.CheckString(2), timeout) + L.Push(lua.LBool(delivered)) + return 1 +} diff --git a/go/plugins/about.lua b/go/plugins/about.lua new file mode 100644 index 0000000..ed60814 --- /dev/null +++ b/go/plugins/about.lua @@ -0,0 +1,42 @@ +plugin = { + name = "About", + description = "Respond to hidden commands with a friendly message.", + version = "1.0", + + commands = { + + { + command = {"/TEST"}, + func = function (message) + bot.memory.write("test", "Party!") + return bot.memory.read("test") + end, + }, + + -- This is a hidden command, which is not listed (because it has no + -- description), but might be "guessed" by users, and will result in + -- expected behaviour. + { + command = {"/ABOUT", "/HELP", "/MESHBOT"}, + prefix = {"/MESHBOT"}, + channel = true, + func = function (message) + message:reply("๐Ÿค–๐Ÿ‘‹ Hello! I'm your friendly neighbourhood Meshbot. My code is available at https://github.com/timendus/meshbot. Send me a direct message to see what I can do!") + return "MAIN" + end, + }, + + -- This is the "catch all" command, if no more specific command is + -- matched in the "MAIN" state when receiving a private message, we reply + -- with the capabilities of this bot. This too is not listed because it + -- has no description. + { + command = bot.CATCH_ALL_TEXT, + func = function (message) + message:reply(tostring(bot)) + return "MAIN" + end, + }, + + }, +} From b9bc5f4c2544736268d92ce67d6695371fdca69a Mon Sep 17 00:00:00 2001 From: Timendus Date: Wed, 27 Nov 2024 16:01:05 +0100 Subject: [PATCH 22/87] Make the Lua plugins kinda work --- go/main.go | 26 +-- go/meshbot/chatbot.go | 130 +++++++++++++++ go/meshbot/{lua_plugins.go => plugins.go} | 188 ++++++++++++---------- go/meshwrapper/message.go | 4 + go/plugins/about.lua | 21 +-- 5 files changed, 254 insertions(+), 115 deletions(-) create mode 100644 go/meshbot/chatbot.go rename go/meshbot/{lua_plugins.go => plugins.go} (51%) diff --git a/go/main.go b/go/main.go index 37ba898..fbb08af 100644 --- a/go/main.go +++ b/go/main.go @@ -9,15 +9,17 @@ import ( "net" "time" + "github.com/timendus/meshbot/meshbot" m "github.com/timendus/meshbot/meshwrapper" "go.bug.st/serial" ) +var bot *meshbot.Chatbot + func main() { log.Println("Starting Meshed Potatoes!") m.MessageEvents.Subscribe(m.AnyMessageEvent, message) - m.MessageEvents.Subscribe(m.TextMessageEvent, textMessage) m.ConnectionEvents.Subscribe(m.ConnectedEvent, connected) m.ConnectionEvents.Subscribe(m.DisconnectedEvent, disconnected) @@ -63,6 +65,15 @@ func main() { defer node.Close() + // Launch the chat bot + + bot = meshbot.NewChatbot() + err = bot.ReloadPlugins() + if err != nil { + log.Fatal(err) + } + log.Println(bot.String()) + for { time.Sleep(100 * time.Millisecond) } @@ -84,16 +95,7 @@ func disconnected(node m.ConnectedNode) { func message(message m.Message) { fmt.Println(message.String()) -} - -func textMessage(message m.Message) { - if message.ToNode.Id != m.Broadcast.Id && message.FromNode.Id == 0x56598860 { - log.Println("Sending message and waiting...") - delivered := <-message.ReplyBlocking("Hello, world!", 10*time.Second) - if delivered { - log.Println("Message delivered!") - } else { - log.Println("No delivery confirmation received within 10 seconds :(") - } + if bot != nil { + bot.HandleMessage(message) } } diff --git a/go/meshbot/chatbot.go b/go/meshbot/chatbot.go new file mode 100644 index 0000000..0da2628 --- /dev/null +++ b/go/meshbot/chatbot.go @@ -0,0 +1,130 @@ +package meshbot + +import ( + "fmt" + "log" + "os" + "strings" + + "github.com/timendus/meshbot/meshwrapper" +) + +type State string + +type Chatbot struct { + state State + plugins []*plugin +} + +func NewChatbot() *Chatbot { + return &Chatbot{ + state: "MAIN", + } +} + +func (c *Chatbot) ReloadPlugins() error { + plugins := make([]*plugin, 0) + entries, err := os.ReadDir("plugins") + if err != nil { + return err + } + for _, entry := range entries { + plugin, err := LoadPlugin("plugins/" + entry.Name()) + if err != nil { + return err + } + plugins = append(plugins, plugin) + } + c.plugins = plugins + return nil +} + +func (c *Chatbot) String() string { + description := "๐Ÿค–๐Ÿ‘‹ Hey there! I understand these commands:\n" + + for _, plugin := range c.plugins { + if plugin.Hidden { + continue + } + if plugin.Name != "nil" && plugin.Description != "nil" { + description += fmt.Sprintf("\n%s - %s\n", plugin.Name, plugin.Description) + } else if plugin.Name != "nil" { + description += fmt.Sprintf("\n%s\n", plugin.Name) + } + for _, command := range plugin.Commands { + if command.Hidden { + continue + } + var commands string + if len(command.Command) > 0 { + commands = strings.Join(command.Command, ", ") + } else if len(command.Prefix) > 0 { + commands = strings.Join(command.Prefix, ", ") + } else { + continue + } + if command.Description != "nil" { + description += fmt.Sprintf("- %s: %s\n", commands, command.Description) + } else { + description += fmt.Sprintf("- %s\n", commands) + } + } + } + + return description +} + +func (c *Chatbot) HandleMessage(message meshwrapper.Message) { + // Messages that are not text messages can only be handled by + // catch all commands + if message.MessageType != meshwrapper.MESSAGE_TYPE_TEXT_MESSAGE { + c.handleMessageIf(message, func(cmd command, _ string) bool { return cmd.IsCatchAll }) + return + } + + // See if we have one or more specific handlers for this text message + if c.handleMessageIf(message, c.matches) { + return + } + + // See if we have one or more catch all text handlers for this text message + c.handleMessageIf(message, func(cmd command, _ string) bool { return cmd.IsCatchAllText }) +} + +func (c *Chatbot) handleMessageIf(message meshwrapper.Message, comp func(command, string) bool) bool { + matchFound := false + for _, plugin := range c.plugins { + for _, command := range plugin.Commands { + validCommand := command.State == c.state && + (command.Private == message.IsPrivateMessage() || + command.Channel == !message.IsPrivateMessage()) + if validCommand && comp(command, message.Text) { + matchFound = true + newState, err := command.Function(&message) + if err != nil { + log.Println("We got an error while handling a message:", err) + } else { + c.state = newState + } + } + } + } + return matchFound +} + +func (c *Chatbot) matches(command command, message string) bool { + for _, command := range command.Command { + if strings.EqualFold(strings.TrimSpace(message), strings.TrimSpace(command)) { + return true + } + } + for _, prefix := range command.Prefix { + if len(strings.TrimSpace(message)) < len(strings.TrimSpace(prefix)) { + continue + } + if strings.EqualFold(strings.TrimSpace(message)[:len(strings.TrimSpace(prefix))], strings.TrimSpace(prefix)) { + return true + } + } + return false +} diff --git a/go/meshbot/lua_plugins.go b/go/meshbot/plugins.go similarity index 51% rename from go/meshbot/lua_plugins.go rename to go/meshbot/plugins.go index dfbe9ec..df6f8ee 100644 --- a/go/meshbot/lua_plugins.go +++ b/go/meshbot/plugins.go @@ -1,8 +1,7 @@ -package main +package meshbot import ( "context" - "fmt" "time" "github.com/timendus/meshbot/meshwrapper" @@ -12,131 +11,146 @@ import ( type plugin struct { Name string Description string - version string + Version string + Hidden bool Commands []command + States []State + LuaState *lua.LState } type command struct { - State State - Command []string - Prefix []string - Description string - Private bool - Channel bool - Function *lua.LFunction -} - -type loadedPlugin struct { - Definition *plugin - State *lua.LState + State State + Command []string + Prefix []string + Description string + Private bool + Channel bool + IsCatchAll bool + IsCatchAllText bool + Hidden bool + Function func(*meshwrapper.Message) (State, error) } -type State string - -const luaMessageTypeName = "message" +type contextKey string -func main() { - // Load plugin - plugin := LoadPlugin("plugins/about.lua") - defer plugin.State.Close() - - // Execute the first command from the plugin and print the result - state, err := Execute(plugin.Definition.Commands[0], &meshwrapper.Message{ - MessageType: meshwrapper.MESSAGE_TYPE_TEXT_MESSAGE, - Text: "Hello World!", - }, plugin.State) - if err != nil { - // Don't panic here, just provide feedback - panic(err) - } - fmt.Println(state) -} +const ( + luaMessageTypeName = "message" + CATCH_ALL_EVENTS = iota + CATCH_ALL_TEXT +) -func LoadPlugin(filename string) loadedPlugin { +func LoadPlugin(filename string) (*plugin, error) { L := createLuaVM() if err := L.DoFile(filename); err != nil { - panic(err) + return nil, err } definition := L.GetGlobal("plugin").(*lua.LTable) - return loadedPlugin{newPlugin(definition), L} + return newPlugin(definition, L), nil } -func Execute(command command, message *meshwrapper.Message, L *lua.LState) (State, error) { - mud := L.NewUserData() - mud.Value = message - L.SetMetatable(mud, L.GetTypeMetatable(luaMessageTypeName)) - err := L.CallByParam(lua.P{ - Fn: command.Function, - NRet: 1, - Protect: true, - }, mud) - if err != nil { - return "ERROR", err - } - ret := L.Get(-1) - L.Pop(1) - return State(ret.String()), nil -} - -func newPlugin(definition *lua.LTable) *plugin { +func newPlugin(definition *lua.LTable, L *lua.LState) *plugin { plugin := plugin{ Name: definition.RawGetString("name").String(), Description: definition.RawGetString("description").String(), - version: definition.RawGetString("version").String(), + Version: definition.RawGetString("version").String(), + Hidden: lua.LVAsBool(definition.RawGetString("hidden")), Commands: make([]command, 0), + States: make([]State, 0), + LuaState: L, } commands := definition.RawGetString("commands") if commands, ok := commands.(*lua.LTable); ok { commands.ForEach(func(k, v lua.LValue) { - command := newCommand(v.(*lua.LTable)) + command := newCommand(v.(*lua.LTable), L) plugin.Commands = append(plugin.Commands, command) }) } + states := definition.RawGetString("states") + if states, ok := states.(*lua.LTable); ok { + states.ForEach(func(k, v lua.LValue) { + plugin.States = append(plugin.States, State(v.String())) + }) + } + return &plugin } -func newCommand(definition *lua.LTable) command { +func newCommand(definition *lua.LTable, L *lua.LState) command { + state := definition.RawGetString("state").String() + if state == "nil" { + state = "MAIN" + } + + private := definition.RawGetString("private") + if private == lua.LNil { + private = lua.LTrue + } + + command := command{ + State: State(state), + Command: make([]string, 0), + Prefix: make([]string, 0), + Description: definition.RawGetString("description").String(), + Private: lua.LVAsBool(private), + Channel: lua.LVAsBool(definition.RawGetString("channel")), + IsCatchAll: false, + IsCatchAllText: false, + Hidden: lua.LVAsBool(definition.RawGetString("hidden")), + Function: func(message *meshwrapper.Message) (State, error) { + function, ok := definition.RawGetString("func").(*lua.LFunction) + if !ok { + return "ERROR", nil + } + messageUserData := L.NewUserData() + messageUserData.Value = message + L.SetMetatable(messageUserData, L.GetTypeMetatable(luaMessageTypeName)) + err := L.CallByParam(lua.P{ + Fn: function, + NRet: 1, + Protect: true, + }, messageUserData) + if err != nil { + return "ERROR", err + } + ret := L.Get(-1) + L.Pop(1) + if ret.Type() == lua.LTNil { + return "MAIN", nil + } else { + return State(ret.String()), nil + } + }, + } + commands := definition.RawGetString("command") - commandList := make([]string, 0) if commands, ok := commands.(*lua.LTable); ok { commands.ForEach(func(k, v lua.LValue) { - commandList = append(commandList, v.String()) + command.Command = append(command.Command, v.String()) }) } - if command, ok := commands.(lua.LString); ok { - commandList = append(commandList, command.String()) - } - // TODO: fix this, commands can be catch all, how? - if command, ok := commands.(lua.LNumber); ok { - commandList = append(commandList, command.String()) + if cmd, ok := commands.(lua.LString); ok { + command.Command = append(command.Command, cmd.String()) } prefixes := definition.RawGetString("prefix") - prefixList := make([]string, 0) if prefixes, ok := prefixes.(*lua.LTable); ok { prefixes.ForEach(func(k, v lua.LValue) { - prefixList = append(prefixList, v.String()) + command.Prefix = append(command.Prefix, v.String()) }) } if prefix, ok := prefixes.(lua.LString); ok { - prefixList = append(prefixList, prefix.String()) - } - - state := definition.RawGetString("state").String() - if state == "nil" { - state = "MAIN" + command.Prefix = append(command.Prefix, prefix.String()) } - command := command{ - State: State(state), - Command: commandList, - Prefix: prefixList, - Description: definition.RawGetString("description").String(), - Private: lua.LVAsBool(definition.RawGetString("private")), - Channel: lua.LVAsBool(definition.RawGetString("channel")), - Function: definition.RawGetString("func").(*lua.LFunction), + if cmd, ok := commands.(lua.LNumber); ok { + if cmd == CATCH_ALL_EVENTS { + command.IsCatchAll = true + } + if cmd == CATCH_ALL_TEXT { + command.IsCatchAllText = true + } } return command @@ -150,8 +164,8 @@ func createLuaVM() *lua.LState { // Make some properties of the bot available to Lua bot := L.NewTable() L.SetGlobal("bot", bot) - bot.RawSetString("CATCH_ALL_TEXT", lua.LNumber(meshwrapper.TextMessageEvent)) - bot.RawSetString("CATCH_ALL_EVENTS", lua.LNumber(meshwrapper.AnyMessageEvent)) + bot.RawSetString("CATCH_ALL_TEXT", lua.LNumber(CATCH_ALL_TEXT)) + bot.RawSetString("CATCH_ALL_EVENTS", lua.LNumber(CATCH_ALL_EVENTS)) botMT := L.NewTable() botMT.RawSetString("__tostring", L.NewFunction(func(L *lua.LState) int { L.Push(lua.LString("Hello, world!")) @@ -162,19 +176,19 @@ func createLuaVM() *lua.LState { // This is pretty crude, but it provides a way to save some data from the // Lua scripts, that we can actually persist and make thread safe in the // future. - L.SetContext(context.WithValue(context.Background(), "storage", make(map[string]string))) + L.SetContext(context.WithValue(context.Background(), contextKey("storage"), make(map[string]string))) memory := L.NewTable() memory.RawSetString("write", L.NewFunction(func(L *lua.LState) int { ctx := L.Context() key := L.CheckString(1) value := L.CheckString(2) - ctx.Value("storage").(map[string]string)[key] = value + ctx.Value(contextKey("storage")).(map[string]string)[key] = value return 0 })) memory.RawSetString("read", L.NewFunction(func(L *lua.LState) int { ctx := L.Context() key := L.CheckString(1) - value := ctx.Value("storage").(map[string]string)[key] + value := ctx.Value(contextKey("storage")).(map[string]string)[key] L.Push(lua.LString(value)) return 1 })) diff --git a/go/meshwrapper/message.go b/go/meshwrapper/message.go index 182adf6..4acbdd2 100644 --- a/go/meshwrapper/message.go +++ b/go/meshwrapper/message.go @@ -95,6 +95,10 @@ func (m *Message) ReplyBlocking(message string, timeout ...time.Duration) chan b return ch } +func (m *Message) IsPrivateMessage() bool { + return m.ToNode.Id != Broadcast.Id +} + func (m *Message) String() string { direction := "" if m.FromNode != nil { diff --git a/go/plugins/about.lua b/go/plugins/about.lua index ed60814..c8538e9 100644 --- a/go/plugins/about.lua +++ b/go/plugins/about.lua @@ -2,39 +2,28 @@ plugin = { name = "About", description = "Respond to hidden commands with a friendly message.", version = "1.0", + hidden = true, commands = { - { - command = {"/TEST"}, - func = function (message) - bot.memory.write("test", "Party!") - return bot.memory.read("test") - end, - }, - - -- This is a hidden command, which is not listed (because it has no - -- description), but might be "guessed" by users, and will result in + -- These commands might be "guessed" by users, and will result in -- expected behaviour. { command = {"/ABOUT", "/HELP", "/MESHBOT"}, prefix = {"/MESHBOT"}, channel = true, - func = function (message) + func = function(message) message:reply("๐Ÿค–๐Ÿ‘‹ Hello! I'm your friendly neighbourhood Meshbot. My code is available at https://github.com/timendus/meshbot. Send me a direct message to see what I can do!") - return "MAIN" end, }, -- This is the "catch all" command, if no more specific command is -- matched in the "MAIN" state when receiving a private message, we reply - -- with the capabilities of this bot. This too is not listed because it - -- has no description. + -- with the capabilities of this bot. { command = bot.CATCH_ALL_TEXT, - func = function (message) + func = function(message) message:reply(tostring(bot)) - return "MAIN" end, }, From 0d54faa33c97ae9e4d456d97d3e4818d2468e2dc Mon Sep 17 00:00:00 2001 From: Timendus Date: Wed, 27 Nov 2024 17:08:49 +0100 Subject: [PATCH 23/87] Couple improvements --- go/meshbot/chatbot.go | 18 ++++++++++++------ go/meshbot/plugins.go | 6 +++++- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/go/meshbot/chatbot.go b/go/meshbot/chatbot.go index 0da2628..55a03ac 100644 --- a/go/meshbot/chatbot.go +++ b/go/meshbot/chatbot.go @@ -29,6 +29,9 @@ func (c *Chatbot) ReloadPlugins() error { return err } for _, entry := range entries { + if !strings.HasSuffix(entry.Name(), ".lua") { + continue + } plugin, err := LoadPlugin("plugins/" + entry.Name()) if err != nil { return err @@ -75,15 +78,17 @@ func (c *Chatbot) String() string { } func (c *Chatbot) HandleMessage(message meshwrapper.Message) { + // See if we have one or more catch all handlers + c.handleMessageIf(message, func(cmd command, _ string) bool { return cmd.IsCatchAll }) + // Messages that are not text messages can only be handled by - // catch all commands + // catch all commands, so in that case we're done here. if message.MessageType != meshwrapper.MESSAGE_TYPE_TEXT_MESSAGE { - c.handleMessageIf(message, func(cmd command, _ string) bool { return cmd.IsCatchAll }) return } // See if we have one or more specific handlers for this text message - if c.handleMessageIf(message, c.matches) { + if c.handleMessageIf(message, matches) { return } @@ -92,12 +97,13 @@ func (c *Chatbot) HandleMessage(message meshwrapper.Message) { } func (c *Chatbot) handleMessageIf(message meshwrapper.Message, comp func(command, string) bool) bool { + isPrivateMessage := message.IsPrivateMessage() matchFound := false for _, plugin := range c.plugins { for _, command := range plugin.Commands { validCommand := command.State == c.state && - (command.Private == message.IsPrivateMessage() || - command.Channel == !message.IsPrivateMessage()) + (command.Private == isPrivateMessage || + command.Channel == !isPrivateMessage) if validCommand && comp(command, message.Text) { matchFound = true newState, err := command.Function(&message) @@ -112,7 +118,7 @@ func (c *Chatbot) handleMessageIf(message meshwrapper.Message, comp func(command return matchFound } -func (c *Chatbot) matches(command command, message string) bool { +func matches(command command, message string) bool { for _, command := range command.Command { if strings.EqualFold(strings.TrimSpace(message), strings.TrimSpace(command)) { return true diff --git a/go/meshbot/plugins.go b/go/meshbot/plugins.go index df6f8ee..b77ac54 100644 --- a/go/meshbot/plugins.go +++ b/go/meshbot/plugins.go @@ -2,6 +2,7 @@ package meshbot import ( "context" + "errors" "time" "github.com/timendus/meshbot/meshwrapper" @@ -44,7 +45,10 @@ func LoadPlugin(filename string) (*plugin, error) { if err := L.DoFile(filename); err != nil { return nil, err } - definition := L.GetGlobal("plugin").(*lua.LTable) + definition, ok := L.GetGlobal("plugin").(*lua.LTable) + if !ok { + return nil, errors.New("no plugin definition found in file " + filename) + } return newPlugin(definition, L), nil } From 70d89cb09d40165dea7cdfddcb02ba6c80c4521d Mon Sep 17 00:00:00 2001 From: Timendus Date: Thu, 30 Jan 2025 18:59:18 +0100 Subject: [PATCH 24/87] Use interface for meshbot package, so we can write a CLI for testing --- go/cli/main.go | 126 ++++++++++++++++++++++++++++++++++++++ go/meshbot/chatbot.go | 10 ++- go/meshbot/interfaces.go | 35 +++++++++++ go/meshbot/plugins.go | 31 ++++++---- go/meshwrapper/message.go | 72 ++++++++++++++++------ go/meshwrapper/node.go | 36 ++++++++++- 6 files changed, 268 insertions(+), 42 deletions(-) create mode 100644 go/cli/main.go create mode 100644 go/meshbot/interfaces.go diff --git a/go/cli/main.go b/go/cli/main.go new file mode 100644 index 0000000..ab8c48e --- /dev/null +++ b/go/cli/main.go @@ -0,0 +1,126 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "strings" + "time" + + "github.com/timendus/meshbot/meshbot" +) + +func main() { + reader := bufio.NewReader(os.Stdin) + bot := meshbot.NewChatbot() + bot.ReloadPlugins() + + for { + fmt.Print("> ") + input, _ := reader.ReadString('\n') + input = strings.Replace(input, "\r", "", -1) + input = strings.Replace(input, "\n", "", -1) + + bot.HandleMessage(chatMessage{ + MessageType: meshbot.TEXT_MESSAGE, + Text: input, + // ToNode: &node{ + // NodeID: 1, + // }, + }) + } +} + +type chatMessage struct { + MessageType string + Text string + FromNode chatUser + ToNode chatUser +} + +func (m chatMessage) GetText() string { + return m.Text +} + +func (m chatMessage) IsPrivateMessage() bool { + return false +} + +func (m chatMessage) GetType() string { + return meshbot.TEXT_MESSAGE +} + +func (m chatMessage) GetChannelName() string { + return "" +} + +func (m chatMessage) GetSenderNode() meshbot.ChatUser { + return m.FromNode +} + +func (m chatMessage) GetReceiverNode() meshbot.ChatUser { + return m.ToNode +} + +func (m chatMessage) FindNode(id string) meshbot.ChatUser { + return nil +} + +func (m chatMessage) String() string { + return m.Text +} + +func (m chatMessage) Reply(message string) { + fmt.Println(message) +} + +func (m chatMessage) ReplyBlocking(message string, timeout ...time.Duration) chan bool { + fmt.Println(message) + ch := make(chan bool, 1) + ch <- true + return ch +} + +type chatUser struct { + NodeID int +} + +func (m chatUser) GetId() int { + return m.NodeID +} + +func (m chatUser) GetIDExpression() string { + return fmt.Sprintf("!%8x", m.NodeID) +} + +func (m chatUser) GetShortName() string { + return m.GetIDExpression()[4:] +} + +func (m chatUser) GetLongName() string { + return fmt.Sprintf("Node %d", m.NodeID) +} + +func (m chatUser) String() string { + return fmt.Sprintf("[%s] %s", m.GetShortName(), m.GetLongName()) +} + +func (m chatUser) VerboseString() string { + return fmt.Sprintf("Node %s", m.String()) +} + +func (m chatUser) GetPosition() [3]float32 { + return [3]float32{0, 0, 0} +} + +func (m chatUser) GetHopsAway() int { + return 0 +} + +func (m chatUser) GetRSSI() float32 { + return 0 +} + +func (m chatUser) GetSNR() float32 { + return 0 +} diff --git a/go/meshbot/chatbot.go b/go/meshbot/chatbot.go index 55a03ac..aa4952e 100644 --- a/go/meshbot/chatbot.go +++ b/go/meshbot/chatbot.go @@ -5,8 +5,6 @@ import ( "log" "os" "strings" - - "github.com/timendus/meshbot/meshwrapper" ) type State string @@ -77,13 +75,13 @@ func (c *Chatbot) String() string { return description } -func (c *Chatbot) HandleMessage(message meshwrapper.Message) { +func (c *Chatbot) HandleMessage(message ChatMessage) { // See if we have one or more catch all handlers c.handleMessageIf(message, func(cmd command, _ string) bool { return cmd.IsCatchAll }) // Messages that are not text messages can only be handled by // catch all commands, so in that case we're done here. - if message.MessageType != meshwrapper.MESSAGE_TYPE_TEXT_MESSAGE { + if message.GetType() != TEXT_MESSAGE { return } @@ -96,7 +94,7 @@ func (c *Chatbot) HandleMessage(message meshwrapper.Message) { c.handleMessageIf(message, func(cmd command, _ string) bool { return cmd.IsCatchAllText }) } -func (c *Chatbot) handleMessageIf(message meshwrapper.Message, comp func(command, string) bool) bool { +func (c *Chatbot) handleMessageIf(message ChatMessage, comp func(command, string) bool) bool { isPrivateMessage := message.IsPrivateMessage() matchFound := false for _, plugin := range c.plugins { @@ -104,7 +102,7 @@ func (c *Chatbot) handleMessageIf(message meshwrapper.Message, comp func(command validCommand := command.State == c.state && (command.Private == isPrivateMessage || command.Channel == !isPrivateMessage) - if validCommand && comp(command, message.Text) { + if validCommand && comp(command, message.GetText()) { matchFound = true newState, err := command.Function(&message) if err != nil { diff --git a/go/meshbot/interfaces.go b/go/meshbot/interfaces.go new file mode 100644 index 0000000..e8de478 --- /dev/null +++ b/go/meshbot/interfaces.go @@ -0,0 +1,35 @@ +package meshbot + +import "time" + +const ( + TEXT_MESSAGE = "text message" + + DEFAULT_BLOCKING_MESSAGE_TIMEOUT = 30 * time.Second +) + +type ChatMessage interface { + GetText() string + IsPrivateMessage() bool + GetType() string + GetChannelName() string + GetSenderNode() ChatUser + GetReceiverNode() ChatUser + FindNode(string) ChatUser + String() string + Reply(string) + ReplyBlocking(string, ...time.Duration) chan bool +} + +type ChatUser interface { + GetId() int + GetIDExpression() string + GetShortName() string + GetLongName() string + String() string + VerboseString() string + GetPosition() [3]float32 + GetHopsAway() int + GetRSSI() float32 + GetSNR() float32 +} diff --git a/go/meshbot/plugins.go b/go/meshbot/plugins.go index b77ac54..1c20a3f 100644 --- a/go/meshbot/plugins.go +++ b/go/meshbot/plugins.go @@ -5,7 +5,6 @@ import ( "errors" "time" - "github.com/timendus/meshbot/meshwrapper" lua "github.com/yuin/gopher-lua" ) @@ -29,7 +28,7 @@ type command struct { IsCatchAll bool IsCatchAllText bool Hidden bool - Function func(*meshwrapper.Message) (State, error) + Function func(*ChatMessage) (State, error) } type contextKey string @@ -102,7 +101,7 @@ func newCommand(definition *lua.LTable, L *lua.LState) command { IsCatchAll: false, IsCatchAllText: false, Hidden: lua.LVAsBool(definition.RawGetString("hidden")), - Function: func(message *meshwrapper.Message) (State, error) { + Function: func(message *ChatMessage) (State, error) { function, ok := definition.RawGetString("func").(*lua.LFunction) if !ok { return "ERROR", nil @@ -185,15 +184,14 @@ func createLuaVM() *lua.LState { memory.RawSetString("write", L.NewFunction(func(L *lua.LState) int { ctx := L.Context() key := L.CheckString(1) - value := L.CheckString(2) - ctx.Value(contextKey("storage")).(map[string]string)[key] = value + ctx.Value(contextKey("storage")).(map[string]lua.LValue)[key] = L.Get(2) return 0 })) memory.RawSetString("read", L.NewFunction(func(L *lua.LState) int { ctx := L.Context() key := L.CheckString(1) - value := ctx.Value(contextKey("storage")).(map[string]string)[key] - L.Push(lua.LString(value)) + value := ctx.Value(contextKey("storage")).(map[string]lua.LValue)[key] + L.Push(value) return 1 })) bot.RawSetString("memory", memory) @@ -209,12 +207,13 @@ func createLuaVM() *lua.LState { var messageMethods = map[string]lua.LGFunction{ "reply": messageReply, "replyBlocking": messageReplyBlocking, + "text": messageText, } -// Checks whether the first lua argument is a *LUserData with *Message and returns this *Message -func checkMessage(L *lua.LState) *meshwrapper.Message { +// Checks whether the first lua argument is a *LUserData with *ChatMessage and returns this *ChatMessage +func checkMessage(L *lua.LState) *ChatMessage { ud := L.CheckUserData(1) - if v, ok := ud.Value.(*meshwrapper.Message); ok { + if v, ok := ud.Value.(*ChatMessage); ok { return v } L.ArgError(1, "message expected") @@ -222,15 +221,21 @@ func checkMessage(L *lua.LState) *meshwrapper.Message { } func messageReply(L *lua.LState) int { - message := checkMessage(L) + message := *checkMessage(L) message.Reply(L.CheckString(2)) return 0 } func messageReplyBlocking(L *lua.LState) int { - message := checkMessage(L) - timeout := time.Second * time.Duration(L.OptInt(3, int(meshwrapper.DEFAULT_BLOCKING_MESSAGE_TIMEOUT))) + message := *checkMessage(L) + timeout := time.Second * time.Duration(L.OptInt(3, int(DEFAULT_BLOCKING_MESSAGE_TIMEOUT))) delivered := <-message.ReplyBlocking(L.CheckString(2), timeout) L.Push(lua.LBool(delivered)) return 1 } + +func messageText(L *lua.LState) int { + message := *checkMessage(L) + L.Push(lua.LString(message.GetText())) + return 1 +} diff --git a/go/meshwrapper/message.go b/go/meshwrapper/message.go index 4acbdd2..50098d2 100644 --- a/go/meshwrapper/message.go +++ b/go/meshwrapper/message.go @@ -6,6 +6,7 @@ import ( "time" "buf.build/gen/go/meshtastic/protobufs/protocolbuffers/go/meshtastic" + "github.com/timendus/meshbot/meshbot" "github.com/timendus/meshbot/meshwrapper/helpers" ) @@ -48,7 +49,29 @@ type Message struct { Position *position } -func (m *Message) Reply(message string) uint32 { +func (m Message) Reply(message string) { + m.doReply(message) +} + +func (m Message) ReplyBlocking(message string, timeout ...time.Duration) chan bool { + if m.ReceivingNode == nil { + return nil + } + if len(timeout) == 0 { + timeout = []time.Duration{DEFAULT_BLOCKING_MESSAGE_TIMEOUT} + } + ch := make(chan bool) + id := m.doReply(message) + m.ReceivingNode.Acks[id] = ch + go func() { + time.Sleep(timeout[0]) + ch <- false + delete(m.ReceivingNode.Acks, id) + }() + return ch +} + +func (m *Message) doReply(message string) uint32 { id := rand.Uint32() if m.ReceivingNode == nil { return id @@ -77,29 +100,38 @@ func (m *Message) Reply(message string) uint32 { return id } -func (m *Message) ReplyBlocking(message string, timeout ...time.Duration) chan bool { - if m.ReceivingNode == nil { - return nil - } - if len(timeout) == 0 { - timeout = []time.Duration{DEFAULT_BLOCKING_MESSAGE_TIMEOUT} - } - ch := make(chan bool) - id := m.Reply(message) - m.ReceivingNode.Acks[id] = ch - go func() { - time.Sleep(timeout[0]) - ch <- false - delete(m.ReceivingNode.Acks, id) - }() - return ch +// Implement meshbot.ChatMessage interface + +func (m Message) GetText() string { + return m.Text +} + +func (m Message) IsPrivateMessage() bool { + return m.ToNode != nil && m.ToNode.Id != Broadcast.Id +} + +func (m Message) GetType() string { + return m.MessageType +} + +func (m Message) GetChannelName() string { + panic("TODO: implement") + // return "" +} + +func (m Message) GetSenderNode() meshbot.ChatUser { + return m.FromNode +} + +func (m Message) GetReceiverNode() meshbot.ChatUser { + return m.ToNode } -func (m *Message) IsPrivateMessage() bool { - return m.ToNode.Id != Broadcast.Id +func (m Message) FindNode(id string) meshbot.ChatUser { + panic("TODO: implement") } -func (m *Message) String() string { +func (m Message) String() string { direction := "" if m.FromNode != nil { direction += m.FromNode.String() diff --git a/go/meshwrapper/node.go b/go/meshwrapper/node.go index fd6fad4..2951831 100644 --- a/go/meshwrapper/node.go +++ b/go/meshwrapper/node.go @@ -89,6 +89,24 @@ func (n *Node) Update(info *meshtastic.NodeInfo) { } } +// Implement meshbot.ChatUser interface + +func (n *Node) GetId() int { + return int(n.Id) +} + +func (n *Node) GetIDExpression() string { + return fmt.Sprintf("!%8x", n.Id) +} + +func (n *Node) GetShortName() string { + return n.ShortName +} + +func (n *Node) GetLongName() string { + return n.LongName +} + func (n *Node) String() string { var col string if n.Connected { @@ -114,7 +132,7 @@ func (n *Node) String() string { col, shortName, n.LongName, - n.IDExpression(), + n.GetIDExpression(), ) } @@ -143,6 +161,18 @@ func (n *Node) VerboseString() string { ) } -func (n *Node) IDExpression() string { - return fmt.Sprintf("!%x", n.Id) +func (n *Node) GetPosition() [3]float32 { + panic("TODO: implement") +} + +func (n *Node) GetHopsAway() int { + return int(n.HopsAway) +} + +func (n *Node) GetRSSI() float32 { + panic("TODO: implement") +} + +func (n *Node) GetSNR() float32 { + return n.Snr } From b939c89ea985b8e901799f451bfd59d935d13549 Mon Sep 17 00:00:00 2001 From: Timendus Date: Thu, 30 Jan 2025 19:07:38 +0100 Subject: [PATCH 25/87] Make `tostring(bot)` work --- go/cli/main.go | 2 +- go/meshbot/chatbot.go | 2 +- go/meshbot/plugins.go | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go/cli/main.go b/go/cli/main.go index ab8c48e..7ae7377 100644 --- a/go/cli/main.go +++ b/go/cli/main.go @@ -43,7 +43,7 @@ func (m chatMessage) GetText() string { } func (m chatMessage) IsPrivateMessage() bool { - return false + return true } func (m chatMessage) GetType() string { diff --git a/go/meshbot/chatbot.go b/go/meshbot/chatbot.go index aa4952e..b4b20da 100644 --- a/go/meshbot/chatbot.go +++ b/go/meshbot/chatbot.go @@ -30,7 +30,7 @@ func (c *Chatbot) ReloadPlugins() error { if !strings.HasSuffix(entry.Name(), ".lua") { continue } - plugin, err := LoadPlugin("plugins/" + entry.Name()) + plugin, err := LoadPlugin("plugins/"+entry.Name(), c) if err != nil { return err } diff --git a/go/meshbot/plugins.go b/go/meshbot/plugins.go index 1c20a3f..1cf42d2 100644 --- a/go/meshbot/plugins.go +++ b/go/meshbot/plugins.go @@ -39,8 +39,8 @@ const ( CATCH_ALL_TEXT ) -func LoadPlugin(filename string) (*plugin, error) { - L := createLuaVM() +func LoadPlugin(filename string, bot *Chatbot) (*plugin, error) { + L := createLuaVM(bot) if err := L.DoFile(filename); err != nil { return nil, err } @@ -159,7 +159,7 @@ func newCommand(definition *lua.LTable, L *lua.LState) command { return command } -func createLuaVM() *lua.LState { +func createLuaVM(cb *Chatbot) *lua.LState { // Initialize a bare-bones Lua VM L := lua.NewState(lua.Options{SkipOpenLibs: true}) lua.OpenBase(L) @@ -171,7 +171,7 @@ func createLuaVM() *lua.LState { bot.RawSetString("CATCH_ALL_EVENTS", lua.LNumber(CATCH_ALL_EVENTS)) botMT := L.NewTable() botMT.RawSetString("__tostring", L.NewFunction(func(L *lua.LState) int { - L.Push(lua.LString("Hello, world!")) + L.Push(lua.LString(cb.String())) return 1 })) L.SetMetatable(bot, botMT) From 86469ed1463f5c22f986aa28d3bc87885c479671 Mon Sep 17 00:00:00 2001 From: Timendus Date: Thu, 30 Jan 2025 19:08:14 +0100 Subject: [PATCH 26/87] Move TimeAgo to language --- go/meshwrapper/helpers/language.go | 39 +++++++++++++++++++++++++++++ go/meshwrapper/helpers/time.go | 40 ------------------------------ 2 files changed, 39 insertions(+), 40 deletions(-) delete mode 100644 go/meshwrapper/helpers/time.go diff --git a/go/meshwrapper/helpers/language.go b/go/meshwrapper/helpers/language.go index e00f80e..0277fa7 100644 --- a/go/meshwrapper/helpers/language.go +++ b/go/meshwrapper/helpers/language.go @@ -1,5 +1,11 @@ package helpers +import ( + "fmt" + "math" + "time" +) + func Pluralize(word string, count int) string { if count == 1 { return word @@ -9,3 +15,36 @@ func Pluralize(word string, count int) string { } return word + "s" } + +func TimeAgo(timestamp time.Time) string { + seconds := int(time.Since(timestamp).Seconds()) + + if seconds == 1 { + return "one second" + } + if seconds < 60 { + return fmt.Sprintf("%d seconds", seconds) + } + + minutes := int(math.Floor(float64(seconds) / 60)) + if minutes == 1 { + return "one minute" + } + if minutes < 60 { + return fmt.Sprintf("%d minutes", minutes) + } + + hours := int(math.Floor(float64(minutes) / 60)) + if hours == 1 { + return "one hour" + } + if hours < 24 { + return fmt.Sprintf("%d hours", hours) + } + + days := int(math.Floor(float64(hours) / 24)) + if days == 1 { + return "one day" + } + return fmt.Sprintf("%d days", days) +} diff --git a/go/meshwrapper/helpers/time.go b/go/meshwrapper/helpers/time.go deleted file mode 100644 index a10d804..0000000 --- a/go/meshwrapper/helpers/time.go +++ /dev/null @@ -1,40 +0,0 @@ -package helpers - -import ( - "fmt" - "math" - "time" -) - -func TimeAgo(timestamp time.Time) string { - seconds := int(time.Since(timestamp).Seconds()) - - if seconds == 1 { - return "one second" - } - if seconds < 60 { - return fmt.Sprintf("%d seconds", seconds) - } - - minutes := int(math.Floor(float64(seconds) / 60)) - if minutes == 1 { - return "one minute" - } - if minutes < 60 { - return fmt.Sprintf("%d minutes", minutes) - } - - hours := int(math.Floor(float64(minutes) / 60)) - if hours == 1 { - return "one hour" - } - if hours < 24 { - return fmt.Sprintf("%d hours", hours) - } - - days := int(math.Floor(float64(hours) / 24)) - if days == 1 { - return "one day" - } - return fmt.Sprintf("%d days", days) -} From dc26695f3044ae7d036e11bef2c9bc0d7003b272 Mon Sep 17 00:00:00 2001 From: Timendus Date: Thu, 30 Jan 2025 20:05:42 +0100 Subject: [PATCH 27/87] Implement bridge between Lua and Go --- go/cli/main.go | 8 +- go/meshbot/interfaces.go | 2 +- go/meshbot/{plugins.go => luaPlugins.go} | 51 ++---- go/meshbot/luaToGoApi.go | 195 +++++++++++++++++++++++ go/meshwrapper/message.go | 2 +- 5 files changed, 214 insertions(+), 44 deletions(-) rename go/meshbot/{plugins.go => luaPlugins.go} (82%) create mode 100644 go/meshbot/luaToGoApi.go diff --git a/go/cli/main.go b/go/cli/main.go index 7ae7377..4f68dc8 100644 --- a/go/cli/main.go +++ b/go/cli/main.go @@ -13,7 +13,11 @@ import ( func main() { reader := bufio.NewReader(os.Stdin) bot := meshbot.NewChatbot() - bot.ReloadPlugins() + err := bot.ReloadPlugins() + if err != nil { + fmt.Println(err) + return + } for { fmt.Print("> ") @@ -62,7 +66,7 @@ func (m chatMessage) GetReceiverNode() meshbot.ChatUser { return m.ToNode } -func (m chatMessage) FindNode(id string) meshbot.ChatUser { +func (m chatMessage) FindNode(id string) *meshbot.ChatUser { return nil } diff --git a/go/meshbot/interfaces.go b/go/meshbot/interfaces.go index e8de478..dea18f3 100644 --- a/go/meshbot/interfaces.go +++ b/go/meshbot/interfaces.go @@ -15,7 +15,7 @@ type ChatMessage interface { GetChannelName() string GetSenderNode() ChatUser GetReceiverNode() ChatUser - FindNode(string) ChatUser + FindNode(string) *ChatUser String() string Reply(string) ReplyBlocking(string, ...time.Duration) chan bool diff --git a/go/meshbot/plugins.go b/go/meshbot/luaPlugins.go similarity index 82% rename from go/meshbot/plugins.go rename to go/meshbot/luaPlugins.go index 1cf42d2..f08af21 100644 --- a/go/meshbot/plugins.go +++ b/go/meshbot/luaPlugins.go @@ -3,7 +3,6 @@ package meshbot import ( "context" "errors" - "time" lua "github.com/yuin/gopher-lua" ) @@ -35,6 +34,7 @@ type contextKey string const ( luaMessageTypeName = "message" + luaUserTypeName = "user" CATCH_ALL_EVENTS = iota CATCH_ALL_TEXT ) @@ -163,6 +163,8 @@ func createLuaVM(cb *Chatbot) *lua.LState { // Initialize a bare-bones Lua VM L := lua.NewState(lua.Options{SkipOpenLibs: true}) lua.OpenBase(L) + lua.OpenString(L) + lua.OpenTable(L) // Make some properties of the bot available to Lua bot := L.NewTable() @@ -197,45 +199,14 @@ func createLuaVM(cb *Chatbot) *lua.LState { bot.RawSetString("memory", memory) // Register the Message usertype - mt := L.NewTypeMetatable(luaMessageTypeName) - L.SetGlobal(luaMessageTypeName, mt) - L.SetField(mt, "__index", L.SetFuncs(L.NewTable(), messageMethods)) + mmt := L.NewTypeMetatable(luaMessageTypeName) + L.SetGlobal(luaMessageTypeName, mmt) + L.SetField(mmt, "__index", L.SetFuncs(L.NewTable(), messageMethods)) - return L -} - -var messageMethods = map[string]lua.LGFunction{ - "reply": messageReply, - "replyBlocking": messageReplyBlocking, - "text": messageText, -} - -// Checks whether the first lua argument is a *LUserData with *ChatMessage and returns this *ChatMessage -func checkMessage(L *lua.LState) *ChatMessage { - ud := L.CheckUserData(1) - if v, ok := ud.Value.(*ChatMessage); ok { - return v - } - L.ArgError(1, "message expected") - return nil -} - -func messageReply(L *lua.LState) int { - message := *checkMessage(L) - message.Reply(L.CheckString(2)) - return 0 -} + // Register the User usertype + umt := L.NewTypeMetatable(luaUserTypeName) + L.SetGlobal(luaUserTypeName, umt) + L.SetField(umt, "__index", L.SetFuncs(L.NewTable(), userMethods)) -func messageReplyBlocking(L *lua.LState) int { - message := *checkMessage(L) - timeout := time.Second * time.Duration(L.OptInt(3, int(DEFAULT_BLOCKING_MESSAGE_TIMEOUT))) - delivered := <-message.ReplyBlocking(L.CheckString(2), timeout) - L.Push(lua.LBool(delivered)) - return 1 -} - -func messageText(L *lua.LState) int { - message := *checkMessage(L) - L.Push(lua.LString(message.GetText())) - return 1 + return L } diff --git a/go/meshbot/luaToGoApi.go b/go/meshbot/luaToGoApi.go new file mode 100644 index 0000000..f0c8fff --- /dev/null +++ b/go/meshbot/luaToGoApi.go @@ -0,0 +1,195 @@ +package meshbot + +// Just pass on the Go interfaces to something that Lua understands + +import ( + "time" + + lua "github.com/yuin/gopher-lua" +) + +var messageMethods = map[string]lua.LGFunction{ + "getText": messageText, + "isPrivate": messageIsPrivate, + "getType": messageGetType, + "getChannel": messageGetChannel, + "getSender": messageGetSender, + "getReceiver": messageGetReceiver, + "findNode": messageFindNode, + "__tostring": messageToString, + "reply": messageReply, + "replyBlocking": messageReplyBlocking, +} + +var userMethods = map[string]lua.LGFunction{ + "getId": userGetId, + "getIdExpression": userGetIDExpression, + "getShortName": userGetShortName, + "getLongName": userGetLongName, + "__tostring": userToString, + "verboseString": userVerboseString, + "getPosition": userGetPosition, + "getHopsAway": userGetHopsAway, + "getRSSI": userGetRSSI, + "getSNR": userGetSNR, +} + +// Checks whether the first lua argument is a *LUserData with *ChatMessage and returns this *ChatMessage +func checkMessage(L *lua.LState) *ChatMessage { + ud := L.CheckUserData(1) + if v, ok := ud.Value.(*ChatMessage); ok { + return v + } + L.ArgError(1, "message expected") + return nil +} + +func messageText(L *lua.LState) int { + message := *checkMessage(L) + L.Push(lua.LString(message.GetText())) + return 1 +} + +func messageIsPrivate(L *lua.LState) int { + message := *checkMessage(L) + L.Push(lua.LBool(message.IsPrivateMessage())) + return 1 +} + +func messageGetType(L *lua.LState) int { + message := *checkMessage(L) + L.Push(lua.LString(message.GetType())) + return 1 +} + +func messageGetChannel(L *lua.LState) int { + message := *checkMessage(L) + L.Push(lua.LString(message.GetChannelName())) + return 1 +} + +func messageGetSender(L *lua.LState) int { + message := *checkMessage(L) + node := message.GetSenderNode() + userUserData := L.NewUserData() + userUserData.Value = node + L.SetMetatable(userUserData, L.GetTypeMetatable(luaUserTypeName)) + L.Push(userUserData) + return 1 +} + +func messageGetReceiver(L *lua.LState) int { + message := *checkMessage(L) + node := message.GetReceiverNode() + userUserData := L.NewUserData() + userUserData.Value = node + L.SetMetatable(userUserData, L.GetTypeMetatable(luaUserTypeName)) + L.Push(userUserData) + return 1 +} + +func messageFindNode(L *lua.LState) int { + message := *checkMessage(L) + node := message.FindNode(L.CheckString(2)) + if node == nil { + L.Push(lua.LNil) + return 1 + } + userUserData := L.NewUserData() + userUserData.Value = node + L.SetMetatable(userUserData, L.GetTypeMetatable(luaUserTypeName)) + L.Push(userUserData) + return 1 +} + +func messageToString(L *lua.LState) int { + message := *checkMessage(L) + L.Push(lua.LString(message.String())) + return 1 +} + +func messageReply(L *lua.LState) int { + message := *checkMessage(L) + message.Reply(L.CheckString(2)) + return 0 +} + +func messageReplyBlocking(L *lua.LState) int { + message := *checkMessage(L) + timeout := time.Second * time.Duration(L.OptInt(3, int(DEFAULT_BLOCKING_MESSAGE_TIMEOUT))) + delivered := <-message.ReplyBlocking(L.CheckString(2), timeout) + L.Push(lua.LBool(delivered)) + return 1 +} + +func checkUser(L *lua.LState) *ChatUser { + ud := L.CheckUserData(1) + if v, ok := ud.Value.(*ChatUser); ok { + return v + } + L.ArgError(1, "user expected") + return nil +} + +func userGetId(L *lua.LState) int { + user := *checkUser(L) + L.Push(lua.LNumber(user.GetId())) + return 1 +} + +func userGetIDExpression(L *lua.LState) int { + user := *checkUser(L) + L.Push(lua.LString(user.GetIDExpression())) + return 1 +} + +func userGetShortName(L *lua.LState) int { + user := *checkUser(L) + L.Push(lua.LString(user.GetShortName())) + return 1 +} + +func userGetLongName(L *lua.LState) int { + user := *checkUser(L) + L.Push(lua.LString(user.GetLongName())) + return 1 +} + +func userToString(L *lua.LState) int { + user := *checkUser(L) + L.Push(lua.LString(user.String())) + return 1 +} + +func userVerboseString(L *lua.LState) int { + user := *checkUser(L) + L.Push(lua.LString(user.VerboseString())) + return 1 +} + +func userGetPosition(L *lua.LState) int { + user := *checkUser(L) + position := user.GetPosition() + L.Push(lua.LNumber(position[0])) + L.Push(lua.LNumber(position[1])) + L.Push(lua.LNumber(position[2])) + return 1 +} + +func userGetHopsAway(L *lua.LState) int { + user := *checkUser(L) + L.Push(lua.LNumber(user.GetHopsAway())) + return 1 +} + +func userGetRSSI(L *lua.LState) int { + user := *checkUser(L) + L.Push(lua.LNumber(user.GetRSSI())) + return 1 +} + +func userGetSNR(L *lua.LState) int { + user := *checkUser(L) + L.Push(lua.LNumber(user.GetSNR())) + return 1 +} diff --git a/go/meshwrapper/message.go b/go/meshwrapper/message.go index 50098d2..99c9a63 100644 --- a/go/meshwrapper/message.go +++ b/go/meshwrapper/message.go @@ -127,7 +127,7 @@ func (m Message) GetReceiverNode() meshbot.ChatUser { return m.ToNode } -func (m Message) FindNode(id string) meshbot.ChatUser { +func (m Message) FindNode(id string) *meshbot.ChatUser { panic("TODO: implement") } From 805462a25a4f493fdc4d54003a8842778786c4bc Mon Sep 17 00:00:00 2001 From: Timendus Date: Thu, 30 Jan 2025 20:45:23 +0100 Subject: [PATCH 28/87] Write a signal reporting plugin, fixing bugs as we go --- go/cli/main.go | 13 ++++++------- go/meshbot/luaPlugins.go | 2 ++ go/meshbot/luaToGoApi.go | 6 ++---- go/meshwrapper/node.go | 2 +- go/plugins/signal.lua | 39 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 50 insertions(+), 12 deletions(-) create mode 100644 go/plugins/signal.lua diff --git a/go/cli/main.go b/go/cli/main.go index 4f68dc8..c2601a8 100644 --- a/go/cli/main.go +++ b/go/cli/main.go @@ -28,9 +28,8 @@ func main() { bot.HandleMessage(chatMessage{ MessageType: meshbot.TEXT_MESSAGE, Text: input, - // ToNode: &node{ - // NodeID: 1, - // }, + FromNode: chatUser{NodeID: 34875}, + ToNode: chatUser{NodeID: 23857}, }) } } @@ -94,11 +93,11 @@ func (m chatUser) GetId() int { } func (m chatUser) GetIDExpression() string { - return fmt.Sprintf("!%8x", m.NodeID) + return fmt.Sprintf("!%08x", m.NodeID) } func (m chatUser) GetShortName() string { - return m.GetIDExpression()[4:] + return m.GetIDExpression()[5:] } func (m chatUser) GetLongName() string { @@ -122,9 +121,9 @@ func (m chatUser) GetHopsAway() int { } func (m chatUser) GetRSSI() float32 { - return 0 + return -50.0 } func (m chatUser) GetSNR() float32 { - return 0 + return 5.2 } diff --git a/go/meshbot/luaPlugins.go b/go/meshbot/luaPlugins.go index f08af21..e35f077 100644 --- a/go/meshbot/luaPlugins.go +++ b/go/meshbot/luaPlugins.go @@ -202,11 +202,13 @@ func createLuaVM(cb *Chatbot) *lua.LState { mmt := L.NewTypeMetatable(luaMessageTypeName) L.SetGlobal(luaMessageTypeName, mmt) L.SetField(mmt, "__index", L.SetFuncs(L.NewTable(), messageMethods)) + mmt.RawSetString("__tostring", L.NewFunction(messageToString)) // Register the User usertype umt := L.NewTypeMetatable(luaUserTypeName) L.SetGlobal(luaUserTypeName, umt) L.SetField(umt, "__index", L.SetFuncs(L.NewTable(), userMethods)) + umt.RawSetString("__tostring", L.NewFunction(userToString)) return L } diff --git a/go/meshbot/luaToGoApi.go b/go/meshbot/luaToGoApi.go index f0c8fff..478530f 100644 --- a/go/meshbot/luaToGoApi.go +++ b/go/meshbot/luaToGoApi.go @@ -16,7 +16,6 @@ var messageMethods = map[string]lua.LGFunction{ "getSender": messageGetSender, "getReceiver": messageGetReceiver, "findNode": messageFindNode, - "__tostring": messageToString, "reply": messageReply, "replyBlocking": messageReplyBlocking, } @@ -26,7 +25,6 @@ var userMethods = map[string]lua.LGFunction{ "getIdExpression": userGetIDExpression, "getShortName": userGetShortName, "getLongName": userGetLongName, - "__tostring": userToString, "verboseString": userVerboseString, "getPosition": userGetPosition, "getHopsAway": userGetHopsAway, @@ -72,7 +70,7 @@ func messageGetSender(L *lua.LState) int { message := *checkMessage(L) node := message.GetSenderNode() userUserData := L.NewUserData() - userUserData.Value = node + userUserData.Value = &node L.SetMetatable(userUserData, L.GetTypeMetatable(luaUserTypeName)) L.Push(userUserData) return 1 @@ -82,7 +80,7 @@ func messageGetReceiver(L *lua.LState) int { message := *checkMessage(L) node := message.GetReceiverNode() userUserData := L.NewUserData() - userUserData.Value = node + userUserData.Value = &node L.SetMetatable(userUserData, L.GetTypeMetatable(luaUserTypeName)) L.Push(userUserData) return 1 diff --git a/go/meshwrapper/node.go b/go/meshwrapper/node.go index 2951831..dadae87 100644 --- a/go/meshwrapper/node.go +++ b/go/meshwrapper/node.go @@ -96,7 +96,7 @@ func (n *Node) GetId() int { } func (n *Node) GetIDExpression() string { - return fmt.Sprintf("!%8x", n.Id) + return fmt.Sprintf("!%08x", n.Id) } func (n *Node) GetShortName() string { diff --git a/go/plugins/signal.lua b/go/plugins/signal.lua new file mode 100644 index 0000000..9390de9 --- /dev/null +++ b/go/plugins/signal.lua @@ -0,0 +1,39 @@ +plugin = { + name = "๐Ÿ“ถ Signal reporting", + description = "Know what I'm seeing", + version = "1.0", + + commands = { + { + prefix = {"/SIGNAL"}, + channel = true, + description = "Get signal report (/SIGNAL [])", + func = function(message) + -- Figure out who we're requesting a signal report about + local text = message:getText() + local user = text:match("^%S+%s+(%S+)") or "" + local subject = nil + if user == "" or user == nil then + -- Send a signal report on the sender + subject = message:getSender() + else + -- Send a signal report on the specified node + subject = message:findNode(user) + end + + -- Do we have a subject? + if subject == nil then + message:reply("๐Ÿค–๐Ÿงจ I don't know who that is. Sorry!\n\nI need the short name (example: TDRP), or node ID (example: !8e92a31f) of a node that I know.") + return + end + + -- Do we have a signal measurement for this node? + if subject:getHopsAway() == 0 then + message:reply("๐Ÿค–๐Ÿ“ถ I'm reading " .. tostring(subject) .. " with an SNR of " .. string.format("%.2f", subject:getSNR()) .. ".") + else + message:reply("๐Ÿค–๐Ÿ“ถ " .. tostring(subject) .. " is " .. subject:getHopsAway() .. " hops away") + end + end, + }, + }, +} From 44896ea8fe7cececcdb1f35c974ffc425cdbd4de Mon Sep 17 00:00:00 2001 From: Timendus Date: Fri, 31 Jan 2025 14:01:40 +0100 Subject: [PATCH 29/87] Implement finding nodes --- go/cli/main.go | 8 +++++- go/meshbot/interfaces.go | 2 +- go/meshbot/luaToGoApi.go | 2 +- go/meshwrapper/message.go | 7 +++-- go/meshwrapper/node_list.go | 53 +++++++++++++++++++++++++++++++++++++ 5 files changed, 67 insertions(+), 5 deletions(-) diff --git a/go/cli/main.go b/go/cli/main.go index c2601a8..28853ae 100644 --- a/go/cli/main.go +++ b/go/cli/main.go @@ -65,7 +65,13 @@ func (m chatMessage) GetReceiverNode() meshbot.ChatUser { return m.ToNode } -func (m chatMessage) FindNode(id string) *meshbot.ChatUser { +func (m chatMessage) FindNode(needle string) meshbot.ChatUser { + if needle == m.FromNode.GetShortName() { + return m.FromNode + } + if needle == m.ToNode.GetShortName() { + return m.ToNode + } return nil } diff --git a/go/meshbot/interfaces.go b/go/meshbot/interfaces.go index dea18f3..e8de478 100644 --- a/go/meshbot/interfaces.go +++ b/go/meshbot/interfaces.go @@ -15,7 +15,7 @@ type ChatMessage interface { GetChannelName() string GetSenderNode() ChatUser GetReceiverNode() ChatUser - FindNode(string) *ChatUser + FindNode(string) ChatUser String() string Reply(string) ReplyBlocking(string, ...time.Duration) chan bool diff --git a/go/meshbot/luaToGoApi.go b/go/meshbot/luaToGoApi.go index 478530f..83e72eb 100644 --- a/go/meshbot/luaToGoApi.go +++ b/go/meshbot/luaToGoApi.go @@ -94,7 +94,7 @@ func messageFindNode(L *lua.LState) int { return 1 } userUserData := L.NewUserData() - userUserData.Value = node + userUserData.Value = &node L.SetMetatable(userUserData, L.GetTypeMetatable(luaUserTypeName)) L.Push(userUserData) return 1 diff --git a/go/meshwrapper/message.go b/go/meshwrapper/message.go index 99c9a63..8c433a8 100644 --- a/go/meshwrapper/message.go +++ b/go/meshwrapper/message.go @@ -127,8 +127,11 @@ func (m Message) GetReceiverNode() meshbot.ChatUser { return m.ToNode } -func (m Message) FindNode(id string) *meshbot.ChatUser { - panic("TODO: implement") +func (m Message) FindNode(needle string) meshbot.ChatUser { + if m.ReceivingNode == nil { + return nil + } + return m.ReceivingNode.NodeList.findNode(needle) } func (m Message) String() string { diff --git a/go/meshwrapper/node_list.go b/go/meshwrapper/node_list.go index dac8be1..08ab7d3 100644 --- a/go/meshwrapper/node_list.go +++ b/go/meshwrapper/node_list.go @@ -2,7 +2,9 @@ package meshwrapper import ( "cmp" + "regexp" "slices" + "strconv" ) type nodeList struct { @@ -55,3 +57,54 @@ func (n *nodeList) sortedNodes() []Node { }) return nodes } + +func (n *nodeList) findNode(needle string) *Node { + needleBytes := []byte(needle) + + // Check if we have a specific, full hexadecimal id + fullHexId, _ := regexp.Compile("![0-9a-fA-F]{8}") + if fullHexId.Match(needleBytes) { + id, _ := strconv.ParseUint(needle[1:], 16, 32) + node, ok := n.nodes[uint32(id)] + if ok { + return node + } + } + shortHexId, _ := regexp.Compile("[0-9a-fA-F]{8}") + if shortHexId.Match(needleBytes) { + id, _ := strconv.ParseUint(needle, 16, 32) + node, ok := n.nodes[uint32(id)] + if ok { + return node + } + } + + // Check if we have a shortName + for _, node := range n.nodes { + if node.ShortName == needle { + return node + } + } + + // Check if we have a decimal id + numericId, _ := regexp.Compile("[0-9]+") + if numericId.Match(needleBytes) { + id, _ := strconv.ParseUint(needle, 10, 32) + node, ok := n.nodes[uint32(id)] + if ok { + return node + } + } + + // Check if we have an abbreviated hexadecimal id + abbreviatedHexId, _ := regexp.Compile("[0-9a-fA-F]{4}") + if abbreviatedHexId.Match(needleBytes) { + id, _ := strconv.ParseUint(needle, 16, 32) + node, ok := n.nodes[uint32(id)] + if ok { + return node + } + } + + return nil +} From ced86cc7e9e7e313c603bbb37bf5add60437843a Mon Sep 17 00:00:00 2001 From: Timendus Date: Fri, 31 Jan 2025 16:55:39 +0100 Subject: [PATCH 30/87] Write message box plugin, fixing more issues as we go --- go/cli/main.go | 4 + go/meshbot/interfaces.go | 1 + go/meshbot/luaPlugins.go | 23 +++- go/meshbot/luaToGoApi.go | 7 ++ go/meshwrapper/node.go | 4 + go/plugins/about.lua | 13 +- go/plugins/message_box.lua | 245 +++++++++++++++++++++++++++++++++++++ go/plugins/signal.lua | 10 +- 8 files changed, 292 insertions(+), 15 deletions(-) create mode 100644 go/plugins/message_box.lua diff --git a/go/cli/main.go b/go/cli/main.go index 28853ae..7d57f2d 100644 --- a/go/cli/main.go +++ b/go/cli/main.go @@ -133,3 +133,7 @@ func (m chatUser) GetRSSI() float32 { func (m chatUser) GetSNR() float32 { return 5.2 } + +func (m chatUser) IsSelf() bool { + return m.NodeID == 23857 +} diff --git a/go/meshbot/interfaces.go b/go/meshbot/interfaces.go index e8de478..8c82047 100644 --- a/go/meshbot/interfaces.go +++ b/go/meshbot/interfaces.go @@ -32,4 +32,5 @@ type ChatUser interface { GetHopsAway() int GetRSSI() float32 GetSNR() float32 + IsSelf() bool } diff --git a/go/meshbot/luaPlugins.go b/go/meshbot/luaPlugins.go index e35f077..633ed0d 100644 --- a/go/meshbot/luaPlugins.go +++ b/go/meshbot/luaPlugins.go @@ -3,6 +3,7 @@ package meshbot import ( "context" "errors" + "time" lua "github.com/yuin/gopher-lua" ) @@ -44,7 +45,7 @@ func LoadPlugin(filename string, bot *Chatbot) (*plugin, error) { if err := L.DoFile(filename); err != nil { return nil, err } - definition, ok := L.GetGlobal("plugin").(*lua.LTable) + definition, ok := L.GetGlobal("Plugin").(*lua.LTable) if !ok { return nil, errors.New("no plugin definition found in file " + filename) } @@ -168,7 +169,7 @@ func createLuaVM(cb *Chatbot) *lua.LState { // Make some properties of the bot available to Lua bot := L.NewTable() - L.SetGlobal("bot", bot) + L.SetGlobal("Bot", bot) bot.RawSetString("CATCH_ALL_TEXT", lua.LNumber(CATCH_ALL_TEXT)) bot.RawSetString("CATCH_ALL_EVENTS", lua.LNumber(CATCH_ALL_EVENTS)) botMT := L.NewTable() @@ -178,10 +179,18 @@ func createLuaVM(cb *Chatbot) *lua.LState { })) L.SetMetatable(bot, botMT) + // Allow Lua scripts to get the time and date on bot, without access to the + // whole `os` library + L.SetField(bot, "date", L.NewFunction(func(L *lua.LState) int { + format := L.OptString(1, "%c") + L.Push(lua.LString(time.Now().Format(format))) + return 1 + })) + // This is pretty crude, but it provides a way to save some data from the // Lua scripts, that we can actually persist and make thread safe in the // future. - L.SetContext(context.WithValue(context.Background(), contextKey("storage"), make(map[string]string))) + L.SetContext(context.WithValue(context.Background(), contextKey("storage"), make(map[string]lua.LValue))) memory := L.NewTable() memory.RawSetString("write", L.NewFunction(func(L *lua.LState) int { ctx := L.Context() @@ -192,8 +201,12 @@ func createLuaVM(cb *Chatbot) *lua.LState { memory.RawSetString("read", L.NewFunction(func(L *lua.LState) int { ctx := L.Context() key := L.CheckString(1) - value := ctx.Value(contextKey("storage")).(map[string]lua.LValue)[key] - L.Push(value) + value, ok := ctx.Value(contextKey("storage")).(map[string]lua.LValue)[key] + if ok { + L.Push(value) + } else { + L.Push(lua.LNil) + } return 1 })) bot.RawSetString("memory", memory) diff --git a/go/meshbot/luaToGoApi.go b/go/meshbot/luaToGoApi.go index 83e72eb..f94ff8d 100644 --- a/go/meshbot/luaToGoApi.go +++ b/go/meshbot/luaToGoApi.go @@ -30,6 +30,7 @@ var userMethods = map[string]lua.LGFunction{ "getHopsAway": userGetHopsAway, "getRSSI": userGetRSSI, "getSNR": userGetSNR, + "isSelf": userIsSelf, } // Checks whether the first lua argument is a *LUserData with *ChatMessage and returns this *ChatMessage @@ -191,3 +192,9 @@ func userGetSNR(L *lua.LState) int { L.Push(lua.LNumber(user.GetSNR())) return 1 } + +func userIsSelf(L *lua.LState) int { + user := *checkUser(L) + L.Push(lua.LBool(user.IsSelf())) + return 1 +} diff --git a/go/meshwrapper/node.go b/go/meshwrapper/node.go index dadae87..631f5a7 100644 --- a/go/meshwrapper/node.go +++ b/go/meshwrapper/node.go @@ -176,3 +176,7 @@ func (n *Node) GetRSSI() float32 { func (n *Node) GetSNR() float32 { return n.Snr } + +func (n *Node) IsSelf() bool { + return n.Connected +} diff --git a/go/plugins/about.lua b/go/plugins/about.lua index c8538e9..8ed3832 100644 --- a/go/plugins/about.lua +++ b/go/plugins/about.lua @@ -1,4 +1,4 @@ -plugin = { +Plugin = { name = "About", description = "Respond to hidden commands with a friendly message.", version = "1.0", @@ -9,11 +9,12 @@ plugin = { -- These commands might be "guessed" by users, and will result in -- expected behaviour. { - command = {"/ABOUT", "/HELP", "/MESHBOT"}, - prefix = {"/MESHBOT"}, + command = { "/ABOUT", "/HELP", "/MESHBOT" }, + prefix = { "/MESHBOT" }, channel = true, func = function(message) - message:reply("๐Ÿค–๐Ÿ‘‹ Hello! I'm your friendly neighbourhood Meshbot. My code is available at https://github.com/timendus/meshbot. Send me a direct message to see what I can do!") + message:reply( + "๐Ÿค–๐Ÿ‘‹ Hello! I'm your friendly neighbourhood Meshbot. My code is available at https://github.com/timendus/meshbot. Send me a direct message to see what I can do!") end, }, @@ -21,9 +22,9 @@ plugin = { -- matched in the "MAIN" state when receiving a private message, we reply -- with the capabilities of this bot. { - command = bot.CATCH_ALL_TEXT, + command = Bot.CATCH_ALL_TEXT, func = function(message) - message:reply(tostring(bot)) + message:reply(tostring(Bot)) end, }, diff --git a/go/plugins/message_box.lua b/go/plugins/message_box.lua new file mode 100644 index 0000000..e5a7727 --- /dev/null +++ b/go/plugins/message_box.lua @@ -0,0 +1,245 @@ +Plugin = { + name = "โœ‰๏ธ Message box", + description = "An answering machine for Meshtastic", + version = "1.0", + + commands = { + { + command = "INBOX", + description = "Check your inbox", + func = function(message) return SendInbox(message) end, + }, + { + command = "NEW", + description = "Get new messages", + func = function(message) return SendNewMessages(message) end, + }, + { + command = "OLD", + description = "Get old messages", + func = function(message) return SendOldMessages(message) end, + }, + { + command = "CLEAR", + description = "Clear old messages", + func = function(message) return ClearOldMessages(message) end + }, + { + prefix = "SEND", + description = "Leave a message (SEND )", + func = function(message) return StoreMessage(message) end + }, + { + command = Bot.CATCH_ALL_EVENTS, + func = function(message) return NotifyUser(message) end + } + }, +} + +-- Inform the user about their inbox stats +function SendInbox(message) + local inbox = GetInbox(message:getSender()) + + if #inbox == 0 then + message:reply("๐Ÿค–๐Ÿ“ญ You have no messages in your inbox") + return + end + + local icon = inbox.numUnread > 0 and "๐Ÿ“ฌ" or "๐Ÿ“ญ" + message:reply( + "๐Ÿค–" .. + icon .. + " You have " .. + inbox.numUnread .. + " unread " .. + Pluralize("message", inbox.numUnread) .. + ", and a grand total of " .. + #inbox .. " " .. Pluralize("message", #inbox) .. " in your inbox. Send `NEW` or `OLD` to fetch your messages." + ) +end + +-- Send all unread messages to the user +function SendNewMessages(message) + local inbox = GetInbox(message:getSender()) + + if inbox.numUnread == 0 then + message:reply("๐Ÿค–๐Ÿ“ญ You have no new messages." .. + (inbox.numRead > 0 and " Send `OLD` to read your older messages." or "")) + return + end + + message:reply("๐Ÿค–๐Ÿ“ฌ You have " .. + inbox.numUnread .. + " new " .. Pluralize("message", inbox.numUnread) .. ". Sending " .. Pluralize("it", inbox.numUnread) .. " now...") + SendMessages(message, inbox, false) +end + +-- Send all read messages to the user +function SendOldMessages(message) + local inbox = GetInbox(message:getSender()) + + if inbox.numRead == 0 then + message:reply("๐Ÿค–๐Ÿ“ญ You have no old messages." .. + (inbox.numUnread > 0 and " Send `NEW` to read your new messages." or "")) + return + end + + message:reply("๐Ÿค–๐Ÿ“ฌ You have " .. + inbox.numRead .. + " old " .. Pluralize("message", inbox.numRead) .. ". Sending " .. Pluralize("it", inbox.numRead) .. " now...") + SendMessages(message, inbox, true) +end + +-- Clear all messages that have already been read +function ClearOldMessages(message) + local inbox = Bot.memory.read(message:getSender():getIdExpression()) + local num + + if inbox ~= nil then + num = 0 + for i, msg in ipairs(inbox) do + if msg["read"] then + table.remove(inbox, i) + num = num + 1 + end + end + Bot.memory.write(message:getSender():getIdExpression(), inbox) + end + + inbox = GetInbox(message:getSender()) + message:reply("๐Ÿค–๐Ÿ—‘๏ธ I removed " .. + num .. + " old " .. + Pluralize("message", num) .. + ". You have " .. inbox.numUnread .. " new " .. Pluralize("message", inbox.numUnread) .. " left in your inbox.") +end + +-- Store new messages when requested by the user +function StoreMessage(message) + local text = message:getText() + local user = text:match("^%S+%s+(%S+)") + local to_send = text:match("^%S+%s+%S+%s+(.*)") + + -- Validate we got a valid request from the user, explain how to use this + -- otherwise + if user == nil or to_send == nil then + message:reply( + "๐Ÿค–๐Ÿงจ The syntax for this command is SEND , where is the short name or id of a node.") + return + end + + -- Find our recipient node + local node = message:findNode(user) + if node == nil then + message:reply("๐Ÿค–๐Ÿงจ I don't know who '" .. + user .. + "' is. The message was not stored.\n\nI need the short name of a node I have seen before (example: TDRP), or the node ID of the recipient (example: !8e92a31f).") + return + end + + -- Store the message to the bot's memory + local inbox = Bot.memory.read(node:getIdExpression()) + if inbox == nil then + inbox = {} + end + table.insert(inbox, { + sender = tostring(message:getSender()), + contents = Trim(to_send), + read = false, + timestamp = Bot.date("2-1-2006 15:04:05"), + }) + Bot.memory.write(node:getIdExpression(), inbox) + + message:reply("๐Ÿค–๐Ÿ“จ Saved this message for node " .. tostring(node) .. ":\n\n" .. to_send) +end + +-- Check to see if one of our recipients came in range, and has new messages. +function NotifyUser(message) + -- If they are messaging us first, they will probably quickly find out that + -- they have messages, and it just breaks the flow. So only check for all + -- other message types. + if message:getType() == "text message" and message:getReceiver():isSelf() then + return + end + + -- We get routing messages for each Ack, so ignore those or we get a royal + -- clusterfuck. + if message:getType() == "routing" then + return + end + + -- Do we have a message box at all? Otherwise we're spamming nodes that have + -- never interacted with this bot, and have not actually been sent messages + -- by real people, with a "friendly welcome message". + local box = Bot.memory.read(message:getSender():getIdExpression()) + if box == nil then + return + end + + -- Do we have new messages? + local inbox = GetInbox(message:getSender()) + if inbox.numUnread == 0 then + return + end + + -- Send this user their new messages + message:reply("๐Ÿค–๐Ÿ“ฌ I have " .. inbox.numUnread .. " new " .. + Pluralize("message", inbox.numUnread) .. " for you! Sending them now...") + SendMessages(message, inbox, false) +end + +---------------------- +-- Helper functions -- +---------------------- + +-- Get a user's inbox, create one if necessary by adding a friendly little +-- welcome message, and collect some stats about the inbox. +function GetInbox(node) + local inbox = Bot.memory.read(node:getIdExpression()) + + if inbox == nil then + inbox = {} + table.insert(inbox, { + sender = "๐Ÿค– Meshbot", + contents = "Welcome to this Meshtastic answering machine, " .. + node:getLongName() .. + "! You can leave messages for other users, and they can leave messages for you! Hope you like it ๐Ÿ˜„", + read = false, + timestamp = Bot.date("2-1-2006 15:04:05"), + }) + Bot.memory.write(node:getIdExpression(), inbox) + end + + local numUnread = 0 + for _, message in ipairs(inbox) do + if not message["read"] then + numUnread = numUnread + 1 + end + end + inbox.numUnread = numUnread + inbox.numRead = #inbox - numUnread + + return inbox +end + +function SendMessages(message, inbox, read) + for _, m in ipairs(inbox) do + if m.read == read then + m.read = message:replyBlocking("๐Ÿค–โœ‰๏ธ From " .. m.sender .. " at " .. m.timestamp .. ":\n\n" .. m.contents) + end + end +end + +function Trim(s) + return s and s:match("^%s*(.-)%s*$") or "" +end + +function Pluralize(word, count) + if count == 1 then + return word + end + if word == "it" then + return "them" + end + return word .. "s" +end diff --git a/go/plugins/signal.lua b/go/plugins/signal.lua index 9390de9..93efd1a 100644 --- a/go/plugins/signal.lua +++ b/go/plugins/signal.lua @@ -1,11 +1,11 @@ -plugin = { +Plugin = { name = "๐Ÿ“ถ Signal reporting", description = "Know what I'm seeing", version = "1.0", commands = { { - prefix = {"/SIGNAL"}, + prefix = { "/SIGNAL" }, channel = true, description = "Get signal report (/SIGNAL [])", func = function(message) @@ -23,13 +23,15 @@ plugin = { -- Do we have a subject? if subject == nil then - message:reply("๐Ÿค–๐Ÿงจ I don't know who that is. Sorry!\n\nI need the short name (example: TDRP), or node ID (example: !8e92a31f) of a node that I know.") + message:reply( + "๐Ÿค–๐Ÿงจ I don't know who that is. Sorry!\n\nI need the short name (example: TDRP), or node ID (example: !8e92a31f) of a node that I know.") return end -- Do we have a signal measurement for this node? if subject:getHopsAway() == 0 then - message:reply("๐Ÿค–๐Ÿ“ถ I'm reading " .. tostring(subject) .. " with an SNR of " .. string.format("%.2f", subject:getSNR()) .. ".") + message:reply("๐Ÿค–๐Ÿ“ถ I'm reading " .. + tostring(subject) .. " with an SNR of " .. string.format("%.2f", subject:getSNR()) .. ".") else message:reply("๐Ÿค–๐Ÿ“ถ " .. tostring(subject) .. " is " .. subject:getHopsAway() .. " hops away") end From c78ded28deb3c0edc107731873407401ece15a28 Mon Sep 17 00:00:00 2001 From: Timendus Date: Fri, 31 Jan 2025 16:56:15 +0100 Subject: [PATCH 31/87] Don't think we need this --- go/Makefile | 4 ---- 1 file changed, 4 deletions(-) diff --git a/go/Makefile b/go/Makefile index 19ef9a0..3e22b50 100644 --- a/go/Makefile +++ b/go/Makefile @@ -6,9 +6,5 @@ update: @go get -u @go mod tidy -install-protobuf-compiler: - @sudo dnf install protobuf-compiler - @go install google.golang.org/protobuf/cmd/protoc-gen-go@latest - run: @go run *.go From d03812928275be0e70fc989715ad6e0c3310451c Mon Sep 17 00:00:00 2001 From: Timendus Date: Fri, 31 Jan 2025 16:57:58 +0100 Subject: [PATCH 32/87] Do updates --- go/go.mod | 6 +++--- go/go.sum | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/go/go.mod b/go/go.mod index c621fb3..2d04b1c 100644 --- a/go/go.mod +++ b/go/go.mod @@ -3,13 +3,13 @@ module github.com/timendus/meshbot go 1.22.7 require ( - buf.build/gen/go/meshtastic/protobufs/protocolbuffers/go v1.35.2-20241006120827-cc36fd21e859.1 + buf.build/gen/go/meshtastic/protobufs/protocolbuffers/go v1.36.4-20241006120827-cc36fd21e859.1 github.com/yuin/gopher-lua v1.1.1 go.bug.st/serial v1.6.2 - google.golang.org/protobuf v1.35.2 + google.golang.org/protobuf v1.36.4 ) require ( github.com/creack/goselect v0.1.2 // indirect - golang.org/x/sys v0.27.0 // indirect + golang.org/x/sys v0.29.0 // indirect ) diff --git a/go/go.sum b/go/go.sum index e2452da..758a0be 100644 --- a/go/go.sum +++ b/go/go.sum @@ -1,5 +1,5 @@ -buf.build/gen/go/meshtastic/protobufs/protocolbuffers/go v1.35.2-20241006120827-cc36fd21e859.1 h1:arn+/xFe4UCiBWK/wjrTI59R9a9t6ZIqLG/vFDxU5Zo= -buf.build/gen/go/meshtastic/protobufs/protocolbuffers/go v1.35.2-20241006120827-cc36fd21e859.1/go.mod h1:ZkaTWUand3LqOJGrTfoO7CeV8WkIuFWi8+cRNfOkaQU= +buf.build/gen/go/meshtastic/protobufs/protocolbuffers/go v1.36.4-20241006120827-cc36fd21e859.1 h1:zyDzextHjGnmep7Gu92L52+cI7criF9wo9x1j1wASjQ= +buf.build/gen/go/meshtastic/protobufs/protocolbuffers/go v1.36.4-20241006120827-cc36fd21e859.1/go.mod h1:MOqI0PPKXtgCAzKurj4jWYru224NHD0SapFCqlHWxew= github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0= github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY= github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= @@ -14,11 +14,11 @@ github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= go.bug.st/serial v1.6.2 h1:kn9LRX3sdm+WxWKufMlIRndwGfPWsH1/9lCWXQCasq8= go.bug.st/serial v1.6.2/go.mod h1:UABfsluHAiaNI+La2iESysd9Vetq7VRdpxvjx7CmmOE= -golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= -golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io= -google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/protobuf v1.36.4 h1:6A3ZDJHn/eNqc1i+IdefRzy/9PokBTPvcqMySR7NNIM= +google.golang.org/protobuf v1.36.4/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From 3ee9ca1a327662193011254ca46df095aca8a7eb Mon Sep 17 00:00:00 2001 From: Timendus Date: Mon, 10 Feb 2025 13:23:06 +0100 Subject: [PATCH 33/87] Make lua to Go api safer, remove duplication of default timeout value --- go/meshbot/interfaces.go | 2 - go/meshbot/luaToGoApi.go | 95 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 92 insertions(+), 5 deletions(-) diff --git a/go/meshbot/interfaces.go b/go/meshbot/interfaces.go index 8c82047..0d4b8cd 100644 --- a/go/meshbot/interfaces.go +++ b/go/meshbot/interfaces.go @@ -4,8 +4,6 @@ import "time" const ( TEXT_MESSAGE = "text message" - - DEFAULT_BLOCKING_MESSAGE_TIMEOUT = 30 * time.Second ) type ChatMessage interface { diff --git a/go/meshbot/luaToGoApi.go b/go/meshbot/luaToGoApi.go index f94ff8d..3e38a62 100644 --- a/go/meshbot/luaToGoApi.go +++ b/go/meshbot/luaToGoApi.go @@ -45,30 +45,50 @@ func checkMessage(L *lua.LState) *ChatMessage { func messageText(L *lua.LState) int { message := *checkMessage(L) + if message == nil { + L.Push(lua.LNil) + return 1 + } L.Push(lua.LString(message.GetText())) return 1 } func messageIsPrivate(L *lua.LState) int { message := *checkMessage(L) + if message == nil { + L.Push(lua.LFalse) + return 1 + } L.Push(lua.LBool(message.IsPrivateMessage())) return 1 } func messageGetType(L *lua.LState) int { message := *checkMessage(L) + if message == nil { + L.Push(lua.LNil) + return 1 + } L.Push(lua.LString(message.GetType())) return 1 } func messageGetChannel(L *lua.LState) int { message := *checkMessage(L) + if message == nil { + L.Push(lua.LNil) + return 1 + } L.Push(lua.LString(message.GetChannelName())) return 1 } func messageGetSender(L *lua.LState) int { message := *checkMessage(L) + if message == nil { + L.Push(lua.LNil) + return 1 + } node := message.GetSenderNode() userUserData := L.NewUserData() userUserData.Value = &node @@ -79,6 +99,10 @@ func messageGetSender(L *lua.LState) int { func messageGetReceiver(L *lua.LState) int { message := *checkMessage(L) + if message == nil { + L.Push(lua.LNil) + return 1 + } node := message.GetReceiverNode() userUserData := L.NewUserData() userUserData.Value = &node @@ -89,6 +113,10 @@ func messageGetReceiver(L *lua.LState) int { func messageFindNode(L *lua.LState) int { message := *checkMessage(L) + if message == nil { + L.Push(lua.LNil) + return 1 + } node := message.FindNode(L.CheckString(2)) if node == nil { L.Push(lua.LNil) @@ -103,21 +131,38 @@ func messageFindNode(L *lua.LState) int { func messageToString(L *lua.LState) int { message := *checkMessage(L) + if message == nil { + L.Push(lua.LNil) + return 1 + } L.Push(lua.LString(message.String())) return 1 } func messageReply(L *lua.LState) int { message := *checkMessage(L) + if message == nil { + return 0 + } message.Reply(L.CheckString(2)) return 0 } func messageReplyBlocking(L *lua.LState) int { message := *checkMessage(L) - timeout := time.Second * time.Duration(L.OptInt(3, int(DEFAULT_BLOCKING_MESSAGE_TIMEOUT))) - delivered := <-message.ReplyBlocking(L.CheckString(2), timeout) - L.Push(lua.LBool(delivered)) + if message == nil { + L.Push(lua.LFalse) + return 1 + } + duration := L.OptInt(3, -1) + if duration == -1 { + delivered := <-message.ReplyBlocking(L.CheckString(2)) + L.Push(lua.LBool(delivered)) + } else { + timeout := time.Second * time.Duration(duration) + delivered := <-message.ReplyBlocking(L.CheckString(2), timeout) + L.Push(lua.LBool(delivered)) + } return 1 } @@ -132,42 +177,70 @@ func checkUser(L *lua.LState) *ChatUser { func userGetId(L *lua.LState) int { user := *checkUser(L) + if user == nil { + L.Push(lua.LNil) + return 1 + } L.Push(lua.LNumber(user.GetId())) return 1 } func userGetIDExpression(L *lua.LState) int { user := *checkUser(L) + if user == nil { + L.Push(lua.LNil) + return 1 + } L.Push(lua.LString(user.GetIDExpression())) return 1 } func userGetShortName(L *lua.LState) int { user := *checkUser(L) + if user == nil { + L.Push(lua.LNil) + return 1 + } L.Push(lua.LString(user.GetShortName())) return 1 } func userGetLongName(L *lua.LState) int { user := *checkUser(L) + if user == nil { + L.Push(lua.LNil) + return 1 + } L.Push(lua.LString(user.GetLongName())) return 1 } func userToString(L *lua.LState) int { user := *checkUser(L) + if user == nil { + L.Push(lua.LNil) + return 1 + } L.Push(lua.LString(user.String())) return 1 } func userVerboseString(L *lua.LState) int { user := *checkUser(L) + if user == nil { + L.Push(lua.LNil) + return 1 + } L.Push(lua.LString(user.VerboseString())) return 1 } func userGetPosition(L *lua.LState) int { user := *checkUser(L) + if user == nil { + L.Push(lua.LNil) + return 1 + } position := user.GetPosition() L.Push(lua.LNumber(position[0])) L.Push(lua.LNumber(position[1])) @@ -177,24 +250,40 @@ func userGetPosition(L *lua.LState) int { func userGetHopsAway(L *lua.LState) int { user := *checkUser(L) + if user == nil { + L.Push(lua.LNil) + return 1 + } L.Push(lua.LNumber(user.GetHopsAway())) return 1 } func userGetRSSI(L *lua.LState) int { user := *checkUser(L) + if user == nil { + L.Push(lua.LNil) + return 1 + } L.Push(lua.LNumber(user.GetRSSI())) return 1 } func userGetSNR(L *lua.LState) int { user := *checkUser(L) + if user == nil { + L.Push(lua.LNil) + return 1 + } L.Push(lua.LNumber(user.GetSNR())) return 1 } func userIsSelf(L *lua.LState) int { user := *checkUser(L) + if user == nil { + L.Push(lua.LFalse) + return 1 + } L.Push(lua.LBool(user.IsSelf())) return 1 } From 5d6cf84cc43f9879d81891412fba338224068f37 Mon Sep 17 00:00:00 2001 From: Timendus Date: Mon, 10 Feb 2025 13:26:37 +0100 Subject: [PATCH 34/87] A few fixes for the message box plugin --- go/plugins/message_box.lua | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/go/plugins/message_box.lua b/go/plugins/message_box.lua index e5a7727..8544b26 100644 --- a/go/plugins/message_box.lua +++ b/go/plugins/message_box.lua @@ -68,7 +68,7 @@ function SendNewMessages(message) return end - message:reply("๐Ÿค–๐Ÿ“ฌ You have " .. + message:replyBlocking("๐Ÿค–๐Ÿ“ฌ You have " .. inbox.numUnread .. " new " .. Pluralize("message", inbox.numUnread) .. ". Sending " .. Pluralize("it", inbox.numUnread) .. " now...") SendMessages(message, inbox, false) @@ -84,7 +84,7 @@ function SendOldMessages(message) return end - message:reply("๐Ÿค–๐Ÿ“ฌ You have " .. + message:replyBlocking("๐Ÿค–๐Ÿ“ฌ You have " .. inbox.numRead .. " old " .. Pluralize("message", inbox.numRead) .. ". Sending " .. Pluralize("it", inbox.numRead) .. " now...") SendMessages(message, inbox, true) @@ -158,7 +158,7 @@ function NotifyUser(message) -- If they are messaging us first, they will probably quickly find out that -- they have messages, and it just breaks the flow. So only check for all -- other message types. - if message:getType() == "text message" and message:getReceiver():isSelf() then + if message:getType() == "text message" and (message:getReceiver() == nil or message:getReceiver():isSelf()) then return end @@ -225,7 +225,7 @@ end function SendMessages(message, inbox, read) for _, m in ipairs(inbox) do if m.read == read then - m.read = message:replyBlocking("๐Ÿค–โœ‰๏ธ From " .. m.sender .. " at " .. m.timestamp .. ":\n\n" .. m.contents) + m.read = message:replyBlocking("๐Ÿค–โœ‰๏ธ From " .. m.sender .. " at " .. m.timestamp .. "\n\n" .. m.contents) end end end From 1a2d7c9028fc57a76d8be6339d105cd3c861942c Mon Sep 17 00:00:00 2001 From: Timendus Date: Mon, 10 Feb 2025 20:14:44 +0100 Subject: [PATCH 35/87] Add method for splitting some random message into Meshtastic messages with a max length --- go/Makefile | 6 + go/meshwrapper/helpers/language.go | 58 +++++++ go/meshwrapper/helpers/language_test.go | 195 ++++++++++++++++++++++++ 3 files changed, 259 insertions(+) create mode 100644 go/meshwrapper/helpers/language_test.go diff --git a/go/Makefile b/go/Makefile index 3e22b50..2dd8c55 100644 --- a/go/Makefile +++ b/go/Makefile @@ -8,3 +8,9 @@ update: run: @go run *.go + +test: + @go test -v ./... + +lines: + @find . -name '*.go' | xargs wc -l diff --git a/go/meshwrapper/helpers/language.go b/go/meshwrapper/helpers/language.go index 0277fa7..3a44d8c 100644 --- a/go/meshwrapper/helpers/language.go +++ b/go/meshwrapper/helpers/language.go @@ -3,7 +3,9 @@ package helpers import ( "fmt" "math" + "strings" "time" + "unicode/utf8" ) func Pluralize(word string, count int) string { @@ -48,3 +50,59 @@ func TimeAgo(timestamp time.Time) string { } return fmt.Sprintf("%d days", days) } + +func BreakMessage(input string) []string { + const MAX_MESSAGE_LENGTH = 200 + input = strings.TrimSpace(input) + messages := make([]string, 0) + startPtr := 0 + endPtr := 0 + resumePtr := 0 + + for startPtr < len(input) { + // Find the next (rough) place where we need to cut the input to get it + // to fit in a message + charEnd := startPtr + MAX_MESSAGE_LENGTH + + if charEnd >= len(input) { + // We can fit the whole rest of the input in the message, in other + // words: we're done + return append(messages, input[startPtr:]) + } + + // Find the "real" charEnd, that considers UTF-8 encoding + // boundaries. This should walk back at most 4 bytes, and since + // we're always considering 200 bytes at once, we should be fine. + for !utf8.ValidString(input[startPtr:charEnd]) { + charEnd-- + } + + // Break on the furthest newline that fits in the next message, if + // the line after that can fit in a single message. Otherwise, break + // on the furthest space. If neither is found, break on character. + wordEnd := strings.LastIndex(input[startPtr:charEnd+1], " ") + lineEnd := strings.LastIndex(input[startPtr:charEnd+1], "\n") + + nextLineEnd := strings.Index(input[charEnd:], "\n") + if nextLineEnd == -1 { + nextLineEnd = len(input) + } + nextLineLength := (nextLineEnd + charEnd) - (lineEnd + startPtr + 1) + + if lineEnd != -1 && nextLineLength <= MAX_MESSAGE_LENGTH { + endPtr = lineEnd + startPtr + resumePtr = endPtr + 1 // Skip the newline character + } else if wordEnd != -1 { + endPtr = wordEnd + startPtr + resumePtr = endPtr + 1 // Skip the space character + } else { + endPtr = charEnd + resumePtr = endPtr + } + + messages = append(messages, input[startPtr:endPtr]) + startPtr = resumePtr + } + + return messages +} diff --git a/go/meshwrapper/helpers/language_test.go b/go/meshwrapper/helpers/language_test.go new file mode 100644 index 0000000..d400b33 --- /dev/null +++ b/go/meshwrapper/helpers/language_test.go @@ -0,0 +1,195 @@ +package helpers + +import ( + "testing" +) + +func TestPluralize(t *testing.T) { + Assert(Pluralize("it", 0) == "them", "Pluralize(it, 0) == them") + Assert(Pluralize("it", 1) == "it", "Pluralize(it, 1) == it") + Assert(Pluralize("it", 2) == "them", "Pluralize(it, 2) == them") + Assert(Pluralize("it", 3) == "them", "Pluralize(it, 3) == them") + Assert(Pluralize("it", 4) == "them", "Pluralize(it, 4) == them") + + Assert(Pluralize("message", 0) == "messages", "Pluralize(message, 0) == messages") + Assert(Pluralize("message", 1) == "message", "Pluralize(message, 1) == message") + Assert(Pluralize("message", 2) == "messages", "Pluralize(message, 2) == messages") + Assert(Pluralize("message", 3) == "messages", "Pluralize(message, 3) == messages") + Assert(Pluralize("message", 4) == "messages", "Pluralize(message, 4) == messages") +} + +func TestBreakMessage(t *testing.T) { + twoHundredChars := "Helloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahe" + twoHundredCharWords := "Hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello he" + + AssertBreaking(t, + "", + []string{""}, + ) + + AssertBreaking(t, + "Hello", + []string{"Hello"}, + ) + + AssertBreaking(t, + "Hello\nHello", + []string{"Hello\nHello"}, + ) + + AssertBreaking(t, + twoHundredChars, + []string{twoHundredChars}, + ) + + AssertBreaking(t, + twoHundredCharWords, + []string{twoHundredCharWords}, + ) + + AssertBreaking(t, + twoHundredChars+"a", + []string{ + twoHundredChars, + "a", + }, + ) + + AssertBreaking(t, + twoHundredChars+"๐Ÿ“Ÿ!", + []string{ + twoHundredChars, + "๐Ÿ“Ÿ!", + }, + ) + + AssertBreaking(t, + twoHundredChars[:len(twoHundredChars)-4]+"๐Ÿ“Ÿ!", + []string{ + twoHundredChars[:len(twoHundredChars)-4] + "๐Ÿ“Ÿ", + "!", + }, + ) + + AssertBreaking(t, + twoHundredChars[:len(twoHundredChars)-2]+"๐Ÿ“Ÿ!", + []string{ + twoHundredChars[:len(twoHundredChars)-2], + "๐Ÿ“Ÿ!", + }, + ) + + AssertBreaking(t, + twoHundredCharWords+"y", + []string{ + twoHundredCharWords[:len(twoHundredCharWords)-3], + "hey", + }, + ) + + AssertBreaking(t, + twoHundredCharWords+` Testing: + - A thing here + - And another one`, + []string{ + twoHundredCharWords, + `Testing: + - A thing here + - And another one`, + }, + ) + + AssertBreaking(t, + twoHundredCharWords+` +Testing: + - A thing here + - And another one`, + []string{ + twoHundredCharWords, + `Testing: + - A thing here + - And another one`, + }, + ) + + AssertBreaking(t, + twoHundredCharWords+"\n"+twoHundredCharWords+"\n"+"Working!\n", + []string{ + twoHundredCharWords, + twoHundredCharWords, + "Working!", + }, + ) + + AssertBreaking(t, + `๐Ÿค–๐Ÿ‘‹ Hey there! I understand these commands: + +โœ‰๏ธ Message box - An answering machine for Meshtastic +- INBOX: Check your inbox +- NEW: Get new messages +- OLD: Get old messages +- CLEAR: Clear old messages +- SEND: Leave a message (SEND ) + +๐Ÿ“ถ Signal reporting - Know what I'm seeing +- /SIGNAL: Get signal report (/SIGNAL [])`, + []string{ + `๐Ÿค–๐Ÿ‘‹ Hey there! I understand these commands: + +โœ‰๏ธ Message box - An answering machine for Meshtastic +- INBOX: Check your inbox +- NEW: Get new messages +- OLD: Get old messages`, + `- CLEAR: Clear old messages +- SEND: Leave a message (SEND ) + +๐Ÿ“ถ Signal reporting - Know what I'm seeing +- /SIGNAL: Get signal report (/SIGNAL [])`, + }, + ) + + AssertBreaking(t, + `lsddjfksdjfhskjfhakfjhakfashflkshv fshdis uh sdkjvh aichua ssklvjhsd ivuhsv kjsdhvd iasvha vjhvl kajvh iusv sivhkjfh aklvh siuvh svhakjhslfgslkvh sdich ivhajkfhs kjvgsliv iuhv skjvhslhvljshlksjhvisudv svhlsiuvhsvjhslkcavshvluishv hslivhslkjvhskjchsldkvhjd kshv kjshv skjhv slhvkjshvlks hvlkshvlskjvh skvhsv kjshv sdfjsl fkslfj sdlfj slfj ksldfj ljdljskf lksdflkjsf lkj sdkfj lskjf sdkfjls sdgkjlk sf klj fslkj lkjdflsdkjglsdfjk lsjf slkfj lshf slkj klsjflshflksj sfkhslfh`, + []string{ + `lsddjfksdjfhskjfhakfjhakfashflkshv fshdis uh sdkjvh aichua ssklvjhsd ivuhsv kjsdhvd iasvha vjhvl kajvh iusv sivhkjfh aklvh siuvh svhakjhslfgslkvh sdich ivhajkfhs kjvgsliv iuhv skjvhslhvljshlksjhvisudv`, + `svhlsiuvhsvjhslkcavshvluishv hslivhslkjvhskjchsldkvhjd kshv kjshv skjhv slhvkjshvlks hvlkshvlskjvh skvhsv kjshv sdfjsl fkslfj sdlfj slfj ksldfj ljdljskf lksdflkjsf lkj sdkfj lskjf sdkfjls sdgkjlk sf`, + `klj fslkj lkjdflsdkjglsdfjk lsjf slkfj lshf slkj klsjflshflksj sfkhslfh`, + }, + ) + + AssertBreaking(t, ` +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed volutpat dolor rhoncus, fringilla mauris sed, tristique elit. Nulla facilisi. Phasellus orci tortor, finibus sed eleifend eget, malesuada at nisl. Nullam viverra libero sit amet metus fermentum, et vestibulum nulla fermentum. Aenean rutrum sed urna in efficitur. Curabitur ac nulla ut ante accumsan facilisis a quis arcu. Ut dapibus dolor lectus, eget semper lectus venenatis finibus. Etiam dapibus pulvinar ex, a dictum sem gravida quis. Praesent suscipit sem orci, a ultricies ligula luctus a. Fusce porta sem sed nibh feugiat condimentum. + +Donec id tortor in ligula scelerisque imperdiet nec eu libero. Morbi congue hendrerit arcu, id rhoncus elit placerat ac. Vivamus efficitur quis nisi a aliquam. Quisque auctor aliquam interdum. Suspendisse facilisis lacus non efficitur ultrices. Cras a lacus dui. Maecenas neque risus, molestie ultrices velit eget, iaculis egestas erat. Praesent pharetra congue justo, id condimentum lectus pharetra sit amet. Proin a sagittis dolor, a interdum sem. Aenean at erat id augue hendrerit efficitur sed ac odio. Phasellus quis malesuada nulla. Sed id consequat erat. Curabitur sagittis eros nec sem facilisis, sed cursus purus pretium. Proin fermentum ut purus nec ultricies. + `, + []string{ + `Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed volutpat dolor rhoncus, fringilla mauris sed, tristique elit. Nulla facilisi. Phasellus orci tortor, finibus sed eleifend eget, malesuada`, + `at nisl. Nullam viverra libero sit amet metus fermentum, et vestibulum nulla fermentum. Aenean rutrum sed urna in efficitur. Curabitur ac nulla ut ante accumsan facilisis a quis arcu. Ut dapibus dolor`, + `lectus, eget semper lectus venenatis finibus. Etiam dapibus pulvinar ex, a dictum sem gravida quis. Praesent suscipit sem orci, a ultricies ligula luctus a. Fusce porta sem sed nibh feugiat`, + `condimentum. + +Donec id tortor in ligula scelerisque imperdiet nec eu libero. Morbi congue hendrerit arcu, id rhoncus elit placerat ac. Vivamus efficitur quis nisi a aliquam. Quisque auctor aliquam`, + `interdum. Suspendisse facilisis lacus non efficitur ultrices. Cras a lacus dui. Maecenas neque risus, molestie ultrices velit eget, iaculis egestas erat. Praesent pharetra congue justo, id condimentum`, + `lectus pharetra sit amet. Proin a sagittis dolor, a interdum sem. Aenean at erat id augue hendrerit efficitur sed ac odio. Phasellus quis malesuada nulla. Sed id consequat erat. Curabitur sagittis`, + `eros nec sem facilisis, sed cursus purus pretium. Proin fermentum ut purus nec ultricies.`, + }, + ) +} + +func AssertBreaking(t *testing.T, message string, expected []string) { + parts := BreakMessage(message) + + for i, part := range parts { + if i >= len(expected) { + t.Errorf(`Got more messages than I expected: +["%v"]`, part) + break + } + if part != expected[i] { + t.Errorf(`Expected message %d to be: +["%v"] +But got: +["%v"]`, i+1, expected[i], part) + } + } +} From 1da76f9dbeebc1fba58807443ee32c9778848fb4 Mon Sep 17 00:00:00 2001 From: Timendus Date: Tue, 11 Feb 2025 15:42:20 +0100 Subject: [PATCH 36/87] Read config from file --- go/config.json | 18 +++++++++ go/config/config.go | 61 ++++++++++++++++++++++++++++ go/main.go | 96 ++++++++++++++++++++++++++------------------- 3 files changed, 134 insertions(+), 41 deletions(-) create mode 100644 go/config.json create mode 100644 go/config/config.go diff --git a/go/config.json b/go/config.json new file mode 100644 index 0000000..9109baf --- /dev/null +++ b/go/config.json @@ -0,0 +1,18 @@ +{ + "connections": [ + { + "name": "Networked device", + "hostname": "meshtastic.local", + "port": 4403 + }, + { + "name": "Local device", + "device": "/dev/ttyUSB0" + } + ], + "settings": { + "allow_tcp": true, + "allow_serial": true, + "allow_transmit": false + } +} diff --git a/go/config/config.go b/go/config/config.go new file mode 100644 index 0000000..6b4dcc8 --- /dev/null +++ b/go/config/config.go @@ -0,0 +1,61 @@ +package config + +import ( + "encoding/json" + "io" + "log" + "os" +) + +type ConnectionType int + +const ( + UNKNOWN = iota + SERIAL_CONNECTION + TCP_CONNECTION +) + +type Config struct { + Connections []Connection `json:"connections"` + Settings Settings `json:"settings"` +} + +type Connection struct { + ConnectionType ConnectionType + Name string `json:"name"` + Hostname string `json:"hostname"` + Port int `json:"port"` + SerialDevice string `json:"device"` +} + +type Settings struct { + AllowTCP bool `json:"allow_tcp"` + AllowSerial bool `json:"allow_serial"` + AllowTransmit bool `json:"allow_transmit"` +} + +var config Config + +func InitConfig() { + configFile, err := os.Open("config.json") + if err != nil { + log.Fatal(err) + } + configBytes, _ := io.ReadAll(configFile) + json.Unmarshal(configBytes, &config) + for i, connection := range config.Connections { + if connection.Port == 0 { + config.Connections[i].Port = 4403 + } + if connection.Hostname != "" { + config.Connections[i].ConnectionType = TCP_CONNECTION + } + if connection.SerialDevice != "" { + config.Connections[i].ConnectionType = SERIAL_CONNECTION + } + } +} + +func GetConfig() Config { + return config +} diff --git a/go/main.go b/go/main.go index fbb08af..6b781c5 100644 --- a/go/main.go +++ b/go/main.go @@ -5,80 +5,94 @@ package main import ( "fmt" + "io" "log" "net" + "strconv" "time" + "github.com/timendus/meshbot/config" "github.com/timendus/meshbot/meshbot" m "github.com/timendus/meshbot/meshwrapper" "go.bug.st/serial" ) -var bot *meshbot.Chatbot - func main() { log.Println("Starting Meshed Potatoes!") + config.InitConfig() + cfg := config.GetConfig() m.MessageEvents.Subscribe(m.AnyMessageEvent, message) m.ConnectionEvents.Subscribe(m.ConnectedEvent, connected) m.ConnectionEvents.Subscribe(m.DisconnectedEvent, disconnected) - // Attempt to auto-detect Meshtestic device on a serial port. Otherwise, - // connect over TCP. - - var node *m.ConnectedNode - - ports, err := serial.GetPortsList() - if err != nil { - log.Println(err) - } - - if err == nil && len(ports) > 0 { - log.Printf("Found %d serial ports:\n", len(ports)) - for i, port := range ports { - log.Printf(" [%d] %s\n", i, port) - } - log.Println("Defaulting to port: " + ports[0]) - - serialPort, err := serial.Open(ports[0], &serial.Mode{ - BaudRate: 115200, - }) - if err != nil { - log.Fatal(err) + // Connect to the meshtastic devices mentioned in the configuration file + for _, connection := range cfg.Connections { + var node *m.ConnectedNode + var port io.ReadWriteCloser + var err error + + switch connection.ConnectionType { + case config.SERIAL_CONNECTION: + if !cfg.Settings.AllowSerial { + log.Fatal("Serial connection configured, but not allowed by settings") + } + port, err = serial.Open(connection.SerialDevice, &serial.Mode{ + BaudRate: 115200, + }) + if err != nil { + log.Fatal("Could not open serial connection to '"+connection.SerialDevice+"': ", err) + } + case config.TCP_CONNECTION: + if !cfg.Settings.AllowTCP { + log.Fatal("TCP connection configured, but not allowed by settings") + } + port, err = net.Dial("tcp", connection.Hostname+":"+strconv.Itoa(connection.Port)) + if err != nil { + log.Fatal("Could not open TCP connection to '"+connection.Hostname+":"+strconv.Itoa(connection.Port)+"': ", err) + } + + default: + log.Fatal("Invalid connection type!") } - node, err = m.NewConnectedNode(serialPort) - if err != nil { - log.Fatal(err) - } - } else { - tcpPort, err := net.Dial("tcp", "meshtastic.thuis:4403") - if err != nil { - log.Fatal(err) - } - - node, err = m.NewConnectedNode(tcpPort) + node, err = m.NewConnectedNode(port) if err != nil { log.Fatal(err) } + defer node.Close() } - defer node.Close() - // Launch the chat bot - - bot = meshbot.NewChatbot() - err = bot.ReloadPlugins() + bot := meshbot.NewChatbot() + err := bot.ReloadPlugins() if err != nil { log.Fatal(err) } - log.Println(bot.String()) + // Endless loop to keep the program from ending for { time.Sleep(100 * time.Millisecond) } } +// For later use +func getSerialDevices() ([]string, error) { + ports, err := serial.GetPortsList() + if err != nil { + return ports, err + } + + if len(ports) > 0 { + log.Printf("Found %d serial ports:\n", len(ports)) + for i, port := range ports { + log.Printf(" [%d] %s\n", i, port) + } + } + + return ports, err +} + func connected(node m.ConnectedNode) { log.Println("Connected to a node!") log.Println("This is me: " + node.String()) From b9495934f4707962be4741104ec800e667b405b7 Mon Sep 17 00:00:00 2001 From: Timendus Date: Tue, 11 Feb 2025 15:45:14 +0100 Subject: [PATCH 37/87] Be less verbose --- go/main.go | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/go/main.go b/go/main.go index 6b781c5..590f9b1 100644 --- a/go/main.go +++ b/go/main.go @@ -94,13 +94,12 @@ func getSerialDevices() ([]string, error) { } func connected(node m.ConnectedNode) { - log.Println("Connected to a node!") - log.Println("This is me: " + node.String()) - log.Println("Node list: \n" + node.NodeList.String()) - log.Println("Channel list:") - for _, channel := range node.Channels { - log.Println(" " + channel.String()) - } + log.Println("Connected to " + node.String()) + // log.Println("Node list: \n" + node.NodeList.String()) + // log.Println("Channel list:") + // for _, channel := range node.Channels { + // log.Println(" " + channel.String()) + // } } func disconnected(node m.ConnectedNode) { From 10d0c9a0bbe30054688c8804140c5330780ecea5 Mon Sep 17 00:00:00 2001 From: Timendus Date: Tue, 11 Feb 2025 17:24:53 +0100 Subject: [PATCH 38/87] Use a callback in Lua to not block the VM, actually break up messages that are too long, adhere to configuration to tell us if we may transmit --- go/cli/main.go | 11 +++--- go/config.json | 3 +- go/config/config.go | 7 ++-- go/main.go | 4 +- go/meshbot/interfaces.go | 3 +- go/meshbot/luaToGoApi.go | 59 +++++++++++++++------------- go/meshwrapper/message.go | 79 ++++++++++++++++++++++++++++---------- go/plugins/message_box.lua | 54 +++++++++++++++++++++----- 8 files changed, 151 insertions(+), 69 deletions(-) diff --git a/go/cli/main.go b/go/cli/main.go index 7d57f2d..d0b81fe 100644 --- a/go/cli/main.go +++ b/go/cli/main.go @@ -79,14 +79,13 @@ func (m chatMessage) String() string { return m.Text } -func (m chatMessage) Reply(message string) { - fmt.Println(message) -} - -func (m chatMessage) ReplyBlocking(message string, timeout ...time.Duration) chan bool { +func (m chatMessage) Reply(message string, timeout ...time.Duration) chan bool { fmt.Println(message) ch := make(chan bool, 1) - ch <- true + go func() { + time.Sleep(2 * time.Second) + ch <- true + }() return ch } diff --git a/go/config.json b/go/config.json index 9109baf..5bf3583 100644 --- a/go/config.json +++ b/go/config.json @@ -13,6 +13,7 @@ "settings": { "allow_tcp": true, "allow_serial": true, - "allow_transmit": false + "allow_transmit": false, + "transmit_exception_node_id": 0 } } diff --git a/go/config/config.go b/go/config/config.go index 6b4dcc8..1de09af 100644 --- a/go/config/config.go +++ b/go/config/config.go @@ -29,9 +29,10 @@ type Connection struct { } type Settings struct { - AllowTCP bool `json:"allow_tcp"` - AllowSerial bool `json:"allow_serial"` - AllowTransmit bool `json:"allow_transmit"` + AllowTCP bool `json:"allow_tcp"` + AllowSerial bool `json:"allow_serial"` + AllowTransmit bool `json:"allow_transmit"` + TransmitExceptionNodeId uint32 `json:"transmit_exception_node_id"` } var config Config diff --git a/go/main.go b/go/main.go index 590f9b1..f956344 100644 --- a/go/main.go +++ b/go/main.go @@ -17,6 +17,8 @@ import ( "go.bug.st/serial" ) +var bot *meshbot.Chatbot + func main() { log.Println("Starting Meshed Potatoes!") config.InitConfig() @@ -64,7 +66,7 @@ func main() { } // Launch the chat bot - bot := meshbot.NewChatbot() + bot = meshbot.NewChatbot() err := bot.ReloadPlugins() if err != nil { log.Fatal(err) diff --git a/go/meshbot/interfaces.go b/go/meshbot/interfaces.go index 0d4b8cd..9486cab 100644 --- a/go/meshbot/interfaces.go +++ b/go/meshbot/interfaces.go @@ -15,8 +15,7 @@ type ChatMessage interface { GetReceiverNode() ChatUser FindNode(string) ChatUser String() string - Reply(string) - ReplyBlocking(string, ...time.Duration) chan bool + Reply(string, ...time.Duration) chan bool } type ChatUser interface { diff --git a/go/meshbot/luaToGoApi.go b/go/meshbot/luaToGoApi.go index 3e38a62..e6ea236 100644 --- a/go/meshbot/luaToGoApi.go +++ b/go/meshbot/luaToGoApi.go @@ -9,15 +9,14 @@ import ( ) var messageMethods = map[string]lua.LGFunction{ - "getText": messageText, - "isPrivate": messageIsPrivate, - "getType": messageGetType, - "getChannel": messageGetChannel, - "getSender": messageGetSender, - "getReceiver": messageGetReceiver, - "findNode": messageFindNode, - "reply": messageReply, - "replyBlocking": messageReplyBlocking, + "getText": messageText, + "isPrivate": messageIsPrivate, + "getType": messageGetType, + "getChannel": messageGetChannel, + "getSender": messageGetSender, + "getReceiver": messageGetReceiver, + "findNode": messageFindNode, + "reply": messageReply, } var userMethods = map[string]lua.LGFunction{ @@ -141,29 +140,37 @@ func messageToString(L *lua.LState) int { func messageReply(L *lua.LState) int { message := *checkMessage(L) - if message == nil { + text := L.CheckString(2) + callback := L.OptFunction(3, nil) + timeout := L.OptInt(4, -1) + + if message == nil || text == "" { + callCallback(L, callback, false) return 0 } - message.Reply(L.CheckString(2)) + + go func(L *lua.LState, callback *lua.LFunction, text string, timeout int) { + var delivered bool + if timeout == -1 { + delivered = <-message.Reply(text) + } else { + timeout := time.Second * time.Duration(timeout) + delivered = <-message.Reply(text, timeout) + } + callCallback(L, callback, delivered) + }(L, callback, text, timeout) return 0 } -func messageReplyBlocking(L *lua.LState) int { - message := *checkMessage(L) - if message == nil { - L.Push(lua.LFalse) - return 1 +func callCallback(L *lua.LState, cb *lua.LFunction, result bool) { + if cb == nil { + return } - duration := L.OptInt(3, -1) - if duration == -1 { - delivered := <-message.ReplyBlocking(L.CheckString(2)) - L.Push(lua.LBool(delivered)) - } else { - timeout := time.Second * time.Duration(duration) - delivered := <-message.ReplyBlocking(L.CheckString(2), timeout) - L.Push(lua.LBool(delivered)) - } - return 1 + L.CallByParam(lua.P{ + Fn: cb, + NRet: 0, + Protect: true, + }, lua.LBool(result)) } func checkUser(L *lua.LState) *ChatUser { diff --git a/go/meshwrapper/message.go b/go/meshwrapper/message.go index 8c433a8..a1a6c59 100644 --- a/go/meshwrapper/message.go +++ b/go/meshwrapper/message.go @@ -2,10 +2,12 @@ package meshwrapper import ( "fmt" + "log" "math/rand/v2" "time" "buf.build/gen/go/meshtastic/protobufs/protocolbuffers/go/meshtastic" + "github.com/timendus/meshbot/config" "github.com/timendus/meshbot/meshbot" "github.com/timendus/meshbot/meshwrapper/helpers" ) @@ -25,7 +27,7 @@ const ( MESSAGE_TYPE_TELEMETRY_LOCAL_STATS = "local stats telemetry" MESSAGE_TYPE_OTHER = "other" - DEFAULT_BLOCKING_MESSAGE_TIMEOUT = 30 * time.Second + DEFAULT_BLOCKING_MESSAGE_TIMEOUT = 60 * time.Second ) type Message struct { @@ -49,46 +51,67 @@ type Message struct { Position *position } -func (m Message) Reply(message string) { - m.doReply(message) +func (m Message) Reply(message string, timeout ...time.Duration) chan bool { + ch := make(chan bool) + + go func() { + messageTimeout := DEFAULT_BLOCKING_MESSAGE_TIMEOUT + if len(timeout) > 0 { + messageTimeout = timeout[0] + } + + for _, msg := range helpers.BreakMessage(message) { + ok := <-m.send(msg, messageTimeout) + if !ok { + ch <- false + return + } + } + + ch <- true + }() + + return ch } -func (m Message) ReplyBlocking(message string, timeout ...time.Duration) chan bool { - if m.ReceivingNode == nil { - return nil - } - if len(timeout) == 0 { - timeout = []time.Duration{DEFAULT_BLOCKING_MESSAGE_TIMEOUT} - } +func (m *Message) send(message string, timeout time.Duration) chan bool { ch := make(chan bool) - id := m.doReply(message) + id := m.sendTextMessage(message) m.ReceivingNode.Acks[id] = ch go func() { - time.Sleep(timeout[0]) + time.Sleep(timeout) ch <- false delete(m.ReceivingNode.Acks, id) }() return ch } -func (m *Message) doReply(message string) uint32 { +func (m *Message) sendTextMessage(message string) uint32 { + helpers.Assert(m.ReceivingNode != nil, "Can't send a message without knowing through which device to send it") + helpers.Assert(m.FromNode != nil, "Can't send a message to an unknown node") + helpers.Assert(m.ToNode != nil, "Can't send a message from an unknown node") + id := rand.Uint32() - if m.ReceivingNode == nil { + cfg := config.GetConfig() + nodeAllowed := cfg.Settings.TransmitExceptionNodeId != 0 && m.FromNode.Id == cfg.Settings.TransmitExceptionNodeId + if !cfg.Settings.AllowTransmit && !nodeAllowed { + log.Println("WARNING: Transmission is not allowed by configuration. Attempted to send: " + message) return id } + + // Show we're transmitting this on the console. TODO: move this out of this + // package. Same with error log above; this code should not be logging + // stuff. + log.Println(m.toReplyString(message)) + m.ReceivingNode.SendMessage(meshtastic.ToRadio_Packet{ Packet: &meshtastic.MeshPacket{ Id: id, To: m.FromNode.Id, From: m.ToNode.Id, - HopLimit: 3, + HopLimit: min(m.HopsAway+2, 7), WantAck: true, Priority: meshtastic.MeshPacket_Priority(meshtastic.MeshPacket_Priority_value["RELIABLE"]), - - // PkiEncrypted: true, - // PublicKey: []byte{1, 2, 3}, - // Channel: 0, - PayloadVariant: &meshtastic.MeshPacket_Decoded{ Decoded: &meshtastic.Data{ Portnum: meshtastic.PortNum_TEXT_MESSAGE_APP, @@ -165,6 +188,22 @@ func (m Message) String() string { return fmt.Sprintf("%s: %s %s", direction, content, m.radioMetricsString()) } +func (m *Message) toReplyString(message string) string { + direction := "" + if m.ToNode != nil { + direction += m.ToNode.String() + } else { + direction += "No node" + } + if m.FromNode != nil { + direction += " -> " + m.FromNode.String() + } else { + direction += " -> No node" + } + + return fmt.Sprintf("%s: %s", direction, message) +} + func (m *Message) radioMetricsString() string { if m.FromNode != nil && m.FromNode.Connected { return "" diff --git a/go/plugins/message_box.lua b/go/plugins/message_box.lua index 8544b26..4a387b0 100644 --- a/go/plugins/message_box.lua +++ b/go/plugins/message_box.lua @@ -68,10 +68,17 @@ function SendNewMessages(message) return end - message:replyBlocking("๐Ÿค–๐Ÿ“ฌ You have " .. + message:reply("๐Ÿค–๐Ÿ“ฌ You have " .. inbox.numUnread .. - " new " .. Pluralize("message", inbox.numUnread) .. ". Sending " .. Pluralize("it", inbox.numUnread) .. " now...") - SendMessages(message, inbox, false) + " new " .. Pluralize("message", inbox.numUnread) .. ". Sending " .. Pluralize("it", inbox.numUnread) .. " now...", + function(success) + if success then + SendMessages(message, inbox, false) + else + print("Could not send new messages, delivery timed out") + end + end + ) end -- Send all read messages to the user @@ -84,10 +91,17 @@ function SendOldMessages(message) return end - message:replyBlocking("๐Ÿค–๐Ÿ“ฌ You have " .. + message:reply("๐Ÿค–๐Ÿ“ฌ You have " .. inbox.numRead .. - " old " .. Pluralize("message", inbox.numRead) .. ". Sending " .. Pluralize("it", inbox.numRead) .. " now...") - SendMessages(message, inbox, true) + " old " .. Pluralize("message", inbox.numRead) .. ". Sending " .. Pluralize("it", inbox.numRead) .. " now...", + function(success) + if success then + SendMessages(message, inbox, true) + else + print("Could not send old messages, delivery timed out") + end + end + ) end -- Clear all messages that have already been read @@ -223,10 +237,30 @@ function GetInbox(node) end function SendMessages(message, inbox, read) - for _, m in ipairs(inbox) do - if m.read == read then - m.read = message:replyBlocking("๐Ÿค–โœ‰๏ธ From " .. m.sender .. " at " .. m.timestamp .. "\n\n" .. m.contents) - end + SendMessage(message, inbox, 1, read) +end + +function SendMessage(message, inbox, index, read) + -- Are we done? + if index > #inbox then + return + end + + -- Send this message if its read status matches the requested read status + local msg = inbox[index] + if msg.read == read then + message:reply("๐Ÿค–โœ‰๏ธ From " .. msg.sender .. " at " .. msg.timestamp .. "\n\n" .. msg.contents, + function(success) + msg.read = success + if success then + SendMessage(message, inbox, index + 1, read) + else + print("Could not send a message, delivery timed out") + end + end + ) + else + SendMessage(message, inbox, index + 1, read) end end From 76bb2f202038ff7723c32353e1cf64b665c01897 Mon Sep 17 00:00:00 2001 From: Timendus Date: Tue, 11 Feb 2025 17:34:27 +0100 Subject: [PATCH 39/87] Don't send ANSI color codes over mesh --- go/meshwrapper/connected_node.go | 2 +- go/meshwrapper/message.go | 8 ++++---- go/meshwrapper/neighbor.go | 2 +- go/meshwrapper/node.go | 11 ++++++++++- 4 files changed, 16 insertions(+), 7 deletions(-) diff --git a/go/meshwrapper/connected_node.go b/go/meshwrapper/connected_node.go index dc03182..2741aa1 100644 --- a/go/meshwrapper/connected_node.go +++ b/go/meshwrapper/connected_node.go @@ -63,7 +63,7 @@ func (n *ConnectedNode) Close() error { } func (n *ConnectedNode) String() string { - return n.Node.String() + return n.Node.ColorString() } func (n *ConnectedNode) SendMessage(message meshtastic.ToRadio_Packet) error { diff --git a/go/meshwrapper/message.go b/go/meshwrapper/message.go index a1a6c59..447f69c 100644 --- a/go/meshwrapper/message.go +++ b/go/meshwrapper/message.go @@ -160,12 +160,12 @@ func (m Message) FindNode(needle string) meshbot.ChatUser { func (m Message) String() string { direction := "" if m.FromNode != nil { - direction += m.FromNode.String() + direction += m.FromNode.ColorString() } else { direction += "No node" } if m.ToNode != nil { - direction += " -> " + m.ToNode.String() + direction += " -> " + m.ToNode.ColorString() } else { direction += " -> No node" } @@ -191,12 +191,12 @@ func (m Message) String() string { func (m *Message) toReplyString(message string) string { direction := "" if m.ToNode != nil { - direction += m.ToNode.String() + direction += m.ToNode.ColorString() } else { direction += "No node" } if m.FromNode != nil { - direction += " -> " + m.FromNode.String() + direction += " -> " + m.FromNode.ColorString() } else { direction += " -> No node" } diff --git a/go/meshwrapper/neighbor.go b/go/meshwrapper/neighbor.go index 19dbafe..c55efb8 100644 --- a/go/meshwrapper/neighbor.go +++ b/go/meshwrapper/neighbor.go @@ -15,7 +15,7 @@ type Neighbor struct { } func (n *Neighbor) String() string { - return fmt.Sprintf("%s \033[90m(last reported %s ago, SNR %.2f)\033[0m", n.Node.String(), helpers.TimeAgo(n.LastReported), n.Snr) + return fmt.Sprintf("%s \033[90m(last reported %s ago, SNR %.2f)\033[0m", n.Node.ColorString(), helpers.TimeAgo(n.LastReported), n.Snr) } type NeighborList []Neighbor diff --git a/go/meshwrapper/node.go b/go/meshwrapper/node.go index 631f5a7..ca881b2 100644 --- a/go/meshwrapper/node.go +++ b/go/meshwrapper/node.go @@ -107,7 +107,7 @@ func (n *Node) GetLongName() string { return n.LongName } -func (n *Node) String() string { +func (n *Node) ColorString() string { var col string if n.Connected { col = "92" @@ -136,6 +136,15 @@ func (n *Node) String() string { ) } +func (n *Node) String() string { + return fmt.Sprintf( + "[%s] %s (%s)", + n.ShortName, + n.LongName, + n.GetIDExpression(), + ) +} + func (n *Node) VerboseString() string { hardware := n.HwModel.String() role := n.Role.String() From 78239f0f05ee779dd78238c0b8587e9e1a5501c0 Mon Sep 17 00:00:00 2001 From: Timendus Date: Wed, 12 Feb 2025 14:00:47 +0100 Subject: [PATCH 40/87] Implement listening in channels --- go/config.json | 3 ++- go/config/config.go | 1 + go/meshwrapper/channel.go | 14 +++++++------- go/meshwrapper/connected_node.go | 9 +++++++-- go/meshwrapper/message.go | 15 ++++++++++++++- 5 files changed, 31 insertions(+), 11 deletions(-) diff --git a/go/config.json b/go/config.json index 5bf3583..c6b6419 100644 --- a/go/config.json +++ b/go/config.json @@ -14,6 +14,7 @@ "allow_tcp": true, "allow_serial": true, "allow_transmit": false, - "transmit_exception_node_id": 0 + "transmit_exception_node_id": 0, + "allow_transmit_to_channels": false } } diff --git a/go/config/config.go b/go/config/config.go index 1de09af..23720b6 100644 --- a/go/config/config.go +++ b/go/config/config.go @@ -33,6 +33,7 @@ type Settings struct { AllowSerial bool `json:"allow_serial"` AllowTransmit bool `json:"allow_transmit"` TransmitExceptionNodeId uint32 `json:"transmit_exception_node_id"` + AllowTransmitToChannels bool `json:"allow_transmit_to_channels"` } var config Config diff --git a/go/meshwrapper/channel.go b/go/meshwrapper/channel.go index bd5f037..112ded8 100644 --- a/go/meshwrapper/channel.go +++ b/go/meshwrapper/channel.go @@ -6,23 +6,23 @@ import ( "buf.build/gen/go/meshtastic/protobufs/protocolbuffers/go/meshtastic" ) -type channel struct { - id int32 +type Channel struct { + id uint32 name string passkey []byte } -func NewChannel(unit *meshtastic.Channel) channel { +func NewChannel(unit *meshtastic.Channel) Channel { if unit == nil { - return channel{} + return Channel{} } - return channel{ - id: unit.Index, + return Channel{ + id: uint32(unit.Index), name: unit.GetSettings().Name, passkey: unit.GetSettings().Psk, } } -func (c channel) String() string { +func (c Channel) String() string { return fmt.Sprintf("[%d] %s", c.id, c.name) } diff --git a/go/meshwrapper/connected_node.go b/go/meshwrapper/connected_node.go index 2741aa1..b74d966 100644 --- a/go/meshwrapper/connected_node.go +++ b/go/meshwrapper/connected_node.go @@ -15,7 +15,7 @@ type ConnectedNode struct { stream io.ReadWriteCloser Connected bool FirmwareVersion string - Channels []channel + Channels map[uint32]Channel Node *Node NodeList nodeList Acks map[uint32]chan bool @@ -28,6 +28,7 @@ func NewConnectedNode(stream io.ReadWriteCloser) (*ConnectedNode, error) { Connected: false, NodeList: NewNodeList(), Acks: make(map[uint32]chan bool), + Channels: make(map[uint32]Channel), Node: &Node{ ShortName: "UNKN", LongName: "Unknown node", @@ -99,7 +100,8 @@ func (n *ConnectedNode) readMessages(stream io.ReadCloser) error { case *meshtastic.FromRadio_NodeInfo: n.parseNodeInfo(packet.GetNodeInfo()) case *meshtastic.FromRadio_Channel: - n.Channels = append(n.Channels, NewChannel(packet.GetChannel())) + channel := NewChannel(packet.GetChannel()) + n.Channels[channel.id] = channel case *meshtastic.FromRadio_Packet: n.parseMeshPacket(packet.GetPacket()) case *meshtastic.FromRadio_Config: @@ -158,10 +160,13 @@ func (n *ConnectedNode) parseMeshPacket(meshPacket *meshtastic.MeshPacket) { fromNode.Snr = meshPacket.RxSnr } + channel := n.Channels[meshPacket.Channel] + message := Message{ FromNode: fromNode, ToNode: toNode, ReceivingNode: n, + Channel: &channel, Timestamp: time.Unix(int64(meshPacket.RxTime), 0), MessageType: MESSAGE_TYPE_OTHER, Snr: meshPacket.RxSnr, diff --git a/go/meshwrapper/message.go b/go/meshwrapper/message.go index 447f69c..c293b24 100644 --- a/go/meshwrapper/message.go +++ b/go/meshwrapper/message.go @@ -41,6 +41,7 @@ type Message struct { MessageType string Text string + Channel *Channel DeviceMetrics *meshtastic.DeviceMetrics EnvironmentMetrics *meshtastic.EnvironmentMetrics HealthMetrics *meshtastic.HealthMetrics @@ -104,10 +105,22 @@ func (m *Message) sendTextMessage(message string) uint32 { // stuff. log.Println(m.toReplyString(message)) + // If message was sent to a channel (and the config allows it), reply in the + // channel instead of privately. + recipient := m.FromNode.Id + if cfg.Settings.AllowTransmitToChannels && m.ToNode.Id == Broadcast.Id { + recipient = Broadcast.Id + } + channelId := uint32(0) + if m.Channel != nil { + channelId = m.Channel.id + } + m.ReceivingNode.SendMessage(meshtastic.ToRadio_Packet{ Packet: &meshtastic.MeshPacket{ Id: id, - To: m.FromNode.Id, + Channel: channelId, + To: recipient, From: m.ToNode.Id, HopLimit: min(m.HopsAway+2, 7), WantAck: true, From 2ad48c5ad712c72a63968bc6c6692437f5302e9e Mon Sep 17 00:00:00 2001 From: Timendus Date: Wed, 12 Feb 2025 14:26:18 +0100 Subject: [PATCH 41/87] Refactor message sending so we don't have our library pollute the console anymore --- go/main.go | 9 ++++-- go/meshwrapper/connected_node.go | 2 +- go/meshwrapper/message.go | 50 ++++++++++++++------------------ go/meshwrapper/pubsub.go | 5 +++- 4 files changed, 33 insertions(+), 33 deletions(-) diff --git a/go/main.go b/go/main.go index f956344..eb4e092 100644 --- a/go/main.go +++ b/go/main.go @@ -24,7 +24,8 @@ func main() { config.InitConfig() cfg := config.GetConfig() - m.MessageEvents.Subscribe(m.AnyMessageEvent, message) + m.MessageEvents.Subscribe(m.IncomingMessageEvent, incoming) + m.MessageEvents.Subscribe(m.OutgoingMessageEvent, outgoing) m.ConnectionEvents.Subscribe(m.ConnectedEvent, connected) m.ConnectionEvents.Subscribe(m.DisconnectedEvent, disconnected) @@ -108,9 +109,13 @@ func disconnected(node m.ConnectedNode) { log.Println("Disconnected from the node. Maybe some retry-logic here?") } -func message(message m.Message) { +func incoming(message m.Message) { fmt.Println(message.String()) if bot != nil { bot.HandleMessage(message) } } + +func outgoing(message m.Message) { + fmt.Println(message.String()) +} diff --git a/go/meshwrapper/connected_node.go b/go/meshwrapper/connected_node.go index b74d966..3ace9aa 100644 --- a/go/meshwrapper/connected_node.go +++ b/go/meshwrapper/connected_node.go @@ -277,5 +277,5 @@ func (n *ConnectedNode) parseMeshPacket(meshPacket *meshtastic.MeshPacket) { log.Println("Warning: Unknown mesh packet:", meshPacket.String()) } - MessageEvents.publish(AnyMessageEvent, message) + MessageEvents.publish(IncomingMessageEvent, message) } diff --git a/go/meshwrapper/message.go b/go/meshwrapper/message.go index c293b24..909661b 100644 --- a/go/meshwrapper/message.go +++ b/go/meshwrapper/message.go @@ -2,7 +2,6 @@ package meshwrapper import ( "fmt" - "log" "math/rand/v2" "time" @@ -93,35 +92,44 @@ func (m *Message) sendTextMessage(message string) uint32 { helpers.Assert(m.ToNode != nil, "Can't send a message from an unknown node") id := rand.Uint32() + + // Only transmit anything if the configuration allows it or the + // configuration has this particular node id as the exception. Otherwise, + // just silently drop the transmission. cfg := config.GetConfig() nodeAllowed := cfg.Settings.TransmitExceptionNodeId != 0 && m.FromNode.Id == cfg.Settings.TransmitExceptionNodeId - if !cfg.Settings.AllowTransmit && !nodeAllowed { - log.Println("WARNING: Transmission is not allowed by configuration. Attempted to send: " + message) + if !(cfg.Settings.AllowTransmit || nodeAllowed) { return id } - // Show we're transmitting this on the console. TODO: move this out of this - // package. Same with error log above; this code should not be logging - // stuff. - log.Println(m.toReplyString(message)) - // If message was sent to a channel (and the config allows it), reply in the - // channel instead of privately. - recipient := m.FromNode.Id + // same channel instead of privately. + recipient := m.FromNode if cfg.Settings.AllowTransmitToChannels && m.ToNode.Id == Broadcast.Id { - recipient = Broadcast.Id + recipient = &Broadcast } channelId := uint32(0) if m.Channel != nil { channelId = m.Channel.id } + // Notify the rest of the system that we're sending this message + MessageEvents.publish(OutgoingMessageEvent, Message{ + FromNode: m.ReceivingNode.Node, + ToNode: recipient, + Text: message, + MessageType: MESSAGE_TYPE_TEXT_MESSAGE, + Timestamp: time.Now(), + Channel: m.Channel, + }) + + // Actually send the message m.ReceivingNode.SendMessage(meshtastic.ToRadio_Packet{ Packet: &meshtastic.MeshPacket{ Id: id, Channel: channelId, - To: recipient, - From: m.ToNode.Id, + To: recipient.Id, + From: m.ReceivingNode.Node.Id, HopLimit: min(m.HopsAway+2, 7), WantAck: true, Priority: meshtastic.MeshPacket_Priority(meshtastic.MeshPacket_Priority_value["RELIABLE"]), @@ -201,22 +209,6 @@ func (m Message) String() string { return fmt.Sprintf("%s: %s %s", direction, content, m.radioMetricsString()) } -func (m *Message) toReplyString(message string) string { - direction := "" - if m.ToNode != nil { - direction += m.ToNode.ColorString() - } else { - direction += "No node" - } - if m.FromNode != nil { - direction += " -> " + m.FromNode.ColorString() - } else { - direction += " -> No node" - } - - return fmt.Sprintf("%s: %s", direction, message) -} - func (m *Message) radioMetricsString() string { if m.FromNode != nil && m.FromNode.Connected { return "" diff --git a/go/meshwrapper/pubsub.go b/go/meshwrapper/pubsub.go index 8dba8d5..bacf6d9 100644 --- a/go/meshwrapper/pubsub.go +++ b/go/meshwrapper/pubsub.go @@ -8,7 +8,10 @@ const ( DisconnectedEvent // Message events - AnyMessageEvent + IncomingMessageEvent + OutgoingMessageEvent + + // Specific message events TextMessageEvent NodeInfoEvent PositionEvent From 9d295527510c6174f6b04169bee16f939700d579 Mon Sep 17 00:00:00 2001 From: Timendus Date: Wed, 12 Feb 2025 15:04:07 +0100 Subject: [PATCH 42/87] Implement getChannelName --- go/meshwrapper/message.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/go/meshwrapper/message.go b/go/meshwrapper/message.go index 909661b..eee9d83 100644 --- a/go/meshwrapper/message.go +++ b/go/meshwrapper/message.go @@ -159,8 +159,10 @@ func (m Message) GetType() string { } func (m Message) GetChannelName() string { - panic("TODO: implement") - // return "" + if m.Channel == nil { + return "UNKNOWN" + } + return m.Channel.name } func (m Message) GetSenderNode() meshbot.ChatUser { From 5d64dbe3e5fbac17b5dc42bb4f3ef162bea193f7 Mon Sep 17 00:00:00 2001 From: Timendus Date: Wed, 12 Feb 2025 23:02:01 +0100 Subject: [PATCH 43/87] Harden code a bit better against issues --- go/meshbot/luaPlugins.go | 38 +++++++++++++++++++++++++------------- go/meshbot/luaToGoApi.go | 8 ++++++++ go/plugins/message_box.lua | 10 +++++++++- 3 files changed, 42 insertions(+), 14 deletions(-) diff --git a/go/meshbot/luaPlugins.go b/go/meshbot/luaPlugins.go index 633ed0d..a614ff5 100644 --- a/go/meshbot/luaPlugins.go +++ b/go/meshbot/luaPlugins.go @@ -49,10 +49,10 @@ func LoadPlugin(filename string, bot *Chatbot) (*plugin, error) { if !ok { return nil, errors.New("no plugin definition found in file " + filename) } - return newPlugin(definition, L), nil + return newPlugin(definition, L) } -func newPlugin(definition *lua.LTable, L *lua.LState) *plugin { +func newPlugin(definition *lua.LTable, L *lua.LState) (*plugin, error) { plugin := plugin{ Name: definition.RawGetString("name").String(), Description: definition.RawGetString("description").String(), @@ -64,11 +64,22 @@ func newPlugin(definition *lua.LTable, L *lua.LState) *plugin { } commands := definition.RawGetString("commands") + errs := make([]error, 0) if commands, ok := commands.(*lua.LTable); ok { commands.ForEach(func(k, v lua.LValue) { - command := newCommand(v.(*lua.LTable), L) - plugin.Commands = append(plugin.Commands, command) + command, err := newCommand(v.(*lua.LTable), L) + if err != nil { + errs = append(errs, err) + } else { + plugin.Commands = append(plugin.Commands, *command) + } }) + } else { + return nil, errors.New("can't have a plugin without commands") + } + + if len(errs) > 0 { + return nil, errors.Join(errs...) } states := definition.RawGetString("states") @@ -78,10 +89,10 @@ func newPlugin(definition *lua.LTable, L *lua.LState) *plugin { }) } - return &plugin + return &plugin, nil } -func newCommand(definition *lua.LTable, L *lua.LState) command { +func newCommand(definition *lua.LTable, L *lua.LState) (*command, error) { state := definition.RawGetString("state").String() if state == "nil" { state = "MAIN" @@ -92,6 +103,11 @@ func newCommand(definition *lua.LTable, L *lua.LState) command { private = lua.LTrue } + luaFunction, ok := definition.RawGetString("func").(*lua.LFunction) + if !ok { + return nil, errors.New("can't have a command without a function") + } + command := command{ State: State(state), Command: make([]string, 0), @@ -103,20 +119,16 @@ func newCommand(definition *lua.LTable, L *lua.LState) command { IsCatchAllText: false, Hidden: lua.LVAsBool(definition.RawGetString("hidden")), Function: func(message *ChatMessage) (State, error) { - function, ok := definition.RawGetString("func").(*lua.LFunction) - if !ok { - return "ERROR", nil - } messageUserData := L.NewUserData() messageUserData.Value = message L.SetMetatable(messageUserData, L.GetTypeMetatable(luaMessageTypeName)) err := L.CallByParam(lua.P{ - Fn: function, + Fn: luaFunction, NRet: 1, Protect: true, }, messageUserData) if err != nil { - return "ERROR", err + return "MAIN", err } ret := L.Get(-1) L.Pop(1) @@ -157,7 +169,7 @@ func newCommand(definition *lua.LTable, L *lua.LState) command { } } - return command + return &command, nil } func createLuaVM(cb *Chatbot) *lua.LState { diff --git a/go/meshbot/luaToGoApi.go b/go/meshbot/luaToGoApi.go index e6ea236..dc3ebcb 100644 --- a/go/meshbot/luaToGoApi.go +++ b/go/meshbot/luaToGoApi.go @@ -89,6 +89,10 @@ func messageGetSender(L *lua.LState) int { return 1 } node := message.GetSenderNode() + if node == nil { + L.Push(lua.LNil) + return 1 + } userUserData := L.NewUserData() userUserData.Value = &node L.SetMetatable(userUserData, L.GetTypeMetatable(luaUserTypeName)) @@ -103,6 +107,10 @@ func messageGetReceiver(L *lua.LState) int { return 1 } node := message.GetReceiverNode() + if node == nil { + L.Push(lua.LNil) + return 1 + } userUserData := L.NewUserData() userUserData.Value = &node L.SetMetatable(userUserData, L.GetTypeMetatable(luaUserTypeName)) diff --git a/go/plugins/message_box.lua b/go/plugins/message_box.lua index 4a387b0..109636f 100644 --- a/go/plugins/message_box.lua +++ b/go/plugins/message_box.lua @@ -185,6 +185,9 @@ function NotifyUser(message) -- Do we have a message box at all? Otherwise we're spamming nodes that have -- never interacted with this bot, and have not actually been sent messages -- by real people, with a "friendly welcome message". + if message:getSender() == nil then + return + end local box = Bot.memory.read(message:getSender():getIdExpression()) if box == nil then return @@ -209,6 +212,9 @@ end -- Get a user's inbox, create one if necessary by adding a friendly little -- welcome message, and collect some stats about the inbox. function GetInbox(node) + if node == nil then + print("ERROR: Unknown node") + end local inbox = Bot.memory.read(node:getIdExpression()) if inbox == nil then @@ -251,7 +257,9 @@ function SendMessage(message, inbox, index, read) if msg.read == read then message:reply("๐Ÿค–โœ‰๏ธ From " .. msg.sender .. " at " .. msg.timestamp .. "\n\n" .. msg.contents, function(success) - msg.read = success + if not msg.read then + msg.read = success + end if success then SendMessage(message, inbox, index + 1, read) else From b6d562fd4d4f6ce273c14c08e1d98f0812c50df8 Mon Sep 17 00:00:00 2001 From: Timendus Date: Fri, 14 Feb 2025 11:46:03 +0100 Subject: [PATCH 44/87] Properly clean up channels --- go/meshwrapper/connected_node.go | 1 + go/meshwrapper/message.go | 7 +++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/go/meshwrapper/connected_node.go b/go/meshwrapper/connected_node.go index 3ace9aa..9c28dfa 100644 --- a/go/meshwrapper/connected_node.go +++ b/go/meshwrapper/connected_node.go @@ -263,6 +263,7 @@ func (n *ConnectedNode) parseMeshPacket(meshPacket *meshtastic.MeshPacket) { messageId := meshPacket.GetDecoded().RequestId if n.Acks[messageId] != nil { n.Acks[messageId] <- true + close(n.Acks[messageId]) delete(n.Acks, messageId) } } diff --git a/go/meshwrapper/message.go b/go/meshwrapper/message.go index eee9d83..6b72e5b 100644 --- a/go/meshwrapper/message.go +++ b/go/meshwrapper/message.go @@ -80,8 +80,11 @@ func (m *Message) send(message string, timeout time.Duration) chan bool { m.ReceivingNode.Acks[id] = ch go func() { time.Sleep(timeout) - ch <- false - delete(m.ReceivingNode.Acks, id) + if m.ReceivingNode.Acks[id] != nil { + ch <- false + close(ch) + delete(m.ReceivingNode.Acks, id) + } }() return ch } From 8e9a2cb56f6916c95085a16418399fc6d8bfcb27 Mon Sep 17 00:00:00 2001 From: Timendus Date: Fri, 14 Feb 2025 11:52:01 +0100 Subject: [PATCH 45/87] Add build to Makefile --- go/.gitignore | 1 + go/Makefile | 7 +++++++ 2 files changed, 8 insertions(+) create mode 100644 go/.gitignore diff --git a/go/.gitignore b/go/.gitignore new file mode 100644 index 0000000..1521c8b --- /dev/null +++ b/go/.gitignore @@ -0,0 +1 @@ +dist diff --git a/go/Makefile b/go/Makefile index 2dd8c55..5064cad 100644 --- a/go/Makefile +++ b/go/Makefile @@ -14,3 +14,10 @@ test: lines: @find . -name '*.go' | xargs wc -l + +build: + @GOOS=linux GOARCH=amd64 go build -o dist/linux/meshbot *.go + @GOOS=windows GOARCH=amd64 go build -o dist/windows/meshbot.exe *.go + @GOOS=linux GOARCH=arm64 go build -o dist/raspberry-pi/meshbot *.go + @GOOS=darwin GOARCH=amd64 go build -o dist/macos-intel/meshbot *.go + @GOOS=darwin GOARCH=arm64 go build -o dist/macos-apple-silicon/meshbot *.go From 90fbbb22918711bb0fd15c251368d7a5f7ba83e5 Mon Sep 17 00:00:00 2001 From: Timendus Date: Fri, 14 Feb 2025 12:15:22 +0100 Subject: [PATCH 46/87] Improve node search --- go/meshwrapper/node_list.go | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/go/meshwrapper/node_list.go b/go/meshwrapper/node_list.go index 08ab7d3..7e70eb8 100644 --- a/go/meshwrapper/node_list.go +++ b/go/meshwrapper/node_list.go @@ -5,6 +5,7 @@ import ( "regexp" "slices" "strconv" + "strings" ) type nodeList struct { @@ -59,29 +60,30 @@ func (n *nodeList) sortedNodes() []Node { } func (n *nodeList) findNode(needle string) *Node { + needle = strings.TrimSpace(needle) needleBytes := []byte(needle) // Check if we have a specific, full hexadecimal id fullHexId, _ := regexp.Compile("![0-9a-fA-F]{8}") if fullHexId.Match(needleBytes) { - id, _ := strconv.ParseUint(needle[1:], 16, 32) + id, err := strconv.ParseUint(needle[1:], 16, 32) node, ok := n.nodes[uint32(id)] - if ok { + if ok && err == nil { return node } } shortHexId, _ := regexp.Compile("[0-9a-fA-F]{8}") if shortHexId.Match(needleBytes) { - id, _ := strconv.ParseUint(needle, 16, 32) + id, err := strconv.ParseUint(needle, 16, 32) node, ok := n.nodes[uint32(id)] - if ok { + if ok && err == nil { return node } } // Check if we have a shortName for _, node := range n.nodes { - if node.ShortName == needle { + if strings.EqualFold(node.ShortName, needle) { return node } } @@ -89,9 +91,9 @@ func (n *nodeList) findNode(needle string) *Node { // Check if we have a decimal id numericId, _ := regexp.Compile("[0-9]+") if numericId.Match(needleBytes) { - id, _ := strconv.ParseUint(needle, 10, 32) + id, err := strconv.ParseUint(needle, 10, 32) node, ok := n.nodes[uint32(id)] - if ok { + if ok && err == nil { return node } } @@ -99,9 +101,16 @@ func (n *nodeList) findNode(needle string) *Node { // Check if we have an abbreviated hexadecimal id abbreviatedHexId, _ := regexp.Compile("[0-9a-fA-F]{4}") if abbreviatedHexId.Match(needleBytes) { - id, _ := strconv.ParseUint(needle, 16, 32) - node, ok := n.nodes[uint32(id)] - if ok { + for _, node := range n.nodes { + if strings.HasSuffix(node.GetIDExpression(), needle) { + return node + } + } + } + + // Check is needle is a substring of a longname + for _, node := range n.nodes { + if strings.Contains(strings.ToUpper(node.LongName), strings.ToUpper(needle)) { return node } } From 37db62f50b183ef19353558eec3734232fbd671a Mon Sep 17 00:00:00 2001 From: Timendus Date: Fri, 14 Feb 2025 12:22:02 +0100 Subject: [PATCH 47/87] Bypass Lua and write signal reporter in Go for now --- go/main.go | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/go/main.go b/go/main.go index eb4e092..0e746bd 100644 --- a/go/main.go +++ b/go/main.go @@ -9,6 +9,7 @@ import ( "log" "net" "strconv" + "strings" "time" "github.com/timendus/meshbot/config" @@ -111,8 +112,26 @@ func disconnected(node m.ConnectedNode) { func incoming(message m.Message) { fmt.Println(message.String()) - if bot != nil { - bot.HandleMessage(message) + // if bot != nil { + // bot.HandleMessage(message) + // } + + if message.MessageType == m.MESSAGE_TYPE_TEXT_MESSAGE && strings.HasPrefix(strings.ToUpper(message.Text), "/SIGNAL") { + input := strings.TrimSpace(message.Text) + subject := message.FromNode + ok := true + if len(input) > len("/SIGNAL") { + needle := input[len("/SIGNAL"):] + subject, ok = message.FindNode(needle).(*m.Node) + } + + if !ok || subject == nil { + message.Reply("๐Ÿค–๐Ÿงจ I don't know who that is. Sorry!\n\nI need the short name (example: TDRP), or node ID (example: !87e35ac8) of a node that I know.") + return + } + + message.Reply("I'm reading " + subject.String() + " with an SNR of " + + strconv.FormatFloat(float64(subject.GetSNR()), 'f', 2, 32)) } } From a620e67fa880e9c1ad59163aea8f99c99c9e5276 Mon Sep 17 00:00:00 2001 From: Timendus Date: Fri, 14 Feb 2025 13:21:04 +0100 Subject: [PATCH 48/87] Add pagination to message breaking --- go/meshwrapper/helpers/language.go | 32 +++- go/meshwrapper/helpers/language_test.go | 196 +++++++++++++++++++----- 2 files changed, 190 insertions(+), 38 deletions(-) diff --git a/go/meshwrapper/helpers/language.go b/go/meshwrapper/helpers/language.go index 3a44d8c..d50cb26 100644 --- a/go/meshwrapper/helpers/language.go +++ b/go/meshwrapper/helpers/language.go @@ -3,6 +3,7 @@ package helpers import ( "fmt" "math" + "strconv" "strings" "time" "unicode/utf8" @@ -53,6 +54,30 @@ func TimeAgo(timestamp time.Time) string { func BreakMessage(input string) []string { const MAX_MESSAGE_LENGTH = 200 + const MAX_LENGTH_WITH_PAGINATION = 200 - len(" [1/2]") + input = strings.TrimSpace(input) + + // Don't try to cut up messages that fit + if len(input) <= MAX_MESSAGE_LENGTH { + return []string{input} + } + + messages := BreakMessageAt(input, MAX_LENGTH_WITH_PAGINATION) + Assert(len(messages) < 1000, "What the hell are you doing creating so many messages..?") + + // Add pagination info to each message + for i := range messages { + if len(messages) > 9 { + messages[i] += " [" + strconv.Itoa(i+1) + "]" + } else { + messages[i] += " [" + strconv.Itoa(i+1) + "/" + strconv.Itoa(len(messages)) + "]" + } + } + + return messages +} + +func BreakMessageAt(input string, maxlength int) []string { input = strings.TrimSpace(input) messages := make([]string, 0) startPtr := 0 @@ -62,12 +87,13 @@ func BreakMessage(input string) []string { for startPtr < len(input) { // Find the next (rough) place where we need to cut the input to get it // to fit in a message - charEnd := startPtr + MAX_MESSAGE_LENGTH + charEnd := startPtr + maxlength if charEnd >= len(input) { // We can fit the whole rest of the input in the message, in other // words: we're done - return append(messages, input[startPtr:]) + messages = append(messages, input[startPtr:]) + break } // Find the "real" charEnd, that considers UTF-8 encoding @@ -89,7 +115,7 @@ func BreakMessage(input string) []string { } nextLineLength := (nextLineEnd + charEnd) - (lineEnd + startPtr + 1) - if lineEnd != -1 && nextLineLength <= MAX_MESSAGE_LENGTH { + if lineEnd != -1 && nextLineLength <= maxlength { endPtr = lineEnd + startPtr resumePtr = endPtr + 1 // Skip the newline character } else if wordEnd != -1 { diff --git a/go/meshwrapper/helpers/language_test.go b/go/meshwrapper/helpers/language_test.go index d400b33..2121a0c 100644 --- a/go/meshwrapper/helpers/language_test.go +++ b/go/meshwrapper/helpers/language_test.go @@ -4,6 +4,9 @@ import ( "testing" ) +const TWO_HUNDRED_CHARS = "Helloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahe" +const TWO_HUNDRED_CHAR_WORDS = "Hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello he" + func TestPluralize(t *testing.T) { Assert(Pluralize("it", 0) == "them", "Pluralize(it, 0) == them") Assert(Pluralize("it", 1) == "it", "Pluralize(it, 1) == it") @@ -18,10 +21,7 @@ func TestPluralize(t *testing.T) { Assert(Pluralize("message", 4) == "messages", "Pluralize(message, 4) == messages") } -func TestBreakMessage(t *testing.T) { - twoHundredChars := "Helloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahelloahe" - twoHundredCharWords := "Hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello he" - +func TestMessagePagination(t *testing.T) { AssertBreaking(t, "", []string{""}, @@ -38,90 +38,198 @@ func TestBreakMessage(t *testing.T) { ) AssertBreaking(t, - twoHundredChars, - []string{twoHundredChars}, + TWO_HUNDRED_CHARS, + []string{TWO_HUNDRED_CHARS}, ) AssertBreaking(t, - twoHundredCharWords, - []string{twoHundredCharWords}, + TWO_HUNDRED_CHAR_WORDS, + []string{TWO_HUNDRED_CHAR_WORDS}, ) AssertBreaking(t, - twoHundredChars+"a", + TWO_HUNDRED_CHARS+"a", []string{ - twoHundredChars, - "a", + TWO_HUNDRED_CHARS[:len(TWO_HUNDRED_CHARS)-6] + " [1/2]", + "lloahea [2/2]", }, ) AssertBreaking(t, - twoHundredChars+"๐Ÿ“Ÿ!", + `๐Ÿค–๐Ÿ‘‹ Hey there! I understand these commands: + +โœ‰๏ธ Message box - An answering machine for Meshtastic +- INBOX: Check your inbox +- NEW: Get new messages +- OLD: Get old messages +- CLEAR: Clear old messages +- SEND: Leave a message (SEND ) + +๐Ÿ“ถ Signal reporting - Know what I'm seeing +- /SIGNAL: Get signal report (/SIGNAL [])`, + []string{ + `๐Ÿค–๐Ÿ‘‹ Hey there! I understand these commands: + +โœ‰๏ธ Message box - An answering machine for Meshtastic +- INBOX: Check your inbox +- NEW: Get new messages +- OLD: Get old messages [1/2]`, + `- CLEAR: Clear old messages +- SEND: Leave a message (SEND ) + +๐Ÿ“ถ Signal reporting - Know what I'm seeing +- /SIGNAL: Get signal report (/SIGNAL []) [2/2]`, + }, + ) + + AssertBreaking(t, ` +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed volutpat dolor rhoncus, fringilla mauris sed, tristique elit. Nulla facilisi. Phasellus orci tortor, finibus sed eleifend eget, malesuada at nisl. Nullam viverra libero sit amet metus fermentum, et vestibulum nulla fermentum. Aenean rutrum sed urna in efficitur. Curabitur ac nulla ut ante accumsan facilisis a quis arcu. Ut dapibus dolor lectus, eget semper lectus venenatis finibus. Etiam dapibus pulvinar ex, a dictum sem gravida quis. Praesent suscipit sem orci, a ultricies ligula luctus a. Fusce porta sem sed nibh feugiat condimentum. + +Donec id tortor in ligula scelerisque imperdiet nec eu libero. Morbi congue hendrerit arcu, id rhoncus elit placerat ac. Vivamus efficitur quis nisi a aliquam. Quisque auctor aliquam interdum. Suspendisse facilisis lacus non efficitur ultrices. Cras a lacus dui. Maecenas neque risus, molestie ultrices velit eget, iaculis egestas erat. Praesent pharetra congue justo, id condimentum lectus pharetra sit amet. Proin a sagittis dolor, a interdum sem. Aenean at erat id augue hendrerit efficitur sed ac odio. Phasellus quis malesuada nulla. Sed id consequat erat. Curabitur sagittis eros nec sem facilisis, sed cursus purus pretium. Proin fermentum ut purus nec ultricies. + `, + []string{ + `Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed volutpat dolor rhoncus, fringilla mauris sed, tristique elit. Nulla facilisi. Phasellus orci tortor, finibus sed eleifend eget, [1/7]`, + `malesuada at nisl. Nullam viverra libero sit amet metus fermentum, et vestibulum nulla fermentum. Aenean rutrum sed urna in efficitur. Curabitur ac nulla ut ante accumsan facilisis a quis arcu. [2/7]`, + `Ut dapibus dolor lectus, eget semper lectus venenatis finibus. Etiam dapibus pulvinar ex, a dictum sem gravida quis. Praesent suscipit sem orci, a ultricies ligula luctus a. Fusce porta sem sed [3/7]`, + `nibh feugiat condimentum. + +Donec id tortor in ligula scelerisque imperdiet nec eu libero. Morbi congue hendrerit arcu, id rhoncus elit placerat ac. Vivamus efficitur quis nisi a aliquam. Quisque [4/7]`, + `auctor aliquam interdum. Suspendisse facilisis lacus non efficitur ultrices. Cras a lacus dui. Maecenas neque risus, molestie ultrices velit eget, iaculis egestas erat. Praesent pharetra congue [5/7]`, + `justo, id condimentum lectus pharetra sit amet. Proin a sagittis dolor, a interdum sem. Aenean at erat id augue hendrerit efficitur sed ac odio. Phasellus quis malesuada nulla. Sed id consequat [6/7]`, + `erat. Curabitur sagittis eros nec sem facilisis, sed cursus purus pretium. Proin fermentum ut purus nec ultricies. [7/7]`, + }, + ) + + // If we have more than 9 message parts, we omit the total number (so the + // pagination numbers always stays withtin three characters) + AssertBreaking(t, ` +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed volutpat dolor rhoncus, fringilla mauris sed, tristique elit. Nulla facilisi. Phasellus orci tortor, finibus sed eleifend eget, malesuada at nisl. Nullam viverra libero sit amet metus fermentum, et vestibulum nulla fermentum. Aenean rutrum sed urna in efficitur. Curabitur ac nulla ut ante accumsan facilisis a quis arcu. Ut dapibus dolor lectus, eget semper lectus venenatis finibus. Etiam dapibus pulvinar ex, a dictum sem gravida quis. Praesent suscipit sem orci, a ultricies ligula luctus a. Fusce porta sem sed nibh feugiat condimentum. + +Donec id tortor in ligula scelerisque imperdiet nec eu libero. Morbi congue hendrerit arcu, id rhoncus elit placerat ac. Vivamus efficitur quis nisi a aliquam. Quisque auctor aliquam interdum. Suspendisse facilisis lacus non efficitur ultrices. Cras a lacus dui. Maecenas neque risus, molestie ultrices velit eget, iaculis egestas erat. Praesent pharetra congue justo, id condimentum lectus pharetra sit amet. Proin a sagittis dolor, a interdum sem. Aenean at erat id augue hendrerit efficitur sed ac odio. Phasellus quis malesuada nulla. Sed id consequat erat. Curabitur sagittis eros nec sem facilisis, sed cursus purus pretium. Proin fermentum ut purus nec ultricies. + +Etiam aliquet neque mollis, commodo sapien non, tincidunt risus. Maecenas sed quam iaculis, vehicula nisi eu, elementum risus. Ut lacinia scelerisque dolor id pharetra. Ut rutrum, mi id viverra commodo, urna leo malesuada sapien, ac aliquam quam orci ac nulla. Nunc feugiat diam id erat luctus dictum. Duis arcu leo, rhoncus id ipsum vitae, auctor mollis est. Donec laoreet rutrum eros a imperdiet. Duis convallis purus eu auctor venenatis. In commodo orci vitae ullamcorper suscipit. Vestibulum eleifend, augue in laoreet eleifend, nisi odio convallis mi, vitae tristique risus risus ut nulla. Duis blandit metus eu diam vehicula, eu vestibulum neque iaculis. Morbi nec viverra nunc. In mollis vitae sem nec placerat. + `, + []string{ + `Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed volutpat dolor rhoncus, fringilla mauris sed, tristique elit. Nulla facilisi. Phasellus orci tortor, finibus sed eleifend eget, [1]`, + `malesuada at nisl. Nullam viverra libero sit amet metus fermentum, et vestibulum nulla fermentum. Aenean rutrum sed urna in efficitur. Curabitur ac nulla ut ante accumsan facilisis a quis arcu. [2]`, + `Ut dapibus dolor lectus, eget semper lectus venenatis finibus. Etiam dapibus pulvinar ex, a dictum sem gravida quis. Praesent suscipit sem orci, a ultricies ligula luctus a. Fusce porta sem sed [3]`, + `nibh feugiat condimentum. + +Donec id tortor in ligula scelerisque imperdiet nec eu libero. Morbi congue hendrerit arcu, id rhoncus elit placerat ac. Vivamus efficitur quis nisi a aliquam. Quisque [4]`, + `auctor aliquam interdum. Suspendisse facilisis lacus non efficitur ultrices. Cras a lacus dui. Maecenas neque risus, molestie ultrices velit eget, iaculis egestas erat. Praesent pharetra congue [5]`, + `justo, id condimentum lectus pharetra sit amet. Proin a sagittis dolor, a interdum sem. Aenean at erat id augue hendrerit efficitur sed ac odio. Phasellus quis malesuada nulla. Sed id consequat [6]`, + `erat. Curabitur sagittis eros nec sem facilisis, sed cursus purus pretium. Proin fermentum ut purus nec ultricies. + +Etiam aliquet neque mollis, commodo sapien non, tincidunt risus. Maecenas sed [7]`, + "quam iaculis, vehicula nisi eu, elementum risus. Ut lacinia scelerisque dolor id pharetra. Ut rutrum, mi id viverra commodo, urna leo malesuada sapien, ac aliquam quam orci ac nulla. Nunc [8]", + "feugiat diam id erat luctus dictum. Duis arcu leo, rhoncus id ipsum vitae, auctor mollis est. Donec laoreet rutrum eros a imperdiet. Duis convallis purus eu auctor venenatis. In commodo orci [9]", + "vitae ullamcorper suscipit. Vestibulum eleifend, augue in laoreet eleifend, nisi odio convallis mi, vitae tristique risus risus ut nulla. Duis blandit metus eu diam vehicula, eu vestibulum neque [10]", + "iaculis. Morbi nec viverra nunc. In mollis vitae sem nec placerat. [11]", + }, + ) +} + +func TestBreakMessage(t *testing.T) { + AssertBreakingAt(t, + "", + []string{""}, + ) + + AssertBreakingAt(t, + "Hello", + []string{"Hello"}, + ) + + AssertBreakingAt(t, + "Hello\nHello", + []string{"Hello\nHello"}, + ) + + AssertBreakingAt(t, + TWO_HUNDRED_CHARS, + []string{TWO_HUNDRED_CHARS}, + ) + + AssertBreakingAt(t, + TWO_HUNDRED_CHAR_WORDS, + []string{TWO_HUNDRED_CHAR_WORDS}, + ) + + AssertBreakingAt(t, + TWO_HUNDRED_CHARS+"a", []string{ - twoHundredChars, + TWO_HUNDRED_CHARS, + "a", + }, + ) + + AssertBreakingAt(t, + TWO_HUNDRED_CHARS+"๐Ÿ“Ÿ!", + []string{ + TWO_HUNDRED_CHARS, "๐Ÿ“Ÿ!", }, ) - AssertBreaking(t, - twoHundredChars[:len(twoHundredChars)-4]+"๐Ÿ“Ÿ!", + AssertBreakingAt(t, + TWO_HUNDRED_CHARS[:len(TWO_HUNDRED_CHARS)-4]+"๐Ÿ“Ÿ!", []string{ - twoHundredChars[:len(twoHundredChars)-4] + "๐Ÿ“Ÿ", + TWO_HUNDRED_CHARS[:len(TWO_HUNDRED_CHARS)-4] + "๐Ÿ“Ÿ", "!", }, ) - AssertBreaking(t, - twoHundredChars[:len(twoHundredChars)-2]+"๐Ÿ“Ÿ!", + AssertBreakingAt(t, + TWO_HUNDRED_CHARS[:len(TWO_HUNDRED_CHARS)-2]+"๐Ÿ“Ÿ!", []string{ - twoHundredChars[:len(twoHundredChars)-2], + TWO_HUNDRED_CHARS[:len(TWO_HUNDRED_CHARS)-2], "๐Ÿ“Ÿ!", }, ) - AssertBreaking(t, - twoHundredCharWords+"y", + AssertBreakingAt(t, + TWO_HUNDRED_CHAR_WORDS+"y", []string{ - twoHundredCharWords[:len(twoHundredCharWords)-3], + TWO_HUNDRED_CHAR_WORDS[:len(TWO_HUNDRED_CHAR_WORDS)-3], "hey", }, ) - AssertBreaking(t, - twoHundredCharWords+` Testing: + AssertBreakingAt(t, + TWO_HUNDRED_CHAR_WORDS+` Testing: - A thing here - And another one`, []string{ - twoHundredCharWords, + TWO_HUNDRED_CHAR_WORDS, `Testing: - A thing here - And another one`, }, ) - AssertBreaking(t, - twoHundredCharWords+` + AssertBreakingAt(t, + TWO_HUNDRED_CHAR_WORDS+` Testing: - A thing here - And another one`, []string{ - twoHundredCharWords, + TWO_HUNDRED_CHAR_WORDS, `Testing: - A thing here - And another one`, }, ) - AssertBreaking(t, - twoHundredCharWords+"\n"+twoHundredCharWords+"\n"+"Working!\n", + AssertBreakingAt(t, + TWO_HUNDRED_CHAR_WORDS+"\n"+TWO_HUNDRED_CHAR_WORDS+"\n"+"Working!\n", []string{ - twoHundredCharWords, - twoHundredCharWords, + TWO_HUNDRED_CHAR_WORDS, + TWO_HUNDRED_CHAR_WORDS, "Working!", }, ) - AssertBreaking(t, + AssertBreakingAt(t, `๐Ÿค–๐Ÿ‘‹ Hey there! I understand these commands: โœ‰๏ธ Message box - An answering machine for Meshtastic @@ -148,7 +256,7 @@ Testing: }, ) - AssertBreaking(t, + AssertBreakingAt(t, `lsddjfksdjfhskjfhakfjhakfashflkshv fshdis uh sdkjvh aichua ssklvjhsd ivuhsv kjsdhvd iasvha vjhvl kajvh iusv sivhkjfh aklvh siuvh svhakjhslfgslkvh sdich ivhajkfhs kjvgsliv iuhv skjvhslhvljshlksjhvisudv svhlsiuvhsvjhslkcavshvluishv hslivhslkjvhskjchsldkvhjd kshv kjshv skjhv slhvkjshvlks hvlkshvlskjvh skvhsv kjshv sdfjsl fkslfj sdlfj slfj ksldfj ljdljskf lksdflkjsf lkj sdkfj lskjf sdkfjls sdgkjlk sf klj fslkj lkjdflsdkjglsdfjk lsjf slkfj lshf slkj klsjflshflksj sfkhslfh`, []string{ `lsddjfksdjfhskjfhakfjhakfashflkshv fshdis uh sdkjvh aichua ssklvjhsd ivuhsv kjsdhvd iasvha vjhvl kajvh iusv sivhkjfh aklvh siuvh svhakjhslfgslkvh sdich ivhajkfhs kjvgsliv iuhv skjvhslhvljshlksjhvisudv`, @@ -157,7 +265,7 @@ Testing: }, ) - AssertBreaking(t, ` + AssertBreakingAt(t, ` Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed volutpat dolor rhoncus, fringilla mauris sed, tristique elit. Nulla facilisi. Phasellus orci tortor, finibus sed eleifend eget, malesuada at nisl. Nullam viverra libero sit amet metus fermentum, et vestibulum nulla fermentum. Aenean rutrum sed urna in efficitur. Curabitur ac nulla ut ante accumsan facilisis a quis arcu. Ut dapibus dolor lectus, eget semper lectus venenatis finibus. Etiam dapibus pulvinar ex, a dictum sem gravida quis. Praesent suscipit sem orci, a ultricies ligula luctus a. Fusce porta sem sed nibh feugiat condimentum. Donec id tortor in ligula scelerisque imperdiet nec eu libero. Morbi congue hendrerit arcu, id rhoncus elit placerat ac. Vivamus efficitur quis nisi a aliquam. Quisque auctor aliquam interdum. Suspendisse facilisis lacus non efficitur ultrices. Cras a lacus dui. Maecenas neque risus, molestie ultrices velit eget, iaculis egestas erat. Praesent pharetra congue justo, id condimentum lectus pharetra sit amet. Proin a sagittis dolor, a interdum sem. Aenean at erat id augue hendrerit efficitur sed ac odio. Phasellus quis malesuada nulla. Sed id consequat erat. Curabitur sagittis eros nec sem facilisis, sed cursus purus pretium. Proin fermentum ut purus nec ultricies. @@ -193,3 +301,21 @@ But got: } } } + +func AssertBreakingAt(t *testing.T, message string, expected []string) { + parts := BreakMessageAt(message, 200) + + for i, part := range parts { + if i >= len(expected) { + t.Errorf(`Got more messages than I expected: +["%v"]`, part) + break + } + if part != expected[i] { + t.Errorf(`Expected message %d to be: +["%v"] +But got: +["%v"]`, i+1, expected[i], part) + } + } +} From 0dcd8713aa6602d8b7187cb4da9b913e88ae68e9 Mon Sep 17 00:00:00 2001 From: Timendus Date: Fri, 14 Feb 2025 20:39:19 +0100 Subject: [PATCH 49/87] Forgot about the hops, added support --- go/main.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/go/main.go b/go/main.go index 0e746bd..c953a20 100644 --- a/go/main.go +++ b/go/main.go @@ -15,6 +15,7 @@ import ( "github.com/timendus/meshbot/config" "github.com/timendus/meshbot/meshbot" m "github.com/timendus/meshbot/meshwrapper" + "github.com/timendus/meshbot/meshwrapper/helpers" "go.bug.st/serial" ) @@ -130,8 +131,12 @@ func incoming(message m.Message) { return } - message.Reply("I'm reading " + subject.String() + " with an SNR of " + - strconv.FormatFloat(float64(subject.GetSNR()), 'f', 2, 32)) + if subject.HopsAway == 0 { + message.Reply("๐Ÿค–๐Ÿ“ถ I'm reading " + subject.String() + " with an SNR of " + + strconv.FormatFloat(float64(subject.GetSNR()), 'f', 2, 32)) + } else { + message.Reply("๐Ÿค–๐Ÿ“ถ " + subject.String() + " is " + strconv.Itoa(int(subject.HopsAway)) + " " + helpers.Pluralize("hop", int(subject.HopsAway)) + " away") + } } } From 62abbe75308c499ade128754c8ba6f0642c1958d Mon Sep 17 00:00:00 2001 From: Timendus Date: Mon, 10 Mar 2025 23:25:30 +0100 Subject: [PATCH 50/87] Dockerize the application --- go/Dockerfile | 52 +++++++++++++++++++++++++++++++++++++++++++++++++++ go/Makefile | 3 +++ 2 files changed, 55 insertions(+) create mode 100644 go/Dockerfile diff --git a/go/Dockerfile b/go/Dockerfile new file mode 100644 index 0000000..b098cef --- /dev/null +++ b/go/Dockerfile @@ -0,0 +1,52 @@ +# syntax=docker/dockerfile:1 + +####################### +### Building container + +FROM golang:latest AS build +WORKDIR /app + +# Install dependencies +COPY go.mod go.sum . +RUN go mod download + +# Copy source +COPY . . + +# Build the application +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o output/meshbot *.go + +###################### +### Running container + +FROM alpine:latest AS run +WORKDIR /app + +# Copy the application executable from the build image +COPY --from=build /app/output /app + +# For when we have a web interface: +# EXPOSE 8080 + +# Have a little runner script that copies the default config and plugins to the +# host directory if not yet present +COPY ./config.json /app/default-config/config.json +COPY ./plugins /app/default-config/plugins +RUN cat >./run-meshbot.sh < dist/docker/meshbot.tar.gz From baae99d5e4b6f91fcf46f2bfd571405e13777808 Mon Sep 17 00:00:00 2001 From: Timendus Date: Thu, 13 Mar 2025 19:44:04 +0100 Subject: [PATCH 51/87] Clean this up a bit --- go/meshbot/chatbot.go | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/go/meshbot/chatbot.go b/go/meshbot/chatbot.go index b4b20da..1adde46 100644 --- a/go/meshbot/chatbot.go +++ b/go/meshbot/chatbot.go @@ -98,24 +98,28 @@ func (c *Chatbot) handleMessageIf(message ChatMessage, comp func(command, string isPrivateMessage := message.IsPrivateMessage() matchFound := false for _, plugin := range c.plugins { - for _, command := range plugin.Commands { - validCommand := command.State == c.state && - (command.Private == isPrivateMessage || - command.Channel == !isPrivateMessage) - if validCommand && comp(command, message.GetText()) { + for _, cmd := range plugin.Commands { + validCommand := cmd.State == c.state && + (cmd.Private == isPrivateMessage || + cmd.Channel == !isPrivateMessage) + if validCommand && comp(cmd, message.GetText()) { matchFound = true - newState, err := command.Function(&message) - if err != nil { - log.Println("We got an error while handling a message:", err) - } else { - c.state = newState - } + c.runFunction(cmd, message) } } } return matchFound } +func (c *Chatbot) runFunction(cmd command, message ChatMessage) { + newState, err := cmd.Function(&message) + if err != nil { + log.Println("We got an error while handling a message:", err) + } else { + c.state = newState + } +} + func matches(command command, message string) bool { for _, command := range command.Command { if strings.EqualFold(strings.TrimSpace(message), strings.TrimSpace(command)) { From 29a6598663915f9133db4984cdd2a735bf5390df Mon Sep 17 00:00:00 2001 From: Timendus Date: Thu, 13 Mar 2025 21:34:55 +0100 Subject: [PATCH 52/87] Handle config parsing errors --- go/config/config.go | 11 +++++++---- go/main.go | 8 ++++++-- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/go/config/config.go b/go/config/config.go index 23720b6..1cb7397 100644 --- a/go/config/config.go +++ b/go/config/config.go @@ -3,7 +3,6 @@ package config import ( "encoding/json" "io" - "log" "os" ) @@ -38,13 +37,16 @@ type Settings struct { var config Config -func InitConfig() { +func InitConfig() error { configFile, err := os.Open("config.json") if err != nil { - log.Fatal(err) + return err } configBytes, _ := io.ReadAll(configFile) - json.Unmarshal(configBytes, &config) + err = json.Unmarshal(configBytes, &config) + if err != nil { + return err + } for i, connection := range config.Connections { if connection.Port == 0 { config.Connections[i].Port = 4403 @@ -56,6 +58,7 @@ func InitConfig() { config.Connections[i].ConnectionType = SERIAL_CONNECTION } } + return nil } func GetConfig() Config { diff --git a/go/main.go b/go/main.go index c953a20..5f49c3f 100644 --- a/go/main.go +++ b/go/main.go @@ -23,7 +23,11 @@ var bot *meshbot.Chatbot func main() { log.Println("Starting Meshed Potatoes!") - config.InitConfig() + err := config.InitConfig() + if err != nil { + log.Println("Encountered issue reading config.json:") + log.Fatal(err) + } cfg := config.GetConfig() m.MessageEvents.Subscribe(m.IncomingMessageEvent, incoming) @@ -70,7 +74,7 @@ func main() { // Launch the chat bot bot = meshbot.NewChatbot() - err := bot.ReloadPlugins() + err = bot.ReloadPlugins() if err != nil { log.Fatal(err) } From 390eff1af8e5d96bb18885672e360cdea0fa68a2 Mon Sep 17 00:00:00 2001 From: Timendus Date: Sat, 18 Oct 2025 17:23:39 +0200 Subject: [PATCH 53/87] Add support for announcements, weather and roomservers in Go (after all) --- go/Dockerfile | 1 + go/config.json | 20 ++- go/config/config.go | 23 ++- go/main.go | 216 ++++++++++++++++++++--- go/meshwrapper/node.go | 11 +- go/meshwrapper/node_list.go | 23 +++ go/meshwrapper/position.go | 6 +- go/roomserver/room.go | 164 +++++++++++++++++ go/wmo_codes.json | 338 ++++++++++++++++++++++++++++++++++++ 9 files changed, 775 insertions(+), 27 deletions(-) create mode 100644 go/roomserver/room.go create mode 100644 go/wmo_codes.json diff --git a/go/Dockerfile b/go/Dockerfile index b098cef..7a7ddc6 100644 --- a/go/Dockerfile +++ b/go/Dockerfile @@ -31,6 +31,7 @@ COPY --from=build /app/output /app # Have a little runner script that copies the default config and plugins to the # host directory if not yet present COPY ./config.json /app/default-config/config.json +COPY ./wmo_codes.json /app/wmo_codes.json COPY ./plugins /app/default-config/plugins RUN cat >./run-meshbot.sh < len("/SIGNAL") { - needle := input[len("/SIGNAL"):] - subject, ok = message.FindNode(needle).(*m.Node) + if message.MessageType == m.MESSAGE_TYPE_TEXT_MESSAGE { + command := strings.ToUpper(message.Text) + + if strings.HasPrefix(command, "/HELP") || strings.HasPrefix(command, "/ABOUT") { + <-message.Reply( + `๐Ÿค–๐Ÿ‘‹ Hello! I'm your friendly neighbourhood roomserver bot. I understand these commands: + + - /rooms + - /join + - /leave `) + message.Reply( + `Bonus features: + + - /neighbours + - /signal + - /weather + - /forecast`) + return + } + + if strings.HasPrefix(command, "/SIGNAL") { + input := strings.TrimSpace(message.Text) + subject := message.FromNode + ok := true + if len(input) > len("/SIGNAL") { + needle := input[len("/SIGNAL"):] + subject, ok = message.FindNode(needle).(*m.Node) + } + + if !ok || subject == nil { + message.Reply("๐Ÿค–๐Ÿงจ I don't know who that is. Sorry!\n\nI need the short name (example: TDRP), node ID (example: !87e35ac8) or part of the long name of a node that I know.") + return + } + + if subject.HopsAway == 0 { + message.Reply("๐Ÿค–๐Ÿ“ถ I last heard " + subject.String() + " " + helpers.TimeAgo(subject.LastHeard) + " ago with an SNR of " + + strconv.FormatFloat(float64(subject.GetSNR()), 'f', 2, 32)) + } else { + message.Reply("๐Ÿค–๐Ÿ“ถ " + subject.String() + " is " + strconv.Itoa(int(subject.HopsAway)) + " " + helpers.Pluralize("hop", int(subject.HopsAway)) + " away") + } + return + } + + if strings.HasPrefix(command, "/NEIGHBOURS") { + message.Reply("๐Ÿค–๐Ÿ‘‚ These are the nodes I've heard in the last hour:\n\n" + message.ReceivingNode.NodeList.Neighbours()) + return + } + + if strings.HasPrefix(command, "/WEATHER") { + var text string + var pos [3]float32 + if message.FromNode != nil { + pos = message.FromNode.GetPosition() + text = "Here's the current weather at your location:" + } + if message.FromNode == nil || pos[0] == 0 || pos[1] == 0 { + pos = message.ToNode.GetPosition() + text = "I can't see your location, so I'll give you the current weather at my location:" + } + if pos[0] == 0 || pos[1] == 0 { + message.Reply("๐Ÿค–๐Ÿงจ I'm sorry! I can't give you a weather report, because I don't know the location of either of us.") + return + } + weather, err := meshbot.FetchWeather(meshbot.Position{ + Latitude: float64(pos[0]), + Longitude: float64(pos[1]), + }) + if err != nil { + message.Reply("๐Ÿค–๐ŸŒ‚ I can't get a weather report at this time.") + } else { + ok := <-message.Reply("๐Ÿค–๐ŸŒ‚ " + text + "\n\n" + weather) + if !ok { + log.Println("Could not send the full weather message :/") + } + } + return + } + + if strings.HasPrefix(command, "/FORECAST") { + var text string + var pos [3]float32 + if message.FromNode != nil { + pos = message.FromNode.GetPosition() + text = "Here's the weather forecast at your location:" + } + if message.FromNode == nil || pos[0] == 0 || pos[1] == 0 { + pos = message.ToNode.GetPosition() + text = "I can't see your location, so I'll give you the weather forecast at my location:" + } + if pos[0] == 0 || pos[1] == 0 { + message.Reply("๐Ÿค–๐Ÿงจ I'm sorry! I can't give you a weather forecast, because I don't know the location of either of us.") + return + } + forecast, err := meshbot.FetchForecast(meshbot.Position{ + Latitude: float64(pos[0]), + Longitude: float64(pos[1]), + }) + if err != nil { + message.Reply("๐Ÿค–๐ŸŒ‚ I can't get a weather forecast at this time.") + } else { + ok := <-message.Reply("๐Ÿค–๐ŸŒ‚ " + text + "\n\n" + forecast) + if !ok { + log.Println("Could not send the full weather message :/") + } + } + return + } + + // We've fallen through the generic queries, roomserver code starts here + + // Make sure we don't spam channels + if !message.IsPrivateMessage() { + return + } + + // Find our user + user := roomserver.GetUser(message) + + if strings.HasPrefix(command, "/ROOMS") { + message.Reply( + `๐Ÿค–๐Ÿ’ฌ These are the available rooms: + +` + roomserver.RoomList(user) + ` +Join by sending /join +Leave by sending /leave `) + return + } + + if strings.HasPrefix(command, "/JOIN") { + params := strings.Split(strings.TrimSpace(message.Text[len("/JOIN"):]), " ") + if len(params) == 0 { + message.Reply("๐Ÿค–๐Ÿงจ You need to specify the name of a room to join") + return + } + roomName := params[0] + password := "" + if len(params) > 1 { + password = params[1] + } + err := roomserver.Join(user, roomName, password) + if err != nil { + message.Reply("๐Ÿค–๐Ÿ’ฌ " + err.Error()) + return + } + message.Reply("๐Ÿค–๐Ÿ’ฌ You joined " + roomName) + return } - if !ok || subject == nil { - message.Reply("๐Ÿค–๐Ÿงจ I don't know who that is. Sorry!\n\nI need the short name (example: TDRP), or node ID (example: !87e35ac8) of a node that I know.") + if strings.HasPrefix(command, "/LEAVE") { + params := strings.Split(strings.TrimSpace(message.Text[len("/LEAVE"):]), " ") + if len(params) == 0 { + message.Reply("๐Ÿค–๐Ÿงจ You need to specify the name of a room to leave") + return + } + roomName := params[0] + err := roomserver.Leave(user, roomName) + if err != nil { + message.Reply("๐Ÿค–๐Ÿงจ " + err.Error()) + return + } + message.Reply("๐Ÿค–๐Ÿ’ฌ You left " + roomName) return } - if subject.HopsAway == 0 { - message.Reply("๐Ÿค–๐Ÿ“ถ I'm reading " + subject.String() + " with an SNR of " + - strconv.FormatFloat(float64(subject.GetSNR()), 'f', 2, 32)) - } else { - message.Reply("๐Ÿค–๐Ÿ“ถ " + subject.String() + " is " + strconv.Itoa(int(subject.HopsAway)) + " " + helpers.Pluralize("hop", int(subject.HopsAway)) + " away") + // Handle freeform messages to a room + msg := strings.TrimSpace(message.Text) + if len(msg) == 0 { + return + } + err := roomserver.Send(user, msg) + if err != nil { + <-message.Reply("๐Ÿค–๐Ÿ’ฌ " + err.Error()) + message.Reply("Send /rooms to see available rooms\nSend /help to see all commands") + return } } } diff --git a/go/meshwrapper/node.go b/go/meshwrapper/node.go index ca881b2..745a813 100644 --- a/go/meshwrapper/node.go +++ b/go/meshwrapper/node.go @@ -23,6 +23,7 @@ type Node struct { Connected bool PublicKey []byte Neighbors NeighborList + Position *Position } func NewNode(info *meshtastic.NodeInfo) *Node { @@ -60,6 +61,7 @@ func (n *Node) Update(info *meshtastic.NodeInfo) { MessageType: MESSAGE_TYPE_POSITION, Position: NewPosition(info.Position), }) + n.Position = NewPosition(info.Position) } if info.DeviceMetrics != nil { @@ -171,7 +173,14 @@ func (n *Node) VerboseString() string { } func (n *Node) GetPosition() [3]float32 { - panic("TODO: implement") + if n.Position == nil { + return [3]float32{0, 0, 0} + } + return [3]float32{ + n.Position.latitude, + n.Position.longitude, + float32(n.Position.altitude), + } } func (n *Node) GetHopsAway() int { diff --git a/go/meshwrapper/node_list.go b/go/meshwrapper/node_list.go index 7e70eb8..baa1a6d 100644 --- a/go/meshwrapper/node_list.go +++ b/go/meshwrapper/node_list.go @@ -2,10 +2,14 @@ package meshwrapper import ( "cmp" + "fmt" "regexp" "slices" "strconv" "strings" + "time" + + "github.com/timendus/meshbot/meshwrapper/helpers" ) type nodeList struct { @@ -45,6 +49,25 @@ func (n *nodeList) String() string { return nodes } +func (n *nodeList) Neighbours() string { + nodes := "" + for _, node := range n.sortedNodes() { + nodeIsValid := node.Id != Broadcast.Id && node.Id != Unknown.Id + nodeIsNeighbour := node.HopsAway == 0 + nodeHeardInLastHour := int(time.Since(node.LastHeard).Seconds()) < 3600 + + if nodeIsValid && nodeIsNeighbour && nodeHeardInLastHour && !node.IsSelf() { + nodes += " - " + node.String() + nodes += fmt.Sprintf(" - %s ago", helpers.TimeAgo(node.LastHeard)) + if node.Snr != 0 { + nodes += fmt.Sprintf(", %.2fdB", node.Snr) + } + nodes += "\n" + } + } + return nodes +} + func (n *nodeList) sortedNodes() []Node { nodes := make([]Node, 0, len(n.nodes)) for _, node := range n.nodes { diff --git a/go/meshwrapper/position.go b/go/meshwrapper/position.go index 0ec4c3d..5264f0b 100644 --- a/go/meshwrapper/position.go +++ b/go/meshwrapper/position.go @@ -6,13 +6,13 @@ import ( "buf.build/gen/go/meshtastic/protobufs/protocolbuffers/go/meshtastic" ) -type position struct { +type Position struct { latitude float32 longitude float32 altitude int32 } -func NewPosition(pos *meshtastic.Position) *position { +func NewPosition(pos *meshtastic.Position) *Position { if pos == nil { return nil } @@ -31,7 +31,7 @@ func NewPosition(pos *meshtastic.Position) *position { if latI == 0 && lonI == 0 && alt == 0 { return nil } - return &position{ + return &Position{ latitude: float32(latI / math.Pow(10, 7)), longitude: float32(lonI / math.Pow(10, 7)), altitude: alt, diff --git a/go/roomserver/room.go b/go/roomserver/room.go new file mode 100644 index 0000000..ab09d53 --- /dev/null +++ b/go/roomserver/room.go @@ -0,0 +1,164 @@ +package roomserver + +import ( + "fmt" + "strings" + + "github.com/timendus/meshbot/config" + m "github.com/timendus/meshbot/meshwrapper" +) + +type Room struct { + Config config.Room + Messages []Message + Users []*User +} + +type Message struct { + Sender *User + Contents string +} + +type User struct { + Node *m.Node + Send func(string) chan bool + Backlog []*Message +} + +var Rooms []Room +var Users map[*m.Node]*User + +func Init(cfg config.Config) { + for _, room := range cfg.Rooms { + Rooms = append(Rooms, Room{ + Config: room, + }) + } + Users = make(map[*m.Node]*User) +} + +func GetUser(msg m.Message) *User { + if user, ok := Users[msg.FromNode]; ok { + return user + } + user := &User{ + Node: msg.FromNode, + Send: func(m string) chan bool { return msg.Reply(m) }, + } + Users[msg.FromNode] = user + return user +} + +func RoomList(user *User) string { + text := "" + for _, room := range Rooms { + public := " โœ… " + if room.Config.Password != "" { + public = " ๐Ÿ” " + } + joined := "" + if room.present(user) { + joined = " (joined)" + } + text += public + room.Config.Name + joined + "\n" + } + return text +} + +func Join(user *User, roomName string, password string) error { + roomName = strings.ToLower(roomName) + for i, room := range Rooms { + if roomName == strings.ToLower(room.Config.Name) { + if room.Config.Password != "" && room.Config.Password != password { + return fmt.Errorf("Invalid password for " + room.Config.Name) + } + if room.present(user) { + return fmt.Errorf("You are already in room " + room.Config.Name) + } + Rooms[i].Users = append(Rooms[i].Users, user) + return nil + } + } + return fmt.Errorf("Can't find that room!") +} + +func Leave(user *User, roomName string) error { + roomName = strings.ToLower(roomName) + for i, room := range Rooms { + if roomName == strings.ToLower(room.Config.Name) { + for j, u := range room.Users { + if u.Node.Id == user.Node.Id { + Rooms[i].Users = append(Rooms[i].Users[:j], Rooms[i].Users[j+1:]...) + return nil + } + } + return fmt.Errorf("Looks like you were not in room " + roomName) + } + } + return fmt.Errorf("Can't find that room!") +} + +func RoomsForUser(user *User) []Room { + rooms := make([]Room, 0) + for _, room := range Rooms { + if room.present(user) { + rooms = append(rooms, room) + } + } + return rooms +} + +func Send(user *User, message string) error { + rooms := RoomsForUser(user) + switch len(rooms) { + case 0: + firstPublicRoom := "" + for _, room := range Rooms { + if room.Config.Password == "" { + firstPublicRoom = room.Config.Name + } + } + if firstPublicRoom == "" { + return fmt.Errorf("You're not in any rooms. /join a room.") + } + err := Join(user, firstPublicRoom, "") + if err != nil { + return fmt.Errorf("You're not in any rooms. /join a room.") + } + return fmt.Errorf("You were not in any rooms. I took the liberty of putting you in room " + firstPublicRoom + ".\n\n๐Ÿ”ด Note: All messages you send to me from now on will be broadcast to room " + firstPublicRoom + "! ๐Ÿ”ด") + case 1: + rooms[0].send(Message{Sender: user, Contents: message}) + return nil + default: + parts := strings.Split(message, " ") + roomName := strings.ToLower(parts[0]) + for _, room := range rooms { + if roomName == strings.ToLower(room.Config.Name) { + room.send(Message{Sender: user, Contents: strings.Join(parts[1:], " ")}) + return nil + } + } + return fmt.Errorf("You've joined multiple rooms, please prefix your message with the name of the room you want to send to.") + } +} + +func (room *Room) send(msg Message) { + room.Messages = append(room.Messages, msg) + for _, user := range room.Users { + go func() { + ok := <-user.Send("[" + msg.Sender.Node.ShortName + "] " + msg.Contents) + if !ok { + user.Backlog = append(user.Backlog, &msg) + } + }() + } +} + +func (room *Room) present(user *User) bool { + for _, u := range room.Users { + if u == user { + return true + } + } + return false +} diff --git a/go/wmo_codes.json b/go/wmo_codes.json new file mode 100644 index 0000000..6c3ac20 --- /dev/null +++ b/go/wmo_codes.json @@ -0,0 +1,338 @@ +{ + "0": { + "day": { + "description": "Sunny", + "image": "http://openweathermap.org/img/wn/01d@2x.png", + "icon": "โ˜€๏ธ" + }, + "night": { + "description": "Clear", + "image": "http://openweathermap.org/img/wn/01n@2x.png", + "icon": "๐ŸŒ™" + } + }, + "1": { + "day": { + "description": "Mainly Sunny", + "image": "http://openweathermap.org/img/wn/01d@2x.png", + "icon": "โ˜€๏ธ" + }, + "night": { + "description": "Mainly Clear", + "image": "http://openweathermap.org/img/wn/01n@2x.png", + "icon": "๐ŸŒ™" + } + }, + "2": { + "day": { + "description": "Partly Cloudy", + "image": "http://openweathermap.org/img/wn/02d@2x.png", + "icon": "โ›…๏ธ" + }, + "night": { + "description": "Partly Cloudy", + "image": "http://openweathermap.org/img/wn/02n@2x.png", + "icon": "โ˜๏ธ" + } + }, + "3": { + "day": { + "description": "Cloudy", + "image": "http://openweathermap.org/img/wn/03d@2x.png", + "icon": "โ˜๏ธ" + }, + "night": { + "description": "Cloudy", + "image": "http://openweathermap.org/img/wn/03n@2x.png", + "icon": "โ˜๏ธ" + } + }, + "45": { + "day": { + "description": "Foggy", + "image": "http://openweathermap.org/img/wn/50d@2x.png", + "icon": "๐ŸŒซ๏ธ" + }, + "night": { + "description": "Foggy", + "image": "http://openweathermap.org/img/wn/50n@2x.png", + "icon": "๐ŸŒซ๏ธ" + } + }, + "48": { + "day": { + "description": "Rime Fog", + "image": "http://openweathermap.org/img/wn/50d@2x.png", + "icon": "๐ŸŒซ๏ธ" + }, + "night": { + "description": "Rime Fog", + "image": "http://openweathermap.org/img/wn/50n@2x.png", + "icon": "๐ŸŒซ๏ธ" + } + }, + "51": { + "day": { + "description": "Light Drizzle", + "image": "http://openweathermap.org/img/wn/09d@2x.png", + "icon": "๐ŸŒง๏ธ" + }, + "night": { + "description": "Light Drizzle", + "image": "http://openweathermap.org/img/wn/09n@2x.png", + "icon": "๐ŸŒง๏ธ" + } + }, + "53": { + "day": { + "description": "Drizzle", + "image": "http://openweathermap.org/img/wn/09d@2x.png", + "icon": "๐ŸŒง๏ธ" + }, + "night": { + "description": "Drizzle", + "image": "http://openweathermap.org/img/wn/09n@2x.png", + "icon": "๐ŸŒง๏ธ" + } + }, + "55": { + "day": { + "description": "Heavy Drizzle", + "image": "http://openweathermap.org/img/wn/09d@2x.png", + "icon": "๐ŸŒง๏ธ" + }, + "night": { + "description": "Heavy Drizzle", + "image": "http://openweathermap.org/img/wn/09n@2x.png", + "icon": "๐ŸŒง๏ธ" + } + }, + "56": { + "day": { + "description": "Light Freezing Drizzle", + "image": "http://openweathermap.org/img/wn/09d@2x.png", + "icon": "๐ŸŒจ๏ธ" + }, + "night": { + "description": "Light Freezing Drizzle", + "image": "http://openweathermap.org/img/wn/09n@2x.png", + "icon": "๐ŸŒจ๏ธ" + } + }, + "57": { + "day": { + "description": "Freezing Drizzle", + "image": "http://openweathermap.org/img/wn/09d@2x.png", + "icon": "๐ŸŒจ๏ธ" + }, + "night": { + "description": "Freezing Drizzle", + "image": "http://openweathermap.org/img/wn/09n@2x.png", + "icon": "๐ŸŒจ๏ธ" + } + }, + "61": { + "day": { + "description": "Light Rain", + "image": "http://openweathermap.org/img/wn/10d@2x.png", + "icon": "๐ŸŒฆ๏ธ" + }, + "night": { + "description": "Light Rain", + "image": "http://openweathermap.org/img/wn/10n@2x.png", + "icon": "๐ŸŒง๏ธ" + } + }, + "63": { + "day": { + "description": "Rain", + "image": "http://openweathermap.org/img/wn/10d@2x.png", + "icon": "๐ŸŒง๏ธ" + }, + "night": { + "description": "Rain", + "image": "http://openweathermap.org/img/wn/10n@2x.png", + "icon": "๐ŸŒง๏ธ" + } + }, + "65": { + "day": { + "description": "Heavy Rain", + "image": "http://openweathermap.org/img/wn/10d@2x.png", + "icon": "๐ŸŒง๏ธ" + }, + "night": { + "description": "Heavy Rain", + "image": "http://openweathermap.org/img/wn/10n@2x.png", + "icon": "๐ŸŒง๏ธ" + } + }, + "66": { + "day": { + "description": "Light Freezing Rain", + "image": "http://openweathermap.org/img/wn/10d@2x.png", + "icon": "๐ŸŒจ๏ธ" + }, + "night": { + "description": "Light Freezing Rain", + "image": "http://openweathermap.org/img/wn/10n@2x.png", + "icon": "๐ŸŒจ๏ธ" + } + }, + "67": { + "day": { + "description": "Freezing Rain", + "image": "http://openweathermap.org/img/wn/10d@2x.png", + "icon": "๐ŸŒจ๏ธ" + }, + "night": { + "description": "Freezing Rain", + "image": "http://openweathermap.org/img/wn/10n@2x.png", + "icon": "๐ŸŒจ๏ธ" + } + }, + "71": { + "day": { + "description": "Light Snow", + "image": "http://openweathermap.org/img/wn/13d@2x.png", + "icon": "๐ŸŒจ๏ธ" + }, + "night": { + "description": "Light Snow", + "image": "http://openweathermap.org/img/wn/13n@2x.png", + "icon": "๐ŸŒจ๏ธ" + } + }, + "73": { + "day": { + "description": "Snow", + "image": "http://openweathermap.org/img/wn/13d@2x.png", + "icon": "๐ŸŒจ๏ธ" + }, + "night": { + "description": "Snow", + "image": "http://openweathermap.org/img/wn/13n@2x.png", + "icon": "๐ŸŒจ๏ธ" + } + }, + "75": { + "day": { + "description": "Heavy Snow", + "image": "http://openweathermap.org/img/wn/13d@2x.png", + "icon": "๐ŸŒจ๏ธ" + }, + "night": { + "description": "Heavy Snow", + "image": "http://openweathermap.org/img/wn/13n@2x.png", + "icon": "๐ŸŒจ๏ธ" + } + }, + "77": { + "day": { + "description": "Snow Grains", + "image": "http://openweathermap.org/img/wn/13d@2x.png", + "icon": "๐ŸŒจ๏ธ" + }, + "night": { + "description": "Snow Grains", + "image": "http://openweathermap.org/img/wn/13n@2x.png", + "icon": "๐ŸŒจ๏ธ" + } + }, + "80": { + "day": { + "description": "Light Showers", + "image": "http://openweathermap.org/img/wn/09d@2x.png", + "icon": "๐ŸŒง๏ธ" + }, + "night": { + "description": "Light Showers", + "image": "http://openweathermap.org/img/wn/09n@2x.png", + "icon": "๐ŸŒง๏ธ" + } + }, + "81": { + "day": { + "description": "Showers", + "image": "http://openweathermap.org/img/wn/09d@2x.png", + "icon": "๐ŸŒง๏ธ" + }, + "night": { + "description": "Showers", + "image": "http://openweathermap.org/img/wn/09n@2x.png", + "icon": "๐ŸŒง๏ธ" + } + }, + "82": { + "day": { + "description": "Heavy Showers", + "image": "http://openweathermap.org/img/wn/09d@2x.png", + "icon": "๐ŸŒง๏ธ" + }, + "night": { + "description": "Heavy Showers", + "image": "http://openweathermap.org/img/wn/09n@2x.png", + "icon": "๐ŸŒง๏ธ" + } + }, + "85": { + "day": { + "description": "Light Snow Showers", + "image": "http://openweathermap.org/img/wn/13d@2x.png", + "icon": "๐ŸŒจ๏ธ" + }, + "night": { + "description": "Light Snow Showers", + "image": "http://openweathermap.org/img/wn/13n@2x.png", + "icon": "๐ŸŒจ๏ธ" + } + }, + "86": { + "day": { + "description": "Snow Showers", + "image": "http://openweathermap.org/img/wn/13d@2x.png", + "icon": "๐ŸŒจ๏ธ" + }, + "night": { + "description": "Snow Showers", + "image": "http://openweathermap.org/img/wn/13n@2x.png", + "icon": "๐ŸŒจ๏ธ" + } + }, + "95": { + "day": { + "description": "Thunderstorm", + "image": "http://openweathermap.org/img/wn/11d@2x.png", + "icon": "๐ŸŒฉ๏ธ" + }, + "night": { + "description": "Thunderstorm", + "image": "http://openweathermap.org/img/wn/11n@2x.png", + "icon": "๐ŸŒฉ๏ธ" + } + }, + "96": { + "day": { + "description": "Light Thunderstorms With Hail", + "image": "http://openweathermap.org/img/wn/11d@2x.png", + "icon": "โ›ˆ๏ธ" + }, + "night": { + "description": "Light Thunderstorms With Hail", + "image": "http://openweathermap.org/img/wn/11n@2x.png", + "icon": "โ›ˆ๏ธ" + } + }, + "99": { + "day": { + "description": "Thunderstorm With Hail", + "image": "http://openweathermap.org/img/wn/11d@2x.png", + "icon": "โ›ˆ๏ธ" + }, + "night": { + "description": "Thunderstorm With Hail", + "image": "http://openweathermap.org/img/wn/11n@2x.png", + "icon": "โ›ˆ๏ธ" + } + } +} From d642afccf8f41d7649ba4f2c5fd526d582b8077d Mon Sep 17 00:00:00 2001 From: Timendus Date: Sat, 18 Oct 2025 17:36:32 +0200 Subject: [PATCH 54/87] Refactor some of the connecting, sending and receiving stuff to be more robust and fool proof --- go/main.go | 42 ++++++++++----- go/meshwrapper/connected_node.go | 91 ++++++++++++++++++++++++++------ go/meshwrapper/message.go | 56 ++++++-------------- 3 files changed, 121 insertions(+), 68 deletions(-) diff --git a/go/main.go b/go/main.go index 57314c9..6de93da 100644 --- a/go/main.go +++ b/go/main.go @@ -40,7 +40,6 @@ func main() { // Connect to the meshtastic devices mentioned in the configuration file for _, connection := range cfg.Connections { var node *m.ConnectedNode - var port io.ReadWriteCloser var err error switch connection.ConnectionType { @@ -48,26 +47,33 @@ func main() { if !cfg.Settings.AllowSerial { log.Fatal("Serial connection configured, but not allowed by settings") } - port, err = serial.Open(connection.SerialDevice, &serial.Mode{ - BaudRate: 115200, + node = m.NewConnectedNode(func() (io.ReadWriteCloser, error) { + stream, err := serial.Open(connection.SerialDevice, &serial.Mode{ + BaudRate: 115200, + }) + if err != nil { + return nil, fmt.Errorf("Could not open serial connection to '"+connection.SerialDevice+"': ", err) + } + return stream, nil }) - if err != nil { - log.Fatal("Could not open serial connection to '"+connection.SerialDevice+"': ", err) - } + case config.TCP_CONNECTION: if !cfg.Settings.AllowTCP { log.Fatal("TCP connection configured, but not allowed by settings") } - port, err = net.Dial("tcp", connection.Hostname+":"+strconv.Itoa(connection.Port)) - if err != nil { - log.Fatal("Could not open TCP connection to '"+connection.Hostname+":"+strconv.Itoa(connection.Port)+"': ", err) - } + node = m.NewConnectedNode(func() (io.ReadWriteCloser, error) { + stream, err := net.Dial("tcp", connection.Hostname+":"+strconv.Itoa(connection.Port)) + if err != nil { + return nil, fmt.Errorf("Could not open TCP connection to '"+connection.Hostname+":"+strconv.Itoa(connection.Port)+"': ", err) + } + return stream, nil + }) default: log.Fatal("Invalid connection type!") } - node, err = m.NewConnectedNode(port) + err = node.Connect() if err != nil { log.Fatal(err) } @@ -133,7 +139,19 @@ func connected(node m.ConnectedNode) { } func disconnected(node m.ConnectedNode) { - log.Println("Disconnected from the node. Maybe some retry-logic here?") + log.Println("Disconnected from the node") + backoff := 1 * time.Second + for !node.Connected { + log.Println("Attemping to reconnect to the node...") + err := node.Connect() + if err == nil { + log.Println("Succesfully reconnected to the node!") + break + } + log.Println("Could not connect, backing off exponentially...", err) + time.Sleep(backoff) + backoff *= 2 + } } func incoming(message m.Message) { diff --git a/go/meshwrapper/connected_node.go b/go/meshwrapper/connected_node.go index 9c28dfa..8ad186e 100644 --- a/go/meshwrapper/connected_node.go +++ b/go/meshwrapper/connected_node.go @@ -1,17 +1,21 @@ package meshwrapper import ( + "fmt" "io" "log" + "math/rand/v2" "strconv" "time" "buf.build/gen/go/meshtastic/protobufs/protocolbuffers/go/meshtastic" + "github.com/timendus/meshbot/config" "github.com/timendus/meshbot/meshwrapper/helpers" "google.golang.org/protobuf/proto" ) type ConnectedNode struct { + aquireStream func() (io.ReadWriteCloser, error) stream io.ReadWriteCloser Connected bool FirmwareVersion string @@ -21,14 +25,13 @@ type ConnectedNode struct { Acks map[uint32]chan bool } -func NewConnectedNode(stream io.ReadWriteCloser) (*ConnectedNode, error) { - // Create the new connected node - newNode := ConnectedNode{ - stream: stream, - Connected: false, - NodeList: NewNodeList(), - Acks: make(map[uint32]chan bool), - Channels: make(map[uint32]Channel), +func NewConnectedNode(aquire func() (io.ReadWriteCloser, error)) *ConnectedNode { + return &ConnectedNode{ + aquireStream: aquire, + Connected: false, + NodeList: NewNodeList(), + Acks: make(map[uint32]chan bool), + Channels: make(map[uint32]Channel), Node: &Node{ ShortName: "UNKN", LongName: "Unknown node", @@ -36,25 +39,33 @@ func NewConnectedNode(stream io.ReadWriteCloser) (*ConnectedNode, error) { Connected: true, }, } +} + +func (n *ConnectedNode) Connect() error { + // Connect to the actual device + stream, err := n.aquireStream() + if err != nil { + return err + } + n.stream = stream // Spin up a goroutine to read messages from the device - go newNode.readMessages(stream) + go n.readMessages(n.stream) // Wake the device - if err := wakeDevice(stream); err != nil { - return nil, err + if err := wakeDevice(n.stream); err != nil { + return err } // Tell the device that we can speak ProtoBuf - if err := writeMessage(stream, &meshtastic.ToRadio{ + if err := writeMessage(n.stream, &meshtastic.ToRadio{ PayloadVariant: &meshtastic.ToRadio_WantConfigId{ WantConfigId: 1, }, }); err != nil { - return nil, err + return err } - - return &newNode, nil + return nil } func (n *ConnectedNode) Close() error { @@ -67,7 +78,44 @@ func (n *ConnectedNode) String() string { return n.Node.ColorString() } -func (n *ConnectedNode) SendMessage(message meshtastic.ToRadio_Packet) error { +func (n *ConnectedNode) SendMessage(channel uint32, recipient *Node, message string, hopLimit uint32) (uint32, error) { + id := rand.Uint32() + err := n.SendPacket(meshtastic.ToRadio_Packet{ + Packet: &meshtastic.MeshPacket{ + Id: id, + Channel: channel, + To: recipient.Id, + From: n.Node.Id, + HopLimit: hopLimit, + WantAck: true, + Priority: meshtastic.MeshPacket_Priority(meshtastic.MeshPacket_Priority_value["RELIABLE"]), + PayloadVariant: &meshtastic.MeshPacket_Decoded{ + Decoded: &meshtastic.Data{ + Portnum: meshtastic.PortNum_TEXT_MESSAGE_APP, + Payload: []byte(message), + }, + }, + }, + }) + return id, err +} + +func (n *ConnectedNode) SendPacket(message meshtastic.ToRadio_Packet) error { + // Only transmit anything if the configuration allows it or the + // configuration has this particular node id as the exception. Otherwise, + // just silently drop the transmission. + cfg := config.GetConfig() + nodeAllowed := cfg.Settings.TransmitExceptionNodeId != 0 && message.Packet.To == cfg.Settings.TransmitExceptionNodeId + if !(cfg.Settings.AllowTransmit || nodeAllowed) { + return fmt.Errorf("not allowed to transmit by config.json") + } + + // If message is a message in a channel, but the configuration does not + // allow this, again just drop the transmission + if !cfg.Settings.AllowTransmitToChannels && message.Packet.To == Broadcast.Id { + return fmt.Errorf("not allowed to transmit in a channel by config.json") + } + if err := writeMessage(n.stream, &meshtastic.ToRadio{ PayloadVariant: &message, }); err != nil { @@ -260,9 +308,18 @@ func (n *ConnectedNode) parseMeshPacket(meshPacket *meshtastic.MeshPacket) { case meshtastic.PortNum_ROUTING_APP: if meshPacket.GetDecoded() != nil { + result := meshtastic.Routing{} + err := proto.Unmarshal(payload, &result) + if err != nil { + log.Println("Error: Could not unmarshall Routing mesh packet: " + err.Error()) + return + } + if result.GetErrorReason() != meshtastic.Routing_NONE { + log.Println("Bad acknowledgement: " + meshtastic.Routing_Error_name[int32(result.GetErrorReason())]) + } messageId := meshPacket.GetDecoded().RequestId if n.Acks[messageId] != nil { - n.Acks[messageId] <- true + n.Acks[messageId] <- result.GetErrorReason() == meshtastic.Routing_NONE close(n.Acks[messageId]) delete(n.Acks, messageId) } diff --git a/go/meshwrapper/message.go b/go/meshwrapper/message.go index 6b72e5b..721712b 100644 --- a/go/meshwrapper/message.go +++ b/go/meshwrapper/message.go @@ -2,11 +2,10 @@ package meshwrapper import ( "fmt" - "math/rand/v2" + "log" "time" "buf.build/gen/go/meshtastic/protobufs/protocolbuffers/go/meshtastic" - "github.com/timendus/meshbot/config" "github.com/timendus/meshbot/meshbot" "github.com/timendus/meshbot/meshwrapper/helpers" ) @@ -48,7 +47,7 @@ type Message struct { PowerMetrics *meshtastic.PowerMetrics LocalStats *meshtastic.LocalStats NeighborInfo *meshtastic.NeighborInfo - Position *position + Position *Position } func (m Message) Reply(message string, timeout ...time.Duration) chan bool { @@ -76,7 +75,13 @@ func (m Message) Reply(message string, timeout ...time.Duration) chan bool { func (m *Message) send(message string, timeout time.Duration) chan bool { ch := make(chan bool) - id := m.sendTextMessage(message) + id, err := m.sendTextMessage(message) + if err != nil { + log.Println("Could not send message:", err) + ch <- false + close(ch) + return ch + } m.ReceivingNode.Acks[id] = ch go func() { time.Sleep(timeout) @@ -89,26 +94,15 @@ func (m *Message) send(message string, timeout time.Duration) chan bool { return ch } -func (m *Message) sendTextMessage(message string) uint32 { +func (m *Message) sendTextMessage(message string) (uint32, error) { helpers.Assert(m.ReceivingNode != nil, "Can't send a message without knowing through which device to send it") helpers.Assert(m.FromNode != nil, "Can't send a message to an unknown node") helpers.Assert(m.ToNode != nil, "Can't send a message from an unknown node") - id := rand.Uint32() - - // Only transmit anything if the configuration allows it or the - // configuration has this particular node id as the exception. Otherwise, - // just silently drop the transmission. - cfg := config.GetConfig() - nodeAllowed := cfg.Settings.TransmitExceptionNodeId != 0 && m.FromNode.Id == cfg.Settings.TransmitExceptionNodeId - if !(cfg.Settings.AllowTransmit || nodeAllowed) { - return id - } - - // If message was sent to a channel (and the config allows it), reply in the - // same channel instead of privately. + // If message was sent to a channel, reply in the same channel instead of + // privately. recipient := m.FromNode - if cfg.Settings.AllowTransmitToChannels && m.ToNode.Id == Broadcast.Id { + if m.ToNode.Id == Broadcast.Id { recipient = &Broadcast } channelId := uint32(0) @@ -117,34 +111,18 @@ func (m *Message) sendTextMessage(message string) uint32 { } // Notify the rest of the system that we're sending this message - MessageEvents.publish(OutgoingMessageEvent, Message{ + msg := Message{ FromNode: m.ReceivingNode.Node, ToNode: recipient, Text: message, MessageType: MESSAGE_TYPE_TEXT_MESSAGE, Timestamp: time.Now(), Channel: m.Channel, - }) + } + MessageEvents.publish(OutgoingMessageEvent, msg) // Actually send the message - m.ReceivingNode.SendMessage(meshtastic.ToRadio_Packet{ - Packet: &meshtastic.MeshPacket{ - Id: id, - Channel: channelId, - To: recipient.Id, - From: m.ReceivingNode.Node.Id, - HopLimit: min(m.HopsAway+2, 7), - WantAck: true, - Priority: meshtastic.MeshPacket_Priority(meshtastic.MeshPacket_Priority_value["RELIABLE"]), - PayloadVariant: &meshtastic.MeshPacket_Decoded{ - Decoded: &meshtastic.Data{ - Portnum: meshtastic.PortNum_TEXT_MESSAGE_APP, - Payload: []byte(message), - }, - }, - }, - }) - return id + return m.ReceivingNode.SendMessage(channelId, recipient, message, min(m.HopsAway+2, 7)) } // Implement meshbot.ChatMessage interface From 659799cc1f966c8d9efd78202a482178bc3b676d Mon Sep 17 00:00:00 2001 From: Timendus Date: Sat, 18 Oct 2025 17:38:11 +0200 Subject: [PATCH 55/87] Add weather service --- go/meshbot/open_meteo.go | 326 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 326 insertions(+) create mode 100644 go/meshbot/open_meteo.go diff --git a/go/meshbot/open_meteo.go b/go/meshbot/open_meteo.go new file mode 100644 index 0000000..8732e11 --- /dev/null +++ b/go/meshbot/open_meteo.go @@ -0,0 +1,326 @@ +// This entire file way ported from Python using ChatGPT. So it's probably +// shite, but it does seem to work. So I'm just going to use it as a black box +// and be done with it. + +package meshbot + +import ( + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "log" + "net/http" + "net/url" + "os" + "strconv" + "time" +) + +// Position represents a geographical coordinate. +type Position struct { + Latitude float64 + Longitude float64 +} + +// WeatherInfo represents weather icon and description. +type WeatherInfo struct { + Icon string `json:"icon"` + Description string `json:"description"` +} + +// WmoCode holds both day and night weather info. +type WmoCode struct { + Day WeatherInfo `json:"day"` + Night WeatherInfo `json:"night"` +} + +// wmoCodes is loaded from the JSON file at initialization. +var wmoCodes map[string]WmoCode + +func init() { + // Load wmo_codes.json + data, err := os.ReadFile("./wmo_codes.json") + if err != nil { + log.Printf("Error reading wmo_codes.json: %v", err) + wmoCodes = make(map[string]WmoCode) + return + } + err = json.Unmarshal(data, &wmoCodes) + if err != nil { + log.Printf("Error parsing wmo_codes.json: %v", err) + wmoCodes = make(map[string]WmoCode) + } +} + +// friendlyDate formats a date in a friendly way. +// Adjust the format string as needed to match your friendly_date helper. +func friendlyDate(t time.Time) string { + return t.Format("Mon Jan 2") +} + +// windDirection converts a numeric wind direction into an arrow string. +func windDirection(direction float64) string { + switch { + case direction >= 0 && direction < 22.5: + return "โ†“" + case direction >= 22.5 && direction < 67.5: + return "โ†™" + case direction >= 67.5 && direction < 112.5: + return "โ†" + case direction >= 112.5 && direction < 157.5: + return "โ†–" + case direction >= 157.5 && direction < 202.5: + return "โ†‘" + case direction >= 202.5 && direction < 247.5: + return "โ†—" + case direction >= 247.5 && direction < 292.5: + return "โ†’" + case direction >= 292.5 && direction < 337.5: + return "โ†˜" + case direction >= 337.5 && direction < 360: + return "โ†“" + default: + return "" + } +} + +// FetchWeather retrieves the current weather at the given position. +func FetchWeather(position Position) (string, error) { + baseURL := "https://api.open-meteo.com/v1/forecast" + params := url.Values{} + params.Set("latitude", fmt.Sprintf("%f", position.Latitude)) + params.Set("longitude", fmt.Sprintf("%f", position.Longitude)) + + // Add current weather parameters + for _, p := range []string{ + "temperature_2m", + "is_day", + "precipitation", + "weather_code", + "wind_speed_10m", + "wind_direction_10m", + } { + params.Add("current", p) + } + + fullURL := baseURL + "?" + params.Encode() + resp, err := http.Get(fullURL) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := ioutil.ReadAll(resp.Body) + return "", fmt.Errorf("could not reach the Open-Meteo server at this time: %d - %s", resp.StatusCode, string(body)) + } + + var weather map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&weather); err != nil { + return "", err + } + + current, ok := weather["current"].(map[string]interface{}) + if !ok { + return "", errors.New("no current weather data") + } + + // Retrieve weather code and check day or night. + codeVal := current["weather_code"] + codeStr := "" + switch v := codeVal.(type) { + case float64: + codeStr = strconv.Itoa(int(v)) + case string: + codeStr = v + } + + isDay := 1 + if v, ok := current["is_day"].(float64); ok { + isDay = int(v) + } + + var weatherInfo WeatherInfo + if code, found := wmoCodes[codeStr]; found { + if isDay == 1 { + weatherInfo = code.Day + } else { + weatherInfo = code.Night + } + } + + icon := weatherInfo.Icon + description := weatherInfo.Description + temp := fmt.Sprintf("%v", current["temperature_2m"]) + + // Retrieve units + currentUnits, _ := weather["current_units"].(map[string]interface{}) + tempUnit := "" + if currentUnits != nil { + if v, ok := currentUnits["temperature_2m"].(string); ok { + tempUnit = v + } + } + precip := fmt.Sprintf("%v", current["precipitation"]) + precipUnit := "" + if currentUnits != nil { + if v, ok := currentUnits["precipitation"].(string); ok { + precipUnit = v + } + } + windSpeed := fmt.Sprintf("%v", current["wind_speed_10m"]) + windSpeedUnit := "" + if currentUnits != nil { + if v, ok := currentUnits["wind_speed_10m"].(string); ok { + windSpeedUnit = v + } + } + + windDirFloat := 0.0 + if v, ok := current["wind_direction_10m"].(float64); ok { + windDirFloat = v + } + windDir := windDirection(windDirFloat) + + // Format the result string + result := fmt.Sprintf( + "๐ŸŒก๏ธ %s%s\n%s %s\n๐Ÿ’ง %s%s\n๐ŸŒฌ๏ธ %s%s %s\n", + temp, tempUnit, + icon, description, + precip, precipUnit, + windSpeed, windSpeedUnit, windDir, + ) + return result, nil +} + +// FetchForecast retrieves the weather forecast for the given position. +func FetchForecast(position Position) (string, error) { + baseURL := "https://api.open-meteo.com/v1/forecast" + params := url.Values{} + params.Set("latitude", fmt.Sprintf("%f", position.Latitude)) + params.Set("longitude", fmt.Sprintf("%f", position.Longitude)) + + // Add daily forecast parameters + for _, p := range []string{ + "weather_code", + "temperature_2m_max", + "temperature_2m_min", + "precipitation_sum", + "precipitation_probability_max", + "wind_speed_10m_max", + "wind_direction_10m_dominant", + } { + params.Add("daily", p) + } + params.Set("timezone", "auto") + + fullURL := baseURL + "?" + params.Encode() + resp, err := http.Get(fullURL) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := ioutil.ReadAll(resp.Body) + return "", fmt.Errorf("could not reach the Open-Meteo server at this time: %d - %s", resp.StatusCode, string(body)) + } + + var forecast map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&forecast); err != nil { + return "", err + } + + daily, ok := forecast["daily"].(map[string]interface{}) + if !ok { + return "", errors.New("no daily forecast data") + } + + units, _ := forecast["daily_units"].(map[string]interface{}) + + // Create a slice of maps (one per day) from the dictionary-of-arrays. + timeArr, ok := daily["time"].([]interface{}) + if !ok { + return "", errors.New("daily time data not found") + } + n := len(timeArr) + structuredForecast := make([]map[string]string, n) + for i := 0; i < n; i++ { + structuredForecast[i] = make(map[string]string) + } + + // Iterate over daily keys and populate each dayโ€™s data. + for key, val := range daily { + arr, ok := val.([]interface{}) + if !ok { + continue + } + newKey := key + if key == "time" { + newKey = "day" + } + if key == "weather_code" { + newKey = "icon" + } + for i, v := range arr { + var valueStr string + if newKey == "day" { + // Parse date string and format it. + if dateStr, ok := v.(string); ok { + t, err := time.Parse("2006-01-02", dateStr) + if err == nil { + valueStr = friendlyDate(t) + } else { + valueStr = dateStr + } + } + } else if newKey == "icon" { + // Lookup weather code and set both icon and description. + codeStr := "" + switch cv := v.(type) { + case float64: + codeStr = strconv.Itoa(int(cv)) + case string: + codeStr = cv + } + if code, found := wmoCodes[codeStr]; found { + valueStr = code.Day.Icon + structuredForecast[i]["description"] = code.Day.Description + } + } else if key == "wind_direction_10m_dominant" { + // Convert numeric wind direction. + if dir, ok := v.(float64); ok { + valueStr = windDirection(dir) + } + } else { + // Append unit if available. + unit := "" + if units != nil { + if u, ok := units[key].(string); ok { + unit = u + } + } + valueStr = fmt.Sprintf("%v%s", v, unit) + } + structuredForecast[i][newKey] = valueStr + } + } + + // Build the forecast string (limit to 6 days if available) + forecastStr := "" + limit := 6 + if n < limit { + limit = n + } + for i := 0; i < limit; i++ { + day := structuredForecast[i] + forecastStr += fmt.Sprintf("โ–ฌโ–ฌ %s โ–ฌโ–ฌ\n", day["day"]) + forecastStr += fmt.Sprintf("๐ŸŒก๏ธ %s / %s\n", day["temperature_2m_max"], day["temperature_2m_min"]) + forecastStr += fmt.Sprintf("%s %s\n", day["icon"], day["description"]) + forecastStr += fmt.Sprintf("๐Ÿ’ง %s %s\n", day["precipitation_sum"], day["precipitation_probability_max"]) + forecastStr += fmt.Sprintf("๐ŸŒฌ๏ธ %s %s\n\n", day["wind_speed_10m_max"], day["wind_direction_10m_dominant"]) + } + + return forecastStr, nil +} From ad81955b1e475d0e08fe4d429e84c1b7a98f5aee Mon Sep 17 00:00:00 2001 From: Timendus Date: Sat, 18 Oct 2025 17:47:27 +0200 Subject: [PATCH 56/87] Clean up all the Lua stuff for now --- go/main.go | 12 -- go/meshbot/chatbot.go | 138 ----------------- go/meshbot/interfaces.go | 33 ---- go/meshbot/luaPlugins.go | 239 ----------------------------- go/meshbot/luaToGoApi.go | 304 ------------------------------------- go/plugins/about.lua | 32 ---- go/plugins/message_box.lua | 287 ---------------------------------- go/plugins/signal.lua | 41 ----- 8 files changed, 1086 deletions(-) delete mode 100644 go/meshbot/chatbot.go delete mode 100644 go/meshbot/interfaces.go delete mode 100644 go/meshbot/luaPlugins.go delete mode 100644 go/meshbot/luaToGoApi.go delete mode 100644 go/plugins/about.lua delete mode 100644 go/plugins/message_box.lua delete mode 100644 go/plugins/signal.lua diff --git a/go/main.go b/go/main.go index 6de93da..bf6739b 100644 --- a/go/main.go +++ b/go/main.go @@ -20,8 +20,6 @@ import ( "go.bug.st/serial" ) -// var bot *meshbot.Chatbot - func main() { log.Println("Starting Meshed Potatoes!") err := config.InitConfig() @@ -80,13 +78,6 @@ func main() { defer node.Close() } - // Launch the chat bot - // bot = meshbot.NewChatbot() - // err = bot.ReloadPlugins() - // if err != nil { - // log.Fatal(err) - // } - // Endless loop to keep the program from ending for { time.Sleep(100 * time.Millisecond) @@ -156,9 +147,6 @@ func disconnected(node m.ConnectedNode) { func incoming(message m.Message) { fmt.Println(message.String()) - // if bot != nil { - // bot.HandleMessage(message) - // } if message.MessageType == m.MESSAGE_TYPE_TEXT_MESSAGE { command := strings.ToUpper(message.Text) diff --git a/go/meshbot/chatbot.go b/go/meshbot/chatbot.go deleted file mode 100644 index 1adde46..0000000 --- a/go/meshbot/chatbot.go +++ /dev/null @@ -1,138 +0,0 @@ -package meshbot - -import ( - "fmt" - "log" - "os" - "strings" -) - -type State string - -type Chatbot struct { - state State - plugins []*plugin -} - -func NewChatbot() *Chatbot { - return &Chatbot{ - state: "MAIN", - } -} - -func (c *Chatbot) ReloadPlugins() error { - plugins := make([]*plugin, 0) - entries, err := os.ReadDir("plugins") - if err != nil { - return err - } - for _, entry := range entries { - if !strings.HasSuffix(entry.Name(), ".lua") { - continue - } - plugin, err := LoadPlugin("plugins/"+entry.Name(), c) - if err != nil { - return err - } - plugins = append(plugins, plugin) - } - c.plugins = plugins - return nil -} - -func (c *Chatbot) String() string { - description := "๐Ÿค–๐Ÿ‘‹ Hey there! I understand these commands:\n" - - for _, plugin := range c.plugins { - if plugin.Hidden { - continue - } - if plugin.Name != "nil" && plugin.Description != "nil" { - description += fmt.Sprintf("\n%s - %s\n", plugin.Name, plugin.Description) - } else if plugin.Name != "nil" { - description += fmt.Sprintf("\n%s\n", plugin.Name) - } - for _, command := range plugin.Commands { - if command.Hidden { - continue - } - var commands string - if len(command.Command) > 0 { - commands = strings.Join(command.Command, ", ") - } else if len(command.Prefix) > 0 { - commands = strings.Join(command.Prefix, ", ") - } else { - continue - } - if command.Description != "nil" { - description += fmt.Sprintf("- %s: %s\n", commands, command.Description) - } else { - description += fmt.Sprintf("- %s\n", commands) - } - } - } - - return description -} - -func (c *Chatbot) HandleMessage(message ChatMessage) { - // See if we have one or more catch all handlers - c.handleMessageIf(message, func(cmd command, _ string) bool { return cmd.IsCatchAll }) - - // Messages that are not text messages can only be handled by - // catch all commands, so in that case we're done here. - if message.GetType() != TEXT_MESSAGE { - return - } - - // See if we have one or more specific handlers for this text message - if c.handleMessageIf(message, matches) { - return - } - - // See if we have one or more catch all text handlers for this text message - c.handleMessageIf(message, func(cmd command, _ string) bool { return cmd.IsCatchAllText }) -} - -func (c *Chatbot) handleMessageIf(message ChatMessage, comp func(command, string) bool) bool { - isPrivateMessage := message.IsPrivateMessage() - matchFound := false - for _, plugin := range c.plugins { - for _, cmd := range plugin.Commands { - validCommand := cmd.State == c.state && - (cmd.Private == isPrivateMessage || - cmd.Channel == !isPrivateMessage) - if validCommand && comp(cmd, message.GetText()) { - matchFound = true - c.runFunction(cmd, message) - } - } - } - return matchFound -} - -func (c *Chatbot) runFunction(cmd command, message ChatMessage) { - newState, err := cmd.Function(&message) - if err != nil { - log.Println("We got an error while handling a message:", err) - } else { - c.state = newState - } -} - -func matches(command command, message string) bool { - for _, command := range command.Command { - if strings.EqualFold(strings.TrimSpace(message), strings.TrimSpace(command)) { - return true - } - } - for _, prefix := range command.Prefix { - if len(strings.TrimSpace(message)) < len(strings.TrimSpace(prefix)) { - continue - } - if strings.EqualFold(strings.TrimSpace(message)[:len(strings.TrimSpace(prefix))], strings.TrimSpace(prefix)) { - return true - } - } - return false -} diff --git a/go/meshbot/interfaces.go b/go/meshbot/interfaces.go deleted file mode 100644 index 9486cab..0000000 --- a/go/meshbot/interfaces.go +++ /dev/null @@ -1,33 +0,0 @@ -package meshbot - -import "time" - -const ( - TEXT_MESSAGE = "text message" -) - -type ChatMessage interface { - GetText() string - IsPrivateMessage() bool - GetType() string - GetChannelName() string - GetSenderNode() ChatUser - GetReceiverNode() ChatUser - FindNode(string) ChatUser - String() string - Reply(string, ...time.Duration) chan bool -} - -type ChatUser interface { - GetId() int - GetIDExpression() string - GetShortName() string - GetLongName() string - String() string - VerboseString() string - GetPosition() [3]float32 - GetHopsAway() int - GetRSSI() float32 - GetSNR() float32 - IsSelf() bool -} diff --git a/go/meshbot/luaPlugins.go b/go/meshbot/luaPlugins.go deleted file mode 100644 index a614ff5..0000000 --- a/go/meshbot/luaPlugins.go +++ /dev/null @@ -1,239 +0,0 @@ -package meshbot - -import ( - "context" - "errors" - "time" - - lua "github.com/yuin/gopher-lua" -) - -type plugin struct { - Name string - Description string - Version string - Hidden bool - Commands []command - States []State - LuaState *lua.LState -} - -type command struct { - State State - Command []string - Prefix []string - Description string - Private bool - Channel bool - IsCatchAll bool - IsCatchAllText bool - Hidden bool - Function func(*ChatMessage) (State, error) -} - -type contextKey string - -const ( - luaMessageTypeName = "message" - luaUserTypeName = "user" - CATCH_ALL_EVENTS = iota - CATCH_ALL_TEXT -) - -func LoadPlugin(filename string, bot *Chatbot) (*plugin, error) { - L := createLuaVM(bot) - if err := L.DoFile(filename); err != nil { - return nil, err - } - definition, ok := L.GetGlobal("Plugin").(*lua.LTable) - if !ok { - return nil, errors.New("no plugin definition found in file " + filename) - } - return newPlugin(definition, L) -} - -func newPlugin(definition *lua.LTable, L *lua.LState) (*plugin, error) { - plugin := plugin{ - Name: definition.RawGetString("name").String(), - Description: definition.RawGetString("description").String(), - Version: definition.RawGetString("version").String(), - Hidden: lua.LVAsBool(definition.RawGetString("hidden")), - Commands: make([]command, 0), - States: make([]State, 0), - LuaState: L, - } - - commands := definition.RawGetString("commands") - errs := make([]error, 0) - if commands, ok := commands.(*lua.LTable); ok { - commands.ForEach(func(k, v lua.LValue) { - command, err := newCommand(v.(*lua.LTable), L) - if err != nil { - errs = append(errs, err) - } else { - plugin.Commands = append(plugin.Commands, *command) - } - }) - } else { - return nil, errors.New("can't have a plugin without commands") - } - - if len(errs) > 0 { - return nil, errors.Join(errs...) - } - - states := definition.RawGetString("states") - if states, ok := states.(*lua.LTable); ok { - states.ForEach(func(k, v lua.LValue) { - plugin.States = append(plugin.States, State(v.String())) - }) - } - - return &plugin, nil -} - -func newCommand(definition *lua.LTable, L *lua.LState) (*command, error) { - state := definition.RawGetString("state").String() - if state == "nil" { - state = "MAIN" - } - - private := definition.RawGetString("private") - if private == lua.LNil { - private = lua.LTrue - } - - luaFunction, ok := definition.RawGetString("func").(*lua.LFunction) - if !ok { - return nil, errors.New("can't have a command without a function") - } - - command := command{ - State: State(state), - Command: make([]string, 0), - Prefix: make([]string, 0), - Description: definition.RawGetString("description").String(), - Private: lua.LVAsBool(private), - Channel: lua.LVAsBool(definition.RawGetString("channel")), - IsCatchAll: false, - IsCatchAllText: false, - Hidden: lua.LVAsBool(definition.RawGetString("hidden")), - Function: func(message *ChatMessage) (State, error) { - messageUserData := L.NewUserData() - messageUserData.Value = message - L.SetMetatable(messageUserData, L.GetTypeMetatable(luaMessageTypeName)) - err := L.CallByParam(lua.P{ - Fn: luaFunction, - NRet: 1, - Protect: true, - }, messageUserData) - if err != nil { - return "MAIN", err - } - ret := L.Get(-1) - L.Pop(1) - if ret.Type() == lua.LTNil { - return "MAIN", nil - } else { - return State(ret.String()), nil - } - }, - } - - commands := definition.RawGetString("command") - if commands, ok := commands.(*lua.LTable); ok { - commands.ForEach(func(k, v lua.LValue) { - command.Command = append(command.Command, v.String()) - }) - } - if cmd, ok := commands.(lua.LString); ok { - command.Command = append(command.Command, cmd.String()) - } - - prefixes := definition.RawGetString("prefix") - if prefixes, ok := prefixes.(*lua.LTable); ok { - prefixes.ForEach(func(k, v lua.LValue) { - command.Prefix = append(command.Prefix, v.String()) - }) - } - if prefix, ok := prefixes.(lua.LString); ok { - command.Prefix = append(command.Prefix, prefix.String()) - } - - if cmd, ok := commands.(lua.LNumber); ok { - if cmd == CATCH_ALL_EVENTS { - command.IsCatchAll = true - } - if cmd == CATCH_ALL_TEXT { - command.IsCatchAllText = true - } - } - - return &command, nil -} - -func createLuaVM(cb *Chatbot) *lua.LState { - // Initialize a bare-bones Lua VM - L := lua.NewState(lua.Options{SkipOpenLibs: true}) - lua.OpenBase(L) - lua.OpenString(L) - lua.OpenTable(L) - - // Make some properties of the bot available to Lua - bot := L.NewTable() - L.SetGlobal("Bot", bot) - bot.RawSetString("CATCH_ALL_TEXT", lua.LNumber(CATCH_ALL_TEXT)) - bot.RawSetString("CATCH_ALL_EVENTS", lua.LNumber(CATCH_ALL_EVENTS)) - botMT := L.NewTable() - botMT.RawSetString("__tostring", L.NewFunction(func(L *lua.LState) int { - L.Push(lua.LString(cb.String())) - return 1 - })) - L.SetMetatable(bot, botMT) - - // Allow Lua scripts to get the time and date on bot, without access to the - // whole `os` library - L.SetField(bot, "date", L.NewFunction(func(L *lua.LState) int { - format := L.OptString(1, "%c") - L.Push(lua.LString(time.Now().Format(format))) - return 1 - })) - - // This is pretty crude, but it provides a way to save some data from the - // Lua scripts, that we can actually persist and make thread safe in the - // future. - L.SetContext(context.WithValue(context.Background(), contextKey("storage"), make(map[string]lua.LValue))) - memory := L.NewTable() - memory.RawSetString("write", L.NewFunction(func(L *lua.LState) int { - ctx := L.Context() - key := L.CheckString(1) - ctx.Value(contextKey("storage")).(map[string]lua.LValue)[key] = L.Get(2) - return 0 - })) - memory.RawSetString("read", L.NewFunction(func(L *lua.LState) int { - ctx := L.Context() - key := L.CheckString(1) - value, ok := ctx.Value(contextKey("storage")).(map[string]lua.LValue)[key] - if ok { - L.Push(value) - } else { - L.Push(lua.LNil) - } - return 1 - })) - bot.RawSetString("memory", memory) - - // Register the Message usertype - mmt := L.NewTypeMetatable(luaMessageTypeName) - L.SetGlobal(luaMessageTypeName, mmt) - L.SetField(mmt, "__index", L.SetFuncs(L.NewTable(), messageMethods)) - mmt.RawSetString("__tostring", L.NewFunction(messageToString)) - - // Register the User usertype - umt := L.NewTypeMetatable(luaUserTypeName) - L.SetGlobal(luaUserTypeName, umt) - L.SetField(umt, "__index", L.SetFuncs(L.NewTable(), userMethods)) - umt.RawSetString("__tostring", L.NewFunction(userToString)) - - return L -} diff --git a/go/meshbot/luaToGoApi.go b/go/meshbot/luaToGoApi.go deleted file mode 100644 index dc3ebcb..0000000 --- a/go/meshbot/luaToGoApi.go +++ /dev/null @@ -1,304 +0,0 @@ -package meshbot - -// Just pass on the Go interfaces to something that Lua understands - -import ( - "time" - - lua "github.com/yuin/gopher-lua" -) - -var messageMethods = map[string]lua.LGFunction{ - "getText": messageText, - "isPrivate": messageIsPrivate, - "getType": messageGetType, - "getChannel": messageGetChannel, - "getSender": messageGetSender, - "getReceiver": messageGetReceiver, - "findNode": messageFindNode, - "reply": messageReply, -} - -var userMethods = map[string]lua.LGFunction{ - "getId": userGetId, - "getIdExpression": userGetIDExpression, - "getShortName": userGetShortName, - "getLongName": userGetLongName, - "verboseString": userVerboseString, - "getPosition": userGetPosition, - "getHopsAway": userGetHopsAway, - "getRSSI": userGetRSSI, - "getSNR": userGetSNR, - "isSelf": userIsSelf, -} - -// Checks whether the first lua argument is a *LUserData with *ChatMessage and returns this *ChatMessage -func checkMessage(L *lua.LState) *ChatMessage { - ud := L.CheckUserData(1) - if v, ok := ud.Value.(*ChatMessage); ok { - return v - } - L.ArgError(1, "message expected") - return nil -} - -func messageText(L *lua.LState) int { - message := *checkMessage(L) - if message == nil { - L.Push(lua.LNil) - return 1 - } - L.Push(lua.LString(message.GetText())) - return 1 -} - -func messageIsPrivate(L *lua.LState) int { - message := *checkMessage(L) - if message == nil { - L.Push(lua.LFalse) - return 1 - } - L.Push(lua.LBool(message.IsPrivateMessage())) - return 1 -} - -func messageGetType(L *lua.LState) int { - message := *checkMessage(L) - if message == nil { - L.Push(lua.LNil) - return 1 - } - L.Push(lua.LString(message.GetType())) - return 1 -} - -func messageGetChannel(L *lua.LState) int { - message := *checkMessage(L) - if message == nil { - L.Push(lua.LNil) - return 1 - } - L.Push(lua.LString(message.GetChannelName())) - return 1 -} - -func messageGetSender(L *lua.LState) int { - message := *checkMessage(L) - if message == nil { - L.Push(lua.LNil) - return 1 - } - node := message.GetSenderNode() - if node == nil { - L.Push(lua.LNil) - return 1 - } - userUserData := L.NewUserData() - userUserData.Value = &node - L.SetMetatable(userUserData, L.GetTypeMetatable(luaUserTypeName)) - L.Push(userUserData) - return 1 -} - -func messageGetReceiver(L *lua.LState) int { - message := *checkMessage(L) - if message == nil { - L.Push(lua.LNil) - return 1 - } - node := message.GetReceiverNode() - if node == nil { - L.Push(lua.LNil) - return 1 - } - userUserData := L.NewUserData() - userUserData.Value = &node - L.SetMetatable(userUserData, L.GetTypeMetatable(luaUserTypeName)) - L.Push(userUserData) - return 1 -} - -func messageFindNode(L *lua.LState) int { - message := *checkMessage(L) - if message == nil { - L.Push(lua.LNil) - return 1 - } - node := message.FindNode(L.CheckString(2)) - if node == nil { - L.Push(lua.LNil) - return 1 - } - userUserData := L.NewUserData() - userUserData.Value = &node - L.SetMetatable(userUserData, L.GetTypeMetatable(luaUserTypeName)) - L.Push(userUserData) - return 1 -} - -func messageToString(L *lua.LState) int { - message := *checkMessage(L) - if message == nil { - L.Push(lua.LNil) - return 1 - } - L.Push(lua.LString(message.String())) - return 1 -} - -func messageReply(L *lua.LState) int { - message := *checkMessage(L) - text := L.CheckString(2) - callback := L.OptFunction(3, nil) - timeout := L.OptInt(4, -1) - - if message == nil || text == "" { - callCallback(L, callback, false) - return 0 - } - - go func(L *lua.LState, callback *lua.LFunction, text string, timeout int) { - var delivered bool - if timeout == -1 { - delivered = <-message.Reply(text) - } else { - timeout := time.Second * time.Duration(timeout) - delivered = <-message.Reply(text, timeout) - } - callCallback(L, callback, delivered) - }(L, callback, text, timeout) - return 0 -} - -func callCallback(L *lua.LState, cb *lua.LFunction, result bool) { - if cb == nil { - return - } - L.CallByParam(lua.P{ - Fn: cb, - NRet: 0, - Protect: true, - }, lua.LBool(result)) -} - -func checkUser(L *lua.LState) *ChatUser { - ud := L.CheckUserData(1) - if v, ok := ud.Value.(*ChatUser); ok { - return v - } - L.ArgError(1, "user expected") - return nil -} - -func userGetId(L *lua.LState) int { - user := *checkUser(L) - if user == nil { - L.Push(lua.LNil) - return 1 - } - L.Push(lua.LNumber(user.GetId())) - return 1 -} - -func userGetIDExpression(L *lua.LState) int { - user := *checkUser(L) - if user == nil { - L.Push(lua.LNil) - return 1 - } - L.Push(lua.LString(user.GetIDExpression())) - return 1 -} - -func userGetShortName(L *lua.LState) int { - user := *checkUser(L) - if user == nil { - L.Push(lua.LNil) - return 1 - } - L.Push(lua.LString(user.GetShortName())) - return 1 -} - -func userGetLongName(L *lua.LState) int { - user := *checkUser(L) - if user == nil { - L.Push(lua.LNil) - return 1 - } - L.Push(lua.LString(user.GetLongName())) - return 1 -} - -func userToString(L *lua.LState) int { - user := *checkUser(L) - if user == nil { - L.Push(lua.LNil) - return 1 - } - L.Push(lua.LString(user.String())) - return 1 -} - -func userVerboseString(L *lua.LState) int { - user := *checkUser(L) - if user == nil { - L.Push(lua.LNil) - return 1 - } - L.Push(lua.LString(user.VerboseString())) - return 1 -} - -func userGetPosition(L *lua.LState) int { - user := *checkUser(L) - if user == nil { - L.Push(lua.LNil) - return 1 - } - position := user.GetPosition() - L.Push(lua.LNumber(position[0])) - L.Push(lua.LNumber(position[1])) - L.Push(lua.LNumber(position[2])) - return 1 -} - -func userGetHopsAway(L *lua.LState) int { - user := *checkUser(L) - if user == nil { - L.Push(lua.LNil) - return 1 - } - L.Push(lua.LNumber(user.GetHopsAway())) - return 1 -} - -func userGetRSSI(L *lua.LState) int { - user := *checkUser(L) - if user == nil { - L.Push(lua.LNil) - return 1 - } - L.Push(lua.LNumber(user.GetRSSI())) - return 1 -} - -func userGetSNR(L *lua.LState) int { - user := *checkUser(L) - if user == nil { - L.Push(lua.LNil) - return 1 - } - L.Push(lua.LNumber(user.GetSNR())) - return 1 -} - -func userIsSelf(L *lua.LState) int { - user := *checkUser(L) - if user == nil { - L.Push(lua.LFalse) - return 1 - } - L.Push(lua.LBool(user.IsSelf())) - return 1 -} diff --git a/go/plugins/about.lua b/go/plugins/about.lua deleted file mode 100644 index 8ed3832..0000000 --- a/go/plugins/about.lua +++ /dev/null @@ -1,32 +0,0 @@ -Plugin = { - name = "About", - description = "Respond to hidden commands with a friendly message.", - version = "1.0", - hidden = true, - - commands = { - - -- These commands might be "guessed" by users, and will result in - -- expected behaviour. - { - command = { "/ABOUT", "/HELP", "/MESHBOT" }, - prefix = { "/MESHBOT" }, - channel = true, - func = function(message) - message:reply( - "๐Ÿค–๐Ÿ‘‹ Hello! I'm your friendly neighbourhood Meshbot. My code is available at https://github.com/timendus/meshbot. Send me a direct message to see what I can do!") - end, - }, - - -- This is the "catch all" command, if no more specific command is - -- matched in the "MAIN" state when receiving a private message, we reply - -- with the capabilities of this bot. - { - command = Bot.CATCH_ALL_TEXT, - func = function(message) - message:reply(tostring(Bot)) - end, - }, - - }, -} diff --git a/go/plugins/message_box.lua b/go/plugins/message_box.lua deleted file mode 100644 index 109636f..0000000 --- a/go/plugins/message_box.lua +++ /dev/null @@ -1,287 +0,0 @@ -Plugin = { - name = "โœ‰๏ธ Message box", - description = "An answering machine for Meshtastic", - version = "1.0", - - commands = { - { - command = "INBOX", - description = "Check your inbox", - func = function(message) return SendInbox(message) end, - }, - { - command = "NEW", - description = "Get new messages", - func = function(message) return SendNewMessages(message) end, - }, - { - command = "OLD", - description = "Get old messages", - func = function(message) return SendOldMessages(message) end, - }, - { - command = "CLEAR", - description = "Clear old messages", - func = function(message) return ClearOldMessages(message) end - }, - { - prefix = "SEND", - description = "Leave a message (SEND )", - func = function(message) return StoreMessage(message) end - }, - { - command = Bot.CATCH_ALL_EVENTS, - func = function(message) return NotifyUser(message) end - } - }, -} - --- Inform the user about their inbox stats -function SendInbox(message) - local inbox = GetInbox(message:getSender()) - - if #inbox == 0 then - message:reply("๐Ÿค–๐Ÿ“ญ You have no messages in your inbox") - return - end - - local icon = inbox.numUnread > 0 and "๐Ÿ“ฌ" or "๐Ÿ“ญ" - message:reply( - "๐Ÿค–" .. - icon .. - " You have " .. - inbox.numUnread .. - " unread " .. - Pluralize("message", inbox.numUnread) .. - ", and a grand total of " .. - #inbox .. " " .. Pluralize("message", #inbox) .. " in your inbox. Send `NEW` or `OLD` to fetch your messages." - ) -end - --- Send all unread messages to the user -function SendNewMessages(message) - local inbox = GetInbox(message:getSender()) - - if inbox.numUnread == 0 then - message:reply("๐Ÿค–๐Ÿ“ญ You have no new messages." .. - (inbox.numRead > 0 and " Send `OLD` to read your older messages." or "")) - return - end - - message:reply("๐Ÿค–๐Ÿ“ฌ You have " .. - inbox.numUnread .. - " new " .. Pluralize("message", inbox.numUnread) .. ". Sending " .. Pluralize("it", inbox.numUnread) .. " now...", - function(success) - if success then - SendMessages(message, inbox, false) - else - print("Could not send new messages, delivery timed out") - end - end - ) -end - --- Send all read messages to the user -function SendOldMessages(message) - local inbox = GetInbox(message:getSender()) - - if inbox.numRead == 0 then - message:reply("๐Ÿค–๐Ÿ“ญ You have no old messages." .. - (inbox.numUnread > 0 and " Send `NEW` to read your new messages." or "")) - return - end - - message:reply("๐Ÿค–๐Ÿ“ฌ You have " .. - inbox.numRead .. - " old " .. Pluralize("message", inbox.numRead) .. ". Sending " .. Pluralize("it", inbox.numRead) .. " now...", - function(success) - if success then - SendMessages(message, inbox, true) - else - print("Could not send old messages, delivery timed out") - end - end - ) -end - --- Clear all messages that have already been read -function ClearOldMessages(message) - local inbox = Bot.memory.read(message:getSender():getIdExpression()) - local num - - if inbox ~= nil then - num = 0 - for i, msg in ipairs(inbox) do - if msg["read"] then - table.remove(inbox, i) - num = num + 1 - end - end - Bot.memory.write(message:getSender():getIdExpression(), inbox) - end - - inbox = GetInbox(message:getSender()) - message:reply("๐Ÿค–๐Ÿ—‘๏ธ I removed " .. - num .. - " old " .. - Pluralize("message", num) .. - ". You have " .. inbox.numUnread .. " new " .. Pluralize("message", inbox.numUnread) .. " left in your inbox.") -end - --- Store new messages when requested by the user -function StoreMessage(message) - local text = message:getText() - local user = text:match("^%S+%s+(%S+)") - local to_send = text:match("^%S+%s+%S+%s+(.*)") - - -- Validate we got a valid request from the user, explain how to use this - -- otherwise - if user == nil or to_send == nil then - message:reply( - "๐Ÿค–๐Ÿงจ The syntax for this command is SEND , where is the short name or id of a node.") - return - end - - -- Find our recipient node - local node = message:findNode(user) - if node == nil then - message:reply("๐Ÿค–๐Ÿงจ I don't know who '" .. - user .. - "' is. The message was not stored.\n\nI need the short name of a node I have seen before (example: TDRP), or the node ID of the recipient (example: !8e92a31f).") - return - end - - -- Store the message to the bot's memory - local inbox = Bot.memory.read(node:getIdExpression()) - if inbox == nil then - inbox = {} - end - table.insert(inbox, { - sender = tostring(message:getSender()), - contents = Trim(to_send), - read = false, - timestamp = Bot.date("2-1-2006 15:04:05"), - }) - Bot.memory.write(node:getIdExpression(), inbox) - - message:reply("๐Ÿค–๐Ÿ“จ Saved this message for node " .. tostring(node) .. ":\n\n" .. to_send) -end - --- Check to see if one of our recipients came in range, and has new messages. -function NotifyUser(message) - -- If they are messaging us first, they will probably quickly find out that - -- they have messages, and it just breaks the flow. So only check for all - -- other message types. - if message:getType() == "text message" and (message:getReceiver() == nil or message:getReceiver():isSelf()) then - return - end - - -- We get routing messages for each Ack, so ignore those or we get a royal - -- clusterfuck. - if message:getType() == "routing" then - return - end - - -- Do we have a message box at all? Otherwise we're spamming nodes that have - -- never interacted with this bot, and have not actually been sent messages - -- by real people, with a "friendly welcome message". - if message:getSender() == nil then - return - end - local box = Bot.memory.read(message:getSender():getIdExpression()) - if box == nil then - return - end - - -- Do we have new messages? - local inbox = GetInbox(message:getSender()) - if inbox.numUnread == 0 then - return - end - - -- Send this user their new messages - message:reply("๐Ÿค–๐Ÿ“ฌ I have " .. inbox.numUnread .. " new " .. - Pluralize("message", inbox.numUnread) .. " for you! Sending them now...") - SendMessages(message, inbox, false) -end - ----------------------- --- Helper functions -- ----------------------- - --- Get a user's inbox, create one if necessary by adding a friendly little --- welcome message, and collect some stats about the inbox. -function GetInbox(node) - if node == nil then - print("ERROR: Unknown node") - end - local inbox = Bot.memory.read(node:getIdExpression()) - - if inbox == nil then - inbox = {} - table.insert(inbox, { - sender = "๐Ÿค– Meshbot", - contents = "Welcome to this Meshtastic answering machine, " .. - node:getLongName() .. - "! You can leave messages for other users, and they can leave messages for you! Hope you like it ๐Ÿ˜„", - read = false, - timestamp = Bot.date("2-1-2006 15:04:05"), - }) - Bot.memory.write(node:getIdExpression(), inbox) - end - - local numUnread = 0 - for _, message in ipairs(inbox) do - if not message["read"] then - numUnread = numUnread + 1 - end - end - inbox.numUnread = numUnread - inbox.numRead = #inbox - numUnread - - return inbox -end - -function SendMessages(message, inbox, read) - SendMessage(message, inbox, 1, read) -end - -function SendMessage(message, inbox, index, read) - -- Are we done? - if index > #inbox then - return - end - - -- Send this message if its read status matches the requested read status - local msg = inbox[index] - if msg.read == read then - message:reply("๐Ÿค–โœ‰๏ธ From " .. msg.sender .. " at " .. msg.timestamp .. "\n\n" .. msg.contents, - function(success) - if not msg.read then - msg.read = success - end - if success then - SendMessage(message, inbox, index + 1, read) - else - print("Could not send a message, delivery timed out") - end - end - ) - else - SendMessage(message, inbox, index + 1, read) - end -end - -function Trim(s) - return s and s:match("^%s*(.-)%s*$") or "" -end - -function Pluralize(word, count) - if count == 1 then - return word - end - if word == "it" then - return "them" - end - return word .. "s" -end diff --git a/go/plugins/signal.lua b/go/plugins/signal.lua deleted file mode 100644 index 93efd1a..0000000 --- a/go/plugins/signal.lua +++ /dev/null @@ -1,41 +0,0 @@ -Plugin = { - name = "๐Ÿ“ถ Signal reporting", - description = "Know what I'm seeing", - version = "1.0", - - commands = { - { - prefix = { "/SIGNAL" }, - channel = true, - description = "Get signal report (/SIGNAL [])", - func = function(message) - -- Figure out who we're requesting a signal report about - local text = message:getText() - local user = text:match("^%S+%s+(%S+)") or "" - local subject = nil - if user == "" or user == nil then - -- Send a signal report on the sender - subject = message:getSender() - else - -- Send a signal report on the specified node - subject = message:findNode(user) - end - - -- Do we have a subject? - if subject == nil then - message:reply( - "๐Ÿค–๐Ÿงจ I don't know who that is. Sorry!\n\nI need the short name (example: TDRP), or node ID (example: !8e92a31f) of a node that I know.") - return - end - - -- Do we have a signal measurement for this node? - if subject:getHopsAway() == 0 then - message:reply("๐Ÿค–๐Ÿ“ถ I'm reading " .. - tostring(subject) .. " with an SNR of " .. string.format("%.2f", subject:getSNR()) .. ".") - else - message:reply("๐Ÿค–๐Ÿ“ถ " .. tostring(subject) .. " is " .. subject:getHopsAway() .. " hops away") - end - end, - }, - }, -} From fdce45a3f59b8f93aceb40eb160751bbedf4d0fe Mon Sep 17 00:00:00 2001 From: Timendus Date: Sat, 18 Oct 2025 20:10:31 +0200 Subject: [PATCH 57/87] Remove old Python project and move new Go project to the project root. Also remove some stragglers from Lua plugins. --- .dockerignore | 4 - .env | 17 - .gitignore | 5 +- go/Dockerfile => Dockerfile | 6 - Makefile | 32 +- cli.py | 85 ----- go/config.json => config.json | 0 {go/config => config}/config.go | 0 dockerfile | 5 - go/go.mod => go.mod | 0 go/go.sum => go.sum | 0 go/.gitignore | 1 - go/Makefile | 26 -- go/cli/main.go | 138 ------- go/main.go => main.go | 0 meshbot/__main__.py | 116 ------ meshbot/about.py | 24 -- meshbot/chatbot.py | 167 --------- meshbot/meshwrapper/__init__.py | 4 - meshbot/meshwrapper/client.py | 93 ----- meshbot/meshwrapper/message.py | 89 ----- meshbot/meshwrapper/node.py | 198 ---------- meshbot/meshwrapper/nodelist.py | 112 ------ meshbot/meshwrapper/time_helper.py | 43 --- meshbot/message_box.py | 221 ------------ meshbot/ollama_llm.py | 304 ---------------- {go/meshbot => meshbot}/open_meteo.go | 0 meshbot/open_meteo.py | 153 -------- meshbot/radio_commands.py | 83 ----- meshbot/tests/test_chatbot.py | 335 ----------------- meshbot/weather.py | 62 ---- meshbot/wmo_codes.json | 338 ------------------ {go/meshwrapper => meshwrapper}/channel.go | 0 .../connected_node.go | 0 .../helpers/assertions.go | 0 .../helpers/language.go | 0 .../helpers/language_test.go | 0 {go/meshwrapper => meshwrapper}/message.go | 0 {go/meshwrapper => meshwrapper}/neighbor.go | 0 {go/meshwrapper => meshwrapper}/node.go | 0 {go/meshwrapper => meshwrapper}/node_list.go | 0 {go/meshwrapper => meshwrapper}/position.go | 0 {go/meshwrapper => meshwrapper}/pubsub.go | 0 .../stream_interface.go | 0 pytest.ini | 3 - requirements.txt | 3 - {go/roomserver => roomserver}/room.go | 0 go/wmo_codes.json => wmo_codes.json | 0 48 files changed, 19 insertions(+), 2648 deletions(-) delete mode 100644 .dockerignore delete mode 100644 .env rename go/Dockerfile => Dockerfile (83%) delete mode 100755 cli.py rename go/config.json => config.json (100%) rename {go/config => config}/config.go (100%) delete mode 100644 dockerfile rename go/go.mod => go.mod (100%) rename go/go.sum => go.sum (100%) delete mode 100644 go/.gitignore delete mode 100644 go/Makefile delete mode 100644 go/cli/main.go rename go/main.go => main.go (100%) delete mode 100644 meshbot/__main__.py delete mode 100644 meshbot/about.py delete mode 100644 meshbot/chatbot.py delete mode 100644 meshbot/meshwrapper/__init__.py delete mode 100644 meshbot/meshwrapper/client.py delete mode 100644 meshbot/meshwrapper/message.py delete mode 100644 meshbot/meshwrapper/node.py delete mode 100644 meshbot/meshwrapper/nodelist.py delete mode 100644 meshbot/meshwrapper/time_helper.py delete mode 100644 meshbot/message_box.py delete mode 100644 meshbot/ollama_llm.py rename {go/meshbot => meshbot}/open_meteo.go (100%) delete mode 100644 meshbot/open_meteo.py delete mode 100644 meshbot/radio_commands.py delete mode 100644 meshbot/tests/test_chatbot.py delete mode 100644 meshbot/weather.py delete mode 100644 meshbot/wmo_codes.json rename {go/meshwrapper => meshwrapper}/channel.go (100%) rename {go/meshwrapper => meshwrapper}/connected_node.go (100%) rename {go/meshwrapper => meshwrapper}/helpers/assertions.go (100%) rename {go/meshwrapper => meshwrapper}/helpers/language.go (100%) rename {go/meshwrapper => meshwrapper}/helpers/language_test.go (100%) rename {go/meshwrapper => meshwrapper}/message.go (100%) rename {go/meshwrapper => meshwrapper}/neighbor.go (100%) rename {go/meshwrapper => meshwrapper}/node.go (100%) rename {go/meshwrapper => meshwrapper}/node_list.go (100%) rename {go/meshwrapper => meshwrapper}/position.go (100%) rename {go/meshwrapper => meshwrapper}/pubsub.go (100%) rename {go/meshwrapper => meshwrapper}/stream_interface.go (100%) delete mode 100644 pytest.ini delete mode 100644 requirements.txt rename {go/roomserver => roomserver}/room.go (100%) rename go/wmo_codes.json => wmo_codes.json (100%) diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index cb697b3..0000000 --- a/.dockerignore +++ /dev/null @@ -1,4 +0,0 @@ -__pycache__ -.DS_Store -development.env -production.env diff --git a/.env b/.env deleted file mode 100644 index 8ac23c3..0000000 --- a/.env +++ /dev/null @@ -1,17 +0,0 @@ -# To connect to a device over a serial connection: - -TRANSPORT=serial -DEVICE=detect # To let the software try to find it -#DEVICE=/dev/ttyUSB0 # To be specific about what device to connect with - -# To connect over your local network: - -#TRANSPORT=net -#DEVICE=meshtastic.local # You can use a locally known hostname -#DEVICE=192.168.1.100 # or an IP address - -# Define an endpoint for the Ollama API to use the LLM module: - -#OLLAMA_API=http://localhost:11434/api -#OLLAMA_MODEL=llama3.1:latest -#OLLAMA_USE_TOOLS=True # Not every model can work with tools diff --git a/.gitignore b/.gitignore index 88b927b..36a416f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,2 @@ -__pycache__ .DS_Store -development.env -production.env -.venv +dist diff --git a/go/Dockerfile b/Dockerfile similarity index 83% rename from go/Dockerfile rename to Dockerfile index 7a7ddc6..67de638 100644 --- a/go/Dockerfile +++ b/Dockerfile @@ -32,19 +32,13 @@ COPY --from=build /app/output /app # host directory if not yet present COPY ./config.json /app/default-config/config.json COPY ./wmo_codes.json /app/wmo_codes.json -COPY ./plugins /app/default-config/plugins RUN cat >./run-meshbot.sh < meshbot-docker-image.tar.gz +test: + @go test -v ./... -dependencies: - @python3 -m venv .venv - @.venv/bin/pip3 install -r requirements.txt +lines: + @find . -name '*.go' | xargs wc -l -run: - @.venv/bin/python3 -m meshbot +build: + @GOOS=linux GOARCH=amd64 go build -o dist/linux/meshbot *.go + @GOOS=windows GOARCH=amd64 go build -o dist/windows/meshbot.exe *.go + @GOOS=linux GOARCH=arm64 go build -o dist/raspberry-pi/meshbot *.go + @GOOS=darwin GOARCH=amd64 go build -o dist/macos-intel/meshbot *.go + @GOOS=darwin GOARCH=arm64 go build -o dist/macos-apple-silicon/meshbot *.go + @docker build --quiet -t timendus/meshbot:latest . + @mkdir -p dist/docker + @docker save timendus/meshbot:latest | gzip > dist/docker/meshbot.tar.gz diff --git a/cli.py b/cli.py deleted file mode 100755 index 336678a..0000000 --- a/cli.py +++ /dev/null @@ -1,85 +0,0 @@ -#!/usr/bin/env python3 - -from datetime import datetime - -from meshbot.meshwrapper import Node, Nodelist, Message -from meshbot.chatbot import Chatbot - - -# Create a bot - -bot = Chatbot() - - -# Import desired modules and register them with the bot - -for module in [ - "about", - "radio_commands", - "weather", - "message_box", - "ollama_llm", -]: - exec(f"from meshbot.{module} import register as register_{module}") - exec(f"register_{module}(bot)") - - -def output(response: str) -> bool: - print(response) - return True - - -print(bot) - - -# Create fake domain model - - -fromNode = Node() -fromNode.num = 1 -fromNode.id = "!00000001" -fromNode.shortName = "USER" -fromNode.longName = "User" -fromNode.snr = 5.0 -fromNode.rssi = -80 -fromNode.hopsAway = 0 -fromNode.send = output -fromNode.lastHeard = datetime.timestamp(datetime.now()) -fromNode.position = [49.911, 9.210] - -toNode = Node() -toNode.num = 2 -toNode.id = "!00000002" -toNode.is_self = lambda: True -toNode.shortName = "MBOT" -toNode.longName = "Meshbot" -toNode.snr = 6.0 -toNode.rssi = -75 -toNode.hopsAway = 0 -toNode.lastHeard = datetime.timestamp(datetime.now()) -toNode.position = [42.428, -4.512] - -nodelist = Nodelist() -nodelist.add(fromNode) -nodelist.add(toNode) - - -# Take input from the user and run it through the bot - -while True: - try: - message = Message() - message.text = input(">>> ") - message.type = "TEXT_MESSAGE_APP" - message.reply = output - message.fromNode = fromNode - message.toNode = toNode - message.nodelist = nodelist - - bot.handle(message) - except KeyboardInterrupt: - break - except EOFError: - break - -print() diff --git a/go/config.json b/config.json similarity index 100% rename from go/config.json rename to config.json diff --git a/go/config/config.go b/config/config.go similarity index 100% rename from go/config/config.go rename to config/config.go diff --git a/dockerfile b/dockerfile deleted file mode 100644 index 5a83586..0000000 --- a/dockerfile +++ /dev/null @@ -1,5 +0,0 @@ -FROM python:latest -LABEL Maintainer="Timendus" -COPY . . -RUN pip3 install -r requirements.txt -CMD [ "python3", "-m", "meshbot" ] diff --git a/go/go.mod b/go.mod similarity index 100% rename from go/go.mod rename to go.mod diff --git a/go/go.sum b/go.sum similarity index 100% rename from go/go.sum rename to go.sum diff --git a/go/.gitignore b/go/.gitignore deleted file mode 100644 index 1521c8b..0000000 --- a/go/.gitignore +++ /dev/null @@ -1 +0,0 @@ -dist diff --git a/go/Makefile b/go/Makefile deleted file mode 100644 index 5d789f1..0000000 --- a/go/Makefile +++ /dev/null @@ -1,26 +0,0 @@ -SHELL := /bin/bash - -all: run - -update: - @go get -u - @go mod tidy - -run: - @go run *.go - -test: - @go test -v ./... - -lines: - @find . -name '*.go' | xargs wc -l - -build: - @GOOS=linux GOARCH=amd64 go build -o dist/linux/meshbot *.go - @GOOS=windows GOARCH=amd64 go build -o dist/windows/meshbot.exe *.go - @GOOS=linux GOARCH=arm64 go build -o dist/raspberry-pi/meshbot *.go - @GOOS=darwin GOARCH=amd64 go build -o dist/macos-intel/meshbot *.go - @GOOS=darwin GOARCH=arm64 go build -o dist/macos-apple-silicon/meshbot *.go - @docker build --quiet -t timendus/meshbot:latest . - @mkdir -p dist/docker - @docker save timendus/meshbot:latest | gzip > dist/docker/meshbot.tar.gz diff --git a/go/cli/main.go b/go/cli/main.go deleted file mode 100644 index d0b81fe..0000000 --- a/go/cli/main.go +++ /dev/null @@ -1,138 +0,0 @@ -package main - -import ( - "bufio" - "fmt" - "os" - "strings" - "time" - - "github.com/timendus/meshbot/meshbot" -) - -func main() { - reader := bufio.NewReader(os.Stdin) - bot := meshbot.NewChatbot() - err := bot.ReloadPlugins() - if err != nil { - fmt.Println(err) - return - } - - for { - fmt.Print("> ") - input, _ := reader.ReadString('\n') - input = strings.Replace(input, "\r", "", -1) - input = strings.Replace(input, "\n", "", -1) - - bot.HandleMessage(chatMessage{ - MessageType: meshbot.TEXT_MESSAGE, - Text: input, - FromNode: chatUser{NodeID: 34875}, - ToNode: chatUser{NodeID: 23857}, - }) - } -} - -type chatMessage struct { - MessageType string - Text string - FromNode chatUser - ToNode chatUser -} - -func (m chatMessage) GetText() string { - return m.Text -} - -func (m chatMessage) IsPrivateMessage() bool { - return true -} - -func (m chatMessage) GetType() string { - return meshbot.TEXT_MESSAGE -} - -func (m chatMessage) GetChannelName() string { - return "" -} - -func (m chatMessage) GetSenderNode() meshbot.ChatUser { - return m.FromNode -} - -func (m chatMessage) GetReceiverNode() meshbot.ChatUser { - return m.ToNode -} - -func (m chatMessage) FindNode(needle string) meshbot.ChatUser { - if needle == m.FromNode.GetShortName() { - return m.FromNode - } - if needle == m.ToNode.GetShortName() { - return m.ToNode - } - return nil -} - -func (m chatMessage) String() string { - return m.Text -} - -func (m chatMessage) Reply(message string, timeout ...time.Duration) chan bool { - fmt.Println(message) - ch := make(chan bool, 1) - go func() { - time.Sleep(2 * time.Second) - ch <- true - }() - return ch -} - -type chatUser struct { - NodeID int -} - -func (m chatUser) GetId() int { - return m.NodeID -} - -func (m chatUser) GetIDExpression() string { - return fmt.Sprintf("!%08x", m.NodeID) -} - -func (m chatUser) GetShortName() string { - return m.GetIDExpression()[5:] -} - -func (m chatUser) GetLongName() string { - return fmt.Sprintf("Node %d", m.NodeID) -} - -func (m chatUser) String() string { - return fmt.Sprintf("[%s] %s", m.GetShortName(), m.GetLongName()) -} - -func (m chatUser) VerboseString() string { - return fmt.Sprintf("Node %s", m.String()) -} - -func (m chatUser) GetPosition() [3]float32 { - return [3]float32{0, 0, 0} -} - -func (m chatUser) GetHopsAway() int { - return 0 -} - -func (m chatUser) GetRSSI() float32 { - return -50.0 -} - -func (m chatUser) GetSNR() float32 { - return 5.2 -} - -func (m chatUser) IsSelf() bool { - return m.NodeID == 23857 -} diff --git a/go/main.go b/main.go similarity index 100% rename from go/main.go rename to main.go diff --git a/meshbot/__main__.py b/meshbot/__main__.py deleted file mode 100644 index d3b8837..0000000 --- a/meshbot/__main__.py +++ /dev/null @@ -1,116 +0,0 @@ -import sys -import os -import time -import threading -import logging -from dotenv import dotenv_values - -from .meshwrapper import MeshtasticClient, Message, MeshtasticConnectionLost -from .chatbot import Chatbot - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger("Meshbot") - -config = { - **dotenv_values(".env"), - **dotenv_values("production.env"), - **dotenv_values("development.env"), - **os.environ, -} - - -# Create a bot and register the desired modules with it - -bot = Chatbot() -for module in [ - "about", - "radio_commands", - "weather", - "message_box", - "ollama_llm", -]: - exec(f"from meshbot.{module} import register as register_{module}") - exec(f"register_{module}(bot)") - - -# Define event handlers - - -def connectionHandler(meshtasticClient: MeshtasticClient): - logger.info("Connection established!") - logger.info(meshtasticClient.nodelist()) - - -def messageHandler(message: Message): - logger.info(message) # So we can actually see messages coming in on the terminal - bot.handle(message) - - -# Start the connection to the Meshtastic node - - -DEBUG = False - -if config["TRANSPORT"] == "serial": - if config["DEVICE"] == "detect": - logger.info("Trying to find serial device...") - else: - logger.info(f"Attempting to open serial connection on {config['DEVICE']}...") - meshtasticClient = MeshtasticClient( - device=None if config["DEVICE"] == "detect" else config["DEVICE"], - connected=lambda: connectionHandler(meshtasticClient), - message=messageHandler, - debug=DEBUG, - ) -elif config["TRANSPORT"] == "net": - host = "meshtastic.local" if config["DEVICE"] == "detect" else config["DEVICE"] - logger.info(f"Attempting to connect to {host}...") - meshtasticClient = MeshtasticClient( - hostname=host, - connected=lambda: connectionHandler(meshtasticClient), - message=messageHandler, - debug=DEBUG, - ) -else: - raise Exception(f"Unknown transport: {config['TRANSPORT']}") - - -# Output the node list every half hour - - -class setInterval: - def __init__(self, interval, action): - self.interval = interval - self.action = action - self.stopEvent = threading.Event() - thread = threading.Thread(target=self.__setInterval) - thread.start() - - def __setInterval(self): - nextTime = time.time() + self.interval - while not self.stopEvent.wait(nextTime - time.time()): - nextTime += self.interval - self.action() - - def cancel(self): - self.stopEvent.set() - - -interval = setInterval(30 * 60, lambda: logger.info(meshtasticClient.nodelist())) - - -# Keep the connection open until the user presses Ctrl+C or the device -# disconnects on the other side - - -try: - while True: - time.sleep(1000) -except KeyboardInterrupt: - logger.info("Closing connection...") - meshtasticClient.close() -except MeshtasticConnectionLost: - logger.error("Connection lost!") -finally: - logger.info("Done!") - interval.cancel() diff --git a/meshbot/about.py b/meshbot/about.py deleted file mode 100644 index 335f01d..0000000 --- a/meshbot/about.py +++ /dev/null @@ -1,24 +0,0 @@ -from .chatbot import Chatbot - - -def register(bot: Chatbot): - bot.add_command( - # This is a hidden command, which is not listed (because it has no - # description), but might be "guessed" by users, and will result in - # expected behaviour. - { - "command": ["/ABOUT", "/HELP", "/MESHBOT"], - "channel": True, - "function": lambda message: message.reply( - "๐Ÿค–๐Ÿ‘‹ Hello! I'm your friendly neighbourhood Meshbot. My code is available at https://github.com/timendus/meshbot. Send me a direct message to see what I can do!" - ), - }, - # This is the "catch all" command, if no more specific command is - # matched in the "MAIN" state when receiving a private message, we reply - # with the capabilities of this bot. This too is not listed because it - # has no description. - { - "command": Chatbot.CATCH_ALL_TEXT, - "function": lambda message: message.reply(str(bot)), - }, - ) diff --git a/meshbot/chatbot.py b/meshbot/chatbot.py deleted file mode 100644 index 9c6a3bf..0000000 --- a/meshbot/chatbot.py +++ /dev/null @@ -1,167 +0,0 @@ -from itertools import groupby -from typing import Callable - -from .meshwrapper import Message - - -class Chatbot: - """ - Helper class for defining the states and commands that a chatbot - understands, and routing the incoming messages to the proper commands. - - This is the structure of the commands that this class understands: - - { - "state": "MAIN", # State in which the command is valid (default: "MAIN") - "command": "/TEST", # Can be a single string, a list of commands or one of the catch alls - "prefix": "/TEST", # Instead of a command we can use a prefix or a list of prefixes - "module": "Test module", # Name of the module this command belongs to - "description": "Test command", # If omitted, command will not be listed - "private": True, # Is command valid in private messages? (default: True) - "channel": False, # Is command valid in channel messages? (default: False) - - # Function to call when the command is matched. Can optionally return a - # string with the name of the next state to change to - "function": lambda message: message.reply("Hello!"), - } - """ - - CATCH_ALL_TEXT = 1 # Get all text messages - CATCH_ALL_EVENTS = 2 # Get all packets - - def __init__(self): - self.states = ["MAIN"] - self.state = "MAIN" - self.commands = [] - - def add_state(self, *states): - for state in states: - self.states.append(state) - - def add_command(self, *commands): - for command in commands: - self.commands.append(command) - - def handle(self, message: Message) -> None: - is_text_message = message.type == "TEXT_MESSAGE_APP" - is_private_message = message.private_message() - is_channel_message = not is_private_message - - # Find commands that are valid in this state and are of the right type - relevant_commands = [ - cmd - for cmd in self.commands - if cmd.get("state", "MAIN") is self.state - and ( - cmd.get("private", True) == is_private_message - or cmd.get("channel", False) == is_channel_message - ) - ] - - # Bail early if we have no relevant commands at all - if len(relevant_commands) == 0: - return - - # Messages that are not text messages can only be handled by - # CATCH_ALL_EVENTS commands - if not is_text_message: - catch_all_events = [ - cmd - for cmd in relevant_commands - if self._matching(cmd, Chatbot.CATCH_ALL_EVENTS) - ] - for cmd in catch_all_events: - self._run_function(cmd["function"], message) - return - - # Messages that are text messages are evaluated specific first, catch - # all later - specific = [ - cmd for cmd in relevant_commands if self._matching(cmd, message.text) - ] - for cmd in specific: - self._run_function(cmd["function"], message) - - # Have we now handled this message? - if len(specific) > 0: - return - - # No specific command matched, try catch all - catch_all = [ - cmd - for cmd in relevant_commands - if self._matching(cmd, Chatbot.CATCH_ALL_TEXT) - or self._matching(cmd, Chatbot.CATCH_ALL_EVENTS) - ] - for cmd in catch_all: - self._run_function(cmd["function"], message) - return - - def _run_function( - self, - function: Callable[[Message], str | None], - message: Message, - ) -> None: - assert function is not None, "Can't call a nonexistant function" - new_state = function(message) - if type(new_state) == str and new_state in self.states: - self.state = new_state - - def __str__(self): - description = "๐Ÿค–๐Ÿ‘‹ Hey there! I understand these commands:\n" - - for module, commands in groupby( - self.commands, - key=lambda c: c.get("module", None), - ): - commands = list(commands) - if not any(self._visible(c) for c in commands): - continue - module = module or "General commands" - description += f"\n{module}\n" - for command in commands: - if self._visible(command): - if "command" in command: - cmd = ( - command["command"] - if type(command["command"]) != list - else ", ".join(command["command"]) - ) - description += f"- {cmd}: {command['description']}\n" - elif "prefix" in command: - description += f"- {command['description']}\n" - - return description - - def _matching(self, command, input): - if "command" in command: - if type(command["command"]) == list: - return any(self._same(c, input) for c in command["command"]) - return self._same(command["command"], input) - - if "prefix" in command: - if type(command["prefix"]) == list: - return any(self._startsWith(c, input) for c in command["prefix"]) - return self._startsWith(command["prefix"], input) - - return False - - def _same(self, command, input): - if type(command) == str and type(input) == str: - return command.upper().strip() == input.upper().strip() - return command is input - - def _startsWith(self, prefix, input): - if type(prefix) == str and type(input) == str: - return input.upper().strip().startswith(prefix.upper().strip()) - return False - - def _visible(self, command): - return ( - ( - "command" in command - and command["command"] is not Chatbot.CATCH_ALL_EVENTS - and command["command"] is not Chatbot.CATCH_ALL_TEXT - ) - or "prefix" in command - ) and command.get("description", None) is not None diff --git a/meshbot/meshwrapper/__init__.py b/meshbot/meshwrapper/__init__.py deleted file mode 100644 index e67dd0d..0000000 --- a/meshbot/meshwrapper/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .client import MeshtasticClient, MeshtasticConnectionLost -from .message import Message -from .node import Node -from .nodelist import Nodelist diff --git a/meshbot/meshwrapper/client.py b/meshbot/meshwrapper/client.py deleted file mode 100644 index f0a6d99..0000000 --- a/meshbot/meshwrapper/client.py +++ /dev/null @@ -1,93 +0,0 @@ -from pubsub import pub -from typing import Callable -import logging - -import meshtastic -import meshtastic.tcp_interface -import meshtastic.serial_interface - -from .node import Node, Everyone -from .nodelist import Nodelist -from .message import Message - -logger = logging.getLogger("Meshbot") - - -class MeshtasticConnectionLost(Exception): - """Thrown when the Meshtastic node disconnects from your project""" - - pass - - -class MeshtasticClient: - """The class used to connect your project to a Meshtastic node.""" - - def __init__( - self, - hostname: str = None, - device: str = None, - connected: Callable[[], None] = None, - message: Callable[[Message], None] = None, - debug: bool = False, - ): - pub.subscribe(self._on_conn_established, "meshtastic.connection.established") - pub.subscribe(self._on_conn_lost, "meshtastic.connection.lost") - pub.subscribe(self._on_receive, "meshtastic.receive") - if debug: - pub.subscribe(self._debug, "meshtastic") - - self.connected = False - self.closing = False - self._connectedCallback = connected - self._messageCallback = message - - if hostname: - self._interface = meshtastic.tcp_interface.TCPInterface(hostname=hostname) - else: - self._interface = meshtastic.serial_interface.SerialInterface( - devPath=device - ) - - Everyone.interface = self._interface - - def close(self): - self.closing = True - self._interface.close() - - def nodelist(self): - nodelist = Nodelist() - for node in self._interface.nodes.values(): - nodelist.add(Node.from_packet(node, self._interface)) - return nodelist - - def _on_receive(self, packet, interface): - nodelist = self.nodelist() - fromNode = nodelist.get(packet["from"]) - if "rxSnr" in packet: - fromNode.snr = packet["rxSnr"] - if "rxRssi" in packet: - fromNode.rssi = packet["rxRssi"] - - message = Message.from_packet(packet) - message.nodelist = nodelist - message.fromNode = fromNode - message.toNode = nodelist.get(packet["to"]) - if self._messageCallback: - self._messageCallback(message) - - def _on_conn_established(self, interface, topic=pub.AUTO_TOPIC): - self.connected = True - if self._connectedCallback: - self._connectedCallback() - - def _on_conn_lost(self, interface, topic=pub.AUTO_TOPIC): - self.connected = False - if not self.closing: - logger.error("ERROR: Connection to node lost") - raise MeshtasticConnectionLost("Connection to node was lost") - - def _debug(self, interface=None, *args, **kwargs): - for arg in args: - logger.debug("Argument:", arg) - for key, value in kwargs.items(): - logger.debug("Key, value:", key, value) diff --git a/meshbot/meshwrapper/message.py b/meshbot/meshwrapper/message.py deleted file mode 100644 index 2746e5c..0000000 --- a/meshbot/meshwrapper/message.py +++ /dev/null @@ -1,89 +0,0 @@ -from datetime import datetime -from .node import Node, Everyone - - -class Message: - """Class representing a message that was received over the LoRa mesh""" - - def __init__(self): - pass - - @staticmethod - def from_packet(data): - message = Message() - message.data = data - - message.id = data.get("id") - message.channel = int(data.get("channel", 0)) - message.timestamp = datetime.fromtimestamp(data.get("rxTime", 0)) - message.type = data.get("decoded", {}).get("portnum") - message.text = data.get("decoded", {}).get("text", "") - - message.telemetry = data.get("decoded", {}).get("telemetry", {}) - if "raw" in message.telemetry: - del message.telemetry["raw"] - - position = data.get("decoded", {}).get("position", None) - message.position_request = data.get("decoded", {}).get("wantResponse", False) - if position and not message.position_request: - message.position = [ - position.get("latitudeI", 0) / pow(10, 7), - position.get("longitudeI", 0) / pow(10, 7), - position.get("altitude", 0), - ] - - message.neighborInfo = data.get("decoded", {}).get("neighborinfo", {}) - if "raw" in message.neighborInfo: - del message.neighborInfo["raw"] - - message.user = data.get("decoded", {}).get("user", {}) - if "raw" in message.user: - del message.user["raw"] - - message.routing = data.get("decoded", {}).get("routing", {}) - if "raw" in message.routing: - del message.routing["raw"] - - message.admin = data.get("decoded", {}).get("admin", {}) - if "raw" in message.admin: - del message.admin["raw"] - - return message - - def private_message(self): - return self.toNode != Everyone - - def reply(self, message: str, **kwargs) -> bool: - if self.toNode == Everyone: - # This was a message in a channel, respond in the same channel - return Everyone.send(message, channelIndex=self.channel, **kwargs) - if self.fromNode: - # This was a direct message, respond to the right node - return self.fromNode.send(message, channelIndex=self.channel, **kwargs) - else: - return False - - def __str__(self): - content = str(self.data) - match self.type: - case "TELEMETRY_APP": - content = f"new telemetry: {self.telemetry}" - case "TEXT_MESSAGE_APP": - content = self.text - case "POSITION_APP": - if self.position_request: - content = f"position request" - else: - content = f"updated location to {self.position}" - case "NEIGHBORINFO_APP": - content = f"I'm seeing these neighbours: {self.neighborInfo}" - case "NODEINFO_APP": - content = f"updated node info to: {self.user}" - case "ROUTING_APP": - content = f"new routing info: {self.routing}" - case "ADMIN_APP": - content = f"administrating: {self.admin}" - case "TRACEROUTE_APP": - content = f"traceroute request" - - return f"{self.fromNode} --> {self.toNode}: {content}" diff --git a/meshbot/meshwrapper/node.py b/meshbot/meshwrapper/node.py deleted file mode 100644 index a62880a..0000000 --- a/meshbot/meshwrapper/node.py +++ /dev/null @@ -1,198 +0,0 @@ -import logging -import textwrap -import time -from threading import Timer - -from .time_helper import time_ago - -logger = logging.getLogger("Meshbot") - -# Message reply timeout delay before we give up -MAX_REPLY_DELAY = 5 - -# Maximum size of a message in UTF-8 bytes that we can send -MAX_SIZE = 234 - -# Size minus `len(" [i/n]")`. -# Note: if we have to split into more than 9 messages, this does break. -SHORT_SIZE = MAX_SIZE - 6 - -wrapper = textwrap.TextWrapper( - width=SHORT_SIZE, replace_whitespace=False, break_long_words=True -) - - -class Node: - """Class representing a Meshtastic node in the LoRa mesh""" - - def __init__(self): - self.transmission = {"sending": None, "last_result": False, "timeout": None} - - @staticmethod - def from_packet(data, interface): - node = Node() - node.interface = interface - - node.num = data.get("num") - assert node.num, "Node should at least have an ID" - - node.id = data.get("user", {}).get("id", "") - node.mac = data.get("user", {}).get("macaddr", "") - node.hardware = data.get("user", {}).get("hwModel", "") - node.role = data.get("user", {}).get("role", None) - node.shortName = data.get("user", {}).get("shortName", "") - node.longName = data.get("user", {}).get("longName", "") - if not node.mac: - node.shortName = "UNKN" - node.longName = "Unknown node" - - position = data.get("position", None) - if position and "latitude" in position and "longitude" in position: - node.position = [ - position["latitude"], - position["longitude"], - position.get("altitude", 0), - ] - else: - node.position = None - - node.lastHeard = data.get("lastHeard", 0) - node.hopsAway = data.get("hopsAway", 0) - node.snr = data.get("snr", None) - node.rssi = None - - return node - - def is_self(self): - return ( - hasattr(self, "interface") - and hasattr(self.interface, "myInfo") - and hasattr(self.interface.myInfo, "my_node_num") - and self.num == self.interface.myInfo.my_node_num - ) - - def is_broadcast(self): - return self is Everyone - - def send(self, message: str, **kwargs) -> bool: - if self.id and self.interface: - messages = self.break_message(message) - oneliner = message.replace("\n", "\\n") - logger.info( - f"Sending to {self} in {len(messages)} {'part' if len(messages) == 1 else 'parts'}: {oneliner}" - ) - for msg in messages: - if not self._send(msg, **kwargs): - return False - return True - else: - return False - - def _send(self, message: str, **kwargs) -> bool: - self.transmission["timeout"] = Timer(MAX_REPLY_DELAY, self.on_timeout) - self.transmission["timeout"].start() - self.transmission["sending"] = self.interface.sendText( - message, - destinationId=self.id, - wantAck=True, - onResponse=self.onAckNak, - **kwargs, - ) - while self.transmission["sending"]: - time.sleep(0.1) - return self.transmission["last_result"] - - # Don't change the name of this callback - # https://github.com/meshtastic/python/blob/c696d59b9052361856630c8eb97a061cdb51dc6b/meshtastic/mesh_interface.py#L415-L418 - def onAckNak(self, response): - if ( - self.transmission["sending"] - and self.transmission["sending"].id == response["decoded"]["requestId"] - ): - # Got a reply to the blocking message! Unblocking... - self.transmission["timeout"].cancel() - self.transmission["last_result"] = ( - response["decoded"]["routing"]["errorReason"] == "NONE" - ) - self.transmission["sending"] = None - - def on_timeout(self): - logger.info( - f"Did not get a reply from {self} within {MAX_REPLY_DELAY} seconds, moving on" - ) - self.transmission["last_result"] = False - self.transmission["sending"] = None - - def break_message(self, message: str): - # Keep it as a single message if possible - if len(message.encode("utf-8")) <= MAX_SIZE: - return [message] - - # Split message into multiple parts - words = wrapper._split_chunks(message) - words.reverse() # use it as a stack - words = [w.encode("utf-8") for w in words] - lines = [b""] - while words: - word = words.pop(-1) - if len(word) > SHORT_SIZE: - assert False, "we should never be here if the wrapper does its job" - if len(lines[-1]) + len(word) <= SHORT_SIZE: - lines[-1] += word - else: - lines.append(word) - return [ - f"{l.decode().rstrip()} [{i+1}/{len(lines)}]" for i, l in enumerate(lines) - ] - - def __str__(self): - if type(self) == SpecialNode: - color = "95" - elif self.is_self(): - color = "92" - elif self.hopsAway == 0: - color = "96" - else: - color = "94" - - if len(self.shortName) == 1 and len(self.shortName.encode("utf-8")) == 4: - # Short name is an emoji - shortName = f" {self.shortName} " - else: - shortName = self.shortName.ljust(4) - - return f"\033[{color}m[{shortName}] {self.longName}\033[0m" - - def to_verbose_string(self): - """Used when stringifying a Nodelist""" - hardware = f"{self.hardware}, " if self.hardware != "UNSET" else "" - role = f"{self.role}, " if self.role else "" - snr = f", SNR {self.snr:.2f}" if self.snr else "" - rssi = f", RSSI {self.rssi:.2f}" if self.rssi else "" - hops = ( - f", {self.hopsAway} {'hop' if self.hopsAway == 1 else 'hops'} away" - if self.hopsAway > 0 - else "" - ) - return f"{str(self)} \033[90m({hardware}{role}last heard {time_ago(self.lastHeard)} ago{snr}{rssi}{hops})\033[0m" - - def to_succinct_string(self): - """Use when indentifying this node in Meshtastic messages""" - return f"[{self.shortName}] {self.longName} ({self.id})" - - -class SpecialNode(Node): - def __init__(self, short, long, id): - self.shortName = short - self.longName = long - self.id = id - self.interface = None - self.hardware = "UNSET" - self.transmission = {"sending": None, "last_result": False, "timeout": None} - - def is_self(self): - return False - - -Everyone = SpecialNode("CAST", "Everyone", 0xFFFFFFFF) -Unknown = SpecialNode("UNKN", "Unknown", 0x00000000) diff --git a/meshbot/meshwrapper/nodelist.py b/meshbot/meshwrapper/nodelist.py deleted file mode 100644 index 30aef28..0000000 --- a/meshbot/meshwrapper/nodelist.py +++ /dev/null @@ -1,112 +0,0 @@ -import re -from datetime import datetime - -from .node import Node, Everyone, Unknown - - -fullHexId = re.compile("![0-9a-fA-F]{8}") -shortHexId = re.compile("[0-9a-fA-F]{8}") - - -class Nodelist: - """Class representing a collection of Meshtastic nodes""" - - def __init__(self): - self.nodes = {} - - def add(self, node: Node): - self.nodes[node.num] = node - - def update(self, node: Node): - self.nodes[node.num] = node - - def get(self, num) -> Node | None: - """Returns Node object""" - if num == 0xFFFFFFFF: - return Everyone - elif num in self.nodes.keys(): - return self.nodes[num] - return Unknown - - def find(self, needle: str) -> Node | None: - """Figure out which node the user intends. Returns Node object or None""" - id = self.find_id(needle) - if id: - return self.nodes.get(int(id[1:], 16), None) - else: - return None - - def find_id(self, needle: str) -> str | None: - """Figure out which node the user intends. Returns full HEX id string or None""" - - if fullHexId.match(needle): - # needle is a HEX notation node number - return needle - - elif shortHexId.match(needle): - # needle is a HEX notation node number, but we're missing the exclamation mark - return f"!{needle}" - - elif len(needle) <= 4 and needle.upper() in [ - node.shortName.upper() for node in self.nodes.values() - ]: - # needle is a known short name - return next( - node.id - for node in self.nodes.values() - if node.shortName.upper() == needle.upper() - ) - - elif needle.isnumeric() and int(needle) > 0: - # needle is a decimal number - return "!" + hex(needle)[2:] - - return None - - def get_self(self) -> Node | None: - return next((n for n in self.nodes.values() if n.is_self()), None) - - def __str__(self): - output = "Node list\n" - output += "---------\n" - nodes = sorted(self.nodes.values(), key=lambda n: n.hopsAway) - for node in nodes: - output += f"{node.to_verbose_string()}\n" - return output - - def to_succinct_string(self): - """Used when sending the node list in Meshtastic messages""" - return "\n".join(node.to_succinct_string() for node in self.nodes.values()) - - def summary(self): - now = datetime.now() - seen_in_the_last_half_hour = [ - node - for node in self.nodes.values() - if not node.is_self() - and node.lastHeard - and (now - datetime.fromtimestamp(node.lastHeard)).total_seconds() < 1800 - ] - - hop_counts = {} - for node in self.nodes.values(): - if not node.is_self(): - hop_counts[node.hopsAway] = hop_counts.get(node.hopsAway, 0) + 1 - - recent_hop_counts = {} - for node in seen_in_the_last_half_hour: - recent_hop_counts[node.hopsAway] = ( - recent_hop_counts.get(node.hopsAway, 0) + 1 - ) - - optional_part = ( - f" Of which {recent_hop_counts.get(0, 0)} directly connected and {recent_hop_counts.get(1, 0)} one hop away." - if len(seen_in_the_last_half_hour) > 0 - else "" - ) - totals_part = ( - f"\n\nIn total I've seen {len(self.nodes)} nodes. {hop_counts.get(0, 0)} of those were directly connected and {hop_counts.get(1, 0)} were one hop away." - if len(self.nodes) != len(seen_in_the_last_half_hour) - else "" - ) - return f"I've seen {len(seen_in_the_last_half_hour)} nodes in the past 30 minutes.{optional_part}{totals_part}" diff --git a/meshbot/meshwrapper/time_helper.py b/meshbot/meshwrapper/time_helper.py deleted file mode 100644 index 496c548..0000000 --- a/meshbot/meshwrapper/time_helper.py +++ /dev/null @@ -1,43 +0,0 @@ -from datetime import datetime, timedelta -import math - - -def time_ago(timestamp): - now = datetime.now() - if timestamp == None: - return "an unknown amount of time" - if type(timestamp) != datetime: - timestamp = datetime.fromtimestamp(timestamp) - seconds = math.floor((now - timestamp).total_seconds()) - if seconds == 1: - return f"one second" - if seconds < 60: - return f"{str(seconds)} seconds" - - minutes = math.floor(seconds / 60) - if minutes == 1: - return f"one minute" - if minutes < 60: - return f"{str(minutes)} minutes" - - hours = math.floor(minutes / 60) - if hours == 1: - return f"one hour" - if hours < 24: - return f"{str(hours)} hours" - - days = math.floor(hours / 24) - if days == 1: - return f"one day" - return f"{str(days)} days" - - -def friendly_date(date): - today = datetime.now().date() - if date.date() == today: - return "Today" - if date.date() == today + timedelta(days=1): - return "Tomorrow" - if date.date() < today + timedelta(days=7): - return date.strftime("%a") - return date.strftime("%d-%m-%Y") diff --git a/meshbot/message_box.py b/meshbot/message_box.py deleted file mode 100644 index de5a5b6..0000000 --- a/meshbot/message_box.py +++ /dev/null @@ -1,221 +0,0 @@ -from datetime import datetime - -from .meshwrapper import Message, Node -from .meshwrapper.time_helper import time_ago -from .chatbot import Chatbot - - -def register(bot: Chatbot): - bot.add_command( - { - "command": "INBOX", - "module": "โœ‰๏ธ Message box", - "description": "Check your inbox", - "function": send_inbox, - }, - { - "command": "NEW", - "module": "โœ‰๏ธ Message box", - "description": "Get new messages", - "function": send_new_messages, - }, - { - "command": "OLD", - "module": "โœ‰๏ธ Message box", - "description": "Get old messages", - "function": send_old_messages, - }, - { - "command": "CLEAR", - "module": "โœ‰๏ธ Message box", - "description": "Clear old messages", - "function": clear_old_messages, - }, - { - "prefix": "SEND", - "module": "โœ‰๏ธ Message box", - "description": "SEND : Leave a message", - "function": store_message, - }, - { - "command": Chatbot.CATCH_ALL_EVENTS, - "module": "โœ‰๏ธ Message box", - "function": notify_user, - }, - ) - - -messageStore = {} - - -def send_inbox(message: Message): - _store_welcome_message(message.fromNode) - stats = _user_stats(message.fromNode) - - if stats["totalMessages"] == 0: - message.fromNode.send("๐Ÿค–๐Ÿ“ญ You have no messages in your inbox") - return - - icon = "๐Ÿ“ฌ" if stats["numUnread"] > 0 else "๐Ÿ“ญ" - message.fromNode.send( - f"๐Ÿค–{icon} You have {stats['numUnread']} unread {_pluralize('message', stats['numUnread'])}, and a grand total of {stats['totalMessages']} {_pluralize('message', stats['totalMessages'])} in your inbox. Send `NEW` or `OLD` to fetch your messages." - ) - - -def send_new_messages(message: Message): - _store_welcome_message(message.fromNode) - stats = _user_stats(message.fromNode) - - if stats["numUnread"] == 0: - old_messages = ( - " Send `OLD` to read your older messages." if stats["numRead"] > 0 else "" - ) - message.fromNode.send(f"๐Ÿค–๐Ÿ“ญ You have no new messages.{old_messages}") - return - - message.fromNode.send( - f"๐Ÿค–๐Ÿ“ฌ You have {stats['numUnread']} new {_pluralize('message', stats['numUnread'])}. Sending {_pluralize('it', stats['numUnread'])} now..." - ) - _send_messages(message.fromNode, read=False) - - -def send_old_messages(message: Message): - _store_welcome_message(message.fromNode) - stats = _user_stats(message.fromNode) - - if stats["numRead"] == 0: - new_messages = ( - " Send `NEW` to read your new messages." if stats["numUnread"] > 0 else "" - ) - message.fromNode.send(f"๐Ÿค–๐Ÿ“ญ You have no old messages.{new_messages}") - return - - message.fromNode.send( - f"๐Ÿค–๐Ÿ“ฌ You have {stats['numRead']} old {_pluralize('message', stats['numRead'])}. Sending {_pluralize('it', stats['numRead'])} now..." - ) - _send_messages(message.fromNode, read=True) - - -def clear_old_messages(message: Message): - _store_welcome_message(message.fromNode) - stats = _user_stats(message.fromNode) - - messageStore[message.fromNode.id] = [ - msg for msg in messageStore[message.fromNode.id] if not msg["read"] - ] - message.fromNode.send( - f"๐Ÿค–๐Ÿ—‘๏ธ I removed {stats['numRead']} old {_pluralize('message', stats['numRead'])}. You have {stats['numUnread']} new {_pluralize('message', stats['numUnread'])} left in your inbox." - ) - - -def store_message(message: Message): - """ - Store new messages when requested by the user - """ - - _store_welcome_message(message.fromNode) - - parts = message.text.split(" ") - msg = " ".join(parts[2:]) - - if len(msg) == 0: - message.fromNode.send("๐Ÿค–๐Ÿงจ I'm sorry, I can't send an empty message.") - return - - # Figure out who the recipient is - id = parts[1] - recipientId = message.nodelist.find_id(id) - if not recipientId: - message.fromNode.send( - "๐Ÿค–๐Ÿงจ I don't know who that is. The message was not stored.\n\nI need the short name of a node I have seen before (example: TDRP), or the node ID of the recipient (example: !8e92a31f)." - ) - return - - # Store the message - if recipientId not in messageStore: - messageStore[recipientId] = [] - messageStore[recipientId].append( - { - "sender": message.fromNode.to_succinct_string(), - "contents": msg, - "read": False, - "timestamp": datetime.now(), - } - ) - message.fromNode.send(f"๐Ÿค–๐Ÿ“จ Saved this message for node `{id}`:\n\n{msg}") - - -def notify_user(message: Message): - """ - Check to see if one of our recipients came in range, and has new messages. - """ - - # If they are messaging us first, they will probably quickly find out that - # they have messages, and it just breaks the flow. So only check for all - # other message types. - if message.type == "TEXT_MESSAGE_APP" and message.toNode.is_self(): - return - - # We get routing messages for each Ack, so ignore those or we get a royal - # clusterfuck. - if message.type == "ROUTING_APP": - return - - # Do we have a message box? - if message.fromNode.id not in messageStore: - return - - # Do we have new messages? - stats = _user_stats(message.fromNode) - if stats["numUnread"] == 0: - return - - # Send this user their new messages - message.fromNode.send( - f"๐Ÿค–๐Ÿ“ฌ I have {stats['numUnread']} new {_pluralize('message', stats['numUnread'])} for you! Sending {_pluralize('it', stats['numUnread'])} now..." - ) - _send_messages(message.fromNode, read=False) - - -def _store_welcome_message(node: Node): - """ - Give the current user an inbox and a welcome message if they are new - """ - if node.id not in messageStore: - messageStore[node.id] = [ - { - "sender": "๐Ÿค– Meshbot", - "contents": f"Welcome to this Meshtastic answering machine, {node.longName}! You can leave messages for other users, and they can leave messages for you! Hope you like it ๐Ÿ˜„", - "read": False, - "timestamp": datetime.now(), - }, - ] - - -def _send_messages(node: Node, read: bool = False): - for msg in messageStore.get(node.id, []): - if msg["read"] != read: - continue - if node.send( - f"๐Ÿค–โœ‰๏ธ From {msg['sender']}, {time_ago(msg['timestamp'])} ago:\n\n{msg['contents']}" - ): - msg["read"] = True - - -def _user_stats(node: Node) -> dict: - messages = messageStore.get(node.id, []) - numUnread = sum(1 for msg in messages if not msg["read"]) - totalMessages = len(messages) - return { - "totalMessages": totalMessages, - "numUnread": numUnread, - "numRead": totalMessages - numUnread, - } - - -def _pluralize(word: str, count: int) -> str: - if count == 1: - return word - if word == "it": - return "them" - return word + "s" diff --git a/meshbot/ollama_llm.py b/meshbot/ollama_llm.py deleted file mode 100644 index da27bfc..0000000 --- a/meshbot/ollama_llm.py +++ /dev/null @@ -1,304 +0,0 @@ -import requests -import os -from dotenv import dotenv_values - -from .meshwrapper import Message, Nodelist, Node -from .chatbot import Chatbot -from .open_meteo import fetch_weather, fetch_forecast - -config = { - **dotenv_values(".env"), - **dotenv_values("production.env"), - **dotenv_values("development.env"), - **os.environ, -} - - -def register(bot: Chatbot): - if not ("OLLAMA_API" in config and "OLLAMA_MODEL" in config): - return - - bot.add_state("LLM") - - bot.add_command( - { - "command": "/LLM", - "module": "๐Ÿง  Ollama LLM", - "description": "Start AI conversation", - "channel": True, - "function": start_conversation, - }, - { - "state": "LLM", - "command": Chatbot.CATCH_ALL_TEXT, - "module": "๐Ÿง  Ollama LLM", - "channel": True, - "function": converse, - }, - { - "state": "LLM", - "command": ["/STOP", "/EXIT"], - "module": "๐Ÿง  Ollama LLM", - "description": "End conversation", - "channel": True, - "function": stop_conversation, - }, - ) - - -conversations = {} - - -def start_conversation(message: Message) -> str: - message.reply("๐Ÿค–โณ Spinning up the LLM, just a moment...") - conversations[identifier(message)] = [ - { - "role": "system", - "content": system_prompt + str(_gather_relevant_stats(message)), - } - ] - reply = _reply_from_ollama(conversations[identifier(message)], message.nodelist) - message.reply("๐Ÿค–๐Ÿง  Started LLM conversation") - reply_if_not_empty(message, reply) - return "LLM" - - -def converse(message: Message): - assert identifier(message) in conversations, "Conversation should have been started" - conversations[identifier(message)].append( - { - "role": "user", - "content": f"Node {message.fromNode.id}: {message.text}", - } - ) - reply_if_not_empty( - message, - _reply_from_ollama(conversations[identifier(message)], message.nodelist), - ) - - -def stop_conversation(message: Message) -> str: - del conversations[identifier(message)] - message.reply("๐Ÿค–๐Ÿง  Ended LLM conversation") - return "MAIN" - - -def identifier(message: Message) -> str: - if message.private_message(): - return message.fromNode.id - else: - return f"Channel {message.channel}" - - -def reply_if_not_empty(message: Message, reply: str): - if reply != "": - conversations[identifier(message)].append( - {"role": "assistant", "content": reply} - ) - message.reply("๐Ÿค– " + reply) - - -system_prompt = f""" -You are Meshbot, a helpful chatbot on the Meshtastic network. You talk a bit -like a radio HAM. Users can talk to you in any language. Just be kind and reply -in the same language if you can. - -Remember that Meshtastic is unlicensed and does not use call signs. Also -remember that Meshtastic uses the LoRa protocol, which can work reliably with -very noisy messages. Messages can hop through the mesh via other nodes. - -These icons are often used in long names of nodes: - -๐Ÿ  - Base node -๐Ÿ“Ÿ - Mobile node -โœˆ - Node on board of a plane -๐ŸŽˆ - Node carried by a balloon -โ˜€๏ธ - Solar powered node -๐Ÿ”Œ - Net powered node -๐ŸŒ - Node connected to MQTT (for sharing locations and passing messages) -๐Ÿ• - Node using a yagi antenna -๐Ÿ›ฐ๏ธ - Node with GPS/GNSS on board - -Keep your replies polite and friendly, but short and to the point, since -bandwidth is very limited. Preferably under 232 characters, so they can be -transmitted in a single packet. - -If you are in a channel (a group chat) and you think you can't answer the -question or you think that you are not being addressed, just say nothing. If you -are talking one-on-one you are always expected to reply. - -Do not hallucinate things, only use the information below and the available -tools/functions that you can call when answering radio reception specific -questions. Otherwise just answer that you do not know, or that you do not know -what to say. Feel free to talk generally about unrelated topics when asked. - -Only respond with your reply to the user(s). Nothing else. - -Information: - -""" - -tools = [ - { - "type": "function", - "function": { - "name": "get_signal_strength", - "description": "Get the signal strength for a node, in SNR and RSSI if available", - "parameters": { - "type": "object", - "properties": { - "node": { - "type": "string", - "description": "The ID or short name of the node for which to get the signal strength, e.g. !9a34ed2b or R3NL", - }, - }, - "required": ["node"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "get_hops", - "description": "Get the number of hops to reach a node in the mesh network", - "parameters": { - "type": "object", - "properties": { - "node": { - "type": "string", - "description": "The ID or short name of the node for which to get the number of hops, e.g. !9a34ed2b or R3NL", - }, - }, - "required": ["node"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "get_current_weather", - "description": "Get the current weather at the location of the given node", - "parameters": { - "type": "object", - "properties": { - "node": { - "type": "string", - "description": "The ID or short name of the node for which to get the current weather, e.g. !9a34ed2b or R3NL", - }, - }, - "required": ["node"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "get_weather_forecast", - "description": "Get a weather forecast for the next six days at the location of the given node", - "parameters": { - "type": "object", - "properties": { - "node": { - "type": "string", - "description": "The ID or short name of the node for which to get the weather forecast, e.g. !9a34ed2b or R3NL", - }, - }, - "required": ["node"], - }, - }, - }, -] - - -def _reply_from_ollama(conversation: list, nodelist: Nodelist): - request = { - "model": config["OLLAMA_MODEL"], - "messages": conversation, - "stream": False, - } - - if config.get("OLLAMA_USE_TOOLS") == "True": - request["tools"] = tools - - working = True - while working: - try: - result = requests.post(config["OLLAMA_API"] + "/chat", json=request) - except requests.exceptions.ConnectionError as err: - return f"Could not reach the Ollama server at this time: {err}" - if not result.ok: - return f"Did not get a valid result from Ollama. Status: {result.status_code} - {result.text}" - - result = result.json() - tool_calls = result.get("message", {}).get("tool_calls", False) - - if tool_calls: - # print("Tool call! " + str(tool_calls)) - for call in tool_calls: - function = call.get("function", {}) - arguments = function.get("arguments", {}) - node = nodelist.find(arguments.get("node", "")) - assert node, "The tool should have been called with a node parameter" - match function.get("name", None): - case "get_signal_strength": - return_value = _get_signal_strength(node) - case "get_hops": - return_value = f"Node {node.to_succinct_string()} is {node.hopsAway} hops away" - case "get_current_weather": - return_value = fetch_weather(node.position) - case "get_weather_forecast": - return_value = fetch_forecast(node.position) - case _: - assert False, "Invalid function name in function call from LLM" - # print("Return value: " + return_value) - conversation.append({"role": "tool", "content": return_value}) - else: - working = False - - return result.get("message", {}).get( - "content", "Did not get a valid result from Ollama :/" - ) - - -def _get_signal_strength(node: Node) -> str: - rssi = f" and an RSSI of {node.rssi}" if node.rssi else "" - qualification = "That's a very good signal! Connection should be strong." - if node.snr < 0: - qualification = "That's a pretty good signal. Connection should be strong." - if node.snr < -10: - qualification = "That's not a very good signal, but it will work." - if node.snr < -15: - qualification = ( - "That's a pretty bad signal. The connection may not be very reliable." - ) - if node.snr < -20: - qualification = "That's a very bad signal. Don't expect to connect reliably." - return f"Node {node.to_succinct_string()} is being received with an SNR of {node.snr}{rssi}. {qualification}" - - -def _gather_relevant_stats(message: Message) -> dict: - meshbot_node = message.nodelist.get_self() - stats = { - "in_channel": not message.private_message(), - "meshbot": { - "shortName": meshbot_node.shortName, - "longName": meshbot_node.longName, - "id": meshbot_node.id, - }, - } - if message.private_message(): - stats["user"] = { - "shortName": message.fromNode.shortName, - "longName": message.fromNode.longName, - "id": message.fromNode.id, - } - else: - stats["users"] = [ - { - "shortName": node.shortName, - "longName": node.longName, - "id": node.id, - } - for node in message.nodelist.nodes.values() - ] - return stats diff --git a/go/meshbot/open_meteo.go b/meshbot/open_meteo.go similarity index 100% rename from go/meshbot/open_meteo.go rename to meshbot/open_meteo.go diff --git a/meshbot/open_meteo.py b/meshbot/open_meteo.py deleted file mode 100644 index fc3aed6..0000000 --- a/meshbot/open_meteo.py +++ /dev/null @@ -1,153 +0,0 @@ -import requests -import json -from datetime import datetime - -from .meshwrapper.time_helper import friendly_date - - -wmo_codes = json.loads(open("./meshbot/wmo_codes.json").read()) - - -def fetch_weather(position) -> str | None: - try: - params = { - "latitude": position[0], - "longitude": position[1], - "current": [ - "temperature_2m", - "is_day", - "precipitation", - "weather_code", - "wind_speed_10m", - "wind_direction_10m", - ], - } - result = requests.get("https://api.open-meteo.com/v1/forecast", params=params) - - if not result.ok: - print( - f"Could not reach the Open-Meteo server at this time: {result.status_code} - {result.text}" - ) - return None - - weather = result.json() - weather_code = wmo_codes.get( - str(weather.get("current", {}).get("weather_code", None)), {} - ).get( - "day" if weather.get("current", {}).get("is_day", 1) == 1 else "night", {} - ) - - icon = weather_code.get("icon", "") - description = weather_code.get("description", "") - temp = weather.get("current", {}).get("temperature_2m", "") - temp_unit = weather.get("current_units", {}).get("temperature_2m", "") - precip = weather.get("current", {}).get("precipitation", "") - precip_unit = weather.get("current_units", {}).get("precipitation", "") - wind_speed = weather.get("current", {}).get("wind_speed_10m", "") - wind_speed_unit = weather.get("current_units", {}).get("wind_speed_10m", "") - wind_dir = wind_direction( - weather.get("current", {}).get("wind_direction_10m", None) - ) - - return f"""๐ŸŒก๏ธ {temp}{temp_unit} -{icon} {description} -๐Ÿ’ง {precip}{precip_unit} -๐ŸŒฌ๏ธ {wind_speed}{wind_speed_unit} {wind_dir} -""" - except Exception as e: - print(e) - return None - - -def fetch_forecast(position) -> str | None: - try: - params = { - "latitude": position[0], - "longitude": position[1], - "daily": [ - "weather_code", - "temperature_2m_max", - "temperature_2m_min", - "precipitation_sum", - "precipitation_probability_max", - "wind_speed_10m_max", - "wind_direction_10m_dominant", - ], - "timezone": "auto", - } - result = requests.get("https://api.open-meteo.com/v1/forecast", params=params) - - if not result.ok: - print( - f"Could not reach the Open-Meteo server at this time: {result.status_code} - {result.text}" - ) - return None - - forecast = result.json() - daily = forecast.get("daily", None) - units = forecast.get("daily_units", None) - - # Rewrite dictionary of arrays to array of dictionaries, rename some - # things, add some units. In short, do all the pre-processing. - structured_forecast = {} - for key, value in daily.items(): - if key == "time": - key = "day" - if key == "weather_code": - key = "icon" - if type(value) == list: - for i, v in enumerate(value): - if i not in structured_forecast: - structured_forecast[i] = {} - if key == "day": - v = friendly_date(datetime.strptime(v, "%Y-%m-%d")) - if key == "icon": - weather_code = wmo_codes.get(str(v), {}).get("day", {}) - v = weather_code.get("icon", "") - structured_forecast[i]["description"] = weather_code.get( - "description", "" - ) - if key == "wind_direction_10m_dominant": - v = wind_direction(v) - else: - v = f"{v}{units.get(key, '')}" - structured_forecast[i][key] = v - - forecast_string = "" - for day in list(structured_forecast.values())[:6]: - forecast_string += f"""โ–ฌโ–ฌ {day["day"]} โ–ฌโ–ฌ -๐ŸŒก๏ธ {day["temperature_2m_max"]} / {day["temperature_2m_min"]} -{day["icon"]} {day["description"]} -๐Ÿ’ง {day["precipitation_sum"]} {day["precipitation_probability_max"]} -๐ŸŒฌ๏ธ {day["wind_speed_10m_max"]} {day["wind_direction_10m_dominant"]} - -""" - - return forecast_string - except Exception as e: - print(e) - return None - - -def wind_direction(direction) -> str: - match direction: - case dir if 0 <= dir < 22.5: - return "โ†“" - case dir if 22.5 <= dir < 67.5: - return "โ†™" - case dir if 67.5 <= dir < 112.5: - return "โ†" - case dir if 112.5 <= dir < 157.5: - return "โ†–" - case dir if 157.5 <= dir < 202.5: - return "โ†‘" - case dir if 202.5 <= dir < 247.5: - return "โ†—" - case dir if 247.5 <= dir < 292.5: - return "โ†’" - case dir if 292.5 <= dir < 337.5: - return "โ†˜" - case dir if 337.5 <= dir < 360: - return "โ†“" - case _: - return "" diff --git a/meshbot/radio_commands.py b/meshbot/radio_commands.py deleted file mode 100644 index 3df2cd2..0000000 --- a/meshbot/radio_commands.py +++ /dev/null @@ -1,83 +0,0 @@ -from .meshwrapper import Message -from .chatbot import Chatbot - - -def register(bot: Chatbot): - bot.add_command( - { - "command": "/NODES", - "module": "๐Ÿ“ก Radio commands", - "description": "Get a summary of nodes", - "channel": True, - "function": nodes_info, - }, - { - "command": "/NODELIST", - "module": "๐Ÿ“ก Radio commands", - "description": "Get a list of the nodes I see", - "channel": True, - "function": node_list, - }, - { - "prefix": "/SIGNAL", - "module": "๐Ÿ“ก Radio commands", - "description": "/SIGNAL []: Get signal report on a node", - "channel": True, - "function": signal_report, - }, - ) - - -def signal_report(message: Message): - # Figure out who we're requesting a signal report about - parts = message.text.split(" ") - if len(parts) == 1: - # Send a signal report on the sender - subject = message.fromNode - else: - # Send a signal report on the specified node - subject = message.nodelist.find(" ".join(parts[1:])) - - if not subject: - message.reply( - "๐Ÿค–๐Ÿงจ I don't know who that is. Sorry!\n\nI need the short name (example: TDRP), or node ID (example: !8e92a31f) of a node that I know." - ) - return - - if subject.hopsAway == 0: - if subject.snr and subject.rssi: - message.reply( - f"๐Ÿค–๐Ÿ“ถ I'm reading {subject.to_succinct_string()} with an SNR of {subject.snr} and an RSSI of {subject.rssi}." - ) - elif subject.snr: - message.reply( - f"๐Ÿค–๐Ÿ“ถ I'm reading {subject.to_succinct_string()} with an SNR of {subject.snr}." - ) - elif subject.rssi: - message.reply( - f"๐Ÿค–๐Ÿ“ถ I'm reading {subject.to_succinct_string()} with an RSSI of {subject.rssi}." - ) - else: - message.reply( - f"๐Ÿค–๐Ÿ“ถ I don't have any readings for {subject.to_succinct_string()}." - ) - else: - rssi = f" and an RSSI of {subject.rssi}" if subject.rssi else "" - snr = ( - f", with an SNR of {subject.snr}{rssi} on the last hop" - if subject.snr - else "" - ) - message.reply( - f"๐Ÿค–๐Ÿ“ถ {subject.to_succinct_string()} is {subject.hopsAway} {'hop' if subject.hopsAway == 1 else 'hops'} away{snr}." - ) - - -def nodes_info(message: Message): - message.reply(f"๐Ÿค–๐Ÿ“ก Nodes report!\n\n{message.nodelist.summary()}") - - -def node_list(message: Message): - message.reply( - f"๐Ÿค–๐Ÿ‘€ I've seen these nodes:\n\n{message.nodelist.to_succinct_string()}" - ) diff --git a/meshbot/tests/test_chatbot.py b/meshbot/tests/test_chatbot.py deleted file mode 100644 index 335556f..0000000 --- a/meshbot/tests/test_chatbot.py +++ /dev/null @@ -1,335 +0,0 @@ -from meshbot.chatbot import Chatbot -from meshbot.meshwrapper import Node, Message - - -def test_registration(): - bot = Chatbot() - - my_state = "MY_STATE" - my_command = { - "command": "TEST", - "description": "Test command", - "function": lambda m, c: "TEST", - "state": "MAIN", - } - - bot.add_state(my_state) - bot.add_command(my_command) - - assert bot.states == ["MAIN", my_state] - assert bot.commands == [my_command] - - -def test_multiple_registrations(): - bot = Chatbot() - - state1 = "MY_STATE_1" - state2 = "MY_STATE_2" - command1 = { - "command": "TEST1", - "description": "Test command 1", - "function": lambda m, c: "TEST1", - "state": "MAIN", - } - command2 = { - "command": "TEST2", - "description": "Test command 2", - "function": lambda m, c: "TEST2", - "state": "MAIN", - } - - bot.add_state(state1, state2) - bot.add_command(command1, command2) - - assert bot.states == ["MAIN", state1, state2] - assert bot.commands == [command1, command2] - - bot.add_state(state1) - bot.add_command(command1) - - assert bot.states == ["MAIN", state1, state2, state1] - assert bot.commands == [command1, command2, command1] - - -def test_to_string(): - bot = Chatbot() - bot.add_command( - { - "command": "TEST1", - "module": "Test Module", - "description": "Test command 1", - "function": lambda m, c: "TEST2", - "state": "MAIN", - } - ) - bot.add_command( - { - "command": "TEST2", - "description": "Test command 2", - "function": lambda m, c: "TEST", - "state": "MAIN", - } - ) - - assert ( - str(bot) - == """๐Ÿค–๐Ÿ‘‹ Hey there! I understand these commands: - -Test Module -- TEST1: Test command 1 - -General commands -- TEST2: Test command 2 -""" - ) - - -def test_simple_message_handling(): - bot = Chatbot() - called = 0 - - message = Message() - message.text = "test" - message.type = "TEXT_MESSAGE_APP" - message.toNode = Node() - - def callback(m): - nonlocal called - assert m == message - called += 1 - - bot.add_command( - { - "command": "TEST", - "description": "Test command", - "function": callback, - "state": "MAIN", - } - ) - - bot.handle(message) - bot.handle(message) - - assert called == 2, "Test message should have been handled by test command twice" - - -def test_specific_before_catch_all_message_handling(): - bot = Chatbot() - called = False - - message = Message() - message.text = "TEST" - message.type = "TEXT_MESSAGE_APP" - message.toNode = Node() - - def callback1(m): - nonlocal called - assert m == message - called = True - - def callback2(m): - assert False, "This should not be called" - - bot.add_command( - { - "command": Chatbot.CATCH_ALL_TEXT, - "description": "Test command", - "function": callback2, - "state": "MAIN", - }, - { - "command": "TEST", - "description": "Test command", - "function": callback1, - "state": "MAIN", - }, - ) - - bot.handle(message) - - assert called, "Test message should have been handled by test command" - - -def test_catch_all_message_handling(): - bot = Chatbot() - called = False - - message = Message() - message.text = "TEST" - message.type = "TEXT_MESSAGE_APP" - message.toNode = Node() - - def callback(m): - nonlocal called - assert m == message - called = True - - bot.add_command( - { - "command": Chatbot.CATCH_ALL_TEXT, - "description": "Test command", - "function": callback, - "state": "MAIN", - } - ) - - bot.handle(message) - - assert called, "Test message should have been handled by catch all command" - - -def test_ignore_other_events_message_handling(): - bot = Chatbot() - - message = Message() - message.text = "TEST" - message.type = "TELEMETRY_APP" - message.toNode = Node() - - def callback(m): - assert False, "This should not be called" - - bot.add_command( - { - "command": Chatbot.CATCH_ALL_TEXT, - "description": "Test command", - "function": callback, - "state": "MAIN", - }, - { - "command": "TEST", - "description": "Test command", - "function": callback, - "state": "MAIN", - }, - ) - - bot.handle(message) - - assert True, "Telemetry packet should have been ignored" - - -def test_catch_all_events_message_handling(): - bot = Chatbot() - called = False - - message = Message() - message.text = "TEST" - message.type = "TELEMETRY_APP" - message.toNode = Node() - - def callback(m): - nonlocal called - assert m == message - called = True - - bot.add_command( - { - "command": Chatbot.CATCH_ALL_EVENTS, - "description": "Test command", - "function": callback, - "state": "MAIN", - } - ) - - bot.handle(message) - - assert ( - called - ), "Telemetry packet should have been handled by catch all events command" - - -def test_multiple_commands_message_handling(): - bot = Chatbot() - called = False - - message = Message() - message.text = "TEST" - message.type = "TEXT_MESSAGE_APP" - message.toNode = Node() - - def callback(m): - nonlocal called - assert m == message - called = True - - bot.add_command( - { - "command": ["THINGS", "TEST"], - "description": "Test command", - "function": callback, - "state": "MAIN", - } - ) - - bot.handle(message) - - assert called, "Test message should have been handled by multi-command test command" - - -def test_multiple_handlers_message_handling(): - bot = Chatbot() - called = 0 - - message = Message() - message.text = "TEST" - message.type = "TEXT_MESSAGE_APP" - message.toNode = Node() - - def callback(m): - nonlocal called - assert m == message - called += 1 - - bot.add_command( - { - "command": "TEST", - "description": "Test command 1", - "function": callback, - "state": "MAIN", - }, - { - "command": "TEST", - "description": "Test command 2", - "function": callback, - "state": "MAIN", - }, - ) - - bot.handle(message) - - assert called == 2, "Test message should have been handled by both commands" - - -def test_multiple_catch_all_handlers_message_handling(): - bot = Chatbot() - called = 0 - - message = Message() - message.text = "TEST" - message.type = "TEXT_MESSAGE_APP" - message.toNode = Node() - - def callback(m): - nonlocal called - assert m == message - called += 1 - - bot.add_command( - { - "command": Chatbot.CATCH_ALL_EVENTS, - "description": "Test command 1", - "function": callback, - "state": "MAIN", - }, - { - "command": Chatbot.CATCH_ALL_TEXT, - "description": "Test command 2", - "function": callback, - "state": "MAIN", - }, - ) - - bot.handle(message) - - assert called == 2, "Test message should have been handled by both commands" diff --git a/meshbot/weather.py b/meshbot/weather.py deleted file mode 100644 index 9b75886..0000000 --- a/meshbot/weather.py +++ /dev/null @@ -1,62 +0,0 @@ -from .meshwrapper import Message -from .chatbot import Chatbot -from .open_meteo import fetch_weather, fetch_forecast - - -def register(bot: Chatbot): - bot.add_command( - { - "command": "/WEATHER", - "module": "๐ŸŒ‚ Weather requests", - "description": "Get the current weather", - "channel": True, - "function": get_weather, - }, - { - "command": "/FORECAST", - "module": "๐ŸŒ‚ Weather requests", - "description": "Get a weather forecast", - "channel": True, - "function": get_forecast, - }, - ) - - -def get_weather(message: Message): - if message.fromNode.position: - position = message.fromNode.position - location_text = "Here's the current weather at your location:" - elif message.nodelist.get_self() and message.nodelist.get_self().position: - position = message.nodelist.get_self().position - location_text = "I can't see your location, so I'll give you the current weather at my location:" - else: - message.reply( - f"๐Ÿค–๐Ÿงจ I'm sorry! I can't give you a weather report, because I don't know the location of either of us." - ) - return - - weather = fetch_weather(position) - if weather: - message.reply(f"๐Ÿค–๐ŸŒ‚ {location_text}\n\n{weather}") - else: - message.reply(f"๐Ÿค–๐ŸŒ‚ I can't get a weather report at this time.") - - -def get_forecast(message: Message): - if message.fromNode.position: - position = message.fromNode.position - location_text = "Here's the weather forecast for your location:" - elif message.nodelist.get_self() and message.nodelist.get_self().position: - position = message.nodelist.get_self().position - location_text = "I can't see your location, so I'll give you the weather forecast for my location:" - else: - message.reply( - f"๐Ÿค–๐Ÿงจ I'm sorry! I can't give you a weather forecast, because I don't know the location of either of us." - ) - return - - forecast = fetch_forecast(position) - if forecast: - message.reply(f"๐Ÿค–๐ŸŒ‚ {location_text}\n\n{forecast}") - else: - message.reply(f"๐Ÿค–๐ŸŒ‚ I can't get a weather forecast at this time.") diff --git a/meshbot/wmo_codes.json b/meshbot/wmo_codes.json deleted file mode 100644 index 6c3ac20..0000000 --- a/meshbot/wmo_codes.json +++ /dev/null @@ -1,338 +0,0 @@ -{ - "0": { - "day": { - "description": "Sunny", - "image": "http://openweathermap.org/img/wn/01d@2x.png", - "icon": "โ˜€๏ธ" - }, - "night": { - "description": "Clear", - "image": "http://openweathermap.org/img/wn/01n@2x.png", - "icon": "๐ŸŒ™" - } - }, - "1": { - "day": { - "description": "Mainly Sunny", - "image": "http://openweathermap.org/img/wn/01d@2x.png", - "icon": "โ˜€๏ธ" - }, - "night": { - "description": "Mainly Clear", - "image": "http://openweathermap.org/img/wn/01n@2x.png", - "icon": "๐ŸŒ™" - } - }, - "2": { - "day": { - "description": "Partly Cloudy", - "image": "http://openweathermap.org/img/wn/02d@2x.png", - "icon": "โ›…๏ธ" - }, - "night": { - "description": "Partly Cloudy", - "image": "http://openweathermap.org/img/wn/02n@2x.png", - "icon": "โ˜๏ธ" - } - }, - "3": { - "day": { - "description": "Cloudy", - "image": "http://openweathermap.org/img/wn/03d@2x.png", - "icon": "โ˜๏ธ" - }, - "night": { - "description": "Cloudy", - "image": "http://openweathermap.org/img/wn/03n@2x.png", - "icon": "โ˜๏ธ" - } - }, - "45": { - "day": { - "description": "Foggy", - "image": "http://openweathermap.org/img/wn/50d@2x.png", - "icon": "๐ŸŒซ๏ธ" - }, - "night": { - "description": "Foggy", - "image": "http://openweathermap.org/img/wn/50n@2x.png", - "icon": "๐ŸŒซ๏ธ" - } - }, - "48": { - "day": { - "description": "Rime Fog", - "image": "http://openweathermap.org/img/wn/50d@2x.png", - "icon": "๐ŸŒซ๏ธ" - }, - "night": { - "description": "Rime Fog", - "image": "http://openweathermap.org/img/wn/50n@2x.png", - "icon": "๐ŸŒซ๏ธ" - } - }, - "51": { - "day": { - "description": "Light Drizzle", - "image": "http://openweathermap.org/img/wn/09d@2x.png", - "icon": "๐ŸŒง๏ธ" - }, - "night": { - "description": "Light Drizzle", - "image": "http://openweathermap.org/img/wn/09n@2x.png", - "icon": "๐ŸŒง๏ธ" - } - }, - "53": { - "day": { - "description": "Drizzle", - "image": "http://openweathermap.org/img/wn/09d@2x.png", - "icon": "๐ŸŒง๏ธ" - }, - "night": { - "description": "Drizzle", - "image": "http://openweathermap.org/img/wn/09n@2x.png", - "icon": "๐ŸŒง๏ธ" - } - }, - "55": { - "day": { - "description": "Heavy Drizzle", - "image": "http://openweathermap.org/img/wn/09d@2x.png", - "icon": "๐ŸŒง๏ธ" - }, - "night": { - "description": "Heavy Drizzle", - "image": "http://openweathermap.org/img/wn/09n@2x.png", - "icon": "๐ŸŒง๏ธ" - } - }, - "56": { - "day": { - "description": "Light Freezing Drizzle", - "image": "http://openweathermap.org/img/wn/09d@2x.png", - "icon": "๐ŸŒจ๏ธ" - }, - "night": { - "description": "Light Freezing Drizzle", - "image": "http://openweathermap.org/img/wn/09n@2x.png", - "icon": "๐ŸŒจ๏ธ" - } - }, - "57": { - "day": { - "description": "Freezing Drizzle", - "image": "http://openweathermap.org/img/wn/09d@2x.png", - "icon": "๐ŸŒจ๏ธ" - }, - "night": { - "description": "Freezing Drizzle", - "image": "http://openweathermap.org/img/wn/09n@2x.png", - "icon": "๐ŸŒจ๏ธ" - } - }, - "61": { - "day": { - "description": "Light Rain", - "image": "http://openweathermap.org/img/wn/10d@2x.png", - "icon": "๐ŸŒฆ๏ธ" - }, - "night": { - "description": "Light Rain", - "image": "http://openweathermap.org/img/wn/10n@2x.png", - "icon": "๐ŸŒง๏ธ" - } - }, - "63": { - "day": { - "description": "Rain", - "image": "http://openweathermap.org/img/wn/10d@2x.png", - "icon": "๐ŸŒง๏ธ" - }, - "night": { - "description": "Rain", - "image": "http://openweathermap.org/img/wn/10n@2x.png", - "icon": "๐ŸŒง๏ธ" - } - }, - "65": { - "day": { - "description": "Heavy Rain", - "image": "http://openweathermap.org/img/wn/10d@2x.png", - "icon": "๐ŸŒง๏ธ" - }, - "night": { - "description": "Heavy Rain", - "image": "http://openweathermap.org/img/wn/10n@2x.png", - "icon": "๐ŸŒง๏ธ" - } - }, - "66": { - "day": { - "description": "Light Freezing Rain", - "image": "http://openweathermap.org/img/wn/10d@2x.png", - "icon": "๐ŸŒจ๏ธ" - }, - "night": { - "description": "Light Freezing Rain", - "image": "http://openweathermap.org/img/wn/10n@2x.png", - "icon": "๐ŸŒจ๏ธ" - } - }, - "67": { - "day": { - "description": "Freezing Rain", - "image": "http://openweathermap.org/img/wn/10d@2x.png", - "icon": "๐ŸŒจ๏ธ" - }, - "night": { - "description": "Freezing Rain", - "image": "http://openweathermap.org/img/wn/10n@2x.png", - "icon": "๐ŸŒจ๏ธ" - } - }, - "71": { - "day": { - "description": "Light Snow", - "image": "http://openweathermap.org/img/wn/13d@2x.png", - "icon": "๐ŸŒจ๏ธ" - }, - "night": { - "description": "Light Snow", - "image": "http://openweathermap.org/img/wn/13n@2x.png", - "icon": "๐ŸŒจ๏ธ" - } - }, - "73": { - "day": { - "description": "Snow", - "image": "http://openweathermap.org/img/wn/13d@2x.png", - "icon": "๐ŸŒจ๏ธ" - }, - "night": { - "description": "Snow", - "image": "http://openweathermap.org/img/wn/13n@2x.png", - "icon": "๐ŸŒจ๏ธ" - } - }, - "75": { - "day": { - "description": "Heavy Snow", - "image": "http://openweathermap.org/img/wn/13d@2x.png", - "icon": "๐ŸŒจ๏ธ" - }, - "night": { - "description": "Heavy Snow", - "image": "http://openweathermap.org/img/wn/13n@2x.png", - "icon": "๐ŸŒจ๏ธ" - } - }, - "77": { - "day": { - "description": "Snow Grains", - "image": "http://openweathermap.org/img/wn/13d@2x.png", - "icon": "๐ŸŒจ๏ธ" - }, - "night": { - "description": "Snow Grains", - "image": "http://openweathermap.org/img/wn/13n@2x.png", - "icon": "๐ŸŒจ๏ธ" - } - }, - "80": { - "day": { - "description": "Light Showers", - "image": "http://openweathermap.org/img/wn/09d@2x.png", - "icon": "๐ŸŒง๏ธ" - }, - "night": { - "description": "Light Showers", - "image": "http://openweathermap.org/img/wn/09n@2x.png", - "icon": "๐ŸŒง๏ธ" - } - }, - "81": { - "day": { - "description": "Showers", - "image": "http://openweathermap.org/img/wn/09d@2x.png", - "icon": "๐ŸŒง๏ธ" - }, - "night": { - "description": "Showers", - "image": "http://openweathermap.org/img/wn/09n@2x.png", - "icon": "๐ŸŒง๏ธ" - } - }, - "82": { - "day": { - "description": "Heavy Showers", - "image": "http://openweathermap.org/img/wn/09d@2x.png", - "icon": "๐ŸŒง๏ธ" - }, - "night": { - "description": "Heavy Showers", - "image": "http://openweathermap.org/img/wn/09n@2x.png", - "icon": "๐ŸŒง๏ธ" - } - }, - "85": { - "day": { - "description": "Light Snow Showers", - "image": "http://openweathermap.org/img/wn/13d@2x.png", - "icon": "๐ŸŒจ๏ธ" - }, - "night": { - "description": "Light Snow Showers", - "image": "http://openweathermap.org/img/wn/13n@2x.png", - "icon": "๐ŸŒจ๏ธ" - } - }, - "86": { - "day": { - "description": "Snow Showers", - "image": "http://openweathermap.org/img/wn/13d@2x.png", - "icon": "๐ŸŒจ๏ธ" - }, - "night": { - "description": "Snow Showers", - "image": "http://openweathermap.org/img/wn/13n@2x.png", - "icon": "๐ŸŒจ๏ธ" - } - }, - "95": { - "day": { - "description": "Thunderstorm", - "image": "http://openweathermap.org/img/wn/11d@2x.png", - "icon": "๐ŸŒฉ๏ธ" - }, - "night": { - "description": "Thunderstorm", - "image": "http://openweathermap.org/img/wn/11n@2x.png", - "icon": "๐ŸŒฉ๏ธ" - } - }, - "96": { - "day": { - "description": "Light Thunderstorms With Hail", - "image": "http://openweathermap.org/img/wn/11d@2x.png", - "icon": "โ›ˆ๏ธ" - }, - "night": { - "description": "Light Thunderstorms With Hail", - "image": "http://openweathermap.org/img/wn/11n@2x.png", - "icon": "โ›ˆ๏ธ" - } - }, - "99": { - "day": { - "description": "Thunderstorm With Hail", - "image": "http://openweathermap.org/img/wn/11d@2x.png", - "icon": "โ›ˆ๏ธ" - }, - "night": { - "description": "Thunderstorm With Hail", - "image": "http://openweathermap.org/img/wn/11n@2x.png", - "icon": "โ›ˆ๏ธ" - } - } -} diff --git a/go/meshwrapper/channel.go b/meshwrapper/channel.go similarity index 100% rename from go/meshwrapper/channel.go rename to meshwrapper/channel.go diff --git a/go/meshwrapper/connected_node.go b/meshwrapper/connected_node.go similarity index 100% rename from go/meshwrapper/connected_node.go rename to meshwrapper/connected_node.go diff --git a/go/meshwrapper/helpers/assertions.go b/meshwrapper/helpers/assertions.go similarity index 100% rename from go/meshwrapper/helpers/assertions.go rename to meshwrapper/helpers/assertions.go diff --git a/go/meshwrapper/helpers/language.go b/meshwrapper/helpers/language.go similarity index 100% rename from go/meshwrapper/helpers/language.go rename to meshwrapper/helpers/language.go diff --git a/go/meshwrapper/helpers/language_test.go b/meshwrapper/helpers/language_test.go similarity index 100% rename from go/meshwrapper/helpers/language_test.go rename to meshwrapper/helpers/language_test.go diff --git a/go/meshwrapper/message.go b/meshwrapper/message.go similarity index 100% rename from go/meshwrapper/message.go rename to meshwrapper/message.go diff --git a/go/meshwrapper/neighbor.go b/meshwrapper/neighbor.go similarity index 100% rename from go/meshwrapper/neighbor.go rename to meshwrapper/neighbor.go diff --git a/go/meshwrapper/node.go b/meshwrapper/node.go similarity index 100% rename from go/meshwrapper/node.go rename to meshwrapper/node.go diff --git a/go/meshwrapper/node_list.go b/meshwrapper/node_list.go similarity index 100% rename from go/meshwrapper/node_list.go rename to meshwrapper/node_list.go diff --git a/go/meshwrapper/position.go b/meshwrapper/position.go similarity index 100% rename from go/meshwrapper/position.go rename to meshwrapper/position.go diff --git a/go/meshwrapper/pubsub.go b/meshwrapper/pubsub.go similarity index 100% rename from go/meshwrapper/pubsub.go rename to meshwrapper/pubsub.go diff --git a/go/meshwrapper/stream_interface.go b/meshwrapper/stream_interface.go similarity index 100% rename from go/meshwrapper/stream_interface.go rename to meshwrapper/stream_interface.go diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index 4c160c4..0000000 --- a/pytest.ini +++ /dev/null @@ -1,3 +0,0 @@ -[pytest] -testpaths = meshbot/tests -pythonpath = . diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 29ebcac..0000000 --- a/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -python-dotenv -pytap2 -meshtastic diff --git a/go/roomserver/room.go b/roomserver/room.go similarity index 100% rename from go/roomserver/room.go rename to roomserver/room.go diff --git a/go/wmo_codes.json b/wmo_codes.json similarity index 100% rename from go/wmo_codes.json rename to wmo_codes.json From a13f19cea7111db2600399b6e0848db5d9ecbca9 Mon Sep 17 00:00:00 2001 From: Timendus Date: Sat, 18 Oct 2025 23:17:18 +0200 Subject: [PATCH 58/87] Move open_meteo to a directory that makes sense --- main.go | 6 +++--- {meshbot => weather}/open_meteo.go | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) rename {meshbot => weather}/open_meteo.go (99%) diff --git a/main.go b/main.go index bf6739b..e43db9d 100644 --- a/main.go +++ b/main.go @@ -13,10 +13,10 @@ import ( "time" "github.com/timendus/meshbot/config" - "github.com/timendus/meshbot/meshbot" m "github.com/timendus/meshbot/meshwrapper" "github.com/timendus/meshbot/meshwrapper/helpers" "github.com/timendus/meshbot/roomserver" + "github.com/timendus/meshbot/weather" "go.bug.st/serial" ) @@ -211,7 +211,7 @@ func incoming(message m.Message) { message.Reply("๐Ÿค–๐Ÿงจ I'm sorry! I can't give you a weather report, because I don't know the location of either of us.") return } - weather, err := meshbot.FetchWeather(meshbot.Position{ + weather, err := weather.FetchWeather(weather.Position{ Latitude: float64(pos[0]), Longitude: float64(pos[1]), }) @@ -241,7 +241,7 @@ func incoming(message m.Message) { message.Reply("๐Ÿค–๐Ÿงจ I'm sorry! I can't give you a weather forecast, because I don't know the location of either of us.") return } - forecast, err := meshbot.FetchForecast(meshbot.Position{ + forecast, err := weather.FetchForecast(weather.Position{ Latitude: float64(pos[0]), Longitude: float64(pos[1]), }) diff --git a/meshbot/open_meteo.go b/weather/open_meteo.go similarity index 99% rename from meshbot/open_meteo.go rename to weather/open_meteo.go index 8732e11..f396708 100644 --- a/meshbot/open_meteo.go +++ b/weather/open_meteo.go @@ -2,7 +2,7 @@ // shite, but it does seem to work. So I'm just going to use it as a black box // and be done with it. -package meshbot +package weather import ( "encoding/json" From 04f6a673b8895f66a3dca4bc049dd1e1317cbf95 Mon Sep 17 00:00:00 2001 From: Timendus Date: Sat, 18 Oct 2025 23:17:31 +0200 Subject: [PATCH 59/87] Update workflow to see if we can test / build this thing --- .github/workflows/python-app.yml | 33 -------------------------- .github/workflows/test.yml | 40 ++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 33 deletions(-) delete mode 100644 .github/workflows/python-app.yml create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml deleted file mode 100644 index 19eda24..0000000 --- a/.github/workflows/python-app.yml +++ /dev/null @@ -1,33 +0,0 @@ -# This workflow will install Python dependencies and run tests with a single version of Python -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python - -name: Python application - -on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] - -permissions: - contents: read - -jobs: - build: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - name: Set up Python 3.12 - uses: actions/setup-python@v3 - with: - python-version: "3.12" - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install pytest - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - - name: Test with pytest - run: | - pytest diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..230ac52 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,40 @@ +name: Test Meshbot + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + test: + name: Run tests + runs-on: ubuntu-latest + strategy: + matrix: + go-version: [ '1.21.x' ] + + steps: + - uses: actions/checkout@v4 + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + - name: Run tests + run: make test + + build: + name: Build assets + runs-on: ubuntu-latest + strategy: + matrix: + go-version: [ '1.21.x' ] + + steps: + - uses: actions/checkout@v4 + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + - name: Build the application + run: make build From 434e619acae30c9ec040cac568e4d387d3658ad6 Mon Sep 17 00:00:00 2001 From: Timendus Date: Sat, 18 Oct 2025 23:25:51 +0200 Subject: [PATCH 60/87] Remove dependency on old meshbot package --- main.go | 2 +- meshwrapper/message.go | 9 +++------ meshwrapper/node.go | 2 -- 3 files changed, 4 insertions(+), 9 deletions(-) diff --git a/main.go b/main.go index e43db9d..3e293bb 100644 --- a/main.go +++ b/main.go @@ -174,7 +174,7 @@ func incoming(message m.Message) { ok := true if len(input) > len("/SIGNAL") { needle := input[len("/SIGNAL"):] - subject, ok = message.FindNode(needle).(*m.Node) + subject = message.FindNode(needle) } if !ok || subject == nil { diff --git a/meshwrapper/message.go b/meshwrapper/message.go index 721712b..028473a 100644 --- a/meshwrapper/message.go +++ b/meshwrapper/message.go @@ -6,7 +6,6 @@ import ( "time" "buf.build/gen/go/meshtastic/protobufs/protocolbuffers/go/meshtastic" - "github.com/timendus/meshbot/meshbot" "github.com/timendus/meshbot/meshwrapper/helpers" ) @@ -125,8 +124,6 @@ func (m *Message) sendTextMessage(message string) (uint32, error) { return m.ReceivingNode.SendMessage(channelId, recipient, message, min(m.HopsAway+2, 7)) } -// Implement meshbot.ChatMessage interface - func (m Message) GetText() string { return m.Text } @@ -146,15 +143,15 @@ func (m Message) GetChannelName() string { return m.Channel.name } -func (m Message) GetSenderNode() meshbot.ChatUser { +func (m Message) GetSenderNode() *Node { return m.FromNode } -func (m Message) GetReceiverNode() meshbot.ChatUser { +func (m Message) GetReceiverNode() *Node { return m.ToNode } -func (m Message) FindNode(needle string) meshbot.ChatUser { +func (m Message) FindNode(needle string) *Node { if m.ReceivingNode == nil { return nil } diff --git a/meshwrapper/node.go b/meshwrapper/node.go index 745a813..4b260cd 100644 --- a/meshwrapper/node.go +++ b/meshwrapper/node.go @@ -91,8 +91,6 @@ func (n *Node) Update(info *meshtastic.NodeInfo) { } } -// Implement meshbot.ChatUser interface - func (n *Node) GetId() int { return int(n.Id) } From 7480549ed53aa1e5c62298b663ea02140ca37638 Mon Sep 17 00:00:00 2001 From: Timendus Date: Sat, 18 Oct 2025 23:36:31 +0200 Subject: [PATCH 61/87] Depend build on test, upload artifacts --- .github/workflows/test.yml | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 230ac52..d7ac46c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,4 +1,4 @@ -name: Test Meshbot +name: Test and build Meshbot on: push: @@ -24,7 +24,8 @@ jobs: run: make test build: - name: Build assets + name: Build binaries + needs: test runs-on: ubuntu-latest strategy: matrix: @@ -38,3 +39,33 @@ jobs: go-version: ${{ matrix.go-version }} - name: Build the application run: make build + - name: Upload Linux binary + uses: actions/upload-artifact@v4 + with: + name: meshbot-linux + path: ./dist/linux/* + - name: Upload Windows binary + uses: actions/upload-artifact@v4 + with: + name: meshbot-windows + path: ./dist/windows/* + - name: Upload Raspberry Pi binary + uses: actions/upload-artifact@v4 + with: + name: meshbot-raspberry-pi + path: ./dist/raspberry-pi/* + - name: Upload MacOS Intel binary + uses: actions/upload-artifact@v4 + with: + name: meshbot-macos-intel + path: ./dist/macos-intel/* + - name: Upload MacOS Apple Silicon binary + uses: actions/upload-artifact@v4 + with: + name: meshbot-macos-apple-silicon + path: ./dist/macos-apple-silicon/* + - name: Upload Docker image + uses: actions/upload-artifact@v4 + with: + name: meshbot-docker-image + path: ./dist/docker/* From 845f5159320ec6c5e29bf808223a5a0050dde0da Mon Sep 17 00:00:00 2001 From: Timendus Date: Sat, 18 Oct 2025 23:46:04 +0200 Subject: [PATCH 62/87] Add config.json to artifact zips --- Makefile | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Makefile b/Makefile index 5d789f1..fc7273c 100644 --- a/Makefile +++ b/Makefile @@ -17,10 +17,20 @@ lines: build: @GOOS=linux GOARCH=amd64 go build -o dist/linux/meshbot *.go + @cp config.json dist/linux/ + @GOOS=windows GOARCH=amd64 go build -o dist/windows/meshbot.exe *.go + @cp config.json dist/windows/ + @GOOS=linux GOARCH=arm64 go build -o dist/raspberry-pi/meshbot *.go + @cp config.json dist/raspberry-pi/ + @GOOS=darwin GOARCH=amd64 go build -o dist/macos-intel/meshbot *.go + @cp config.json dist/macos-intel/ + @GOOS=darwin GOARCH=arm64 go build -o dist/macos-apple-silicon/meshbot *.go + @cp config.json dist/macos-apple-silicon/ + @docker build --quiet -t timendus/meshbot:latest . @mkdir -p dist/docker @docker save timendus/meshbot:latest | gzip > dist/docker/meshbot.tar.gz From a0dff552ef1e2ee39a399dff283e506644c2832d Mon Sep 17 00:00:00 2001 From: Timendus Date: Sun, 19 Oct 2025 01:35:49 +0200 Subject: [PATCH 63/87] Update README --- README.md | 211 ++++++++++++++++++++++++++++++------------------------ 1 file changed, 118 insertions(+), 93 deletions(-) diff --git a/README.md b/README.md index 70cc09b..60d88e4 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,111 @@ +> This version of Meshbot is a rewrite in Go. If for some weird reason you +> **really** need the broken old Python version, I have saved that in the +> [branch +> legacy-version-in-python](https://github.com/Timendus/meshbot/tree/legacy-version-in-python). + # Meshbot A simple bot for use with Meshtastic. I know the name isn't very original ๐Ÿ˜„ Some people would probably call this a "BBS", but personally I think it has more -in common with something like a Slack / Telegram / Discord bot. +in common with a crossover between a Slack / Telegram / Discord bot and a +MeshCore room server. ## Current features -- Mail box / message box for communicating with other Mestastic users. These - commands only work in direct messages with the bot, not in channels for - obvious reasons. -- Ask the bot for signal reports, nodes it currently sees and nodes it has seen. -- Ask the bot for weather reports and forecasts in your area from - [open-meteo.com](https://open-meteo.com/) -- Talk to a self-hosted LLM using [Ollama](https://ollama.com/). - -Some of these things are being demonstrated in this screenshot: - -![A screenshot of the Meshtastic app in a conversation with -Meshbot](./screenshot.jpeg) - -## Setup +- "Room server" that supports multiple rooms with subscriptions and + semi-reliable message delivery ([see + below](#why-rooms-are-more-reliable-than-channels) how we do that) +- Signal reports and neighbours can be queried +- Weather reports and forecasts can be queried, using + [open-meteo.com](https://open-meteo.com/) (requires the bot to have an + Internet connection) +- Programmable regular announcements to channels for service messages in your + area + +## Usage on the mesh + +As a user of Meshbot, you can send these commands and Meshbot will reply. +Commands are not case-sensitive. + +### Either in a channel or as a direct message + +- `/about` or `/help` - Get a short overview of these commands +- `/signal ` - Fetch a signal report on yourself (default) or the + node you ask for +- `/neighbours` - Fetch the list of neighbours that the bot can see over LoRa +- `/weather` - Fetch a report of the current weather conditions +- `/forecast` - Fetch a weather forecast for the coming days + +### As direct messages only + +- `/rooms` - Fetch a list of available rooms and your status in them +- `/join ` - Join a room, so you will receive + messages sent to it. Supply a password for private rooms +- `/leave ` - Leave a room, so you will no longer receive messages + sent to it + +For any other DM you send to the bot: + +- If you have not joined any rooms, and a public room exists (one without a + password), it will add you to this room automatically. +- If you have joined one room, any DM you send to the bot will be sent to all + users in that room. +- If you have joined multiple rooms, prefix your message with the name of the + room you want to send it to, and it will be sent to all users in that room. + +### Why rooms are more reliable than channels + +Direct messages have delivery notification feedback in the app to show you if +your message successfully arrived at its destination. Channels only show that +your message was repeated by _someone_. Also, since Meshtastic 2.6, it makes use +of ["next-hop" routing for direct +messages](https://meshtastic.org/blog/meshtastic-2-6-preview/#next-hop-routing-for-dms). +Channels however do not benefit from this improvement. + +Sometimes direct messages arrive properly, but the delivery notification doesn't +make it back to you. As additional feedback that you have successfully sent a +message, any messages you send to a room will also be echoed back to you. If +your connection to the bot is poor, this may take a while though, so be patient +before sending again. + +Finally, Meshbot will keep trying to send messages to all users in a room +(including the sender) until it receives good delivery notifications. This means +that you may sometimes receive messages multiple times, but it ensures that your +communication is fairly reliable. Even if you move out of range, Meshbot will +remember which messages you missed and as soon as it sees you coming back into +range it will send you the entire history since you left. + +## Hosting Meshbot + +### Be responsible -You will need a Meshtastic node and a computer to host the bot. +There is very little bandwidth available on Meshtastic. If you use this bot, and +especially if you wish to modify it, please make sure it doesn't spam your local +mesh. Make sure it only speaks when spoken to. Et cetera. Be a good neighbour. -- For the node: this software is being developed using a Heltec v3, but I - suppose any Meshtastic node should work well. +### Setup -- For the computer you can just use your laptop or desktop, but for a slightly - more permanent setup you may want to use a dedicated server. A NAS, an old - computer or even an old Raspberry Pi works great for the bot. +> **Please note** that this bot has currently **only** been tested on Linux and +> as a Docker image, over TCP. I expect it will probably work over USB and/or on +> MacOS, Windows or Raspberry Pi, but beware there may be dragons ๐Ÿ˜‰ Feel free +> to create an issue if you run into things, but broad support is currently not +> a high priority. -- To use the LLM feature you will need to run Ollama, which requires a bit more - horse power or preferably a good GPU. This feature is optional though, and it - is also entirely possible to run the bot on one machine and Ollama on another. +You will need a Meshtastic node and a computer to host the bot. The node and the +computer can either be connected through a USB cable, or [trough your network +over wifi or +ethernet](https://meshtastic.org/docs/configuration/radio/network/). -The node and the host can either be connected through a USB cable, or [trough -your network over wifi or -ethernet](https://meshtastic.org/docs/configuration/radio/network/). The former -can be super mobile and does not depend on your local network being up. The -latter allows you the luxury of having your node in the best possible spot for -reception, while the bot is running wherever you happen to have compute. +The former can be super mobile and does not depend on your local network being +up (for example during a power outage). The latter allows you the luxury of +having your node in the best possible spot for reception, while the bot is +running wherever you happen to have compute. -> Note that this bot will most probably **not work on Windows**. It hasn't been -> tested on Windows and I don't wish to ever support Windows. If it does, it's -> just dumb luck ๐Ÿ˜‰ Get someone to build a [Docker image](#docker) for you to -> run or find a Mac or Linux machine. A Raspberry Pi is a great option to get -> started. +#### Meshtastic node -### Meshtastic node +This software is being developed using a Heltec v3, but any Meshtastic node +should do. Make sure no other client besides the bot is communicating with the node, otherwise both clients will be missing messages and things will appear to be @@ -60,73 +117,41 @@ Pro-tips on the Meshtastic side: - Add a robot emoji (๐Ÿค–) to your node name to make it clear to other users that your node is a bot. - You can add quick chat messages -- at least in the Android Meshtastic app. - Adding the commands that the bot accepts (like `NEW` and `/SIGNAL`) as quick - messages makes them really easily accessible with one click. + Adding the commands that the bot accepts (like `/rooms` and `/signal`) as + quick messages makes them really easily accessible with one click. -### Computer +#### Computer -You can run the bot [through Docker (see below)](#docker) or directly on the +Any computer will do as long as it stays on, of course. I run it locally on my +NAS, but even an old Raspberry Pi should work great for the bot. It requires +very few resources. You can run the bot through Docker or directly on the computer. -Assuming you have `git`, `make` and Python 3 installed, clone the project and -copy [`.env`](./.env) to a new file named `production.env` in the project root: +Download the appropriate version of the software from the [releases +page](https://github.com/Timendus/meshbot/releases). Edit the `config.json` file +to tell the bot how to connect to your node and how to behave and start the +software. `config.json` should be in the same directory as the software. -```bash -git clone git@github.com:Timendus/meshbot.git -cd meshbot -cp .env production.env -``` +For the docker version, mount a directory to `/app/config`. Launch the +container. The first time, if configured correctly, a `config.json` file will be +created in the mounted directory for you to edit. Stop the container, edit the +config file and restart the container. + +## Local development -Edit the `production.env` file to specify how the bot should connect to your -node and also where to find Ollama if you wish to use the self-hosted LLM -feature. The file has some examples to get you started: +Dependencies: -https://github.com/Timendus/meshbot/blob/735012c0db883f43378fceedabd81103a04355e7/.env#L1-L17 +- Golang +- Git +- make -Then install the dependencies in a new virtual environment and run the bot: +Then do something like: ```bash -make dependencies +git clone git@github.com:Timendus/meshbot.git +cd meshbot +vi config.json make ``` -The software will attempt to connect to the network address or USB device you -specified in `production.env`. If all goes well you should be greeted by a list -of nodes your bot node has seen, and you should be seeing Meshtastic packets get -logged to the console. - -## Usage - -I'll probably document this at some point, but for now: - -Send a direct message to the node you have connected Meshbot to from another -Meshtastic node, and it will reply to you with the available commands. - -## Be responsible - -There is very little bandwidth available on Meshtastic. If you use this bot, and -especially if you wish to modify it, please make sure it doesn't spam your local -mesh. Make sure it only speaks when spoken to. Et cetera. Be a good neighbour. - -For this reason I have tried to design the commands in such a way that you can -do anything you want by sending a single message. No traversing deep menus or -having to send multiple messages to achieve your goals. - -## Docker - -There is a dockerfile available if you wish to run this bot in Docker. I will -probably put this on Dockerhub when it is a bit more polished, but for now you -have to build the image yourself. - -Run `make build` to create the docker image. Run `make run-image` to run locally -or `make export-image` to build a `.tar.gz` file to run elsewhere. - -To configure which host to connect to, either mount a `production.env` file in -the project root, or set environment variables to match the settings in -`production.env`. - -The command line way for this is: - -```bash -docker run --name=meshbot --env=TRANSPORT=serial --env=DEVICE=/dev/ttyUSB0 -d timendus/meshbot -``` +And the bot should start. From e0bd93a3814f27beefb93cda5781b72913daf240 Mon Sep 17 00:00:00 2001 From: Timendus Date: Sun, 19 Oct 2025 13:22:26 +0200 Subject: [PATCH 64/87] Remove Lua dependency and bump all the things --- go.mod | 15 ++++++++------- go.sum | 40 ++++++++++++++++++---------------------- 2 files changed, 26 insertions(+), 29 deletions(-) diff --git a/go.mod b/go.mod index 2d04b1c..3b17ab2 100644 --- a/go.mod +++ b/go.mod @@ -1,15 +1,16 @@ module github.com/timendus/meshbot -go 1.22.7 +go 1.24.0 + +toolchain go1.24.8 require ( - buf.build/gen/go/meshtastic/protobufs/protocolbuffers/go v1.36.4-20241006120827-cc36fd21e859.1 - github.com/yuin/gopher-lua v1.1.1 - go.bug.st/serial v1.6.2 - google.golang.org/protobuf v1.36.4 + buf.build/gen/go/meshtastic/protobufs/protocolbuffers/go v1.36.10-20251009001424-584e30dfb2f3.1 + go.bug.st/serial v1.6.4 + google.golang.org/protobuf v1.36.10 ) require ( - github.com/creack/goselect v0.1.2 // indirect - golang.org/x/sys v0.29.0 // indirect + github.com/creack/goselect v0.1.3 // indirect + golang.org/x/sys v0.37.0 // indirect ) diff --git a/go.sum b/go.sum index 758a0be..2b9bf79 100644 --- a/go.sum +++ b/go.sum @@ -1,24 +1,20 @@ -buf.build/gen/go/meshtastic/protobufs/protocolbuffers/go v1.36.4-20241006120827-cc36fd21e859.1 h1:zyDzextHjGnmep7Gu92L52+cI7criF9wo9x1j1wASjQ= -buf.build/gen/go/meshtastic/protobufs/protocolbuffers/go v1.36.4-20241006120827-cc36fd21e859.1/go.mod h1:MOqI0PPKXtgCAzKurj4jWYru224NHD0SapFCqlHWxew= -github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0= -github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY= -github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +buf.build/gen/go/meshtastic/protobufs/protocolbuffers/go v1.36.10-20251009001424-584e30dfb2f3.1 h1:b/hOQ34+H2lk03kVzDlw2FwRCo6dmmCZn2IJDp/7uVY= +buf.build/gen/go/meshtastic/protobufs/protocolbuffers/go v1.36.10-20251009001424-584e30dfb2f3.1/go.mod h1:fOg3RiuK/WaC5L0Zpw/FpWDeZHH2LiI9kT7PgmAakI4= +github.com/creack/goselect v0.1.3 h1:MaGNMclRo7P2Jl21hBpR1Cn33ITSbKP6E49RtfblLKc= +github.com/creack/goselect v0.1.3/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= -github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= -go.bug.st/serial v1.6.2 h1:kn9LRX3sdm+WxWKufMlIRndwGfPWsH1/9lCWXQCasq8= -go.bug.st/serial v1.6.2/go.mod h1:UABfsluHAiaNI+La2iESysd9Vetq7VRdpxvjx7CmmOE= -golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= -golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v1.36.4 h1:6A3ZDJHn/eNqc1i+IdefRzy/9PokBTPvcqMySR7NNIM= -google.golang.org/protobuf v1.36.4/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +go.bug.st/serial v1.6.4 h1:7FmqNPgVp3pu2Jz5PoPtbZ9jJO5gnEnZIvnI1lzve8A= +go.bug.st/serial v1.6.4/go.mod h1:nofMJxTeNVny/m6+KaafC6vJGj3miwQZ6vW4BZUGJPI= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From 9f57c58e398b873f13cbb9277ca7eb3816368160 Mon Sep 17 00:00:00 2001 From: Timendus Date: Wed, 22 Oct 2025 17:49:27 +0200 Subject: [PATCH 65/87] Move conversion from meshPacket to Message mostly to message.go, feed Message to Node so it can update itself, and no more stale nodes! --- meshwrapper/connected_node.go | 165 +++++----------------------------- meshwrapper/message.go | 120 +++++++++++++++++++++++++ meshwrapper/neighbor.go | 15 ++-- meshwrapper/node.go | 44 +++++++-- 4 files changed, 183 insertions(+), 161 deletions(-) diff --git a/meshwrapper/connected_node.go b/meshwrapper/connected_node.go index 8ad186e..0dc8ea2 100644 --- a/meshwrapper/connected_node.go +++ b/meshwrapper/connected_node.go @@ -5,13 +5,10 @@ import ( "io" "log" "math/rand/v2" - "strconv" "time" "buf.build/gen/go/meshtastic/protobufs/protocolbuffers/go/meshtastic" "github.com/timendus/meshbot/config" - "github.com/timendus/meshbot/meshwrapper/helpers" - "google.golang.org/protobuf/proto" ) type ConnectedNode struct { @@ -167,9 +164,9 @@ func (n *ConnectedNode) parseNodeInfo(nodeInfo *meshtastic.NodeInfo) { // Create or update the node that this info relates to relevantNode, exists := n.NodeList.nodes[nodeInfo.Num] if !exists { - n.NodeList.nodes[nodeInfo.Num] = NewNode(nodeInfo) + n.NodeList.nodes[nodeInfo.Num] = NewNode(n, nodeInfo) } else { - relevantNode.Update(nodeInfo) + relevantNode.ingestNodeInfo(n, nodeInfo) } } @@ -179,36 +176,29 @@ func (n *ConnectedNode) parseMeshPacket(meshPacket *meshtastic.MeshPacket) { return } - var hops uint32 - if meshPacket.HopStart == 0 { - hops = 0 - } else { - hops = meshPacket.HopStart - meshPacket.HopLimit - } - - payload := meshPacket.GetDecoded().GetPayload() - - toNode := n.NodeList.nodes[meshPacket.To] - fromNode := n.NodeList.nodes[meshPacket.From] - - if fromNode == nil { - // If the sending node is not in our node list yet, just add it. - fromNode = NewNode(&meshtastic.NodeInfo{ - Num: meshPacket.From, - LastHeard: meshPacket.RxTime, + fromNode, ok := n.NodeList.nodes[meshPacket.From] + if !ok { + fromNode = NewNode(n, &meshtastic.NodeInfo{ + Num: meshPacket.From, }) n.NodeList.nodes[meshPacket.From] = fromNode } - fromNode.HopsAway = hops - if hops == 0 { - // Assumption: the packet RxSnr is the signal quality of the received - // packet, which may have hopped through other nodes. So only update - // this node's SNR if we haven't hopped yet. - fromNode.Snr = meshPacket.RxSnr + toNode, ok := n.NodeList.nodes[meshPacket.To] + if !ok { + toNode = NewNode(n, &meshtastic.NodeInfo{ + Num: meshPacket.To, + }) + n.NodeList.nodes[meshPacket.To] = toNode } - channel := n.Channels[meshPacket.Channel] + channel, ok := n.Channels[meshPacket.Channel] + if !ok { + channel = Channel{ + id: meshPacket.Channel, + } + n.Channels[meshPacket.Channel] = channel + } message := Message{ FromNode: fromNode, @@ -218,122 +208,9 @@ func (n *ConnectedNode) parseMeshPacket(meshPacket *meshtastic.MeshPacket) { Timestamp: time.Unix(int64(meshPacket.RxTime), 0), MessageType: MESSAGE_TYPE_OTHER, Snr: meshPacket.RxSnr, - HopsAway: hops, - } - - fromNode.ReceivedMessages = append(fromNode.ReceivedMessages, &message) - - switch meshPacket.GetDecoded().Portnum { - case meshtastic.PortNum_NODEINFO_APP: - result := meshtastic.User{} - err := proto.Unmarshal(payload, &result) - if err != nil { - log.Println("Error: Could not unmarshall NodeInfo User mesh packet: " + err.Error()) - return - } - fromNode.ShortName = result.ShortName - fromNode.LongName = result.LongName - fromNode.HwModel = result.HwModel - fromNode.Role = result.Role - fromNode.IsLicensed = result.IsLicensed - fromNode.PublicKey = result.PublicKey - message.MessageType = MESSAGE_TYPE_NODE_INFO - MessageEvents.publish(NodeInfoEvent, message) - - case meshtastic.PortNum_TELEMETRY_APP: - result := meshtastic.Telemetry{} - err := proto.Unmarshal(payload, &result) - if err != nil { - log.Println("Error: Could not unmarshall Telemetry mesh packet: " + err.Error()) - return - } - switch result.Variant.(type) { - case *meshtastic.Telemetry_DeviceMetrics: - message.MessageType = MESSAGE_TYPE_TELEMETRY_DEVICE - message.DeviceMetrics = result.GetDeviceMetrics() - MessageEvents.publish(DeviceTelemetryEvent, message) - case *meshtastic.Telemetry_EnvironmentMetrics: - message.MessageType = MESSAGE_TYPE_TELEMETRY_ENVIRONMENT - message.EnvironmentMetrics = result.GetEnvironmentMetrics() - MessageEvents.publish(EnvironmentTelemetryEvent, message) - case *meshtastic.Telemetry_HealthMetrics: - message.MessageType = MESSAGE_TYPE_TELEMETRY_HEALTH - message.HealthMetrics = result.GetHealthMetrics() - MessageEvents.publish(HealthTelemetryEvent, message) - case *meshtastic.Telemetry_AirQualityMetrics: - message.MessageType = MESSAGE_TYPE_TELEMETRY_AIR_QUALITY - message.AirQualityMetrics = result.GetAirQualityMetrics() - MessageEvents.publish(AirQualityTelemetryEvent, message) - case *meshtastic.Telemetry_PowerMetrics: - message.MessageType = MESSAGE_TYPE_TELEMETRY_POWER - message.PowerMetrics = result.GetPowerMetrics() - MessageEvents.publish(PowerTelemetryEvent, message) - case *meshtastic.Telemetry_LocalStats: - message.MessageType = MESSAGE_TYPE_TELEMETRY_LOCAL_STATS - message.LocalStats = result.GetLocalStats() - MessageEvents.publish(LocalStatsTelemetryEvent, message) - default: - log.Println("Warning: Unknown telemetry variant:", result.String()) - } - MessageEvents.publish(TelemetryEvent, message) - - case meshtastic.PortNum_POSITION_APP: - result := meshtastic.Position{} - err := proto.Unmarshal(payload, &result) - if err != nil { - log.Println("Error: Could not unmarshall Position mesh packet: " + err.Error()) - return - } - message.MessageType = MESSAGE_TYPE_POSITION - message.Position = NewPosition(&result) - MessageEvents.publish(PositionEvent, message) - - case meshtastic.PortNum_NEIGHBORINFO_APP: - result := meshtastic.NeighborInfo{} - err := proto.Unmarshal(payload, &result) - if err != nil { - log.Println("Error: Could not unmarshall NeighborInfo mesh packet: " + err.Error()) - return - } - message.MessageType = MESSAGE_TYPE_NEIGHBOR_INFO - message.NeighborInfo = &result - helpers.Assert(result.NodeId == meshPacket.From, "I don't understand this format well enough: received "+message.String()+" but it has NodeId "+strconv.Itoa(int(result.NodeId))) - fromNode.Neighbors = NewNeighbourList(&n.NodeList, meshPacket.RxTime, result.Neighbors) - MessageEvents.publish(NeighborInfoEvent, message) - - case meshtastic.PortNum_TEXT_MESSAGE_APP: - message.MessageType = MESSAGE_TYPE_TEXT_MESSAGE - message.Text = string(payload) - MessageEvents.publish(TextMessageEvent, message) - - case meshtastic.PortNum_ROUTING_APP: - if meshPacket.GetDecoded() != nil { - result := meshtastic.Routing{} - err := proto.Unmarshal(payload, &result) - if err != nil { - log.Println("Error: Could not unmarshall Routing mesh packet: " + err.Error()) - return - } - if result.GetErrorReason() != meshtastic.Routing_NONE { - log.Println("Bad acknowledgement: " + meshtastic.Routing_Error_name[int32(result.GetErrorReason())]) - } - messageId := meshPacket.GetDecoded().RequestId - if n.Acks[messageId] != nil { - n.Acks[messageId] <- result.GetErrorReason() == meshtastic.Routing_NONE - close(n.Acks[messageId]) - delete(n.Acks, messageId) - } - } - message.MessageType = MESSAGE_TYPE_ROUTING - MessageEvents.publish(RoutingEvent, message) - - case meshtastic.PortNum_TRACEROUTE_APP: - message.MessageType = MESSAGE_TYPE_TRACEROUTE - MessageEvents.publish(TraceRouteEvent, message) - - default: - log.Println("Warning: Unknown mesh packet:", meshPacket.String()) } + message.ingestMeshPacket(n, meshPacket) + fromNode.receiveMessage(n, message) MessageEvents.publish(IncomingMessageEvent, message) } diff --git a/meshwrapper/message.go b/meshwrapper/message.go index 028473a..f02d76e 100644 --- a/meshwrapper/message.go +++ b/meshwrapper/message.go @@ -3,10 +3,12 @@ package meshwrapper import ( "fmt" "log" + "strconv" "time" "buf.build/gen/go/meshtastic/protobufs/protocolbuffers/go/meshtastic" "github.com/timendus/meshbot/meshwrapper/helpers" + "google.golang.org/protobuf/proto" ) const ( @@ -44,11 +46,129 @@ type Message struct { HealthMetrics *meshtastic.HealthMetrics AirQualityMetrics *meshtastic.AirQualityMetrics PowerMetrics *meshtastic.PowerMetrics + UserInfo *meshtastic.User LocalStats *meshtastic.LocalStats NeighborInfo *meshtastic.NeighborInfo Position *Position } +func (m *Message) ingestMeshPacket(connectedNode *ConnectedNode, meshPacket *meshtastic.MeshPacket) { + if meshPacket.HopStart == 0 { + m.HopsAway = 0 + } else { + m.HopsAway = meshPacket.HopStart - meshPacket.HopLimit + } + + payload := meshPacket.GetDecoded().GetPayload() + switch meshPacket.GetDecoded().Portnum { + + case meshtastic.PortNum_NODEINFO_APP: + result := meshtastic.User{} + err := proto.Unmarshal(payload, &result) + if err != nil { + log.Println("Error: Could not unmarshall NodeInfo User mesh packet: " + err.Error()) + return + } + m.MessageType = MESSAGE_TYPE_NODE_INFO + m.UserInfo = &result + MessageEvents.publish(NodeInfoEvent, *m) + + case meshtastic.PortNum_TELEMETRY_APP: + result := meshtastic.Telemetry{} + err := proto.Unmarshal(payload, &result) + if err != nil { + log.Println("Error: Could not unmarshall Telemetry mesh packet: " + err.Error()) + return + } + switch result.Variant.(type) { + case *meshtastic.Telemetry_DeviceMetrics: + m.MessageType = MESSAGE_TYPE_TELEMETRY_DEVICE + m.DeviceMetrics = result.GetDeviceMetrics() + MessageEvents.publish(DeviceTelemetryEvent, *m) + case *meshtastic.Telemetry_EnvironmentMetrics: + m.MessageType = MESSAGE_TYPE_TELEMETRY_ENVIRONMENT + m.EnvironmentMetrics = result.GetEnvironmentMetrics() + MessageEvents.publish(EnvironmentTelemetryEvent, *m) + case *meshtastic.Telemetry_HealthMetrics: + m.MessageType = MESSAGE_TYPE_TELEMETRY_HEALTH + m.HealthMetrics = result.GetHealthMetrics() + MessageEvents.publish(HealthTelemetryEvent, *m) + case *meshtastic.Telemetry_AirQualityMetrics: + m.MessageType = MESSAGE_TYPE_TELEMETRY_AIR_QUALITY + m.AirQualityMetrics = result.GetAirQualityMetrics() + MessageEvents.publish(AirQualityTelemetryEvent, *m) + case *meshtastic.Telemetry_PowerMetrics: + m.MessageType = MESSAGE_TYPE_TELEMETRY_POWER + m.PowerMetrics = result.GetPowerMetrics() + MessageEvents.publish(PowerTelemetryEvent, *m) + case *meshtastic.Telemetry_LocalStats: + m.MessageType = MESSAGE_TYPE_TELEMETRY_LOCAL_STATS + m.LocalStats = result.GetLocalStats() + MessageEvents.publish(LocalStatsTelemetryEvent, *m) + default: + log.Println("Warning: Unknown telemetry variant:", result.String()) + } + MessageEvents.publish(TelemetryEvent, *m) + + case meshtastic.PortNum_POSITION_APP: + result := meshtastic.Position{} + err := proto.Unmarshal(payload, &result) + if err != nil { + log.Println("Error: Could not unmarshall Position mesh packet: " + err.Error()) + return + } + m.MessageType = MESSAGE_TYPE_POSITION + m.Position = NewPosition(&result) + MessageEvents.publish(PositionEvent, *m) + + case meshtastic.PortNum_NEIGHBORINFO_APP: + result := meshtastic.NeighborInfo{} + err := proto.Unmarshal(payload, &result) + if err != nil { + log.Println("Error: Could not unmarshall NeighborInfo mesh packet: " + err.Error()) + return + } + m.MessageType = MESSAGE_TYPE_NEIGHBOR_INFO + m.NeighborInfo = &result + helpers.Assert(result.NodeId == meshPacket.From, "I don't understand this format well enough: received "+m.String()+" but it has NodeId "+strconv.Itoa(int(result.NodeId))) + MessageEvents.publish(NeighborInfoEvent, *m) + + case meshtastic.PortNum_TEXT_MESSAGE_APP: + m.MessageType = MESSAGE_TYPE_TEXT_MESSAGE + m.Text = string(payload) + MessageEvents.publish(TextMessageEvent, *m) + + case meshtastic.PortNum_ROUTING_APP: + if meshPacket.GetDecoded() != nil { + result := meshtastic.Routing{} + err := proto.Unmarshal(payload, &result) + if err != nil { + log.Println("Error: Could not unmarshall Routing mesh packet: " + err.Error()) + return + } + if result.GetErrorReason() != meshtastic.Routing_NONE { + log.Println("Bad acknowledgement: " + meshtastic.Routing_Error_name[int32(result.GetErrorReason())]) + } + messageId := meshPacket.GetDecoded().RequestId + if connectedNode.Acks[messageId] != nil { + connectedNode.Acks[messageId] <- result.GetErrorReason() == meshtastic.Routing_NONE + close(connectedNode.Acks[messageId]) + delete(connectedNode.Acks, messageId) + } + } + m.MessageType = MESSAGE_TYPE_ROUTING + MessageEvents.publish(RoutingEvent, *m) + + case meshtastic.PortNum_TRACEROUTE_APP: + m.MessageType = MESSAGE_TYPE_TRACEROUTE + MessageEvents.publish(TraceRouteEvent, *m) + + default: + log.Println("Warning: Unknown mesh packet:", meshPacket.String()) + + } +} + func (m Message) Reply(message string, timeout ...time.Duration) chan bool { ch := make(chan bool) diff --git a/meshwrapper/neighbor.go b/meshwrapper/neighbor.go index c55efb8..a4dd925 100644 --- a/meshwrapper/neighbor.go +++ b/meshwrapper/neighbor.go @@ -20,21 +20,20 @@ func (n *Neighbor) String() string { type NeighborList []Neighbor -func NewNeighbourList(nodelist *nodeList, timestamp uint32, neighbors []*meshtastic.Neighbor) NeighborList { - properTimestamp := time.Unix(int64(timestamp), 0) +func NewNeighbourList(connectedNode *ConnectedNode, message Message) NeighborList { neighbourList := make([]Neighbor, 0) - for _, neighbor := range neighbors { - node := nodelist.nodes[neighbor.NodeId] - if node == nil { - node = NewNode(&meshtastic.NodeInfo{ + for _, neighbor := range message.NeighborInfo.Neighbors { + node, ok := connectedNode.NodeList.nodes[neighbor.NodeId] + if !ok { + node = NewNode(connectedNode, &meshtastic.NodeInfo{ Num: neighbor.NodeId, }) - nodelist.nodes[neighbor.NodeId] = node + connectedNode.NodeList.nodes[neighbor.NodeId] = node } neighbourList = append(neighbourList, Neighbor{ Node: node, Snr: neighbor.Snr, - LastReported: properTimestamp, + LastReported: message.Timestamp, }) } return neighbourList diff --git a/meshwrapper/node.go b/meshwrapper/node.go index 4b260cd..5b1eb8d 100644 --- a/meshwrapper/node.go +++ b/meshwrapper/node.go @@ -26,7 +26,7 @@ type Node struct { Position *Position } -func NewNode(info *meshtastic.NodeInfo) *Node { +func NewNode(connectedNode *ConnectedNode, info *meshtastic.NodeInfo) *Node { node := Node{ Id: info.Num, HopsAway: 0, @@ -38,11 +38,13 @@ func NewNode(info *meshtastic.NodeInfo) *Node { Neighbors: make(NeighborList, 0), } - node.Update(info) + node.ingestNodeInfo(connectedNode, info) return &node } -func (n *Node) Update(info *meshtastic.NodeInfo) { +// NodeInfo is basically the node list we get from the device when we first +// connect over serial (I think) as well as the list of nodes in Neighbour Info +func (n *Node) ingestNodeInfo(connectedNode *ConnectedNode, info *meshtastic.NodeInfo) { if info == nil || info.Num != n.Id { return } @@ -51,12 +53,10 @@ func (n *Node) Update(info *meshtastic.NodeInfo) { n.LastHeard = time.Unix(int64(info.LastHeard), 0) if info.Position != nil { - // TODO: move this to connected_node, I think. Because we don't have the - // receiving node here. Does that even matter..? n.ReceivedMessages = append(n.ReceivedMessages, &Message{ FromNode: n, ToNode: &Broadcast, - ReceivingNode: nil, + ReceivingNode: connectedNode, Timestamp: time.Unix(int64(info.LastHeard), 0), MessageType: MESSAGE_TYPE_POSITION, Position: NewPosition(info.Position), @@ -65,12 +65,10 @@ func (n *Node) Update(info *meshtastic.NodeInfo) { } if info.DeviceMetrics != nil { - // TODO: move this to connected_node, I think. Because we don't have the - // receiving node here. Does that even matter..? n.ReceivedMessages = append(n.ReceivedMessages, &Message{ FromNode: n, ToNode: &Broadcast, - ReceivingNode: nil, + ReceivingNode: connectedNode, Timestamp: time.Unix(int64(info.LastHeard), 0), MessageType: MESSAGE_TYPE_TELEMETRY_DEVICE, DeviceMetrics: info.DeviceMetrics, @@ -91,6 +89,34 @@ func (n *Node) Update(info *meshtastic.NodeInfo) { } } +// If we receive a messages that came from this node, make sure we update our +// node accordingly and store the message in our list +func (n *Node) receiveMessage(connectedNode *ConnectedNode, message Message) { + n.ReceivedMessages = append(n.ReceivedMessages, &message) + n.LastHeard = message.Timestamp + n.HopsAway = message.HopsAway + if message.HopsAway == 0 { + // Assumption: the packet RxSnr is the signal quality of the received + // packet, which may have hopped through other nodes. So only update + // this node's SNR if we haven't hopped yet. + n.Snr = message.Snr + } + + switch message.MessageType { + case MESSAGE_TYPE_NODE_INFO: + n.ShortName = message.UserInfo.ShortName + n.LongName = message.UserInfo.LongName + n.HwModel = message.UserInfo.HwModel + n.Role = message.UserInfo.Role + n.IsLicensed = message.UserInfo.IsLicensed + n.PublicKey = message.UserInfo.PublicKey + case MESSAGE_TYPE_POSITION: + n.Position = message.Position + case MESSAGE_TYPE_NEIGHBOR_INFO: + n.Neighbors = NewNeighbourList(connectedNode, message) + } +} + func (n *Node) GetId() int { return int(n.Id) } From b0b84572b664f31200eaee84afa6dd9aee9642b3 Mon Sep 17 00:00:00 2001 From: Timendus Date: Wed, 22 Oct 2025 17:59:26 +0200 Subject: [PATCH 66/87] Be explicit in conversion for new Go 1.24 rules --- roomserver/room.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/roomserver/room.go b/roomserver/room.go index ab09d53..7bcaf99 100644 --- a/roomserver/room.go +++ b/roomserver/room.go @@ -70,10 +70,10 @@ func Join(user *User, roomName string, password string) error { for i, room := range Rooms { if roomName == strings.ToLower(room.Config.Name) { if room.Config.Password != "" && room.Config.Password != password { - return fmt.Errorf("Invalid password for " + room.Config.Name) + return fmt.Errorf("Invalid password for %s", room.Config.Name) } if room.present(user) { - return fmt.Errorf("You are already in room " + room.Config.Name) + return fmt.Errorf("You are already in room %s", room.Config.Name) } Rooms[i].Users = append(Rooms[i].Users, user) return nil @@ -92,7 +92,7 @@ func Leave(user *User, roomName string) error { return nil } } - return fmt.Errorf("Looks like you were not in room " + roomName) + return fmt.Errorf("Looks like you were not in room %s", roomName) } } return fmt.Errorf("Can't find that room!") @@ -125,7 +125,7 @@ func Send(user *User, message string) error { if err != nil { return fmt.Errorf("You're not in any rooms. /join a room.") } - return fmt.Errorf("You were not in any rooms. I took the liberty of putting you in room " + firstPublicRoom + ".\n\n๐Ÿ”ด Note: All messages you send to me from now on will be broadcast to room " + firstPublicRoom + "! ๐Ÿ”ด") + return fmt.Errorf("You were not in any rooms. I took the liberty of putting you in room %s.\n\n๐Ÿ”ด Note: All messages you send to me from now on will be broadcast to room %s! ๐Ÿ”ด", firstPublicRoom, firstPublicRoom) case 1: rooms[0].send(Message{Sender: user, Contents: message}) return nil From 107dec84d8621ae5151a71dfb1841932acaa23d8 Mon Sep 17 00:00:00 2001 From: Timendus Date: Wed, 22 Oct 2025 18:00:12 +0200 Subject: [PATCH 67/87] We want to be testing with this version --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d7ac46c..9c1d001 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - go-version: [ '1.21.x' ] + go-version: [ '1.24.x' ] steps: - uses: actions/checkout@v4 @@ -29,7 +29,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - go-version: [ '1.21.x' ] + go-version: [ '1.24.x' ] steps: - uses: actions/checkout@v4 From 841b200ad886f1b24993ff45db3a2c7f3daa0695 Mon Sep 17 00:00:00 2001 From: Timendus Date: Wed, 22 Oct 2025 19:14:10 +0200 Subject: [PATCH 68/87] Add /ping command --- main.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/main.go b/main.go index 3e293bb..6a817bb 100644 --- a/main.go +++ b/main.go @@ -151,6 +151,11 @@ func incoming(message m.Message) { if message.MessageType == m.MESSAGE_TYPE_TEXT_MESSAGE { command := strings.ToUpper(message.Text) + if strings.HasPrefix(command, "/PING") { + message.Reply("๐Ÿค–๐Ÿ“ Pong!") + return + } + if strings.HasPrefix(command, "/HELP") || strings.HasPrefix(command, "/ABOUT") { <-message.Reply( `๐Ÿค–๐Ÿ‘‹ Hello! I'm your friendly neighbourhood roomserver bot. I understand these commands: From b9bdb40b57e41c13e22e15b00c1712639edaa5fc Mon Sep 17 00:00:00 2001 From: Timendus Date: Wed, 22 Oct 2025 20:23:24 +0200 Subject: [PATCH 69/87] Improve logging a bit --- main.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/main.go b/main.go index 6a817bb..df830be 100644 --- a/main.go +++ b/main.go @@ -46,6 +46,7 @@ func main() { log.Fatal("Serial connection configured, but not allowed by settings") } node = m.NewConnectedNode(func() (io.ReadWriteCloser, error) { + log.Println("Attempting to open serial connection to " + connection.SerialDevice) stream, err := serial.Open(connection.SerialDevice, &serial.Mode{ BaudRate: 115200, }) @@ -60,9 +61,11 @@ func main() { log.Fatal("TCP connection configured, but not allowed by settings") } node = m.NewConnectedNode(func() (io.ReadWriteCloser, error) { - stream, err := net.Dial("tcp", connection.Hostname+":"+strconv.Itoa(connection.Port)) + conn := connection.Hostname + ":" + strconv.Itoa(connection.Port) + log.Println("Attempting to open TCP connection to " + conn) + stream, err := net.Dial("tcp", conn) if err != nil { - return nil, fmt.Errorf("Could not open TCP connection to '"+connection.Hostname+":"+strconv.Itoa(connection.Port)+"': ", err) + return nil, fmt.Errorf("Could not open TCP connection to '"+conn+"': ", err) } return stream, nil }) From 321f20fac1b9a9074e9bbf75e758ab03ebc64ce7 Mon Sep 17 00:00:00 2001 From: Timendus Date: Wed, 22 Oct 2025 20:26:09 +0200 Subject: [PATCH 70/87] Embed WMO codes instead of loading from file --- Dockerfile | 1 - weather/open_meteo.go | 13 +++++-------- wmo_codes.json => weather/wmo_codes.json | 0 3 files changed, 5 insertions(+), 9 deletions(-) rename wmo_codes.json => weather/wmo_codes.json (100%) diff --git a/Dockerfile b/Dockerfile index 67de638..3d675f1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,7 +31,6 @@ COPY --from=build /app/output /app # Have a little runner script that copies the default config and plugins to the # host directory if not yet present COPY ./config.json /app/default-config/config.json -COPY ./wmo_codes.json /app/wmo_codes.json RUN cat >./run-meshbot.sh < Date: Wed, 22 Oct 2025 22:54:55 +0200 Subject: [PATCH 71/87] Create draft release on request, I think..? --- .github/workflows/test.yml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9c1d001..36f5f99 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,6 +5,15 @@ on: branches: [ "main" ] pull_request: branches: [ "main" ] + workflow_dispatch: + inputs: + tag: + description: "Tag name for the release (e.g., v1.2.3)" + required: true + default: "" + +permissions: + contents: write # required to create releases & upload assets jobs: test: @@ -39,6 +48,7 @@ jobs: go-version: ${{ matrix.go-version }} - name: Build the application run: make build + - name: Upload Linux binary uses: actions/upload-artifact@v4 with: @@ -69,3 +79,20 @@ jobs: with: name: meshbot-docker-image path: ./dist/docker/* + + - name: Collect all build artifacts for release + uses: actions/download-artifact@v4 + if: github.event.inputs.tag != "" + with: + pattern: meshbot-* + merge-multiple: true + path: release_assets + - name: Create release + uses: softprops/action-gh-release@v2 + if: github.event.inputs.tag != "" + with: + draft: true + tag_name: github.event.inputs.tag + generate_release_notes: true + files: | + release_assets/* From 7cd69e5249c7a24629be0b8fc91a53517825c4d1 Mon Sep 17 00:00:00 2001 From: Timendus Date: Thu, 23 Oct 2025 00:16:50 +0200 Subject: [PATCH 72/87] Have users have a selected room that they send to, so no more prefixing messages with room names --- README.md | 14 ++++--- main.go | 37 +++++++++++++---- roomserver/room.go | 101 ++++++++++++++++++++++++++++++++------------- 3 files changed, 110 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 60d88e4..0f2bb23 100644 --- a/README.md +++ b/README.md @@ -41,18 +41,20 @@ Commands are not case-sensitive. - `/rooms` - Fetch a list of available rooms and your status in them - `/join ` - Join a room, so you will receive - messages sent to it. Supply a password for private rooms + messages sent to it. Supply a password for private rooms. Joining a room also + selects it +- `/select ` - Select a room. Messages you send will be broadcast to + the selected room. Only a single room can be selected at a time. You can only + select a room that you have joined - `/leave ` - Leave a room, so you will no longer receive messages sent to it For any other DM you send to the bot: - If you have not joined any rooms, and a public room exists (one without a - password), it will add you to this room automatically. -- If you have joined one room, any DM you send to the bot will be sent to all - users in that room. -- If you have joined multiple rooms, prefix your message with the name of the - room you want to send it to, and it will be sent to all users in that room. + password), it will make you join this room and select it automatically. +- Otherwise, any DM you send to the bot will be sent to all users in the + selected room. ### Why rooms are more reliable than channels diff --git a/main.go b/main.go index df830be..e06231d 100644 --- a/main.go +++ b/main.go @@ -165,6 +165,7 @@ func incoming(message m.Message) { - /rooms - /join + - /select - /leave `) message.Reply( `Bonus features: @@ -275,12 +276,7 @@ func incoming(message m.Message) { user := roomserver.GetUser(message) if strings.HasPrefix(command, "/ROOMS") { - message.Reply( - `๐Ÿค–๐Ÿ’ฌ These are the available rooms: - -` + roomserver.RoomList(user) + ` -Join by sending /join -Leave by sending /leave `) + message.Reply("๐Ÿค–๐Ÿ’ฌ These are the available rooms:\n\n" + roomserver.RoomList(user)) return } @@ -300,7 +296,7 @@ Leave by sending /leave `) message.Reply("๐Ÿค–๐Ÿ’ฌ " + err.Error()) return } - message.Reply("๐Ÿค–๐Ÿ’ฌ You joined " + roomName) + message.Reply("๐Ÿค–๐Ÿ’ฌ You joined " + roomName + ". It was also automatically selected for you to send to.") return } @@ -320,6 +316,27 @@ Leave by sending /leave `) return } + if strings.HasPrefix(command, "/SELECT") { + params := strings.Split(strings.TrimSpace(message.Text[len("/SELECT"):]), " ") + if len(params) == 0 { + message.Reply("๐Ÿค–๐Ÿงจ You need to specify the name of a room to select") + return + } + roomName := params[0] + err := roomserver.Select(user, roomName) + if err != nil { + message.Reply("๐Ÿค–๐Ÿ’ฌ " + err.Error()) + return + } + message.Reply("๐Ÿค–๐Ÿ’ฌ You selected " + roomName) + return + } + + if strings.HasPrefix(command, "/") { + message.Reply("๐Ÿค–โ“ I don't know that command. See /help for the things I understand!") + return + } + // Handle freeform messages to a room msg := strings.TrimSpace(message.Text) if len(msg) == 0 { @@ -328,7 +345,11 @@ Leave by sending /leave `) err := roomserver.Send(user, msg) if err != nil { <-message.Reply("๐Ÿค–๐Ÿ’ฌ " + err.Error()) - message.Reply("Send /rooms to see available rooms\nSend /help to see all commands") + message.Reply( + `You receive messages from rooms you have joined. +You send messages to the room you have selected. +Send /rooms to see available rooms. +Send /help to see all commands`) return } } diff --git a/roomserver/room.go b/roomserver/room.go index 7bcaf99..e075c0a 100644 --- a/roomserver/room.go +++ b/roomserver/room.go @@ -20,9 +20,10 @@ type Message struct { } type User struct { - Node *m.Node - Send func(string) chan bool - Backlog []*Message + Node *m.Node + Send func(string) chan bool + Backlog []*Message + Selected *Room } var Rooms []Room @@ -51,7 +52,7 @@ func GetUser(msg m.Message) *User { func RoomList(user *User) string { text := "" - for _, room := range Rooms { + for i, room := range Rooms { public := " โœ… " if room.Config.Password != "" { public = " ๐Ÿ” " @@ -60,7 +61,11 @@ func RoomList(user *User) string { if room.present(user) { joined = " (joined)" } - text += public + room.Config.Name + joined + "\n" + selected := "" + if user.Selected == &Rooms[i] { + selected = " (selected)" + } + text += public + room.Config.Name + joined + selected + "\n" } return text } @@ -76,6 +81,7 @@ func Join(user *User, roomName string, password string) error { return fmt.Errorf("You are already in room %s", room.Config.Name) } Rooms[i].Users = append(Rooms[i].Users, user) + user.Selected = &Rooms[i] return nil } } @@ -89,6 +95,7 @@ func Leave(user *User, roomName string) error { for j, u := range room.Users { if u.Node.Id == user.Node.Id { Rooms[i].Users = append(Rooms[i].Users[:j], Rooms[i].Users[j+1:]...) + user.autoSelectRoom() return nil } } @@ -98,20 +105,20 @@ func Leave(user *User, roomName string) error { return fmt.Errorf("Can't find that room!") } -func RoomsForUser(user *User) []Room { - rooms := make([]Room, 0) - for _, room := range Rooms { - if room.present(user) { - rooms = append(rooms, room) +func Select(user *User, roomName string) error { + roomName = strings.ToLower(roomName) + for i, room := range Rooms { + if roomName == strings.ToLower(room.Config.Name) { + return user.selectRoom(&Rooms[i]) } } - return rooms + return fmt.Errorf("Can't find that room!") } func Send(user *User, message string) error { - rooms := RoomsForUser(user) - switch len(rooms) { - case 0: + rooms := user.rooms() + + if len(rooms) == 0 { firstPublicRoom := "" for _, room := range Rooms { if room.Config.Password == "" { @@ -126,27 +133,20 @@ func Send(user *User, message string) error { return fmt.Errorf("You're not in any rooms. /join a room.") } return fmt.Errorf("You were not in any rooms. I took the liberty of putting you in room %s.\n\n๐Ÿ”ด Note: All messages you send to me from now on will be broadcast to room %s! ๐Ÿ”ด", firstPublicRoom, firstPublicRoom) - case 1: - rooms[0].send(Message{Sender: user, Contents: message}) - return nil - default: - parts := strings.Split(message, " ") - roomName := strings.ToLower(parts[0]) - for _, room := range rooms { - if roomName == strings.ToLower(room.Config.Name) { - room.send(Message{Sender: user, Contents: strings.Join(parts[1:], " ")}) - return nil - } - } - return fmt.Errorf("You've joined multiple rooms, please prefix your message with the name of the room you want to send to.") } + + if user.Selected == nil { + return fmt.Errorf("You have not selected a room to send to. Please /select a room.") + } + user.Selected.send(Message{Sender: user, Contents: message}) + return nil } func (room *Room) send(msg Message) { room.Messages = append(room.Messages, msg) for _, user := range room.Users { go func() { - ok := <-user.Send("[" + msg.Sender.Node.ShortName + "] " + msg.Contents) + ok := <-user.Send("[" + msg.Sender.Node.ShortName + " in " + room.Config.Name + "] " + msg.Contents) if !ok { user.Backlog = append(user.Backlog, &msg) } @@ -162,3 +162,48 @@ func (room *Room) present(user *User) bool { } return false } + +func (user *User) rooms() []Room { + rooms := make([]Room, 0) + for _, room := range Rooms { + if room.present(user) { + rooms = append(rooms, room) + } + } + return rooms +} + +func (user *User) selectRoom(room *Room) error { + if !room.present(user) { + return fmt.Errorf("You can't select a room you haven't joined") + } + + if user.Selected == room { + return fmt.Errorf("Room %s was already selected", room.Config.Name) + } + + user.Selected = room + return nil +} + +func (user *User) autoSelectRoom() { + rooms := user.rooms() + + // If no rooms; no selection + if len(rooms) == 0 { + user.Selected = nil + return + } + + // If selected is a valid room, keep it + if user.Selected != nil { + for i := range rooms { + if user.Selected == &rooms[i] { + return + } + } + } + + // Otherwise, select the first room + user.Selected = &rooms[0] +} From f7dd46abe4287c3eb833d9d1cf4a10234e26fb67 Mon Sep 17 00:00:00 2001 From: Timendus Date: Thu, 23 Oct 2025 00:17:01 +0200 Subject: [PATCH 73/87] Only send forecast for three days --- weather/open_meteo.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/weather/open_meteo.go b/weather/open_meteo.go index 1b5c2d4..aa46c75 100644 --- a/weather/open_meteo.go +++ b/weather/open_meteo.go @@ -304,9 +304,9 @@ func FetchForecast(position Position) (string, error) { } } - // Build the forecast string (limit to 6 days if available) + // Build the forecast string (limit to 3 days if available) forecastStr := "" - limit := 6 + limit := 3 if n < limit { limit = n } From d6d2895bc7ad2a073bdca88a600bf8bd66995c4c Mon Sep 17 00:00:00 2001 From: Timendus Date: Thu, 23 Oct 2025 00:18:44 +0200 Subject: [PATCH 74/87] Happy with this, Github? --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 36f5f99..79502fb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -82,14 +82,14 @@ jobs: - name: Collect all build artifacts for release uses: actions/download-artifact@v4 - if: github.event.inputs.tag != "" + if: github.event.inputs.tag != '' with: pattern: meshbot-* merge-multiple: true path: release_assets - name: Create release uses: softprops/action-gh-release@v2 - if: github.event.inputs.tag != "" + if: github.event.inputs.tag != '' with: draft: true tag_name: github.event.inputs.tag From ecbc33d7a418586627cbdf20d68e7dee62ecd04f Mon Sep 17 00:00:00 2001 From: Timendus Date: Wed, 29 Oct 2025 15:56:49 +0100 Subject: [PATCH 75/87] Change if into guard clause --- main.go | 313 ++++++++++++++++++++++++++++---------------------------- 1 file changed, 158 insertions(+), 155 deletions(-) diff --git a/main.go b/main.go index e06231d..3fa0afa 100644 --- a/main.go +++ b/main.go @@ -151,207 +151,210 @@ func disconnected(node m.ConnectedNode) { func incoming(message m.Message) { fmt.Println(message.String()) - if message.MessageType == m.MESSAGE_TYPE_TEXT_MESSAGE { - command := strings.ToUpper(message.Text) - if strings.HasPrefix(command, "/PING") { - message.Reply("๐Ÿค–๐Ÿ“ Pong!") - return - } + if message.MessageType != m.MESSAGE_TYPE_TEXT_MESSAGE { + return + } - if strings.HasPrefix(command, "/HELP") || strings.HasPrefix(command, "/ABOUT") { - <-message.Reply( - `๐Ÿค–๐Ÿ‘‹ Hello! I'm your friendly neighbourhood roomserver bot. I understand these commands: + command := strings.ToUpper(message.Text) + + if strings.HasPrefix(command, "/PING") { + message.Reply("๐Ÿค–๐Ÿ“ Pong!") + return + } + + if strings.HasPrefix(command, "/HELP") || strings.HasPrefix(command, "/ABOUT") { + <-message.Reply( + `๐Ÿค–๐Ÿ‘‹ Hello! I'm your friendly neighbourhood roomserver bot. I understand these commands: - /rooms - /join - /select - /leave `) - message.Reply( - `Bonus features: + message.Reply( + `Bonus features: - /neighbours - /signal - /weather - /forecast`) + return + } + + if strings.HasPrefix(command, "/SIGNAL") { + input := strings.TrimSpace(message.Text) + subject := message.FromNode + ok := true + if len(input) > len("/SIGNAL") { + needle := input[len("/SIGNAL"):] + subject = message.FindNode(needle) + } + + if !ok || subject == nil { + message.Reply("๐Ÿค–๐Ÿงจ I don't know who that is. Sorry!\n\nI need the short name (example: TDRP), node ID (example: !87e35ac8) or part of the long name of a node that I know.") return } - if strings.HasPrefix(command, "/SIGNAL") { - input := strings.TrimSpace(message.Text) - subject := message.FromNode - ok := true - if len(input) > len("/SIGNAL") { - needle := input[len("/SIGNAL"):] - subject = message.FindNode(needle) - } + if subject.HopsAway == 0 { + message.Reply("๐Ÿค–๐Ÿ“ถ I last heard " + subject.String() + " " + helpers.TimeAgo(subject.LastHeard) + " ago with an SNR of " + + strconv.FormatFloat(float64(subject.GetSNR()), 'f', 2, 32)) + } else { + message.Reply("๐Ÿค–๐Ÿ“ถ " + subject.String() + " is " + strconv.Itoa(int(subject.HopsAway)) + " " + helpers.Pluralize("hop", int(subject.HopsAway)) + " away") + } + return + } - if !ok || subject == nil { - message.Reply("๐Ÿค–๐Ÿงจ I don't know who that is. Sorry!\n\nI need the short name (example: TDRP), node ID (example: !87e35ac8) or part of the long name of a node that I know.") - return - } + if strings.HasPrefix(command, "/NEIGHBOURS") { + message.Reply("๐Ÿค–๐Ÿ‘‚ These are the nodes I've heard in the last hour:\n\n" + message.ReceivingNode.NodeList.Neighbours()) + return + } - if subject.HopsAway == 0 { - message.Reply("๐Ÿค–๐Ÿ“ถ I last heard " + subject.String() + " " + helpers.TimeAgo(subject.LastHeard) + " ago with an SNR of " + - strconv.FormatFloat(float64(subject.GetSNR()), 'f', 2, 32)) - } else { - message.Reply("๐Ÿค–๐Ÿ“ถ " + subject.String() + " is " + strconv.Itoa(int(subject.HopsAway)) + " " + helpers.Pluralize("hop", int(subject.HopsAway)) + " away") - } - return + if strings.HasPrefix(command, "/WEATHER") { + var text string + var pos [3]float32 + if message.FromNode != nil { + pos = message.FromNode.GetPosition() + text = "Here's the current weather at your location:" } - - if strings.HasPrefix(command, "/NEIGHBOURS") { - message.Reply("๐Ÿค–๐Ÿ‘‚ These are the nodes I've heard in the last hour:\n\n" + message.ReceivingNode.NodeList.Neighbours()) + if message.FromNode == nil || pos[0] == 0 || pos[1] == 0 { + pos = message.ToNode.GetPosition() + text = "I can't see your location, so I'll give you the current weather at my location:" + } + if pos[0] == 0 || pos[1] == 0 { + message.Reply("๐Ÿค–๐Ÿงจ I'm sorry! I can't give you a weather report, because I don't know the location of either of us.") return } - - if strings.HasPrefix(command, "/WEATHER") { - var text string - var pos [3]float32 - if message.FromNode != nil { - pos = message.FromNode.GetPosition() - text = "Here's the current weather at your location:" - } - if message.FromNode == nil || pos[0] == 0 || pos[1] == 0 { - pos = message.ToNode.GetPosition() - text = "I can't see your location, so I'll give you the current weather at my location:" - } - if pos[0] == 0 || pos[1] == 0 { - message.Reply("๐Ÿค–๐Ÿงจ I'm sorry! I can't give you a weather report, because I don't know the location of either of us.") - return - } - weather, err := weather.FetchWeather(weather.Position{ - Latitude: float64(pos[0]), - Longitude: float64(pos[1]), - }) - if err != nil { - message.Reply("๐Ÿค–๐ŸŒ‚ I can't get a weather report at this time.") - } else { - ok := <-message.Reply("๐Ÿค–๐ŸŒ‚ " + text + "\n\n" + weather) - if !ok { - log.Println("Could not send the full weather message :/") - } + weather, err := weather.FetchWeather(weather.Position{ + Latitude: float64(pos[0]), + Longitude: float64(pos[1]), + }) + if err != nil { + message.Reply("๐Ÿค–๐ŸŒ‚ I can't get a weather report at this time.") + } else { + ok := <-message.Reply("๐Ÿค–๐ŸŒ‚ " + text + "\n\n" + weather) + if !ok { + log.Println("Could not send the full weather message :/") } - return } + return + } - if strings.HasPrefix(command, "/FORECAST") { - var text string - var pos [3]float32 - if message.FromNode != nil { - pos = message.FromNode.GetPosition() - text = "Here's the weather forecast at your location:" - } - if message.FromNode == nil || pos[0] == 0 || pos[1] == 0 { - pos = message.ToNode.GetPosition() - text = "I can't see your location, so I'll give you the weather forecast at my location:" - } - if pos[0] == 0 || pos[1] == 0 { - message.Reply("๐Ÿค–๐Ÿงจ I'm sorry! I can't give you a weather forecast, because I don't know the location of either of us.") - return - } - forecast, err := weather.FetchForecast(weather.Position{ - Latitude: float64(pos[0]), - Longitude: float64(pos[1]), - }) - if err != nil { - message.Reply("๐Ÿค–๐ŸŒ‚ I can't get a weather forecast at this time.") - } else { - ok := <-message.Reply("๐Ÿค–๐ŸŒ‚ " + text + "\n\n" + forecast) - if !ok { - log.Println("Could not send the full weather message :/") - } - } + if strings.HasPrefix(command, "/FORECAST") { + var text string + var pos [3]float32 + if message.FromNode != nil { + pos = message.FromNode.GetPosition() + text = "Here's the weather forecast at your location:" + } + if message.FromNode == nil || pos[0] == 0 || pos[1] == 0 { + pos = message.ToNode.GetPosition() + text = "I can't see your location, so I'll give you the weather forecast at my location:" + } + if pos[0] == 0 || pos[1] == 0 { + message.Reply("๐Ÿค–๐Ÿงจ I'm sorry! I can't give you a weather forecast, because I don't know the location of either of us.") return } + forecast, err := weather.FetchForecast(weather.Position{ + Latitude: float64(pos[0]), + Longitude: float64(pos[1]), + }) + if err != nil { + message.Reply("๐Ÿค–๐ŸŒ‚ I can't get a weather forecast at this time.") + } else { + ok := <-message.Reply("๐Ÿค–๐ŸŒ‚ " + text + "\n\n" + forecast) + if !ok { + log.Println("Could not send the full weather message :/") + } + } + return + } - // We've fallen through the generic queries, roomserver code starts here + // We've fallen through the generic queries, roomserver code starts here - // Make sure we don't spam channels - if !message.IsPrivateMessage() { - return - } + // Make sure we don't spam channels + if !message.IsPrivateMessage() { + return + } - // Find our user - user := roomserver.GetUser(message) + // Find our user + user := roomserver.GetUser(message) - if strings.HasPrefix(command, "/ROOMS") { - message.Reply("๐Ÿค–๐Ÿ’ฌ These are the available rooms:\n\n" + roomserver.RoomList(user)) - return - } + if strings.HasPrefix(command, "/ROOMS") { + message.Reply("๐Ÿค–๐Ÿ’ฌ These are the available rooms:\n\n" + roomserver.RoomList(user)) + return + } - if strings.HasPrefix(command, "/JOIN") { - params := strings.Split(strings.TrimSpace(message.Text[len("/JOIN"):]), " ") - if len(params) == 0 { - message.Reply("๐Ÿค–๐Ÿงจ You need to specify the name of a room to join") - return - } - roomName := params[0] - password := "" - if len(params) > 1 { - password = params[1] - } - err := roomserver.Join(user, roomName, password) - if err != nil { - message.Reply("๐Ÿค–๐Ÿ’ฌ " + err.Error()) - return - } - message.Reply("๐Ÿค–๐Ÿ’ฌ You joined " + roomName + ". It was also automatically selected for you to send to.") + if strings.HasPrefix(command, "/JOIN") { + params := strings.Split(strings.TrimSpace(message.Text[len("/JOIN"):]), " ") + if len(params) == 0 { + message.Reply("๐Ÿค–๐Ÿงจ You need to specify the name of a room to join") return } - - if strings.HasPrefix(command, "/LEAVE") { - params := strings.Split(strings.TrimSpace(message.Text[len("/LEAVE"):]), " ") - if len(params) == 0 { - message.Reply("๐Ÿค–๐Ÿงจ You need to specify the name of a room to leave") - return - } - roomName := params[0] - err := roomserver.Leave(user, roomName) - if err != nil { - message.Reply("๐Ÿค–๐Ÿงจ " + err.Error()) - return - } - message.Reply("๐Ÿค–๐Ÿ’ฌ You left " + roomName) + roomName := params[0] + password := "" + if len(params) > 1 { + password = params[1] + } + err := roomserver.Join(user, roomName, password) + if err != nil { + message.Reply("๐Ÿค–๐Ÿ’ฌ " + err.Error()) return } + message.Reply("๐Ÿค–๐Ÿ’ฌ You joined " + roomName + ". It was also automatically selected for you to send to.") + return + } - if strings.HasPrefix(command, "/SELECT") { - params := strings.Split(strings.TrimSpace(message.Text[len("/SELECT"):]), " ") - if len(params) == 0 { - message.Reply("๐Ÿค–๐Ÿงจ You need to specify the name of a room to select") - return - } - roomName := params[0] - err := roomserver.Select(user, roomName) - if err != nil { - message.Reply("๐Ÿค–๐Ÿ’ฌ " + err.Error()) - return - } - message.Reply("๐Ÿค–๐Ÿ’ฌ You selected " + roomName) + if strings.HasPrefix(command, "/LEAVE") { + params := strings.Split(strings.TrimSpace(message.Text[len("/LEAVE"):]), " ") + if len(params) == 0 { + message.Reply("๐Ÿค–๐Ÿงจ You need to specify the name of a room to leave") return } - - if strings.HasPrefix(command, "/") { - message.Reply("๐Ÿค–โ“ I don't know that command. See /help for the things I understand!") + roomName := params[0] + err := roomserver.Leave(user, roomName) + if err != nil { + message.Reply("๐Ÿค–๐Ÿงจ " + err.Error()) return } + message.Reply("๐Ÿค–๐Ÿ’ฌ You left " + roomName) + return + } - // Handle freeform messages to a room - msg := strings.TrimSpace(message.Text) - if len(msg) == 0 { + if strings.HasPrefix(command, "/SELECT") { + params := strings.Split(strings.TrimSpace(message.Text[len("/SELECT"):]), " ") + if len(params) == 0 { + message.Reply("๐Ÿค–๐Ÿงจ You need to specify the name of a room to select") return } - err := roomserver.Send(user, msg) + roomName := params[0] + err := roomserver.Select(user, roomName) if err != nil { - <-message.Reply("๐Ÿค–๐Ÿ’ฌ " + err.Error()) - message.Reply( - `You receive messages from rooms you have joined. + message.Reply("๐Ÿค–๐Ÿ’ฌ " + err.Error()) + return + } + message.Reply("๐Ÿค–๐Ÿ’ฌ You selected " + roomName) + return + } + + if strings.HasPrefix(command, "/") { + message.Reply("๐Ÿค–โ“ I don't know that command. See /help for the things I understand!") + return + } + + // Handle freeform messages to a room + msg := strings.TrimSpace(message.Text) + if len(msg) == 0 { + return + } + err := roomserver.Send(user, msg) + if err != nil { + <-message.Reply("๐Ÿค–๐Ÿ’ฌ " + err.Error()) + message.Reply( + `You receive messages from rooms you have joined. You send messages to the room you have selected. Send /rooms to see available rooms. Send /help to see all commands`) - return - } + return } } From 92a5d0e2f89e1bf95416fbddd1b2abe78f31e76f Mon Sep 17 00:00:00 2001 From: Timendus Date: Wed, 29 Oct 2025 15:57:48 +0100 Subject: [PATCH 76/87] Add some basic retry logic to sending messages and implement sending backlog when user comes back --- main.go | 30 ++++++++++++--------- meshwrapper/message.go | 28 ++++++++++++++++++++ roomserver/room.go | 60 ++++++++++++++++++++++++++++++++++++------ 3 files changed, 97 insertions(+), 21 deletions(-) diff --git a/main.go b/main.go index 3fa0afa..7fc75da 100644 --- a/main.go +++ b/main.go @@ -151,6 +151,10 @@ func disconnected(node m.ConnectedNode) { func incoming(message m.Message) { fmt.Println(message.String()) + if roomserver.UserExists(message) { + user := roomserver.GetUser(message) + go user.SendBacklog() + } if message.MessageType != m.MESSAGE_TYPE_TEXT_MESSAGE { return @@ -280,14 +284,14 @@ func incoming(message m.Message) { user := roomserver.GetUser(message) if strings.HasPrefix(command, "/ROOMS") { - message.Reply("๐Ÿค–๐Ÿ’ฌ These are the available rooms:\n\n" + roomserver.RoomList(user)) + message.ReplyReliably("๐Ÿค–๐Ÿ’ฌ These are the available rooms:\n\n" + roomserver.RoomList(user)) return } if strings.HasPrefix(command, "/JOIN") { params := strings.Split(strings.TrimSpace(message.Text[len("/JOIN"):]), " ") if len(params) == 0 { - message.Reply("๐Ÿค–๐Ÿงจ You need to specify the name of a room to join") + message.ReplyReliably("๐Ÿค–๐Ÿงจ You need to specify the name of a room to join") return } roomName := params[0] @@ -297,47 +301,47 @@ func incoming(message m.Message) { } err := roomserver.Join(user, roomName, password) if err != nil { - message.Reply("๐Ÿค–๐Ÿ’ฌ " + err.Error()) + message.ReplyReliably("๐Ÿค–๐Ÿ’ฌ " + err.Error()) return } - message.Reply("๐Ÿค–๐Ÿ’ฌ You joined " + roomName + ". It was also automatically selected for you to send to.") + message.ReplyReliably("๐Ÿค–๐Ÿ’ฌ You joined " + roomName + ". It was also automatically selected for you to send to.") return } if strings.HasPrefix(command, "/LEAVE") { params := strings.Split(strings.TrimSpace(message.Text[len("/LEAVE"):]), " ") if len(params) == 0 { - message.Reply("๐Ÿค–๐Ÿงจ You need to specify the name of a room to leave") + message.ReplyReliably("๐Ÿค–๐Ÿงจ You need to specify the name of a room to leave") return } roomName := params[0] err := roomserver.Leave(user, roomName) if err != nil { - message.Reply("๐Ÿค–๐Ÿงจ " + err.Error()) + message.ReplyReliably("๐Ÿค–๐Ÿงจ " + err.Error()) return } - message.Reply("๐Ÿค–๐Ÿ’ฌ You left " + roomName) + message.ReplyReliably("๐Ÿค–๐Ÿ’ฌ You left " + roomName) return } if strings.HasPrefix(command, "/SELECT") { params := strings.Split(strings.TrimSpace(message.Text[len("/SELECT"):]), " ") if len(params) == 0 { - message.Reply("๐Ÿค–๐Ÿงจ You need to specify the name of a room to select") + message.ReplyReliably("๐Ÿค–๐Ÿงจ You need to specify the name of a room to select") return } roomName := params[0] err := roomserver.Select(user, roomName) if err != nil { - message.Reply("๐Ÿค–๐Ÿ’ฌ " + err.Error()) + message.ReplyReliably("๐Ÿค–๐Ÿ’ฌ " + err.Error()) return } - message.Reply("๐Ÿค–๐Ÿ’ฌ You selected " + roomName) + message.ReplyReliably("๐Ÿค–๐Ÿ’ฌ You selected " + roomName) return } if strings.HasPrefix(command, "/") { - message.Reply("๐Ÿค–โ“ I don't know that command. See /help for the things I understand!") + message.ReplyReliably("๐Ÿค–โ“ I don't know that command. See /help for the things I understand!") return } @@ -348,8 +352,8 @@ func incoming(message m.Message) { } err := roomserver.Send(user, msg) if err != nil { - <-message.Reply("๐Ÿค–๐Ÿ’ฌ " + err.Error()) - message.Reply( + <-message.ReplyReliably("๐Ÿค–๐Ÿ’ฌ " + err.Error()) + message.ReplyReliably( `You receive messages from rooms you have joined. You send messages to the room you have selected. Send /rooms to see available rooms. diff --git a/meshwrapper/message.go b/meshwrapper/message.go index f02d76e..4bca834 100644 --- a/meshwrapper/message.go +++ b/meshwrapper/message.go @@ -169,6 +169,34 @@ func (m *Message) ingestMeshPacket(connectedNode *ConnectedNode, meshPacket *mes } } +func (m Message) ReplyReliably(message string, retries ...int) chan bool { + ch := make(chan bool) + attempt := 1 + maxAttempts := 3 + if len(retries) > 0 { + maxAttempts = retries[0] + } + go func() { + delivered := false + for { + log.Printf("Attempt %d to send message...\n", attempt) + delivered = <-m.Reply(message) + if delivered { + log.Println("Delivered successfully") + break + } + if attempt == maxAttempts { + log.Printf("Made %d attempts to send message, aborting\n", attempt) + break + } + attempt++ + } + ch <- delivered + close(ch) + }() + return ch +} + func (m Message) Reply(message string, timeout ...time.Duration) chan bool { ch := make(chan bool) diff --git a/roomserver/room.go b/roomserver/room.go index e075c0a..30752c0 100644 --- a/roomserver/room.go +++ b/roomserver/room.go @@ -3,6 +3,8 @@ package roomserver import ( "fmt" "strings" + "sync" + "sync/atomic" "github.com/timendus/meshbot/config" m "github.com/timendus/meshbot/meshwrapper" @@ -20,10 +22,12 @@ type Message struct { } type User struct { - Node *m.Node - Send func(string) chan bool - Backlog []*Message - Selected *Room + Node *m.Node + Send func(string) chan bool + Sending atomic.Bool + UpdatingBacklog sync.Mutex + Backlog []string + Selected *Room } var Rooms []Room @@ -38,13 +42,18 @@ func Init(cfg config.Config) { Users = make(map[*m.Node]*User) } +func UserExists(msg m.Message) bool { + _, ok := Users[msg.FromNode] + return ok +} + func GetUser(msg m.Message) *User { if user, ok := Users[msg.FromNode]; ok { return user } user := &User{ Node: msg.FromNode, - Send: func(m string) chan bool { return msg.Reply(m) }, + Send: func(m string) chan bool { return msg.ReplyReliably(m) }, } Users[msg.FromNode] = user return user @@ -142,14 +151,49 @@ func Send(user *User, message string) error { return nil } +func (u *User) SendBacklog() { + // Check if another attempt to send the backlog is already running + if !u.Sending.CompareAndSwap(false, true) { + // Another Goroutine is already running this + return + } + defer u.Sending.Store(false) + + // Also, we're mutating the backlog, keep anyone else from messing with it + // for a while + u.UpdatingBacklog.Lock() + defer u.UpdatingBacklog.Unlock() + + // Do we have a backlog to send to this user? + successful := 0 + for _, msg := range u.Backlog { + ok := <-u.Send(msg) // With retries, this can take a couple of minutes + if !ok { + // It seems we're not getting through, try again later + break + } + // We can remove message from backlog + successful++ + } + u.Backlog = u.Backlog[successful:] +} + func (room *Room) send(msg Message) { room.Messages = append(room.Messages, msg) for _, user := range room.Users { go func() { - ok := <-user.Send("[" + msg.Sender.Node.ShortName + " in " + room.Config.Name + "] " + msg.Contents) - if !ok { - user.Backlog = append(user.Backlog, &msg) + var text string + if len(user.rooms()) > 1 { + text = "[" + msg.Sender.Node.ShortName + " in " + room.Config.Name + "] " + msg.Contents + } else { + text = "[" + msg.Sender.Node.ShortName + "] " + msg.Contents } + + // Safely mutate backlog and send new message + user.UpdatingBacklog.Lock() + user.Backlog = append(user.Backlog, text) + user.UpdatingBacklog.Unlock() + user.SendBacklog() }() } } From bc9cc604f74ac63183d15dacc8a4ce24dfac634e Mon Sep 17 00:00:00 2001 From: Timendus Date: Wed, 29 Oct 2025 16:54:25 +0100 Subject: [PATCH 77/87] Allow breaking messages before regular breaking. This is exposed to users, is that a good idea? Not sure --- main.go | 17 ++++++-------- meshwrapper/helpers/language.go | 33 ++++++++++++++++------------ meshwrapper/helpers/language_test.go | 14 ++++++++++++ roomserver/room.go | 15 ++++++++----- 4 files changed, 50 insertions(+), 29 deletions(-) diff --git a/main.go b/main.go index 7fc75da..d49a214 100644 --- a/main.go +++ b/main.go @@ -168,15 +168,17 @@ func incoming(message m.Message) { } if strings.HasPrefix(command, "/HELP") || strings.HasPrefix(command, "/ABOUT") { - <-message.Reply( + message.Reply( `๐Ÿค–๐Ÿ‘‹ Hello! I'm your friendly neighbourhood roomserver bot. I understand these commands: - /rooms - /join - /select - - /leave `) - message.Reply( - `Bonus features: + - /leave + + + + Bonus features: - /neighbours - /signal @@ -352,12 +354,7 @@ func incoming(message m.Message) { } err := roomserver.Send(user, msg) if err != nil { - <-message.ReplyReliably("๐Ÿค–๐Ÿ’ฌ " + err.Error()) - message.ReplyReliably( - `You receive messages from rooms you have joined. -You send messages to the room you have selected. -Send /rooms to see available rooms. -Send /help to see all commands`) + message.ReplyReliably("๐Ÿค–๐Ÿ’ฌ " + err.Error()) return } } diff --git a/meshwrapper/helpers/language.go b/meshwrapper/helpers/language.go index d50cb26..c8c7b73 100644 --- a/meshwrapper/helpers/language.go +++ b/meshwrapper/helpers/language.go @@ -56,24 +56,29 @@ func BreakMessage(input string) []string { const MAX_MESSAGE_LENGTH = 200 const MAX_LENGTH_WITH_PAGINATION = 200 - len(" [1/2]") input = strings.TrimSpace(input) + messages := make([]string, 0) + for _, message := range strings.Split(input, "") { + message = strings.TrimSpace(message) - // Don't try to cut up messages that fit - if len(input) <= MAX_MESSAGE_LENGTH { - return []string{input} - } - - messages := BreakMessageAt(input, MAX_LENGTH_WITH_PAGINATION) - Assert(len(messages) < 1000, "What the hell are you doing creating so many messages..?") + // Don't try to cut up messages that fit + if len(message) <= MAX_MESSAGE_LENGTH { + messages = append(messages, message) + continue + } - // Add pagination info to each message - for i := range messages { - if len(messages) > 9 { - messages[i] += " [" + strconv.Itoa(i+1) + "]" - } else { - messages[i] += " [" + strconv.Itoa(i+1) + "/" + strconv.Itoa(len(messages)) + "]" + // Cut message in parts and add pagination info to each part + messageParts := BreakMessageAt(message, MAX_LENGTH_WITH_PAGINATION) + for i := range messageParts { + if len(messageParts) > 9 { + messageParts[i] += " [" + strconv.Itoa(i+1) + "]" + } else { + messageParts[i] += " [" + strconv.Itoa(i+1) + "/" + strconv.Itoa(len(messageParts)) + "]" + } } - } + messages = append(messages, messageParts...) + } + Assert(len(messages) < 1000, "What the hell are you doing creating so many messages..?") return messages } diff --git a/meshwrapper/helpers/language_test.go b/meshwrapper/helpers/language_test.go index 2121a0c..c0de18b 100644 --- a/meshwrapper/helpers/language_test.go +++ b/meshwrapper/helpers/language_test.go @@ -37,6 +37,11 @@ func TestMessagePagination(t *testing.T) { []string{"Hello\nHello"}, ) + AssertBreaking(t, + "HelloHello", + []string{"Hello", "Hello"}, + ) + AssertBreaking(t, TWO_HUNDRED_CHARS, []string{TWO_HUNDRED_CHARS}, @@ -47,6 +52,15 @@ func TestMessagePagination(t *testing.T) { []string{TWO_HUNDRED_CHAR_WORDS}, ) + AssertBreaking(t, + TWO_HUNDRED_CHAR_WORDS+" Hello Hello", + []string{ + TWO_HUNDRED_CHAR_WORDS, + "Hello", + "Hello", + }, + ) + AssertBreaking(t, TWO_HUNDRED_CHARS+"a", []string{ diff --git a/roomserver/room.go b/roomserver/room.go index 30752c0..1edd198 100644 --- a/roomserver/room.go +++ b/roomserver/room.go @@ -125,8 +125,13 @@ func Select(user *User, roomName string) error { } func Send(user *User, message string) error { - rooms := user.rooms() + helpText := ` +You receive messages from rooms you have joined. +You send messages to the room you have selected. +Send /rooms to see available rooms. +Send /help to see all commands` + rooms := user.rooms() if len(rooms) == 0 { firstPublicRoom := "" for _, room := range Rooms { @@ -135,17 +140,17 @@ func Send(user *User, message string) error { } } if firstPublicRoom == "" { - return fmt.Errorf("You're not in any rooms. /join a room.") + return fmt.Errorf("You're not in any rooms. /join a room." + helpText) } err := Join(user, firstPublicRoom, "") if err != nil { - return fmt.Errorf("You're not in any rooms. /join a room.") + return fmt.Errorf("You're not in any rooms. /join a room." + helpText) } - return fmt.Errorf("You were not in any rooms. I took the liberty of putting you in room %s.\n\n๐Ÿ”ด Note: All messages you send to me from now on will be broadcast to room %s! ๐Ÿ”ด", firstPublicRoom, firstPublicRoom) + return fmt.Errorf("You were not in any rooms. I took the liberty of putting you in room %s.\n\n๐Ÿ”ด Note: All messages you send to me from now on will be broadcast to room %s! ๐Ÿ”ด"+helpText, firstPublicRoom, firstPublicRoom) } if user.Selected == nil { - return fmt.Errorf("You have not selected a room to send to. Please /select a room.") + return fmt.Errorf("You have not selected a room to send to. Please /select a room." + helpText) } user.Selected.send(Message{Sender: user, Contents: message}) return nil From 3deabcf7a71801c1e30b47e613131c3e342e0a71 Mon Sep 17 00:00:00 2001 From: Timendus Date: Thu, 30 Oct 2025 15:37:54 +0100 Subject: [PATCH 78/87] Slightly improve console output of text messages --- meshwrapper/helpers/language.go | 10 ++++++++++ meshwrapper/message.go | 7 ++----- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/meshwrapper/helpers/language.go b/meshwrapper/helpers/language.go index c8c7b73..36b01d9 100644 --- a/meshwrapper/helpers/language.go +++ b/meshwrapper/helpers/language.go @@ -137,3 +137,13 @@ func BreakMessageAt(input string, maxlength int) []string { return messages } + +func Indent(s, prefix string) string { + lines := strings.SplitAfter(s, "\n") + for i, line := range lines { + if line != "" { + lines[i] = prefix + line + } + } + return strings.Join(lines, "") +} diff --git a/meshwrapper/message.go b/meshwrapper/message.go index 4bca834..0ba285d 100644 --- a/meshwrapper/message.go +++ b/meshwrapper/message.go @@ -327,14 +327,11 @@ func (m Message) String() string { return fmt.Sprintf("%s: \033[1mNeighbor list:\033[0m %s %s", direction, m.radioMetricsString(), neighbours) } - var content string if m.MessageType == MESSAGE_TYPE_TEXT_MESSAGE { - content = m.Text - } else { - content = "\033[1m" + m.MessageType + " packet\033[0m" + return fmt.Sprintf("%s: %s\n%s", direction, m.radioMetricsString(), helpers.Indent(m.Text, "\t")) } - return fmt.Sprintf("%s: %s %s", direction, content, m.radioMetricsString()) + return fmt.Sprintf("%s: \033[1m%s packet\033[0m %s", direction, m.MessageType, m.radioMetricsString()) } func (m *Message) radioMetricsString() string { From 5aab99e9f48870a83d22c8c34d19074fe66d11f5 Mon Sep 17 00:00:00 2001 From: Timendus Date: Thu, 30 Oct 2025 15:51:45 +0100 Subject: [PATCH 79/87] Rewrite how acknowledgements work and make retries more robust --- meshwrapper/acknowledgement.go | 89 ++++++++++++++++++++++++++++++++++ meshwrapper/connected_node.go | 4 +- meshwrapper/message.go | 72 ++++++++++++++------------- 3 files changed, 130 insertions(+), 35 deletions(-) create mode 100644 meshwrapper/acknowledgement.go diff --git a/meshwrapper/acknowledgement.go b/meshwrapper/acknowledgement.go new file mode 100644 index 0000000..7a7cacb --- /dev/null +++ b/meshwrapper/acknowledgement.go @@ -0,0 +1,89 @@ +package meshwrapper + +import ( + "log" + "math/rand/v2" + "sync/atomic" + + "buf.build/gen/go/meshtastic/protobufs/protocolbuffers/go/meshtastic" +) + +const VERBOSE = false + +type acknowledgement struct { + id int32 + waiting atomic.Bool + delivered chan bool + repeated chan bool + recipient *Node + status string +} + +// Create a new acknowledgement for a message we're sending to the given node +func newAcknowledgement(node *Node) *acknowledgement { + ack := acknowledgement{ + id: rand.Int32(), + delivered: make(chan bool, 1), + repeated: make(chan bool, 1), + recipient: node, + waiting: atomic.Bool{}, + status: "Waiting", + } + ack.waiting.Store(true) + ack.spam() + return &ack +} + +func (a *acknowledgement) receive(node *Node, err meshtastic.Routing_Error) { + if !a.waiting.Load() { + if VERBOSE { + log.Printf("Acknowledgement %d to %s: Received packed, but was no longer waiting\n", a.id, a.recipient.ColorString()) + } + return + } + if err != meshtastic.Routing_NONE { + a.negative("Routing error: " + meshtastic.Routing_Error_name[int32(err)]) + return + } + if node.Id == a.recipient.Id { + a.status = "Delivered" + a.delivered <- true + a.repeated <- false + a.close() + } else { + a.status = "Repeated" + a.repeated <- true + a.spam() + } +} + +func (a *acknowledgement) timeout() { + a.negative("Timed out") +} + +func (a *acknowledgement) error(err error) { + a.negative("Could not send message: " + err.Error()) +} + +func (a *acknowledgement) negative(reason string) { + if !a.waiting.Load() { + return + } + a.status = reason + a.delivered <- false + a.repeated <- false + a.close() +} + +func (a *acknowledgement) close() { + a.waiting.Store(false) + close(a.delivered) + close(a.repeated) + a.spam() +} + +func (a *acknowledgement) spam() { + if VERBOSE { + log.Printf("Acknowledgement %d to %s: %s\n", a.id, a.recipient.ColorString(), a.status) + } +} diff --git a/meshwrapper/connected_node.go b/meshwrapper/connected_node.go index 0dc8ea2..6e2907d 100644 --- a/meshwrapper/connected_node.go +++ b/meshwrapper/connected_node.go @@ -19,7 +19,7 @@ type ConnectedNode struct { Channels map[uint32]Channel Node *Node NodeList nodeList - Acks map[uint32]chan bool + Acks map[uint32]*acknowledgement } func NewConnectedNode(aquire func() (io.ReadWriteCloser, error)) *ConnectedNode { @@ -27,7 +27,7 @@ func NewConnectedNode(aquire func() (io.ReadWriteCloser, error)) *ConnectedNode aquireStream: aquire, Connected: false, NodeList: NewNodeList(), - Acks: make(map[uint32]chan bool), + Acks: make(map[uint32]*acknowledgement), Channels: make(map[uint32]Channel), Node: &Node{ ShortName: "UNKN", diff --git a/meshwrapper/message.go b/meshwrapper/message.go index 0ba285d..4d14076 100644 --- a/meshwrapper/message.go +++ b/meshwrapper/message.go @@ -146,13 +146,10 @@ func (m *Message) ingestMeshPacket(connectedNode *ConnectedNode, meshPacket *mes log.Println("Error: Could not unmarshall Routing mesh packet: " + err.Error()) return } - if result.GetErrorReason() != meshtastic.Routing_NONE { - log.Println("Bad acknowledgement: " + meshtastic.Routing_Error_name[int32(result.GetErrorReason())]) - } messageId := meshPacket.GetDecoded().RequestId - if connectedNode.Acks[messageId] != nil { - connectedNode.Acks[messageId] <- result.GetErrorReason() == meshtastic.Routing_NONE - close(connectedNode.Acks[messageId]) + ack, ok := connectedNode.Acks[messageId] + if ok { + ack.receive(m.FromNode, result.GetErrorReason()) delete(connectedNode.Acks, messageId) } } @@ -171,29 +168,38 @@ func (m *Message) ingestMeshPacket(connectedNode *ConnectedNode, meshPacket *mes func (m Message) ReplyReliably(message string, retries ...int) chan bool { ch := make(chan bool) - attempt := 1 + messageTimeout := DEFAULT_BLOCKING_MESSAGE_TIMEOUT + maxAttempts := 3 if len(retries) > 0 { maxAttempts = retries[0] } + go func() { - delivered := false - for { - log.Printf("Attempt %d to send message...\n", attempt) - delivered = <-m.Reply(message) - if delivered { - log.Println("Delivered successfully") - break + for _, msg := range helpers.BreakMessage(message) { + attempt := 1 + delivered := false + for attempt <= maxAttempts { + ack := m.send(msg, messageTimeout) + delivered = <-ack.delivered + if delivered { + break + } + attempt++ } - if attempt == maxAttempts { - log.Printf("Made %d attempts to send message, aborting\n", attempt) - break + if !delivered { + // Failed to deliver at least part of the message, abort + ch <- false + close(ch) + return } - attempt++ } - ch <- delivered + + // Made it through all parts of the message successfully + ch <- true close(ch) }() + return ch } @@ -207,8 +213,9 @@ func (m Message) Reply(message string, timeout ...time.Duration) chan bool { } for _, msg := range helpers.BreakMessage(message) { - ok := <-m.send(msg, messageTimeout) - if !ok { + ack := m.send(msg, messageTimeout) + delivered := <-ack.delivered + if !delivered { ch <- false return } @@ -220,25 +227,24 @@ func (m Message) Reply(message string, timeout ...time.Duration) chan bool { return ch } -func (m *Message) send(message string, timeout time.Duration) chan bool { - ch := make(chan bool) +func (m *Message) send(message string, timeout time.Duration) *acknowledgement { + ack := newAcknowledgement(m.FromNode) id, err := m.sendTextMessage(message) if err != nil { + // Give user feedback, also when acknowledgements are not verbose, + // because there's a good chance that the error we get here is due to + // the user's configuration choices. log.Println("Could not send message:", err) - ch <- false - close(ch) - return ch + ack.error(err) + return ack } - m.ReceivingNode.Acks[id] = ch + m.ReceivingNode.Acks[id] = ack go func() { time.Sleep(timeout) - if m.ReceivingNode.Acks[id] != nil { - ch <- false - close(ch) - delete(m.ReceivingNode.Acks, id) - } + ack.timeout() + delete(m.ReceivingNode.Acks, id) }() - return ch + return ack } func (m *Message) sendTextMessage(message string) (uint32, error) { From 18e098c6590fdf214adfb4536fa00a9c202461ec Mon Sep 17 00:00:00 2001 From: Timendus Date: Thu, 30 Oct 2025 15:52:14 +0100 Subject: [PATCH 80/87] Solve this issue again --- roomserver/room.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/roomserver/room.go b/roomserver/room.go index 1edd198..2ac2f27 100644 --- a/roomserver/room.go +++ b/roomserver/room.go @@ -140,17 +140,17 @@ Send /help to see all commands` } } if firstPublicRoom == "" { - return fmt.Errorf("You're not in any rooms. /join a room." + helpText) + return fmt.Errorf("You're not in any rooms. /join a room.%s", helpText) } err := Join(user, firstPublicRoom, "") if err != nil { - return fmt.Errorf("You're not in any rooms. /join a room." + helpText) + return fmt.Errorf("You're not in any rooms. /join a room.%s", helpText) } - return fmt.Errorf("You were not in any rooms. I took the liberty of putting you in room %s.\n\n๐Ÿ”ด Note: All messages you send to me from now on will be broadcast to room %s! ๐Ÿ”ด"+helpText, firstPublicRoom, firstPublicRoom) + return fmt.Errorf("You were not in any rooms. I took the liberty of putting you in room %s.\n\n๐Ÿ”ด Note: All messages you send to me from now on will be broadcast to room %s! ๐Ÿ”ด%s", firstPublicRoom, firstPublicRoom, helpText) } if user.Selected == nil { - return fmt.Errorf("You have not selected a room to send to. Please /select a room." + helpText) + return fmt.Errorf("You have not selected a room to send to. Please /select a room.%s", helpText) } user.Selected.send(Message{Sender: user, Contents: message}) return nil From 5ee6d8d8a758b0939eb17a5b9e4f87fb6e48a05a Mon Sep 17 00:00:00 2001 From: Timendus Date: Thu, 30 Oct 2025 16:20:18 +0100 Subject: [PATCH 81/87] Improve readme, move manual to its own file --- README.md | 70 ++++++++++++-------------------------------------- manual.md | 77 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 54 deletions(-) create mode 100644 manual.md diff --git a/README.md b/README.md index 0f2bb23..f261f4c 100644 --- a/README.md +++ b/README.md @@ -11,11 +11,17 @@ Some people would probably call this a "BBS", but personally I think it has more in common with a crossover between a Slack / Telegram / Discord bot and a MeshCore room server. +It is written in Golang, which makes for a very efficient piece of software +compared to all the similar programs written in Python. The binary and docker +image are a couple of megabytes. It needs barely and CPU and a couple of +megabyte of RAM to run. It should also be pretty stable and robust. + ## Current features - "Room server" that supports multiple rooms with subscriptions and semi-reliable message delivery ([see - below](#why-rooms-are-more-reliable-than-channels) how we do that) + here](./manual.md#why-meshbot-rooms-are-more-reliable-than-regular-channels) + how we do that) - Signal reports and neighbours can be queried - Weather reports and forecasts can be queried, using [open-meteo.com](https://open-meteo.com/) (requires the bot to have an @@ -25,58 +31,8 @@ MeshCore room server. ## Usage on the mesh -As a user of Meshbot, you can send these commands and Meshbot will reply. -Commands are not case-sensitive. - -### Either in a channel or as a direct message - -- `/about` or `/help` - Get a short overview of these commands -- `/signal ` - Fetch a signal report on yourself (default) or the - node you ask for -- `/neighbours` - Fetch the list of neighbours that the bot can see over LoRa -- `/weather` - Fetch a report of the current weather conditions -- `/forecast` - Fetch a weather forecast for the coming days - -### As direct messages only - -- `/rooms` - Fetch a list of available rooms and your status in them -- `/join ` - Join a room, so you will receive - messages sent to it. Supply a password for private rooms. Joining a room also - selects it -- `/select ` - Select a room. Messages you send will be broadcast to - the selected room. Only a single room can be selected at a time. You can only - select a room that you have joined -- `/leave ` - Leave a room, so you will no longer receive messages - sent to it - -For any other DM you send to the bot: - -- If you have not joined any rooms, and a public room exists (one without a - password), it will make you join this room and select it automatically. -- Otherwise, any DM you send to the bot will be sent to all users in the - selected room. - -### Why rooms are more reliable than channels - -Direct messages have delivery notification feedback in the app to show you if -your message successfully arrived at its destination. Channels only show that -your message was repeated by _someone_. Also, since Meshtastic 2.6, it makes use -of ["next-hop" routing for direct -messages](https://meshtastic.org/blog/meshtastic-2-6-preview/#next-hop-routing-for-dms). -Channels however do not benefit from this improvement. - -Sometimes direct messages arrive properly, but the delivery notification doesn't -make it back to you. As additional feedback that you have successfully sent a -message, any messages you send to a room will also be echoed back to you. If -your connection to the bot is poor, this may take a while though, so be patient -before sending again. - -Finally, Meshbot will keep trying to send messages to all users in a room -(including the sender) until it receives good delivery notifications. This means -that you may sometimes receive messages multiple times, but it ensures that your -communication is fairly reliable. Even if you move out of range, Meshbot will -remember which messages you missed and as soon as it sees you coming back into -range it will send you the entire history since you left. +See the [user manual](./manual.md) for instructions on how to use Meshbot over +Meshtastic. ## Hosting Meshbot @@ -84,7 +40,13 @@ range it will send you the entire history since you left. There is very little bandwidth available on Meshtastic. If you use this bot, and especially if you wish to modify it, please make sure it doesn't spam your local -mesh. Make sure it only speaks when spoken to. Et cetera. Be a good neighbour. +mesh. Make sure it only speaks when spoken to. Et cetera. + +Also, be aware that Meshbot rooms with many users will generate a lot of +traffic, since every message is sent to every user, with retries. This grows +exponentially. + +In short: be a good neighbour. ### Setup diff --git a/manual.md b/manual.md new file mode 100644 index 0000000..6d80859 --- /dev/null +++ b/manual.md @@ -0,0 +1,77 @@ +# How to use Meshbot through the mesh + +As a user of Meshbot, you can send any of the commands below over Meshtastic and +Meshbot will reply. + +Commands are not case-sensitive. + +## Commands + +### In a channel or as a direct message + +- `/about` or `/help` - Get a short overview of these commands +- `/signal ` - Fetch a signal report on yourself (default) or the + node you ask for +- `/neighbours` - Fetch the list of neighbours that the bot can see over LoRa +- `/weather` - Fetch a report of the current weather conditions +- `/forecast` - Fetch a weather forecast for the coming days + +Replies will be sent like normal Meshtastic messages, either in the channel you +send the command to, or as a DM back to you. + +### As direct messages only + +- `/rooms` - Fetch a list of available rooms and your status in them +- `/join ` - Join a room, so you will receive + messages sent to it. Supply a password for private rooms. Joining a room also + selects it +- `/select ` - Select a room. Messages you send will be broadcast to + the selected room. Only a single room can be selected at a time. You can only + select a room that you have joined +- `/leave ` - Leave a room, so you will no longer receive messages + sent to it + +Replies to these commands will be sent "reliably", which means Meshbot will +retry sending until it sees a delivery notification, with a maximum of three +attempts. + +## Sending to rooms + +For any other DM you send to the bot: + +- If you have not joined any rooms, and a public room exists (one without a + password), it will make you join this room and select it automatically. +- Otherwise, any DM you send to the bot will be sent to all users in the + selected room, including an echo back to yourself. + +Messages sent to rooms will also retry at most three times, but when delivery +still fails after that, these messages will be stored for you in a backlog +queue. When Meshbot receives any packets from you it will assume that you have +come back into range and retry sending the messages from the queue. + +## Why Meshbot rooms are more reliable than regular channels + +For surprisingly many reasons, actually. + +Direct messages have delivery notification feedback in the app to show you if +your message successfully arrived at its destination. Channels only show that +your message was repeated by _someone_. Also, since Meshtastic 2.6, direct +messages make use of ["next-hop" +routing](https://meshtastic.org/blog/meshtastic-2-6-preview/#next-hop-routing-for-dms). +Channels do not benefit from this improvement. + +Sometimes direct messages arrive properly, but the delivery notification doesn't +make it back to you. As additional feedback that you have successfully sent a +message, any messages you send to a room will also be echoed back to you. If +your connection to the bot is poor, this may take a while though, so be patient +before sending again. + +Finally, Meshbot will keep trying to send messages to all users in a room +(including the sender) until it receives good delivery notifications. This means +that you may sometimes receive messages multiple times, but it ensures that your +communication is fairly reliable. + +Even if you move out of range, Meshbot will remember which messages you missed +and as soon as it sees you coming back into range it will send you the entire +history since you left. So in that sense it also works a bit like a more +convenient version of Meshtastic's Store&Forward feature. From 1fbbfe3fa847fe947ed56777217b49dba667d56c Mon Sep 17 00:00:00 2001 From: Timendus Date: Thu, 30 Oct 2025 16:38:48 +0100 Subject: [PATCH 82/87] Reference manual in /help --- main.go | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/main.go b/main.go index d49a214..e7cbb55 100644 --- a/main.go +++ b/main.go @@ -175,15 +175,17 @@ func incoming(message m.Message) { - /join - /select - /leave - - - - Bonus features: + + + +Bonus features: - /neighbours - /signal - /weather - - /forecast`) + - /forecast + +For details, see: github.com/Timendus/meshbot/blob/main/manual.md`) return } From 43bfb8b1227bbe23193fa0cd6f379e2ac5657310 Mon Sep 17 00:00:00 2001 From: Timendus Date: Thu, 30 Oct 2025 19:25:29 +0100 Subject: [PATCH 83/87] Rename Message -> IncomingMessage and add OutgoingMessage --- main.go | 8 +- meshwrapper/connected_node.go | 4 +- .../{message.go => incoming_message.go} | 60 +++--- meshwrapper/neighbor.go | 2 +- meshwrapper/node.go | 10 +- meshwrapper/outgoing_message.go | 171 ++++++++++++++++++ meshwrapper/pubsub.go | 5 +- roomserver/room.go | 4 +- 8 files changed, 218 insertions(+), 46 deletions(-) rename meshwrapper/{message.go => incoming_message.go} (83%) create mode 100644 meshwrapper/outgoing_message.go diff --git a/main.go b/main.go index e7cbb55..be8c4e1 100644 --- a/main.go +++ b/main.go @@ -30,8 +30,8 @@ func main() { cfg := config.GetConfig() roomserver.Init(cfg) - m.MessageEvents.Subscribe(m.IncomingMessageEvent, incoming) - m.MessageEvents.Subscribe(m.OutgoingMessageEvent, outgoing) + m.IncomingMessageEvents.Subscribe(m.IncomingMessageEvent, incoming) + m.OutgoingMessageEvents.Subscribe(m.OutgoingMessageEvent, outgoing) m.ConnectionEvents.Subscribe(m.ConnectedEvent, connected) m.ConnectionEvents.Subscribe(m.DisconnectedEvent, disconnected) @@ -148,7 +148,7 @@ func disconnected(node m.ConnectedNode) { } } -func incoming(message m.Message) { +func incoming(message m.IncomingMessage) { fmt.Println(message.String()) if roomserver.UserExists(message) { @@ -361,6 +361,6 @@ For details, see: github.com/Timendus/meshbot/blob/main/manual.md`) } } -func outgoing(message m.Message) { +func outgoing(message m.OutgoingMessage) { fmt.Println(message.String()) } diff --git a/meshwrapper/connected_node.go b/meshwrapper/connected_node.go index 6e2907d..88293af 100644 --- a/meshwrapper/connected_node.go +++ b/meshwrapper/connected_node.go @@ -200,7 +200,7 @@ func (n *ConnectedNode) parseMeshPacket(meshPacket *meshtastic.MeshPacket) { n.Channels[meshPacket.Channel] = channel } - message := Message{ + message := IncomingMessage{ FromNode: fromNode, ToNode: toNode, ReceivingNode: n, @@ -212,5 +212,5 @@ func (n *ConnectedNode) parseMeshPacket(meshPacket *meshtastic.MeshPacket) { message.ingestMeshPacket(n, meshPacket) fromNode.receiveMessage(n, message) - MessageEvents.publish(IncomingMessageEvent, message) + IncomingMessageEvents.publish(IncomingMessageEvent, message) } diff --git a/meshwrapper/message.go b/meshwrapper/incoming_message.go similarity index 83% rename from meshwrapper/message.go rename to meshwrapper/incoming_message.go index 4d14076..5e3fd66 100644 --- a/meshwrapper/message.go +++ b/meshwrapper/incoming_message.go @@ -29,7 +29,7 @@ const ( DEFAULT_BLOCKING_MESSAGE_TIMEOUT = 60 * time.Second ) -type Message struct { +type IncomingMessage struct { FromNode *Node ToNode *Node ReceivingNode *ConnectedNode @@ -52,7 +52,7 @@ type Message struct { Position *Position } -func (m *Message) ingestMeshPacket(connectedNode *ConnectedNode, meshPacket *meshtastic.MeshPacket) { +func (m *IncomingMessage) ingestMeshPacket(connectedNode *ConnectedNode, meshPacket *meshtastic.MeshPacket) { if meshPacket.HopStart == 0 { m.HopsAway = 0 } else { @@ -71,7 +71,7 @@ func (m *Message) ingestMeshPacket(connectedNode *ConnectedNode, meshPacket *mes } m.MessageType = MESSAGE_TYPE_NODE_INFO m.UserInfo = &result - MessageEvents.publish(NodeInfoEvent, *m) + IncomingMessageEvents.publish(NodeInfoEvent, *m) case meshtastic.PortNum_TELEMETRY_APP: result := meshtastic.Telemetry{} @@ -84,31 +84,31 @@ func (m *Message) ingestMeshPacket(connectedNode *ConnectedNode, meshPacket *mes case *meshtastic.Telemetry_DeviceMetrics: m.MessageType = MESSAGE_TYPE_TELEMETRY_DEVICE m.DeviceMetrics = result.GetDeviceMetrics() - MessageEvents.publish(DeviceTelemetryEvent, *m) + IncomingMessageEvents.publish(DeviceTelemetryEvent, *m) case *meshtastic.Telemetry_EnvironmentMetrics: m.MessageType = MESSAGE_TYPE_TELEMETRY_ENVIRONMENT m.EnvironmentMetrics = result.GetEnvironmentMetrics() - MessageEvents.publish(EnvironmentTelemetryEvent, *m) + IncomingMessageEvents.publish(EnvironmentTelemetryEvent, *m) case *meshtastic.Telemetry_HealthMetrics: m.MessageType = MESSAGE_TYPE_TELEMETRY_HEALTH m.HealthMetrics = result.GetHealthMetrics() - MessageEvents.publish(HealthTelemetryEvent, *m) + IncomingMessageEvents.publish(HealthTelemetryEvent, *m) case *meshtastic.Telemetry_AirQualityMetrics: m.MessageType = MESSAGE_TYPE_TELEMETRY_AIR_QUALITY m.AirQualityMetrics = result.GetAirQualityMetrics() - MessageEvents.publish(AirQualityTelemetryEvent, *m) + IncomingMessageEvents.publish(AirQualityTelemetryEvent, *m) case *meshtastic.Telemetry_PowerMetrics: m.MessageType = MESSAGE_TYPE_TELEMETRY_POWER m.PowerMetrics = result.GetPowerMetrics() - MessageEvents.publish(PowerTelemetryEvent, *m) + IncomingMessageEvents.publish(PowerTelemetryEvent, *m) case *meshtastic.Telemetry_LocalStats: m.MessageType = MESSAGE_TYPE_TELEMETRY_LOCAL_STATS m.LocalStats = result.GetLocalStats() - MessageEvents.publish(LocalStatsTelemetryEvent, *m) + IncomingMessageEvents.publish(LocalStatsTelemetryEvent, *m) default: log.Println("Warning: Unknown telemetry variant:", result.String()) } - MessageEvents.publish(TelemetryEvent, *m) + IncomingMessageEvents.publish(TelemetryEvent, *m) case meshtastic.PortNum_POSITION_APP: result := meshtastic.Position{} @@ -119,7 +119,7 @@ func (m *Message) ingestMeshPacket(connectedNode *ConnectedNode, meshPacket *mes } m.MessageType = MESSAGE_TYPE_POSITION m.Position = NewPosition(&result) - MessageEvents.publish(PositionEvent, *m) + IncomingMessageEvents.publish(PositionEvent, *m) case meshtastic.PortNum_NEIGHBORINFO_APP: result := meshtastic.NeighborInfo{} @@ -131,12 +131,12 @@ func (m *Message) ingestMeshPacket(connectedNode *ConnectedNode, meshPacket *mes m.MessageType = MESSAGE_TYPE_NEIGHBOR_INFO m.NeighborInfo = &result helpers.Assert(result.NodeId == meshPacket.From, "I don't understand this format well enough: received "+m.String()+" but it has NodeId "+strconv.Itoa(int(result.NodeId))) - MessageEvents.publish(NeighborInfoEvent, *m) + IncomingMessageEvents.publish(NeighborInfoEvent, *m) case meshtastic.PortNum_TEXT_MESSAGE_APP: m.MessageType = MESSAGE_TYPE_TEXT_MESSAGE m.Text = string(payload) - MessageEvents.publish(TextMessageEvent, *m) + IncomingMessageEvents.publish(TextMessageEvent, *m) case meshtastic.PortNum_ROUTING_APP: if meshPacket.GetDecoded() != nil { @@ -154,11 +154,11 @@ func (m *Message) ingestMeshPacket(connectedNode *ConnectedNode, meshPacket *mes } } m.MessageType = MESSAGE_TYPE_ROUTING - MessageEvents.publish(RoutingEvent, *m) + IncomingMessageEvents.publish(RoutingEvent, *m) case meshtastic.PortNum_TRACEROUTE_APP: m.MessageType = MESSAGE_TYPE_TRACEROUTE - MessageEvents.publish(TraceRouteEvent, *m) + IncomingMessageEvents.publish(TraceRouteEvent, *m) default: log.Println("Warning: Unknown mesh packet:", meshPacket.String()) @@ -166,7 +166,7 @@ func (m *Message) ingestMeshPacket(connectedNode *ConnectedNode, meshPacket *mes } } -func (m Message) ReplyReliably(message string, retries ...int) chan bool { +func (m IncomingMessage) ReplyReliably(message string, retries ...int) chan bool { ch := make(chan bool) messageTimeout := DEFAULT_BLOCKING_MESSAGE_TIMEOUT @@ -203,7 +203,7 @@ func (m Message) ReplyReliably(message string, retries ...int) chan bool { return ch } -func (m Message) Reply(message string, timeout ...time.Duration) chan bool { +func (m IncomingMessage) Reply(message string, timeout ...time.Duration) chan bool { ch := make(chan bool) go func() { @@ -227,7 +227,7 @@ func (m Message) Reply(message string, timeout ...time.Duration) chan bool { return ch } -func (m *Message) send(message string, timeout time.Duration) *acknowledgement { +func (m *IncomingMessage) send(message string, timeout time.Duration) *acknowledgement { ack := newAcknowledgement(m.FromNode) id, err := m.sendTextMessage(message) if err != nil { @@ -247,7 +247,7 @@ func (m *Message) send(message string, timeout time.Duration) *acknowledgement { return ack } -func (m *Message) sendTextMessage(message string) (uint32, error) { +func (m *IncomingMessage) sendTextMessage(message string) (uint32, error) { helpers.Assert(m.ReceivingNode != nil, "Can't send a message without knowing through which device to send it") helpers.Assert(m.FromNode != nil, "Can't send a message to an unknown node") helpers.Assert(m.ToNode != nil, "Can't send a message from an unknown node") @@ -264,7 +264,7 @@ func (m *Message) sendTextMessage(message string) (uint32, error) { } // Notify the rest of the system that we're sending this message - msg := Message{ + msg := IncomingMessage{ FromNode: m.ReceivingNode.Node, ToNode: recipient, Text: message, @@ -272,47 +272,47 @@ func (m *Message) sendTextMessage(message string) (uint32, error) { Timestamp: time.Now(), Channel: m.Channel, } - MessageEvents.publish(OutgoingMessageEvent, msg) + IncomingMessageEvents.publish(OutgoingMessageEvent, msg) // Actually send the message return m.ReceivingNode.SendMessage(channelId, recipient, message, min(m.HopsAway+2, 7)) } -func (m Message) GetText() string { +func (m IncomingMessage) GetText() string { return m.Text } -func (m Message) IsPrivateMessage() bool { +func (m IncomingMessage) IsPrivateMessage() bool { return m.ToNode != nil && m.ToNode.Id != Broadcast.Id } -func (m Message) GetType() string { +func (m IncomingMessage) GetType() string { return m.MessageType } -func (m Message) GetChannelName() string { +func (m IncomingMessage) GetChannelName() string { if m.Channel == nil { return "UNKNOWN" } return m.Channel.name } -func (m Message) GetSenderNode() *Node { +func (m IncomingMessage) GetSenderNode() *Node { return m.FromNode } -func (m Message) GetReceiverNode() *Node { +func (m IncomingMessage) GetReceiverNode() *Node { return m.ToNode } -func (m Message) FindNode(needle string) *Node { +func (m IncomingMessage) FindNode(needle string) *Node { if m.ReceivingNode == nil { return nil } return m.ReceivingNode.NodeList.findNode(needle) } -func (m Message) String() string { +func (m IncomingMessage) String() string { direction := "" if m.FromNode != nil { direction += m.FromNode.ColorString() @@ -340,7 +340,7 @@ func (m Message) String() string { return fmt.Sprintf("%s: \033[1m%s packet\033[0m %s", direction, m.MessageType, m.radioMetricsString()) } -func (m *Message) radioMetricsString() string { +func (m *IncomingMessage) radioMetricsString() string { if m.FromNode != nil && m.FromNode.Connected { return "" } diff --git a/meshwrapper/neighbor.go b/meshwrapper/neighbor.go index a4dd925..ddf0b58 100644 --- a/meshwrapper/neighbor.go +++ b/meshwrapper/neighbor.go @@ -20,7 +20,7 @@ func (n *Neighbor) String() string { type NeighborList []Neighbor -func NewNeighbourList(connectedNode *ConnectedNode, message Message) NeighborList { +func NewNeighbourList(connectedNode *ConnectedNode, message IncomingMessage) NeighborList { neighbourList := make([]Neighbor, 0) for _, neighbor := range message.NeighborInfo.Neighbors { node, ok := connectedNode.NodeList.nodes[neighbor.NodeId] diff --git a/meshwrapper/node.go b/meshwrapper/node.go index 5b1eb8d..f345112 100644 --- a/meshwrapper/node.go +++ b/meshwrapper/node.go @@ -19,7 +19,7 @@ type Node struct { LastHeard time.Time HopsAway uint32 IsLicensed bool - ReceivedMessages []*Message + ReceivedMessages []*IncomingMessage Connected bool PublicKey []byte Neighbors NeighborList @@ -34,7 +34,7 @@ func NewNode(connectedNode *ConnectedNode, info *meshtastic.NodeInfo) *Node { LongName: "Unknown node", HwModel: meshtastic.HardwareModel_UNSET, IsLicensed: false, - ReceivedMessages: make([]*Message, 0), + ReceivedMessages: make([]*IncomingMessage, 0), Neighbors: make(NeighborList, 0), } @@ -53,7 +53,7 @@ func (n *Node) ingestNodeInfo(connectedNode *ConnectedNode, info *meshtastic.Nod n.LastHeard = time.Unix(int64(info.LastHeard), 0) if info.Position != nil { - n.ReceivedMessages = append(n.ReceivedMessages, &Message{ + n.ReceivedMessages = append(n.ReceivedMessages, &IncomingMessage{ FromNode: n, ToNode: &Broadcast, ReceivingNode: connectedNode, @@ -65,7 +65,7 @@ func (n *Node) ingestNodeInfo(connectedNode *ConnectedNode, info *meshtastic.Nod } if info.DeviceMetrics != nil { - n.ReceivedMessages = append(n.ReceivedMessages, &Message{ + n.ReceivedMessages = append(n.ReceivedMessages, &IncomingMessage{ FromNode: n, ToNode: &Broadcast, ReceivingNode: connectedNode, @@ -91,7 +91,7 @@ func (n *Node) ingestNodeInfo(connectedNode *ConnectedNode, info *meshtastic.Nod // If we receive a messages that came from this node, make sure we update our // node accordingly and store the message in our list -func (n *Node) receiveMessage(connectedNode *ConnectedNode, message Message) { +func (n *Node) receiveMessage(connectedNode *ConnectedNode, message IncomingMessage) { n.ReceivedMessages = append(n.ReceivedMessages, &message) n.LastHeard = message.Timestamp n.HopsAway = message.HopsAway diff --git a/meshwrapper/outgoing_message.go b/meshwrapper/outgoing_message.go new file mode 100644 index 0000000..582e7ec --- /dev/null +++ b/meshwrapper/outgoing_message.go @@ -0,0 +1,171 @@ +package meshwrapper + +import ( + "fmt" + "log" + "time" + + "github.com/timendus/meshbot/meshwrapper/helpers" +) + +const DEFAULT_DELIVERY_TIMEOUT = 60 * time.Second + +type OutgoingMessage struct { + FromNode *Node + ToNode *Node + Channel *Channel + ReceivingNode *ConnectedNode + Text string + + MaxHops int + Retries int + Timeout time.Duration +} + +func NewOutgoingDirectMessage(message string, from *ConnectedNode, to *Node, hops int) *OutgoingMessage { + return &OutgoingMessage{ + FromNode: from.Node, + ToNode: to, + ReceivingNode: from, + Text: message, + + MaxHops: hops, + Retries: 3, + Timeout: DEFAULT_DELIVERY_TIMEOUT, + } +} + +func NewOutgoingChannelMessage(message string, from *ConnectedNode, to *Channel, hops int) *OutgoingMessage { + return &OutgoingMessage{ + FromNode: from.Node, + ToNode: &Broadcast, + Channel: to, + ReceivingNode: from, + Text: message, + + MaxHops: hops, + Retries: 3, + Timeout: DEFAULT_DELIVERY_TIMEOUT, + } +} + +// Regular, boring old send +func (m *OutgoingMessage) Send() chan bool { + helpers.Assert(m.ReceivingNode != nil, "Can't send a message without knowing through which device to send it") + helpers.Assert(m.FromNode != nil, "Can't send a message to an unknown node") + helpers.Assert(m.ToNode != nil, "Can't send a message from an unknown node") + + ch := make(chan bool) + + go func() { + for _, msg := range helpers.BreakMessage(m.Text) { + ack := m.send(msg) + delivered := m.delivered(ack) + if !delivered { + ch <- false + return + } + } + + ch <- true + }() + + return ch +} + +// Send with retries on delivery failure +func (m *OutgoingMessage) SendReliably() chan bool { + helpers.Assert(m.ReceivingNode != nil, "Can't send a message without knowing through which device to send it") + helpers.Assert(m.FromNode != nil, "Can't send a message to an unknown node") + helpers.Assert(m.ToNode != nil, "Can't send a message from an unknown node") + + ch := make(chan bool) + + go func() { + for _, msg := range helpers.BreakMessage(m.Text) { + attempt := 1 + delivered := false + for attempt <= m.Retries { + ack := m.send(msg) + delivered = m.delivered(ack) + if delivered { + break + } + attempt++ + } + if !delivered { + // Failed to deliver at least part of the message, abort + ch <- false + close(ch) + return + } + } + + // Made it through all parts of the message successfully + ch <- true + close(ch) + }() + + return ch +} + +func (m *OutgoingMessage) send(message string) *acknowledgement { + // Notify the rest of the system that we're sending this message + OutgoingMessageEvents.publish(OutgoingMessageEvent, *m) + + var channelId uint32 + if m.isPrivateMessage() { + channelId = 0 + } else { + channelId = m.Channel.id + } + + // Actually send the message + ack := newAcknowledgement(m.FromNode) + id, err := m.ReceivingNode.SendMessage(channelId, m.ToNode, message, uint32(m.MaxHops)) + if err != nil { + // Give user feedback, also when acknowledgements are not verbose, + // because there's a good chance that the error we get here is due to + // the user's configuration choices. + log.Println("Could not send message:", err) + ack.error(err) + return ack + } + m.ReceivingNode.Acks[id] = ack + + // Make the acknowledgement timeout work + go func() { + time.Sleep(m.Timeout) + ack.timeout() + delete(m.ReceivingNode.Acks, id) + }() + + return ack +} + +func (m *OutgoingMessage) isPrivateMessage() bool { + helpers.Assert(m.ToNode != nil, "How the hell did we get here? This should have been caught earlier") + return m.ToNode.Id != Broadcast.Id +} + +func (m *OutgoingMessage) delivered(ack *acknowledgement) bool { + if m.isPrivateMessage() { + return <-ack.delivered + } else { + return <-ack.repeated + } +} + +func (m *OutgoingMessage) String() string { + helpers.Assert(m.FromNode != nil, "I should have a known FromNode at this point") + helpers.Assert(m.ToNode != nil, "I should have a known ToNode at this point") + + direction := m.FromNode.ColorString() + if m.isPrivateMessage() { + direction += " -> " + m.ToNode.ColorString() + } else { + direction += " -> Channel " + m.Channel.name + } + + return fmt.Sprintf("%s:\n%s", direction, helpers.Indent(m.Text, "\t")) +} diff --git a/meshwrapper/pubsub.go b/meshwrapper/pubsub.go index bacf6d9..20b4e00 100644 --- a/meshwrapper/pubsub.go +++ b/meshwrapper/pubsub.go @@ -28,7 +28,7 @@ const ( ) type EventBody interface { - Message | Node | ConnectedNode + IncomingMessage | OutgoingMessage | Node | ConnectedNode } type pubSub[T EventBody] struct { @@ -46,5 +46,6 @@ func (ps *pubSub[T]) publish(topic Event, msg T) { } var ConnectionEvents = pubSub[ConnectedNode]{make(map[Event][]func(ConnectedNode))} -var MessageEvents = pubSub[Message]{make(map[Event][]func(Message))} +var IncomingMessageEvents = pubSub[IncomingMessage]{make(map[Event][]func(IncomingMessage))} +var OutgoingMessageEvents = pubSub[OutgoingMessage]{make(map[Event][]func(OutgoingMessage))} var NodeEvents = pubSub[Node]{make(map[Event][]func(Node))} diff --git a/roomserver/room.go b/roomserver/room.go index 2ac2f27..c846b0b 100644 --- a/roomserver/room.go +++ b/roomserver/room.go @@ -42,12 +42,12 @@ func Init(cfg config.Config) { Users = make(map[*m.Node]*User) } -func UserExists(msg m.Message) bool { +func UserExists(msg m.IncomingMessage) bool { _, ok := Users[msg.FromNode] return ok } -func GetUser(msg m.Message) *User { +func GetUser(msg m.IncomingMessage) *User { if user, ok := Users[msg.FromNode]; ok { return user } From 621985375070f78f10b764e628312041950af98d Mon Sep 17 00:00:00 2001 From: Timendus Date: Thu, 30 Oct 2025 20:19:42 +0100 Subject: [PATCH 84/87] Fix acknowledgements and logging and actually use the new OutgoingMessage type --- meshwrapper/incoming_message.go | 115 +++----------------------------- meshwrapper/outgoing_message.go | 26 ++++++-- 2 files changed, 30 insertions(+), 111 deletions(-) diff --git a/meshwrapper/incoming_message.go b/meshwrapper/incoming_message.go index 5e3fd66..9d4ae78 100644 --- a/meshwrapper/incoming_message.go +++ b/meshwrapper/incoming_message.go @@ -166,116 +166,21 @@ func (m *IncomingMessage) ingestMeshPacket(connectedNode *ConnectedNode, meshPac } } -func (m IncomingMessage) ReplyReliably(message string, retries ...int) chan bool { - ch := make(chan bool) - messageTimeout := DEFAULT_BLOCKING_MESSAGE_TIMEOUT - - maxAttempts := 3 - if len(retries) > 0 { - maxAttempts = retries[0] - } - - go func() { - for _, msg := range helpers.BreakMessage(message) { - attempt := 1 - delivered := false - for attempt <= maxAttempts { - ack := m.send(msg, messageTimeout) - delivered = <-ack.delivered - if delivered { - break - } - attempt++ - } - if !delivered { - // Failed to deliver at least part of the message, abort - ch <- false - close(ch) - return - } - } - - // Made it through all parts of the message successfully - ch <- true - close(ch) - }() - - return ch -} - -func (m IncomingMessage) Reply(message string, timeout ...time.Duration) chan bool { - ch := make(chan bool) - - go func() { - messageTimeout := DEFAULT_BLOCKING_MESSAGE_TIMEOUT - if len(timeout) > 0 { - messageTimeout = timeout[0] - } - - for _, msg := range helpers.BreakMessage(message) { - ack := m.send(msg, messageTimeout) - delivered := <-ack.delivered - if !delivered { - ch <- false - return - } - } - - ch <- true - }() - - return ch +func (m *IncomingMessage) Reply(message string) chan bool { + return m.newOutgoingMessage(message).Send() } -func (m *IncomingMessage) send(message string, timeout time.Duration) *acknowledgement { - ack := newAcknowledgement(m.FromNode) - id, err := m.sendTextMessage(message) - if err != nil { - // Give user feedback, also when acknowledgements are not verbose, - // because there's a good chance that the error we get here is due to - // the user's configuration choices. - log.Println("Could not send message:", err) - ack.error(err) - return ack - } - m.ReceivingNode.Acks[id] = ack - go func() { - time.Sleep(timeout) - ack.timeout() - delete(m.ReceivingNode.Acks, id) - }() - return ack +func (m *IncomingMessage) ReplyReliably(message string) chan bool { + return m.newOutgoingMessage(message).SendReliably() } -func (m *IncomingMessage) sendTextMessage(message string) (uint32, error) { - helpers.Assert(m.ReceivingNode != nil, "Can't send a message without knowing through which device to send it") - helpers.Assert(m.FromNode != nil, "Can't send a message to an unknown node") - helpers.Assert(m.ToNode != nil, "Can't send a message from an unknown node") - - // If message was sent to a channel, reply in the same channel instead of - // privately. - recipient := m.FromNode - if m.ToNode.Id == Broadcast.Id { - recipient = &Broadcast - } - channelId := uint32(0) - if m.Channel != nil { - channelId = m.Channel.id - } - - // Notify the rest of the system that we're sending this message - msg := IncomingMessage{ - FromNode: m.ReceivingNode.Node, - ToNode: recipient, - Text: message, - MessageType: MESSAGE_TYPE_TEXT_MESSAGE, - Timestamp: time.Now(), - Channel: m.Channel, +func (m *IncomingMessage) newOutgoingMessage(message string) *OutgoingMessage { + hops := min(int(m.HopsAway)+2, 7) + if m.IsPrivateMessage() { + return NewOutgoingDirectMessage(message, m.ReceivingNode, m.FromNode, hops) + } else { + return NewOutgoingChannelMessage(message, m.ReceivingNode, m.Channel, hops) } - IncomingMessageEvents.publish(OutgoingMessageEvent, msg) - - // Actually send the message - return m.ReceivingNode.SendMessage(channelId, recipient, message, min(m.HopsAway+2, 7)) } func (m IncomingMessage) GetText() string { diff --git a/meshwrapper/outgoing_message.go b/meshwrapper/outgoing_message.go index 582e7ec..525c81d 100644 --- a/meshwrapper/outgoing_message.go +++ b/meshwrapper/outgoing_message.go @@ -17,9 +17,10 @@ type OutgoingMessage struct { ReceivingNode *ConnectedNode Text string - MaxHops int - Retries int - Timeout time.Duration + MaxHops int + Retries int + Timeout time.Duration + CurrentMessagePart string } func NewOutgoingDirectMessage(message string, from *ConnectedNode, to *Node, hops int) *OutgoingMessage { @@ -111,6 +112,7 @@ func (m *OutgoingMessage) SendReliably() chan bool { func (m *OutgoingMessage) send(message string) *acknowledgement { // Notify the rest of the system that we're sending this message + m.CurrentMessagePart = message OutgoingMessageEvents.publish(OutgoingMessageEvent, *m) var channelId uint32 @@ -121,7 +123,7 @@ func (m *OutgoingMessage) send(message string) *acknowledgement { } // Actually send the message - ack := newAcknowledgement(m.FromNode) + ack := newAcknowledgement(m.ToNode) id, err := m.ReceivingNode.SendMessage(channelId, m.ToNode, message, uint32(m.MaxHops)) if err != nil { // Give user feedback, also when acknowledgements are not verbose, @@ -150,9 +152,17 @@ func (m *OutgoingMessage) isPrivateMessage() bool { func (m *OutgoingMessage) delivered(ack *acknowledgement) bool { if m.isPrivateMessage() { + // A private message is delivered when it reaches its destination return <-ack.delivered } else { - return <-ack.repeated + // A channel message is delivered when it reaches its destination or we + // hear it repeated somewhere. Either is fine. + select { + case delivered := <-ack.delivered: + return delivered + case delivered := <-ack.repeated: + return delivered + } } } @@ -167,5 +177,9 @@ func (m *OutgoingMessage) String() string { direction += " -> Channel " + m.Channel.name } - return fmt.Sprintf("%s:\n%s", direction, helpers.Indent(m.Text, "\t")) + contents := m.CurrentMessagePart + if contents == "" { + contents = m.Text + } + return fmt.Sprintf("%s:\n%s", direction, helpers.Indent(contents, "\t")) } From 996aa98781e4bfc67eb2908137e26d81586009fa Mon Sep 17 00:00:00 2001 From: Timendus Date: Thu, 30 Oct 2025 20:27:18 +0100 Subject: [PATCH 85/87] Use new OutgoingMessage type for announcers as well --- config.json | 2 +- config/config.go | 6 +++--- main.go | 12 +++++++----- meshwrapper/connected_node.go | 9 +++++++++ 4 files changed, 20 insertions(+), 9 deletions(-) diff --git a/config.json b/config.json index 3a9d7cc..48e6f3d 100644 --- a/config.json +++ b/config.json @@ -30,7 +30,7 @@ "announcements": [ { "message": "๐Ÿค–๐Ÿ‘‹ Welcome to our mesh!", - "channel": 0, + "channel": "YourChannel", "delayMinutes": 1440, "maxHops": 1 } diff --git a/config/config.go b/config/config.go index 8776bbf..9082ae2 100644 --- a/config/config.go +++ b/config/config.go @@ -44,9 +44,9 @@ type Room struct { type Announcement struct { Message string `json:"message"` - Channel uint32 `json:"channel"` - DelayMinutes uint32 `json:"delayMinutes"` - MaxHops uint32 `json:"maxHops"` + Channel string `json:"channel"` + DelayMinutes int `json:"delayMinutes"` + MaxHops int `json:"maxHops"` } var config Config diff --git a/main.go b/main.go index be8c4e1..7ee0089 100644 --- a/main.go +++ b/main.go @@ -117,13 +117,15 @@ func connected(node m.ConnectedNode) { // Start announcer service(s) if !announcersRunning { for _, announcement := range config.GetConfig().Announcements { + channel, ok := node.FindChannel(announcement.Channel) + if !ok { + log.Printf("Announcer: Can't find channel %s\n", announcement.Channel) + continue + } go func() { for { - log.Println("Announcer: broadcasting to channel", announcement.Channel, "-", announcement.Message) - _, err := node.SendMessage(announcement.Channel, &m.Broadcast, announcement.Message, announcement.MaxHops) - if err != nil { - log.Println("Could not announce:", err) - } + log.Println("Announcement time!") + m.NewOutgoingChannelMessage(announcement.Message, &node, channel, announcement.MaxHops).Send() time.Sleep(time.Duration(announcement.DelayMinutes) * time.Minute) } }() diff --git a/meshwrapper/connected_node.go b/meshwrapper/connected_node.go index 88293af..8ed9033 100644 --- a/meshwrapper/connected_node.go +++ b/meshwrapper/connected_node.go @@ -38,6 +38,15 @@ func NewConnectedNode(aquire func() (io.ReadWriteCloser, error)) *ConnectedNode } } +func (n *ConnectedNode) FindChannel(name string) (*Channel, bool) { + for _, channel := range n.Channels { + if channel.name == name { + return &channel, true + } + } + return nil, false +} + func (n *ConnectedNode) Connect() error { // Connect to the actual device stream, err := n.aquireStream() From b24232e1655c0f4d543199bbe13d8484c359299d Mon Sep 17 00:00:00 2001 From: Timendus Date: Thu, 30 Oct 2025 21:40:14 +0100 Subject: [PATCH 86/87] Show user available channels on startup, print channel names in log --- main.go | 15 +++++++++++---- meshwrapper/channel.go | 18 +++++++++++++++--- meshwrapper/connected_node.go | 9 ++++++--- meshwrapper/incoming_message.go | 14 +++++++++++--- 4 files changed, 43 insertions(+), 13 deletions(-) diff --git a/main.go b/main.go index 7ee0089..bdaaa71 100644 --- a/main.go +++ b/main.go @@ -7,7 +7,9 @@ import ( "fmt" "io" "log" + "maps" "net" + "slices" "strconv" "strings" "time" @@ -109,10 +111,15 @@ var announcersRunning bool func connected(node m.ConnectedNode) { log.Println("Connected to " + node.String()) // log.Println("Node list: \n" + node.NodeList.String()) - // log.Println("Channel list:") - // for _, channel := range node.Channels { - // log.Println(" " + channel.String()) - // } + + // Inform user of available channels + log.Println("Channel list:") + keys := slices.Collect(maps.Keys(node.Channels)) + slices.Sort(keys) + for _, key := range keys { + channel := node.Channels[key] + log.Println(" " + channel.String()) + } // Start announcer service(s) if !announcersRunning { diff --git a/meshwrapper/channel.go b/meshwrapper/channel.go index 112ded8..12296c4 100644 --- a/meshwrapper/channel.go +++ b/meshwrapper/channel.go @@ -13,13 +13,25 @@ type Channel struct { } func NewChannel(unit *meshtastic.Channel) Channel { - if unit == nil { + if unit == nil || unit.Settings == nil { return Channel{} } + + name := unit.Settings.Name + if name == "" { + name = "Default" + } + + passkey := unit.Settings.Psk + if len(passkey) == 0 { + // This comes from the Protobuf documentation, untested + passkey = []byte{0xd4, 0xf1, 0xbb, 0x3a, 0x20, 0x29, 0x07, 0x59, 0xf0, 0xbc, 0xff, 0xab, 0xcf, 0x4e, 0x69, 0x01} + } + return Channel{ id: uint32(unit.Index), - name: unit.GetSettings().Name, - passkey: unit.GetSettings().Psk, + name: name, + passkey: passkey, } } diff --git a/meshwrapper/connected_node.go b/meshwrapper/connected_node.go index 8ed9033..8315c62 100644 --- a/meshwrapper/connected_node.go +++ b/meshwrapper/connected_node.go @@ -154,8 +154,10 @@ func (n *ConnectedNode) readMessages(stream io.ReadCloser) error { case *meshtastic.FromRadio_NodeInfo: n.parseNodeInfo(packet.GetNodeInfo()) case *meshtastic.FromRadio_Channel: - channel := NewChannel(packet.GetChannel()) - n.Channels[channel.id] = channel + channel := packet.GetChannel() + if channel != nil && channel.Index >= 0 && channel.GetRole() != meshtastic.Channel_DISABLED { + n.Channels[uint32(channel.Index)] = NewChannel(channel) + } case *meshtastic.FromRadio_Packet: n.parseMeshPacket(packet.GetPacket()) case *meshtastic.FromRadio_Config: @@ -204,7 +206,8 @@ func (n *ConnectedNode) parseMeshPacket(meshPacket *meshtastic.MeshPacket) { channel, ok := n.Channels[meshPacket.Channel] if !ok { channel = Channel{ - id: meshPacket.Channel, + id: meshPacket.Channel, + name: "Unknown", } n.Channels[meshPacket.Channel] = channel } diff --git a/meshwrapper/incoming_message.go b/meshwrapper/incoming_message.go index 9d4ae78..b809ec5 100644 --- a/meshwrapper/incoming_message.go +++ b/meshwrapper/incoming_message.go @@ -224,10 +224,18 @@ func (m IncomingMessage) String() string { } else { direction += "No node" } - if m.ToNode != nil { - direction += " -> " + m.ToNode.ColorString() + if m.IsPrivateMessage() { + if m.ToNode != nil { + direction += " -> " + m.ToNode.ColorString() + } else { + direction += " -> No node" + } } else { - direction += " -> No node" + if m.Channel != nil { + direction += " -> Channel " + m.Channel.name + } else { + direction += " -> Unknown channel" + } } if m.MessageType == MESSAGE_TYPE_NEIGHBOR_INFO { From b36d8dfba748503c5133b7aba286771a0cf8dea8 Mon Sep 17 00:00:00 2001 From: Timendus Date: Thu, 30 Oct 2025 21:40:44 +0100 Subject: [PATCH 87/87] Only trigger message event if the message actually sends --- meshwrapper/outgoing_message.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/meshwrapper/outgoing_message.go b/meshwrapper/outgoing_message.go index 525c81d..197a5f2 100644 --- a/meshwrapper/outgoing_message.go +++ b/meshwrapper/outgoing_message.go @@ -111,10 +111,6 @@ func (m *OutgoingMessage) SendReliably() chan bool { } func (m *OutgoingMessage) send(message string) *acknowledgement { - // Notify the rest of the system that we're sending this message - m.CurrentMessagePart = message - OutgoingMessageEvents.publish(OutgoingMessageEvent, *m) - var channelId uint32 if m.isPrivateMessage() { channelId = 0 @@ -142,6 +138,10 @@ func (m *OutgoingMessage) send(message string) *acknowledgement { delete(m.ReceivingNode.Acks, id) }() + // Notify the rest of the system that we've sent this message + m.CurrentMessagePart = message + OutgoingMessageEvents.publish(OutgoingMessageEvent, *m) + return ack }