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/.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..79502fb --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,98 @@ +name: Test and build Meshbot + +on: + push: + 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: + name: Run tests + runs-on: ubuntu-latest + strategy: + matrix: + go-version: [ '1.24.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 binaries + needs: test + runs-on: ubuntu-latest + strategy: + matrix: + go-version: [ '1.24.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 + + - 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/* + + - 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/* 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/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3d675f1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,46 @@ +# 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 +RUN cat >./run-meshbot.sh < meshbot-docker-image.tar.gz + @GOOS=linux GOARCH=arm64 go build -o dist/raspberry-pi/meshbot *.go + @cp config.json dist/raspberry-pi/ -dependencies: - @python3 -m venv .venv - @.venv/bin/pip3 install -r requirements.txt + @GOOS=darwin GOARCH=amd64 go build -o dist/macos-intel/meshbot *.go + @cp config.json dist/macos-intel/ -run: - @.venv/bin/python3 -m meshbot + @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 diff --git a/README.md b/README.md index 9373bab..f261f4c 100644 --- a/README.md +++ b/README.md @@ -1,56 +1,75 @@ -> Warning: this bot currently doesn't work properly with firmware 2.5+. It has trouble getting delivery acknowledgements, which leads to all kinds of issues. I'm working on a rewrite (slowly) and don't plan on supporting this bot much anymore, but feel free to open a pull request if you know how to fix this issue. +> 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. + +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 -- 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/). +- "Room server" that supports multiple rooms with subscriptions and + semi-reliable message delivery ([see + 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 + Internet connection) +- Programmable regular announcements to channels for service messages in your + area + +## Usage on the mesh + +See the [user manual](./manual.md) for instructions on how to use Meshbot over +Meshtastic. + +## Hosting Meshbot -Some of these things are being demonstrated in this screenshot: +### Be responsible -![A screenshot of the Meshtastic app in a conversation with -Meshbot](./screenshot.jpeg) +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. -## Setup +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. -You will need a Meshtastic node and a computer to host the bot. +In short: 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 @@ -62,73 +81,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. -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: +## Local development -https://github.com/Timendus/meshbot/blob/735012c0db883f43378fceedabd81103a04355e7/.env#L1-L17 +Dependencies: -Then install the dependencies in a new virtual environment and run the bot: +- Golang +- Git +- make + +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. 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/config.json b/config.json new file mode 100644 index 0000000..48e6f3d --- /dev/null +++ b/config.json @@ -0,0 +1,38 @@ +{ + "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, + "transmit_exception_node_id": 0, + "allow_transmit_to_channels": false + }, + "rooms": [ + { + "name": "General", + "password": null + }, + { + "name": "Private", + "password": "SuperSecret" + } + ], + "announcements": [ + { + "message": "๐Ÿค–๐Ÿ‘‹ Welcome to our mesh!", + "channel": "YourChannel", + "delayMinutes": 1440, + "maxHops": 1 + } + ] +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..9082ae2 --- /dev/null +++ b/config/config.go @@ -0,0 +1,85 @@ +package config + +import ( + "encoding/json" + "io" + "os" +) + +type ConnectionType int + +const ( + UNKNOWN = iota + SERIAL_CONNECTION + TCP_CONNECTION +) + +type Config struct { + Connections []Connection `json:"connections"` + Settings Settings `json:"settings"` + Rooms []Room `json:"rooms"` + Announcements []Announcement `json:"announcements"` +} + +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"` + TransmitExceptionNodeId uint32 `json:"transmit_exception_node_id"` + AllowTransmitToChannels bool `json:"allow_transmit_to_channels"` +} + +type Room struct { + Name string `json:"name"` + Password string `json:"password"` +} + +type Announcement struct { + Message string `json:"message"` + Channel string `json:"channel"` + DelayMinutes int `json:"delayMinutes"` + MaxHops int `json:"maxHops"` +} + +var config Config + +func InitConfig() error { + configFile, err := os.Open("config.json") + if err != nil { + return err + } + configBytes, _ := io.ReadAll(configFile) + 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 + } + if connection.Hostname != "" { + config.Connections[i].ConnectionType = TCP_CONNECTION + } + if connection.SerialDevice != "" { + config.Connections[i].ConnectionType = SERIAL_CONNECTION + } + } + for i, announcement := range config.Announcements { + if announcement.DelayMinutes == 0 { + config.Announcements[i].DelayMinutes = 1440 // Default to once per day + } + } + return nil +} + +func GetConfig() Config { + return config +} 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.mod b/go.mod new file mode 100644 index 0000000..3b17ab2 --- /dev/null +++ b/go.mod @@ -0,0 +1,16 @@ +module github.com/timendus/meshbot + +go 1.24.0 + +toolchain go1.24.8 + +require ( + 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.3 // indirect + golang.org/x/sys v0.37.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2b9bf79 --- /dev/null +++ b/go.sum @@ -0,0 +1,20 @@ +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.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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..bdaaa71 --- /dev/null +++ b/main.go @@ -0,0 +1,375 @@ +package main + +// https://meshtastic.org/docs/development/device/client-api/ +// https://buf.build/meshtastic/protobufs/docs/main:meshtastic#meshtastic.ToRadio + +import ( + "fmt" + "io" + "log" + "maps" + "net" + "slices" + "strconv" + "strings" + "time" + + "github.com/timendus/meshbot/config" + 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" +) + +func main() { + log.Println("Starting Meshed Potatoes!") + err := config.InitConfig() + if err != nil { + log.Println("Encountered issue reading config.json:") + log.Fatal(err) + } + cfg := config.GetConfig() + roomserver.Init(cfg) + + m.IncomingMessageEvents.Subscribe(m.IncomingMessageEvent, incoming) + m.OutgoingMessageEvents.Subscribe(m.OutgoingMessageEvent, outgoing) + m.ConnectionEvents.Subscribe(m.ConnectedEvent, connected) + m.ConnectionEvents.Subscribe(m.DisconnectedEvent, disconnected) + + // Connect to the meshtastic devices mentioned in the configuration file + for _, connection := range cfg.Connections { + var node *m.ConnectedNode + var err error + + switch connection.ConnectionType { + case config.SERIAL_CONNECTION: + if !cfg.Settings.AllowSerial { + 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, + }) + if err != nil { + return nil, fmt.Errorf("Could not open serial connection to '"+connection.SerialDevice+"': ", err) + } + return stream, nil + }) + + case config.TCP_CONNECTION: + if !cfg.Settings.AllowTCP { + log.Fatal("TCP connection configured, but not allowed by settings") + } + node = m.NewConnectedNode(func() (io.ReadWriteCloser, error) { + 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 '"+conn+"': ", err) + } + return stream, nil + }) + + default: + log.Fatal("Invalid connection type!") + } + + err = node.Connect() + if err != nil { + log.Fatal(err) + } + defer node.Close() + } + + // 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 +} + +var announcersRunning bool + +func connected(node m.ConnectedNode) { + log.Println("Connected to " + node.String()) + // log.Println("Node list: \n" + node.NodeList.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 { + 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("Announcement time!") + m.NewOutgoingChannelMessage(announcement.Message, &node, channel, announcement.MaxHops).Send() + time.Sleep(time.Duration(announcement.DelayMinutes) * time.Minute) + } + }() + } + announcersRunning = true + } +} + +func disconnected(node m.ConnectedNode) { + 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.IncomingMessage) { + fmt.Println(message.String()) + + if roomserver.UserExists(message) { + user := roomserver.GetUser(message) + go user.SendBacklog() + } + + if message.MessageType != m.MESSAGE_TYPE_TEXT_MESSAGE { + return + } + + 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 + + + +Bonus features: + + - /neighbours + - /signal + - /weather + - /forecast + +For details, see: github.com/Timendus/meshbot/blob/main/manual.md`) + 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 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 := 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 + } + + 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 + + // Make sure we don't spam channels + if !message.IsPrivateMessage() { + return + } + + // Find our user + user := roomserver.GetUser(message) + + if strings.HasPrefix(command, "/ROOMS") { + 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.ReplyReliably("๐Ÿค–๐Ÿงจ 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.ReplyReliably("๐Ÿค–๐Ÿ’ฌ " + err.Error()) + return + } + 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.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.ReplyReliably("๐Ÿค–๐Ÿงจ " + err.Error()) + return + } + 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.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.ReplyReliably("๐Ÿค–๐Ÿ’ฌ " + err.Error()) + return + } + message.ReplyReliably("๐Ÿค–๐Ÿ’ฌ You selected " + roomName) + return + } + + if strings.HasPrefix(command, "/") { + message.ReplyReliably("๐Ÿค–โ“ 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.ReplyReliably("๐Ÿค–๐Ÿ’ฌ " + err.Error()) + return + } +} + +func outgoing(message m.OutgoingMessage) { + fmt.Println(message.String()) +} 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. 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/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/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/channel.go b/meshwrapper/channel.go new file mode 100644 index 0000000..12296c4 --- /dev/null +++ b/meshwrapper/channel.go @@ -0,0 +1,40 @@ +package meshwrapper + +import ( + "fmt" + + "buf.build/gen/go/meshtastic/protobufs/protocolbuffers/go/meshtastic" +) + +type Channel struct { + id uint32 + name string + passkey []byte +} + +func NewChannel(unit *meshtastic.Channel) Channel { + 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: name, + passkey: passkey, + } +} + +func (c Channel) String() string { + return fmt.Sprintf("[%d] %s", c.id, c.name) +} diff --git a/meshwrapper/connected_node.go b/meshwrapper/connected_node.go new file mode 100644 index 0000000..8315c62 --- /dev/null +++ b/meshwrapper/connected_node.go @@ -0,0 +1,228 @@ +package meshwrapper + +import ( + "fmt" + "io" + "log" + "math/rand/v2" + "time" + + "buf.build/gen/go/meshtastic/protobufs/protocolbuffers/go/meshtastic" + "github.com/timendus/meshbot/config" +) + +type ConnectedNode struct { + aquireStream func() (io.ReadWriteCloser, error) + stream io.ReadWriteCloser + Connected bool + FirmwareVersion string + Channels map[uint32]Channel + Node *Node + NodeList nodeList + Acks map[uint32]*acknowledgement +} + +func NewConnectedNode(aquire func() (io.ReadWriteCloser, error)) *ConnectedNode { + return &ConnectedNode{ + aquireStream: aquire, + Connected: false, + NodeList: NewNodeList(), + Acks: make(map[uint32]*acknowledgement), + Channels: make(map[uint32]Channel), + Node: &Node{ + ShortName: "UNKN", + LongName: "Unknown node", + Id: 0, + Connected: true, + }, + } +} + +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() + if err != nil { + return err + } + n.stream = stream + + // Spin up a goroutine to read messages from the device + go n.readMessages(n.stream) + + // Wake the device + if err := wakeDevice(n.stream); err != nil { + return err + } + + // Tell the device that we can speak ProtoBuf + if err := writeMessage(n.stream, &meshtastic.ToRadio{ + PayloadVariant: &meshtastic.ToRadio_WantConfigId{ + WantConfigId: 1, + }, + }); err != nil { + return err + } + return nil +} + +func (n *ConnectedNode) Close() error { + n.Connected = false + ConnectionEvents.publish(DisconnectedEvent, *n) + return n.stream.Close() +} + +func (n *ConnectedNode) String() string { + return n.Node.ColorString() +} + +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 { + return err + } + return nil +} + +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: + n.Connected = true + ConnectionEvents.publish(ConnectedEvent, *n) + case *meshtastic.FromRadio_MyInfo: + n.Node.Id = packet.GetMyInfo().MyNodeNum + n.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: + 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: + case *meshtastic.FromRadio_ModuleConfig: + case *meshtastic.FromRadio_FileInfo: + case *meshtastic.FromRadio_QueueStatus: + // Silently ignore these packets + default: + log.Println("Unhandled message:" + packet.String()) + } + } +} + +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(n, nodeInfo) + } else { + relevantNode.ingestNodeInfo(n, 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 + } + + fromNode, ok := n.NodeList.nodes[meshPacket.From] + if !ok { + fromNode = NewNode(n, &meshtastic.NodeInfo{ + Num: meshPacket.From, + }) + n.NodeList.nodes[meshPacket.From] = fromNode + } + + toNode, ok := n.NodeList.nodes[meshPacket.To] + if !ok { + toNode = NewNode(n, &meshtastic.NodeInfo{ + Num: meshPacket.To, + }) + n.NodeList.nodes[meshPacket.To] = toNode + } + + channel, ok := n.Channels[meshPacket.Channel] + if !ok { + channel = Channel{ + id: meshPacket.Channel, + name: "Unknown", + } + n.Channels[meshPacket.Channel] = channel + } + + message := IncomingMessage{ + FromNode: fromNode, + ToNode: toNode, + ReceivingNode: n, + Channel: &channel, + Timestamp: time.Unix(int64(meshPacket.RxTime), 0), + MessageType: MESSAGE_TYPE_OTHER, + Snr: meshPacket.RxSnr, + } + message.ingestMeshPacket(n, meshPacket) + fromNode.receiveMessage(n, message) + + IncomingMessageEvents.publish(IncomingMessageEvent, message) +} diff --git a/meshwrapper/helpers/assertions.go b/meshwrapper/helpers/assertions.go new file mode 100644 index 0000000..f72904a --- /dev/null +++ b/meshwrapper/helpers/assertions.go @@ -0,0 +1,7 @@ +package helpers + +func Assert(condition bool, message string) { + if !condition { + panic(message) + } +} diff --git a/meshwrapper/helpers/language.go b/meshwrapper/helpers/language.go new file mode 100644 index 0000000..36b01d9 --- /dev/null +++ b/meshwrapper/helpers/language.go @@ -0,0 +1,149 @@ +package helpers + +import ( + "fmt" + "math" + "strconv" + "strings" + "time" + "unicode/utf8" +) + +func Pluralize(word string, count int) string { + if count == 1 { + return word + } + if word == "it" { + return "them" + } + 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) +} + +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(message) <= MAX_MESSAGE_LENGTH { + messages = append(messages, message) + continue + } + + // 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 +} + +func BreakMessageAt(input string, maxlength int) []string { + 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 + maxlength + + if charEnd >= len(input) { + // We can fit the whole rest of the input in the message, in other + // words: we're done + messages = append(messages, input[startPtr:]) + break + } + + // 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 <= maxlength { + 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 +} + +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/helpers/language_test.go b/meshwrapper/helpers/language_test.go new file mode 100644 index 0000000..c0de18b --- /dev/null +++ b/meshwrapper/helpers/language_test.go @@ -0,0 +1,335 @@ +package helpers + +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") + 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 TestMessagePagination(t *testing.T) { + AssertBreaking(t, + "", + []string{""}, + ) + + AssertBreaking(t, + "Hello", + []string{"Hello"}, + ) + + AssertBreaking(t, + "Hello\nHello", + []string{"Hello\nHello"}, + ) + + AssertBreaking(t, + "HelloHello", + []string{"Hello", "Hello"}, + ) + + AssertBreaking(t, + TWO_HUNDRED_CHARS, + []string{TWO_HUNDRED_CHARS}, + ) + + AssertBreaking(t, + TWO_HUNDRED_CHAR_WORDS, + []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{ + TWO_HUNDRED_CHARS[:len(TWO_HUNDRED_CHARS)-6] + " [1/2]", + "lloahea [2/2]", + }, + ) + + 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 [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{ + TWO_HUNDRED_CHARS, + "a", + }, + ) + + AssertBreakingAt(t, + TWO_HUNDRED_CHARS+"๐Ÿ“Ÿ!", + []string{ + TWO_HUNDRED_CHARS, + "๐Ÿ“Ÿ!", + }, + ) + + AssertBreakingAt(t, + TWO_HUNDRED_CHARS[:len(TWO_HUNDRED_CHARS)-4]+"๐Ÿ“Ÿ!", + []string{ + TWO_HUNDRED_CHARS[:len(TWO_HUNDRED_CHARS)-4] + "๐Ÿ“Ÿ", + "!", + }, + ) + + AssertBreakingAt(t, + TWO_HUNDRED_CHARS[:len(TWO_HUNDRED_CHARS)-2]+"๐Ÿ“Ÿ!", + []string{ + TWO_HUNDRED_CHARS[:len(TWO_HUNDRED_CHARS)-2], + "๐Ÿ“Ÿ!", + }, + ) + + AssertBreakingAt(t, + TWO_HUNDRED_CHAR_WORDS+"y", + []string{ + TWO_HUNDRED_CHAR_WORDS[:len(TWO_HUNDRED_CHAR_WORDS)-3], + "hey", + }, + ) + + AssertBreakingAt(t, + TWO_HUNDRED_CHAR_WORDS+` Testing: + - A thing here + - And another one`, + []string{ + TWO_HUNDRED_CHAR_WORDS, + `Testing: + - A thing here + - And another one`, + }, + ) + + AssertBreakingAt(t, + TWO_HUNDRED_CHAR_WORDS+` +Testing: + - A thing here + - And another one`, + []string{ + TWO_HUNDRED_CHAR_WORDS, + `Testing: + - A thing here + - And another one`, + }, + ) + + AssertBreakingAt(t, + TWO_HUNDRED_CHAR_WORDS+"\n"+TWO_HUNDRED_CHAR_WORDS+"\n"+"Working!\n", + []string{ + TWO_HUNDRED_CHAR_WORDS, + TWO_HUNDRED_CHAR_WORDS, + "Working!", + }, + ) + + AssertBreakingAt(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 [])`, + }, + ) + + 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`, + `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`, + }, + ) + + 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. + `, + []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) + } + } +} + +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) + } + } +} diff --git a/meshwrapper/incoming_message.go b/meshwrapper/incoming_message.go new file mode 100644 index 0000000..b809ec5 --- /dev/null +++ b/meshwrapper/incoming_message.go @@ -0,0 +1,271 @@ +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 ( + 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" + + DEFAULT_BLOCKING_MESSAGE_TIMEOUT = 60 * time.Second +) + +type IncomingMessage struct { + FromNode *Node + ToNode *Node + ReceivingNode *ConnectedNode + + Timestamp time.Time + Snr float32 + HopsAway uint32 + + MessageType string + Text string + Channel *Channel + DeviceMetrics *meshtastic.DeviceMetrics + EnvironmentMetrics *meshtastic.EnvironmentMetrics + HealthMetrics *meshtastic.HealthMetrics + AirQualityMetrics *meshtastic.AirQualityMetrics + PowerMetrics *meshtastic.PowerMetrics + UserInfo *meshtastic.User + LocalStats *meshtastic.LocalStats + NeighborInfo *meshtastic.NeighborInfo + Position *Position +} + +func (m *IncomingMessage) 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 + IncomingMessageEvents.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() + IncomingMessageEvents.publish(DeviceTelemetryEvent, *m) + case *meshtastic.Telemetry_EnvironmentMetrics: + m.MessageType = MESSAGE_TYPE_TELEMETRY_ENVIRONMENT + m.EnvironmentMetrics = result.GetEnvironmentMetrics() + IncomingMessageEvents.publish(EnvironmentTelemetryEvent, *m) + case *meshtastic.Telemetry_HealthMetrics: + m.MessageType = MESSAGE_TYPE_TELEMETRY_HEALTH + m.HealthMetrics = result.GetHealthMetrics() + IncomingMessageEvents.publish(HealthTelemetryEvent, *m) + case *meshtastic.Telemetry_AirQualityMetrics: + m.MessageType = MESSAGE_TYPE_TELEMETRY_AIR_QUALITY + m.AirQualityMetrics = result.GetAirQualityMetrics() + IncomingMessageEvents.publish(AirQualityTelemetryEvent, *m) + case *meshtastic.Telemetry_PowerMetrics: + m.MessageType = MESSAGE_TYPE_TELEMETRY_POWER + m.PowerMetrics = result.GetPowerMetrics() + IncomingMessageEvents.publish(PowerTelemetryEvent, *m) + case *meshtastic.Telemetry_LocalStats: + m.MessageType = MESSAGE_TYPE_TELEMETRY_LOCAL_STATS + m.LocalStats = result.GetLocalStats() + IncomingMessageEvents.publish(LocalStatsTelemetryEvent, *m) + default: + log.Println("Warning: Unknown telemetry variant:", result.String()) + } + IncomingMessageEvents.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) + IncomingMessageEvents.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))) + IncomingMessageEvents.publish(NeighborInfoEvent, *m) + + case meshtastic.PortNum_TEXT_MESSAGE_APP: + m.MessageType = MESSAGE_TYPE_TEXT_MESSAGE + m.Text = string(payload) + IncomingMessageEvents.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 + } + messageId := meshPacket.GetDecoded().RequestId + ack, ok := connectedNode.Acks[messageId] + if ok { + ack.receive(m.FromNode, result.GetErrorReason()) + delete(connectedNode.Acks, messageId) + } + } + m.MessageType = MESSAGE_TYPE_ROUTING + IncomingMessageEvents.publish(RoutingEvent, *m) + + case meshtastic.PortNum_TRACEROUTE_APP: + m.MessageType = MESSAGE_TYPE_TRACEROUTE + IncomingMessageEvents.publish(TraceRouteEvent, *m) + + default: + log.Println("Warning: Unknown mesh packet:", meshPacket.String()) + + } +} + +func (m *IncomingMessage) Reply(message string) chan bool { + return m.newOutgoingMessage(message).Send() +} + +func (m *IncomingMessage) ReplyReliably(message string) chan bool { + return m.newOutgoingMessage(message).SendReliably() +} + +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) + } +} + +func (m IncomingMessage) GetText() string { + return m.Text +} + +func (m IncomingMessage) IsPrivateMessage() bool { + return m.ToNode != nil && m.ToNode.Id != Broadcast.Id +} + +func (m IncomingMessage) GetType() string { + return m.MessageType +} + +func (m IncomingMessage) GetChannelName() string { + if m.Channel == nil { + return "UNKNOWN" + } + return m.Channel.name +} + +func (m IncomingMessage) GetSenderNode() *Node { + return m.FromNode +} + +func (m IncomingMessage) GetReceiverNode() *Node { + return m.ToNode +} + +func (m IncomingMessage) FindNode(needle string) *Node { + if m.ReceivingNode == nil { + return nil + } + return m.ReceivingNode.NodeList.findNode(needle) +} + +func (m IncomingMessage) String() string { + direction := "" + if m.FromNode != nil { + direction += m.FromNode.ColorString() + } else { + direction += "No node" + } + if m.IsPrivateMessage() { + if m.ToNode != nil { + direction += " -> " + m.ToNode.ColorString() + } else { + direction += " -> No node" + } + } else { + if m.Channel != nil { + direction += " -> Channel " + m.Channel.name + } else { + direction += " -> Unknown channel" + } + } + + if m.MessageType == MESSAGE_TYPE_NEIGHBOR_INFO { + 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) + } + + if m.MessageType == MESSAGE_TYPE_TEXT_MESSAGE { + return fmt.Sprintf("%s: %s\n%s", direction, m.radioMetricsString(), helpers.Indent(m.Text, "\t")) + } + + return fmt.Sprintf("%s: \033[1m%s packet\033[0m %s", direction, m.MessageType, m.radioMetricsString()) +} + +func (m *IncomingMessage) radioMetricsString() string { + if m.FromNode != nil && 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, + helpers.Pluralize("hop", int(m.HopsAway)), + ) +} diff --git a/meshwrapper/neighbor.go b/meshwrapper/neighbor.go new file mode 100644 index 0000000..ddf0b58 --- /dev/null +++ b/meshwrapper/neighbor.go @@ -0,0 +1,48 @@ +package meshwrapper + +import ( + "fmt" + "time" + + "buf.build/gen/go/meshtastic/protobufs/protocolbuffers/go/meshtastic" + "github.com/timendus/meshbot/meshwrapper/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.ColorString(), helpers.TimeAgo(n.LastReported), n.Snr) +} + +type NeighborList []Neighbor + +func NewNeighbourList(connectedNode *ConnectedNode, message IncomingMessage) NeighborList { + neighbourList := make([]Neighbor, 0) + for _, neighbor := range message.NeighborInfo.Neighbors { + node, ok := connectedNode.NodeList.nodes[neighbor.NodeId] + if !ok { + node = NewNode(connectedNode, &meshtastic.NodeInfo{ + Num: neighbor.NodeId, + }) + connectedNode.NodeList.nodes[neighbor.NodeId] = node + } + neighbourList = append(neighbourList, Neighbor{ + Node: node, + Snr: neighbor.Snr, + LastReported: message.Timestamp, + }) + } + return neighbourList +} + +func (nl NeighborList) String() string { + nodes := "" + for _, node := range nl { + nodes += "\n - " + node.String() + } + return nodes +} diff --git a/meshwrapper/node.go b/meshwrapper/node.go new file mode 100644 index 0000000..f345112 --- /dev/null +++ b/meshwrapper/node.go @@ -0,0 +1,224 @@ +package meshwrapper + +import ( + "fmt" + "time" + "unicode/utf8" + + "buf.build/gen/go/meshtastic/protobufs/protocolbuffers/go/meshtastic" + "github.com/timendus/meshbot/meshwrapper/helpers" +) + +type Node struct { + ShortName string + LongName string + Id uint32 + HwModel meshtastic.HardwareModel + Role meshtastic.Config_DeviceConfig_Role + Snr float32 + LastHeard time.Time + HopsAway uint32 + IsLicensed bool + ReceivedMessages []*IncomingMessage + Connected bool + PublicKey []byte + Neighbors NeighborList + Position *Position +} + +func NewNode(connectedNode *ConnectedNode, info *meshtastic.NodeInfo) *Node { + node := Node{ + Id: info.Num, + HopsAway: 0, + ShortName: "UNKN", + LongName: "Unknown node", + HwModel: meshtastic.HardwareModel_UNSET, + IsLicensed: false, + ReceivedMessages: make([]*IncomingMessage, 0), + Neighbors: make(NeighborList, 0), + } + + node.ingestNodeInfo(connectedNode, info) + return &node +} + +// 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 + } + + n.Snr = info.Snr + n.LastHeard = time.Unix(int64(info.LastHeard), 0) + + if info.Position != nil { + n.ReceivedMessages = append(n.ReceivedMessages, &IncomingMessage{ + FromNode: n, + ToNode: &Broadcast, + ReceivingNode: connectedNode, + Timestamp: time.Unix(int64(info.LastHeard), 0), + MessageType: MESSAGE_TYPE_POSITION, + Position: NewPosition(info.Position), + }) + n.Position = NewPosition(info.Position) + } + + if info.DeviceMetrics != nil { + n.ReceivedMessages = append(n.ReceivedMessages, &IncomingMessage{ + FromNode: n, + ToNode: &Broadcast, + ReceivingNode: connectedNode, + Timestamp: time.Unix(int64(info.LastHeard), 0), + MessageType: MESSAGE_TYPE_TELEMETRY_DEVICE, + DeviceMetrics: 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.HwModel = info.User.HwModel + n.Role = info.User.Role + n.IsLicensed = info.User.IsLicensed + n.PublicKey = info.User.PublicKey + } +} + +// 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 IncomingMessage) { + 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) +} + +func (n *Node) GetIDExpression() string { + return fmt.Sprintf("!%08x", n.Id) +} + +func (n *Node) GetShortName() string { + return n.ShortName +} + +func (n *Node) GetLongName() string { + return n.LongName +} + +func (n *Node) ColorString() string { + var col string + 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 { + 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.GetIDExpression(), + ) +} + +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() + + 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, helpers.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, + helpers.TimeAgo(n.LastHeard), + snr, + hopsAway, + ) +} + +func (n *Node) GetPosition() [3]float32 { + 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 { + return int(n.HopsAway) +} + +func (n *Node) GetRSSI() float32 { + panic("TODO: implement") +} + +func (n *Node) GetSNR() float32 { + return n.Snr +} + +func (n *Node) IsSelf() bool { + return n.Connected +} diff --git a/meshwrapper/node_list.go b/meshwrapper/node_list.go new file mode 100644 index 0000000..baa1a6d --- /dev/null +++ b/meshwrapper/node_list.go @@ -0,0 +1,142 @@ +package meshwrapper + +import ( + "cmp" + "fmt" + "regexp" + "slices" + "strconv" + "strings" + "time" + + "github.com/timendus/meshbot/meshwrapper/helpers" +) + +type nodeList struct { + 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 { + 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() { + if node.Id != Broadcast.Id && node.Id != Unknown.Id { + nodes += node.VerboseString() + "\n" + } + } + 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 { + 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 +} + +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, err := strconv.ParseUint(needle[1:], 16, 32) + node, ok := n.nodes[uint32(id)] + if ok && err == nil { + return node + } + } + shortHexId, _ := regexp.Compile("[0-9a-fA-F]{8}") + if shortHexId.Match(needleBytes) { + id, err := strconv.ParseUint(needle, 16, 32) + node, ok := n.nodes[uint32(id)] + if ok && err == nil { + return node + } + } + + // Check if we have a shortName + for _, node := range n.nodes { + if strings.EqualFold(node.ShortName, needle) { + return node + } + } + + // Check if we have a decimal id + numericId, _ := regexp.Compile("[0-9]+") + if numericId.Match(needleBytes) { + id, err := strconv.ParseUint(needle, 10, 32) + node, ok := n.nodes[uint32(id)] + if ok && err == nil { + return node + } + } + + // Check if we have an abbreviated hexadecimal id + abbreviatedHexId, _ := regexp.Compile("[0-9a-fA-F]{4}") + if abbreviatedHexId.Match(needleBytes) { + 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 + } + } + + return nil +} diff --git a/meshwrapper/outgoing_message.go b/meshwrapper/outgoing_message.go new file mode 100644 index 0000000..197a5f2 --- /dev/null +++ b/meshwrapper/outgoing_message.go @@ -0,0 +1,185 @@ +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 + CurrentMessagePart string +} + +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 { + var channelId uint32 + if m.isPrivateMessage() { + channelId = 0 + } else { + channelId = m.Channel.id + } + + // Actually send the message + 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, + // 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) + }() + + // Notify the rest of the system that we've sent this message + m.CurrentMessagePart = message + OutgoingMessageEvents.publish(OutgoingMessageEvent, *m) + + 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() { + // A private message is delivered when it reaches its destination + return <-ack.delivered + } else { + // 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 + } + } +} + +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 + } + + contents := m.CurrentMessagePart + if contents == "" { + contents = m.Text + } + return fmt.Sprintf("%s:\n%s", direction, helpers.Indent(contents, "\t")) +} diff --git a/meshwrapper/position.go b/meshwrapper/position.go new file mode 100644 index 0000000..5264f0b --- /dev/null +++ b/meshwrapper/position.go @@ -0,0 +1,39 @@ +package meshwrapper + +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/meshwrapper/pubsub.go b/meshwrapper/pubsub.go new file mode 100644 index 0000000..20b4e00 --- /dev/null +++ b/meshwrapper/pubsub.go @@ -0,0 +1,51 @@ +package meshwrapper + +type Event int + +const ( + // Connection events + ConnectedEvent Event = iota + DisconnectedEvent + + // Message events + IncomingMessageEvent + OutgoingMessageEvent + + // Specific message events + TextMessageEvent + NodeInfoEvent + PositionEvent + TelemetryEvent + NeighborInfoEvent + RoutingEvent + TraceRouteEvent + DeviceTelemetryEvent + EnvironmentTelemetryEvent + HealthTelemetryEvent + AirQualityTelemetryEvent + PowerTelemetryEvent + LocalStatsTelemetryEvent +) + +type EventBody interface { + IncomingMessage | OutgoingMessage | Node | ConnectedNode +} + +type pubSub[T EventBody] struct { + subscriptions map[Event][]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 Event, msg T) { + for _, function := range ps.subscriptions[topic] { + go function(msg) + } +} + +var ConnectionEvents = pubSub[ConnectedNode]{make(map[Event][]func(ConnectedNode))} +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/meshwrapper/stream_interface.go b/meshwrapper/stream_interface.go new file mode 100644 index 0000000..592bbb5 --- /dev/null +++ b/meshwrapper/stream_interface.go @@ -0,0 +1,134 @@ +package meshwrapper + +import ( + "errors" + "fmt" + "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.Writer) 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.Writer, 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.Reader) (*meshtastic.FromRadio, error) { + buffer := make([]byte, 1) + state := 0 + length := 0 + +searching: + for { + n, err := reader.Read(buffer) + if err != nil { + return nil, err + } + if n == 0 { + return nil, errors.New("unexpected end of file") + } + + switch state { + case 0: + if buffer[0] == START1 { + state = 1 + } else if DEBUGGING { + // Handle any other bytes as text debug output + fmt.Print(buffer) + } + case 1: + if buffer[0] == START2 { + state = 2 + } else { + state = 0 + if DEBUGGING { + fmt.Print([]byte{START1}) + fmt.Print(buffer) + } + } + case 2: + length = int(buffer[0]) << 8 + state = 3 + case 3: + length |= int(buffer[0]) & 0xFF + if length > MAX_SIZE { + log.Printf("Invalid packet size: %d\n", length) + 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 +} 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/roomserver/room.go b/roomserver/room.go new file mode 100644 index 0000000..c846b0b --- /dev/null +++ b/roomserver/room.go @@ -0,0 +1,258 @@ +package roomserver + +import ( + "fmt" + "strings" + "sync" + "sync/atomic" + + "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 + Sending atomic.Bool + UpdatingBacklog sync.Mutex + Backlog []string + Selected *Room +} + +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 UserExists(msg m.IncomingMessage) bool { + _, ok := Users[msg.FromNode] + return ok +} + +func GetUser(msg m.IncomingMessage) *User { + if user, ok := Users[msg.FromNode]; ok { + return user + } + user := &User{ + Node: msg.FromNode, + Send: func(m string) chan bool { return msg.ReplyReliably(m) }, + } + Users[msg.FromNode] = user + return user +} + +func RoomList(user *User) string { + text := "" + for i, room := range Rooms { + public := " โœ… " + if room.Config.Password != "" { + public = " ๐Ÿ” " + } + joined := "" + if room.present(user) { + joined = " (joined)" + } + selected := "" + if user.Selected == &Rooms[i] { + selected = " (selected)" + } + text += public + room.Config.Name + joined + selected + "\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 %s", room.Config.Name) + } + if room.present(user) { + 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 + } + } + 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:]...) + user.autoSelectRoom() + return nil + } + } + return fmt.Errorf("Looks like you were not in room %s", roomName) + } + } + return fmt.Errorf("Can't find that 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 fmt.Errorf("Can't find that room!") +} + +func Send(user *User, message string) error { + 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 { + if room.Config.Password == "" { + firstPublicRoom = room.Config.Name + } + } + if firstPublicRoom == "" { + 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.%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! ๐Ÿ”ด%s", firstPublicRoom, firstPublicRoom, helpText) + } + + if user.Selected == nil { + 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 +} + +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() { + 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() + }() + } +} + +func (room *Room) present(user *User) bool { + for _, u := range room.Users { + if u == user { + return true + } + } + 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] +} diff --git a/weather/open_meteo.go b/weather/open_meteo.go new file mode 100644 index 0000000..aa46c75 --- /dev/null +++ b/weather/open_meteo.go @@ -0,0 +1,323 @@ +// 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 weather + +import ( + _ "embed" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "log" + "net/http" + "net/url" + "strconv" + "time" +) + +//go:embed wmo_codes.json +var wmoCodesJSON string + +// 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 + err := json.Unmarshal([]byte(wmoCodesJSON), &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 3 days if available) + forecastStr := "" + limit := 3 + 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 +} diff --git a/meshbot/wmo_codes.json b/weather/wmo_codes.json similarity index 100% rename from meshbot/wmo_codes.json rename to weather/wmo_codes.json