commit fb898bb058a9e8907812775cc65ff5ae3d69bd2e Author: maze Date: Sun Feb 22 20:27:07 2026 +0100 Initial import diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d207b18 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.go text eol=lf diff --git a/.gitea/workflows/dev.yaml b/.gitea/workflows/dev.yaml new file mode 100644 index 0000000..053ea30 --- /dev/null +++ b/.gitea/workflows/dev.yaml @@ -0,0 +1,23 @@ +name: Run tests +on: + push: + +permissions: + contents: read + +jobs: + test: + strategy: + matrix: + go: [stable, 1.25] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-go@v6 + with: + go-version: ${{ matrix.go }} + - name: golangci-lint + uses: golangci/golangci-lint-action@v9 + with: + go-version: ${{ matrix.go }} + version: v2.6 diff --git a/aprsis.go b/aprsis.go new file mode 100644 index 0000000..3195d47 --- /dev/null +++ b/aprsis.go @@ -0,0 +1,11 @@ +package hamview + +const ( + DefaultAPRSISListen = ":14580" + DefaultAPRSISServer = "rotate.aprs2.net:14580" +) + +type APRSISConfig struct { + Listen string `yaml:"listen"` + Server string `yaml:"server"` +} diff --git a/asset/image/device/esp32.svg b/asset/image/device/esp32.svg new file mode 100644 index 0000000..b5cc14a --- /dev/null +++ b/asset/image/device/esp32.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/asset/image/device/esp_now.svg b/asset/image/device/esp_now.svg new file mode 100644 index 0000000..7e5b234 --- /dev/null +++ b/asset/image/device/esp_now.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/asset/image/device/faketec.svg b/asset/image/device/faketec.svg new file mode 100644 index 0000000..3ee55f3 --- /dev/null +++ b/asset/image/device/faketec.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/asset/image/device/heltec_mesh_solar.svg b/asset/image/device/heltec_mesh_solar.svg new file mode 100644 index 0000000..bbd203b --- /dev/null +++ b/asset/image/device/heltec_mesh_solar.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/asset/image/device/heltec_meshpocket.svg b/asset/image/device/heltec_meshpocket.svg new file mode 100644 index 0000000..4b2f259 --- /dev/null +++ b/asset/image/device/heltec_meshpocket.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/asset/image/device/heltec_paper.svg b/asset/image/device/heltec_paper.svg new file mode 100644 index 0000000..4403e19 --- /dev/null +++ b/asset/image/device/heltec_paper.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/asset/image/device/heltec_t114.svg b/asset/image/device/heltec_t114.svg new file mode 100644 index 0000000..0c0cdc1 --- /dev/null +++ b/asset/image/device/heltec_t114.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/asset/image/device/heltec_v2.svg b/asset/image/device/heltec_v2.svg new file mode 100644 index 0000000..a598b0f --- /dev/null +++ b/asset/image/device/heltec_v2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/asset/image/device/heltec_v3.svg b/asset/image/device/heltec_v3.svg new file mode 100644 index 0000000..89470fb --- /dev/null +++ b/asset/image/device/heltec_v3.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/asset/image/device/heltec_v4.svg b/asset/image/device/heltec_v4.svg new file mode 100644 index 0000000..39a9991 --- /dev/null +++ b/asset/image/device/heltec_v4.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/asset/image/device/heltec_wp.svg b/asset/image/device/heltec_wp.svg new file mode 100644 index 0000000..4020b43 --- /dev/null +++ b/asset/image/device/heltec_wp.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/asset/image/device/heltec_wsl3.svg b/asset/image/device/heltec_wsl3.svg new file mode 100644 index 0000000..20ef8c8 --- /dev/null +++ b/asset/image/device/heltec_wsl3.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/asset/image/device/heltec_wt3.svg b/asset/image/device/heltec_wt3.svg new file mode 100644 index 0000000..26aeb6c --- /dev/null +++ b/asset/image/device/heltec_wt3.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/asset/image/device/ikoka_nano.svg b/asset/image/device/ikoka_nano.svg new file mode 100644 index 0000000..3f0bfc5 --- /dev/null +++ b/asset/image/device/ikoka_nano.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/asset/image/device/ikoka_stick.svg b/asset/image/device/ikoka_stick.svg new file mode 100644 index 0000000..ea96c17 --- /dev/null +++ b/asset/image/device/ikoka_stick.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/asset/image/device/keepteen_lt1.svg b/asset/image/device/keepteen_lt1.svg new file mode 100644 index 0000000..44addc8 --- /dev/null +++ b/asset/image/device/keepteen_lt1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/asset/image/device/lilygo_pager.svg b/asset/image/device/lilygo_pager.svg new file mode 100644 index 0000000..6c801b2 --- /dev/null +++ b/asset/image/device/lilygo_pager.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/asset/image/device/lilygo_t3s3.svg b/asset/image/device/lilygo_t3s3.svg new file mode 100644 index 0000000..b130f93 --- /dev/null +++ b/asset/image/device/lilygo_t3s3.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/asset/image/device/lilygo_t5_pro.svg b/asset/image/device/lilygo_t5_pro.svg new file mode 100644 index 0000000..df1f87c --- /dev/null +++ b/asset/image/device/lilygo_t5_pro.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/asset/image/device/lilygo_tbeam.svg b/asset/image/device/lilygo_tbeam.svg new file mode 100644 index 0000000..aa8fa8b --- /dev/null +++ b/asset/image/device/lilygo_tbeam.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/asset/image/device/lilygo_tbeam_supreme.svg b/asset/image/device/lilygo_tbeam_supreme.svg new file mode 100644 index 0000000..5ad636a --- /dev/null +++ b/asset/image/device/lilygo_tbeam_supreme.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/asset/image/device/lilygo_tdeck.svg b/asset/image/device/lilygo_tdeck.svg new file mode 100644 index 0000000..46bb85b --- /dev/null +++ b/asset/image/device/lilygo_tdeck.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/asset/image/device/lilygo_tdeck_pro.svg b/asset/image/device/lilygo_tdeck_pro.svg new file mode 100644 index 0000000..ab34520 --- /dev/null +++ b/asset/image/device/lilygo_tdeck_pro.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/asset/image/device/lilygo_tdisplay.svg b/asset/image/device/lilygo_tdisplay.svg new file mode 100644 index 0000000..3b9bf5d --- /dev/null +++ b/asset/image/device/lilygo_tdisplay.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/asset/image/device/lilygo_techo.svg b/asset/image/device/lilygo_techo.svg new file mode 100644 index 0000000..b052e64 --- /dev/null +++ b/asset/image/device/lilygo_techo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/asset/image/device/lilygo_techo_lite.svg b/asset/image/device/lilygo_techo_lite.svg new file mode 100644 index 0000000..f55b7f2 --- /dev/null +++ b/asset/image/device/lilygo_techo_lite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/asset/image/device/lilygo_tlora_1.6.svg b/asset/image/device/lilygo_tlora_1.6.svg new file mode 100644 index 0000000..f7dc6d7 --- /dev/null +++ b/asset/image/device/lilygo_tlora_1.6.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/asset/image/device/lilygo_tlora_c6.svg b/asset/image/device/lilygo_tlora_c6.svg new file mode 100644 index 0000000..89d7a23 --- /dev/null +++ b/asset/image/device/lilygo_tlora_c6.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/asset/image/device/lora.svg b/asset/image/device/lora.svg new file mode 100644 index 0000000..741433f --- /dev/null +++ b/asset/image/device/lora.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/asset/image/device/meshcore.svg b/asset/image/device/meshcore.svg new file mode 100644 index 0000000..eb1b7bc --- /dev/null +++ b/asset/image/device/meshcore.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/asset/image/device/nano_g2.svg b/asset/image/device/nano_g2.svg new file mode 100644 index 0000000..1375236 --- /dev/null +++ b/asset/image/device/nano_g2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/asset/image/device/nrf52.svg b/asset/image/device/nrf52.svg new file mode 100644 index 0000000..9d31219 --- /dev/null +++ b/asset/image/device/nrf52.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/asset/image/device/rak_11300.svg b/asset/image/device/rak_11300.svg new file mode 100644 index 0000000..e06e038 --- /dev/null +++ b/asset/image/device/rak_11300.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/asset/image/device/rak_4631.svg b/asset/image/device/rak_4631.svg new file mode 100644 index 0000000..4ddef1b --- /dev/null +++ b/asset/image/device/rak_4631.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/asset/image/device/rak_wismesh_tag.svg b/asset/image/device/rak_wismesh_tag.svg new file mode 100644 index 0000000..275b0a9 --- /dev/null +++ b/asset/image/device/rak_wismesh_tag.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/asset/image/device/rpi.svg b/asset/image/device/rpi.svg new file mode 100644 index 0000000..6b6c7f3 --- /dev/null +++ b/asset/image/device/rpi.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/asset/image/device/rpi_picow.svg b/asset/image/device/rpi_picow.svg new file mode 100644 index 0000000..c892a29 --- /dev/null +++ b/asset/image/device/rpi_picow.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/asset/image/device/sensecap_solar.svg b/asset/image/device/sensecap_solar.svg new file mode 100644 index 0000000..d928218 --- /dev/null +++ b/asset/image/device/sensecap_solar.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/asset/image/device/sensecap_t1000e.svg b/asset/image/device/sensecap_t1000e.svg new file mode 100644 index 0000000..cf41e7f --- /dev/null +++ b/asset/image/device/sensecap_t1000e.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/asset/image/device/station_g2.svg b/asset/image/device/station_g2.svg new file mode 100644 index 0000000..8a67dbf --- /dev/null +++ b/asset/image/device/station_g2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/asset/image/device/thinknode_m1.svg b/asset/image/device/thinknode_m1.svg new file mode 100644 index 0000000..e4124fd --- /dev/null +++ b/asset/image/device/thinknode_m1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/asset/image/device/thinknode_m2.svg b/asset/image/device/thinknode_m2.svg new file mode 100644 index 0000000..2635b30 --- /dev/null +++ b/asset/image/device/thinknode_m2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/asset/image/device/thinknode_m3.svg b/asset/image/device/thinknode_m3.svg new file mode 100644 index 0000000..d652d40 --- /dev/null +++ b/asset/image/device/thinknode_m3.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/asset/image/device/thinknode_m5.svg b/asset/image/device/thinknode_m5.svg new file mode 100644 index 0000000..73b3a9d --- /dev/null +++ b/asset/image/device/thinknode_m5.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/asset/image/device/thinknode_m6.svg b/asset/image/device/thinknode_m6.svg new file mode 100644 index 0000000..2dcc666 --- /dev/null +++ b/asset/image/device/thinknode_m6.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/asset/image/device/wio_tracker_l1.svg b/asset/image/device/wio_tracker_l1.svg new file mode 100644 index 0000000..72179df --- /dev/null +++ b/asset/image/device/wio_tracker_l1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/asset/image/device/wio_tracker_l1_eink.svg b/asset/image/device/wio_tracker_l1_eink.svg new file mode 100644 index 0000000..b711fdc --- /dev/null +++ b/asset/image/device/wio_tracker_l1_eink.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/asset/image/device/xiao_esp32c3.svg b/asset/image/device/xiao_esp32c3.svg new file mode 100644 index 0000000..037d792 --- /dev/null +++ b/asset/image/device/xiao_esp32c3.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/asset/image/device/xiao_esp32c6.svg b/asset/image/device/xiao_esp32c6.svg new file mode 100644 index 0000000..f4e894d --- /dev/null +++ b/asset/image/device/xiao_esp32c6.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/asset/image/device/xiao_esp32s3.svg b/asset/image/device/xiao_esp32s3.svg new file mode 100644 index 0000000..e22a6c5 --- /dev/null +++ b/asset/image/device/xiao_esp32s3.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/asset/image/device/xiao_nrf52.svg b/asset/image/device/xiao_nrf52.svg new file mode 100644 index 0000000..5f8feb9 --- /dev/null +++ b/asset/image/device/xiao_nrf52.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/asset/image/device/yeasu_ft817.jpg b/asset/image/device/yeasu_ft817.jpg new file mode 100644 index 0000000..80cc3c7 Binary files /dev/null and b/asset/image/device/yeasu_ft817.jpg differ diff --git a/asset/image/protocol/meshcore.org.png b/asset/image/protocol/meshcore.org.png new file mode 100644 index 0000000..e7c2fce Binary files /dev/null and b/asset/image/protocol/meshcore.org.png differ diff --git a/asset/image/protocol/meshcore.png b/asset/image/protocol/meshcore.png new file mode 100644 index 0000000..ed58f67 Binary files /dev/null and b/asset/image/protocol/meshcore.png differ diff --git a/asset/image/protocol/meshtastic.org.png b/asset/image/protocol/meshtastic.org.png new file mode 100644 index 0000000..c4069fa Binary files /dev/null and b/asset/image/protocol/meshtastic.org.png differ diff --git a/asset/image/protocol/meshtastic.png b/asset/image/protocol/meshtastic.png new file mode 100644 index 0000000..5ad5796 Binary files /dev/null and b/asset/image/protocol/meshtastic.png differ diff --git a/broker.go b/broker.go new file mode 100644 index 0000000..ee18fc2 --- /dev/null +++ b/broker.go @@ -0,0 +1,433 @@ +package hamview + +import ( + "crypto/rand" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "strings" + "time" + + mqtt "github.com/eclipse/paho.mqtt.golang" + "github.com/golang-jwt/jwt/v5" + "go.yaml.in/yaml/v3" + + "git.maze.io/go/ham/protocol" + "git.maze.io/go/ham/protocol/meshcore/crypto" + meshcorejwt "git.maze.io/go/ham/protocol/meshcore/crypto/jwt" + "git.maze.io/go/ham/radio" +) + +var ErrBrokerNotStarted = errors.New("broker not started") + +// Broker for publishing and receiving raw packets. +type Broker interface { + Start() error + StartRadio(protocol string, info *radio.Info) error + + Close() error + + SubscribeRadios() (<-chan *Radio, error) + + PublishPacket(topic string, packet *protocol.Packet) error + SubscribePackets(topic string) (<-chan *protocol.Packet, error) +} + +type Receiver interface { + Disconnected() +} + +type BrokerConfig struct { + Type string `yaml:"type"` + Config yaml.Node `yaml:"conf"` +} + +func NewBroker(config *BrokerConfig) (Broker, error) { + if config.Config.Kind != yaml.MappingNode { + return nil, fmt.Errorf("broker: conf should be a mapping") + } + + switch config.Type { + case "mqtt": + return newMQTTBroker(&config.Config) + //case "kafka": + // return newKafkaBroker(&config.Config) + default: + return nil, fmt.Errorf("broker: unknown type %q", config.Type) + } +} + +type mqttBrokerConfig struct { + Host string `yaml:"host"` + Brokers []string `yaml:"brokers"` + Auth string `yaml:"auth"` + Username string `yaml:"username"` + Password string `yaml:"password"` + ClientID string `yaml:"client_id"` +} + +type mqttBroker struct { + options *mqtt.ClientOptions + client mqtt.Client +} + +func newMQTTBroker(node *yaml.Node) (*mqttBroker, error) { + Logger.Info("broker: setting up MQTT") + + var config mqttBrokerConfig + if err := node.Decode(&config); err != nil { + return nil, err + } + + Logger.Tracef("broker: config: %#+v", config) + if len(config.Host) == 0 && len(config.Brokers) == 0 { + return nil, errors.New("at least one host or broker must be configured") + } + + options := mqtt.NewClientOptions() + if config.Host != "" { + Logger.Debugf("broker: adding %s", config.Host) + options.AddBroker(config.Host) + } + for _, broker := range config.Brokers { + Logger.Debugf("broker: adding %s", broker) + options.AddBroker(broker) + } + if config.Auth != "" { + if err := configureAuth(options, config.Auth); err != nil { + return nil, err + } + } else { + if config.Password != "" { + options.SetPassword(config.Password) + } + } + if config.Username != "" { + options.SetUsername(config.Username) + } + if config.ClientID != "" { + options.SetClientID(config.ClientID) + } else { + clientID, err := generateClientID() + if err != nil { + return nil, err + } + options.SetClientID(clientID) + } + + options.OnConnect = func(_ mqtt.Client) { + Logger.Info("broker: connected to MQTT broker") + } + options.OnConnectionLost = func(_ mqtt.Client, err error) { + Logger.Warnf("broker: connection to MQTT broker lost: %v", err) + } + + return &mqttBroker{ + options: options, + }, nil +} + +func (broker *mqttBroker) Close() error { + Logger.Warn("broker: closing") + if broker.client != nil { + broker.client.Disconnect(100) + broker.client = nil + } + return nil +} + +func (broker *mqttBroker) Start() error { + // Connect to the broker + broker.client = mqtt.NewClient(broker.options) + token := broker.client.Connect() + token.Wait() + if err := token.Error(); err != nil { + return err + } + return nil +} + +func (broker *mqttBroker) StartRadio(protocol string, info *radio.Info) error { + if info.Name == "" { + return errors.New("broker: radio has no name") + } + + // Setup last will + var radio = &Radio{ + Info: info, + Protocol: protocol, + IsOnline: false, + } + // Configure last will + will, err := json.Marshal(&radio) + if err != nil { + return err + } + topic := fmt.Sprintf("radio/%s/%s", + protocol, + base64.RawURLEncoding.EncodeToString([]byte(info.Name))) + + Logger.Debugf("broker: configure last will %s", topic) + broker.options.SetWill(topic, string(will), 1, true) + + // Connect to the broker + if err = broker.Start(); err != nil { + return err + } + + // Send status + radio.IsOnline = true + payload, err := json.Marshal(radio) + if err != nil { + return err + } + + Logger.Infof("broker: radio %s online at %s", info.Name, topic) + token := broker.client.Publish(topic, 1, true, string(payload)) + token.Wait() + return token.Error() +} + +func (broker *mqttBroker) SubscribeRadios() (<-chan *Radio, error) { + if broker.client == nil { + return nil, ErrBrokerNotStarted + } + + radios := make(chan *Radio, 8) + token := broker.client.Subscribe("radio/#", 0, func(_ mqtt.Client, message mqtt.Message) { + var radio Radio + if err := json.Unmarshal(message.Payload(), &radio); err == nil { + select { + case radios <- &radio: + default: + } + } + }) + + if token.Wait() && token.Error() != nil { + close(radios) + return nil, token.Error() + } + + return radios, nil +} + +func (broker *mqttBroker) PublishPacket(topic string, packet *protocol.Packet) error { + if broker.client == nil { + return ErrBrokerNotStarted + } + + b, err := json.Marshal(packet) + if err != nil { + return err + } + + token := broker.client.Publish(topic, 0, true, string(b)) + if token.Wait() && token.Error() != nil { + return token.Error() + } + return nil +} + +func (broker *mqttBroker) SubscribePackets(topic string) (<-chan *protocol.Packet, error) { + if broker.client == nil { + return nil, ErrBrokerNotStarted + } + + packets := make(chan *protocol.Packet, 16) + token := broker.client.Subscribe(topic, 0, func(_ mqtt.Client, message mqtt.Message) { + var packet protocol.Packet + if err := json.Unmarshal(message.Payload(), &packet); err == nil { + select { + case packets <- &packet: + default: + } + } + }) + + if token.Wait() && token.Error() != nil { + close(packets) + return nil, token.Error() + } + + return packets, nil +} + +/* +type kafkaBroker struct { + configMap kafka.ConfigMap + producer *kafka.Producer +} + +func newKafkaBroker(node *yaml.Node) (*kafkaBroker, error) { + Logger.Info("broker: setting up Kafka") + + var config = make(map[string]kafka.ConfigValue) + if err := node.Decode(config); err != nil { + return nil, err + } + + // Ensure default values: + config["acks"] = "all" + if s, ok := config["client.id"]; !ok || s == "" { + var err error + if config["client.id"], err = generateClientID(); err != nil { + return nil, err + } + } + + return &kafkaBroker{ + configMap: config, + }, nil +} + +func (broker *kafkaBroker) Close() error { + Logger.Warn("broker: closing") + if broker.producer != nil { + broker.producer.Close() + broker.producer = nil + } + return nil +} + +func (broker *kafkaBroker) Start() (err error) { + if broker.producer != nil { + return nil + } + + if broker.producer, err = kafka.NewProducer(&broker.configMap); err != nil { + return err + } + + return nil +} + +func (broker *kafkaBroker) StartRadio(protocol string, info *radio.Info) error { + return broker.Start() +} + +func (broker *kafkaBroker) PublishPacket(topic string, packet *protocol.Packet) error { + if broker.producer == nil { + return ErrBrokerNotStarted + } + + data, err := json.Marshal(packet) + if err != nil { + return err + } + + return broker.producer.Produce(&kafka.Message{ + TopicPartition: kafka.TopicPartition{ + Topic: &topic, + Partition: kafka.PartitionAny, + }, + Value: data, + }, nil) +} + +func (broker *kafkaBroker) ensureProducer() (err error) { + if broker.producer == nil { + broker.producer, err = kafka.NewProducer(&broker.configMap) + } + return +} + +func (broker *kafkaBroker) SubscribePackets(topic string) (<-chan *protocol.Packet, error) { + consumer, err := kafka.NewConsumer(&broker.configMap) + if err != nil { + return nil, err + } else if err = consumer.Subscribe(topic, nil); err != nil { + return nil, err + } + + packets := make(chan *protocol.Packet, 16) + + go func() { + defer close(packets) + for { + event := consumer.Poll(100) + if event == nil { + continue + } + + switch event := event.(type) { + case kafka.Error: + // TODO + if event.IsFatal() { + return + } + + case *kafka.Message: + var packet protocol.Packet + if err := json.Unmarshal(event.Value, &packet); err == nil { + select { + case packets <- &packet: + default: + } + } + } + } + }() + + return packets, nil +} +*/ + +func generateClientID() (string, error) { + name, err := os.Hostname() + if err != nil { + return "", err + } + var ( + node = strings.ToLower(strings.SplitN(name, ".", 2)[0]) + random = make([]byte, 4) + ) + rand.Read(random) + return fmt.Sprintf("%s_%08x", node, random), nil +} + +func configureAuth(options *mqtt.ClientOptions, value string) error { + part := strings.Split(value, ":") + if len(part) < 2 { + return errors.New("broker: mqtt.auth must be in `type:value` format") + } + + switch part[0] { + case "jwt-ed25519": + var key *crypto.PrivateKey + f, err := os.ReadFile(part[1]) + if err != nil { + if !os.IsNotExist(err) { + return err + } + if _, key, err = crypto.GenerateKey(); err != nil { + return err + } + if err = os.WriteFile(part[1], key.Bytes(), 0600); err != nil { + return err + } + } else { + if key, err = crypto.NewPrivateKey(f); err != nil { + return err + } + } + + token := jwt.NewWithClaims(meshcorejwt.SigningMethod, jwt.MapClaims{ + "publickey": hex.EncodeToString(key.PublicKey()), + "iat": time.Now().Unix(), + }) + tokenString, err := token.SignedString(key) + if err != nil { + return err + } + + options.SetPassword(tokenString) + return nil + + default: + return fmt.Errorf("broker: unknown auth method %q", part[0]) + } +} diff --git a/cmd/hamview-collector/main.go b/cmd/hamview-collector/main.go new file mode 100644 index 0000000..c9100c1 --- /dev/null +++ b/cmd/hamview-collector/main.go @@ -0,0 +1,86 @@ +package main + +import ( + "context" + "log" + "os" + + "github.com/sirupsen/logrus" + "github.com/urfave/cli/v3" + + "git.maze.io/go/ham/protocol" + + "git.maze.io/ham/hamview" + "git.maze.io/ham/hamview/internal/cmd" +) + +var logger *logrus.Logger + +func init() { + logger = cmd.NewLogger(nil) +} + +func main() { + cmd := &cli.Command{ + Name: "hamview-collector", + Usage: "Collector for HAM radio protocols", + Action: run, + Before: cmd.ConfigureLogging(&logger), + Flags: cmd.AllFlags("hamview-collector.yaml"), + } + + if err := cmd.Run(context.Background(), os.Args); err != nil { + log.Fatal(err) + } +} + +type collectorConfig struct { + hamview.CollectorConfig `yaml:",inline"` + Broker hamview.BrokerConfig `yaml:"broker"` + Include []string `yaml:"include"` + MeshCore struct { + Group meshCoreGroupConfig `yaml:"group"` + } `yaml:"meshcore"` +} + +func (config *collectorConfig) Includes() []string { + includes := config.Include + config.Include = nil + return includes +} + +type meshCoreGroupConfig struct { + Secret map[string]string `yaml:"secret"` + Public []string `yaml:"public"` +} + +func run(ctx context.Context, command *cli.Command) error { + var config collectorConfig + if err := cmd.Load(logger, command.String(cmd.FlagConfig), &config); err != nil { + return err + } + + collector, err := hamview.NewCollector(&config.CollectorConfig) + if err != nil { + return err + } + defer collector.Close() + + broker, err := hamview.NewBroker(&config.Broker) + if err != nil { + return err + } + if err = broker.Start(); err != nil { + return err + } + defer broker.Close() + + for _, proto := range []string{ + protocol.APRS, + protocol.MeshCore, + } { + go collector.Collect(broker, proto+"/packet") + } + + return cmd.WaitForInterrupt(logger, "collector") +} diff --git a/cmd/hamview-receiver/main.go b/cmd/hamview-receiver/main.go new file mode 100644 index 0000000..f1ce96e --- /dev/null +++ b/cmd/hamview-receiver/main.go @@ -0,0 +1,49 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + + "github.com/sirupsen/logrus" + "github.com/urfave/cli/v3" + + "git.maze.io/ham/hamview/internal/cmd" +) + +var logger *logrus.Logger + +func main() { + cmd := &cli.Command{ + Name: "hamview-receiver", + Usage: "Receiver for HAM radio protocols", + Action: func(context.Context, *cli.Command) error { + fmt.Println("boom! I say!") + return nil + }, + Flags: cmd.AllFlags("hamview-receiver.yaml"), + Commands: []*cli.Command{ + { + Name: "aprsis", + Usage: "Start an APRS-IS proxy", + Before: cmd.ConfigureLogging(&logger), + Action: runAPRSIS, + }, + { + Name: "meshcore", + Usage: "Start a MeshCore receiver", + Before: cmd.ConfigureLogging(&logger), + Action: runMeshCore, + }, + }, + } + + if err := cmd.Run(context.Background(), os.Args); err != nil { + log.Fatal(err) + } +} + +func waitForInterrupt() error { + return cmd.WaitForInterrupt(logger, "receiver") +} diff --git a/cmd/hamview-receiver/run_aprsis.go b/cmd/hamview-receiver/run_aprsis.go new file mode 100644 index 0000000..2a172c3 --- /dev/null +++ b/cmd/hamview-receiver/run_aprsis.go @@ -0,0 +1,77 @@ +package main + +import ( + "context" + + "github.com/urfave/cli/v3" + + "git.maze.io/go/ham/protocol/aprs/aprsis" + + "git.maze.io/ham/hamview" + "git.maze.io/ham/hamview/internal/cmd" +) + +type aprsisConfig struct { + Broker hamview.BrokerConfig `yaml:"broker"` + Receiver hamview.APRSISConfig `yaml:"receiver"` + Include []string `yaml:"include"` +} + +func (config *aprsisConfig) Includes() []string { + includes := config.Include + config.Include = nil + return includes +} + +func runAPRSIS(ctx context.Context, command *cli.Command) error { + var config = aprsisConfig{ + Receiver: hamview.APRSISConfig{ + Listen: hamview.DefaultAPRSISListen, + Server: hamview.DefaultAPRSISServer, + }, + } + if err := cmd.Load(logger, command.String(cmd.FlagConfig), &config); err != nil { + return err + } + + logger.Infof("receiver: starting APRS-IS proxy on tcp://%s to tcp://%s", + config.Receiver.Listen, + config.Receiver.Server) + proxy, err := aprsis.NewProxy(config.Receiver.Listen, config.Receiver.Server) + if err != nil { + return err + } + + proxy.OnClient = func(callsign string, client *aprsis.ProxyClient) { + go receiveAPRSIS(&config.Broker, callsign, client) + } + + return waitForInterrupt() +} + +func receiveAPRSIS(config *hamview.BrokerConfig, callsign string, client *aprsis.ProxyClient) { + defer client.Close() + + broker, err := hamview.NewBroker(config) + if err != nil { + logger.Errorf("receiver: can't setup to broker: %v", err) + return + } + defer broker.Close() + + info := client.Info() // TODO: enrich info from config? + + if err = broker.StartRadio("aprs", info); err != nil { + logger.Fatalf("receiver: can't start broker: %v", err) + return + } + + logger.Infof("receiver: start receiving packets from station: %s", callsign) + for packet := range client.RawPackets() { + logger.Debugf("aprs packet: %#+v", packet) + if err := broker.PublishPacket("aprs/packet", packet); err != nil { + logger.Error(err) + } + } + logger.Info("receiver: stopped receiving packets from station: %s", callsign) +} diff --git a/cmd/hamview-receiver/run_meshcore.go b/cmd/hamview-receiver/run_meshcore.go new file mode 100644 index 0000000..d54fa99 --- /dev/null +++ b/cmd/hamview-receiver/run_meshcore.go @@ -0,0 +1,79 @@ +package main + +import ( + "context" + + "github.com/urfave/cli/v3" + + "git.maze.io/go/ham/protocol" + "git.maze.io/go/ham/protocol/meshcore" + + "git.maze.io/ham/hamview" + "git.maze.io/ham/hamview/internal/cmd" +) + +type meshCoreConfig struct { + Broker hamview.BrokerConfig `yaml:"broker"` + Receiver hamview.MeshCoreConfig `yaml:"receiver"` + Include []string `yaml:"include"` +} + +func (config *meshCoreConfig) Includes() []string { + includes := config.Include + config.Include = nil + return includes +} + +func runMeshCore(ctx context.Context, command *cli.Command) error { + var config meshCoreConfig + if err := cmd.Load(logger, command.String(cmd.FlagConfig), &config); err != nil { + return err + } + + broker, err := hamview.NewBroker(&config.Broker) + if err != nil { + return err + } + defer broker.Close() + + receiver, err := hamview.NewMeshCoreReceiver(&config.Receiver) + if err != nil { + return err + } + defer receiver.Close() + + info := receiver.Info() // TODO: enrich info from config? + if err = broker.StartRadio(protocol.MeshCore, info); err != nil { + logger.Fatalf("receiver: can't start broker: %v", err) + return err + } + + // Trace scheduler + //go receiver.RunTraces() + + // Packet decoder + go func() { + logger.Info("receiver: start receiving packets") + for packet := range receiver.RawPackets() { + if len(packet.Raw) >= 1 { + var ( + header = packet.Raw[0] + version = (header >> 6) & 0x03 + routeType = meshcore.RouteType(header & 0x03) + payloadType = meshcore.PayloadType((header >> 2) & 0x0F) + ) + logger.Debugf("meshcore packet: %d %s %s: %d bytes", + version, + routeType, + payloadType, + len(packet.Raw)) + } + if err = broker.PublishPacket("meshcore/packet", packet); err != nil { + logger.Errorf("receiver: failed to publish packet: %v", err) + } + } + logger.Warn("receiver: closing") + }() + + return waitForInterrupt() +} diff --git a/cmd/hamview-server/main.go b/cmd/hamview-server/main.go new file mode 100644 index 0000000..4e78437 --- /dev/null +++ b/cmd/hamview-server/main.go @@ -0,0 +1,55 @@ +package main + +import ( + "context" + "os" + + "github.com/sirupsen/logrus" + "github.com/urfave/cli/v3" + + "git.maze.io/ham/hamview" + "git.maze.io/ham/hamview/internal/cmd" +) + +var logger = logrus.New() + +func main() { + cmd := &cli.Command{ + Name: "hamview-server", + Usage: "Server for HAM radio protocols", + Action: run, + Before: cmd.ConfigureLogging(&logger), + Flags: cmd.AllFlags("hamview-server.yaml"), + } + + if err := cmd.Run(context.Background(), os.Args); err != nil { + logger.Fatal(err) + } +} + +type serverConfig struct { + Database hamview.DatabaseConfig `yaml:"database"` + Broker hamview.BrokerConfig `yaml:"broker"` + Server hamview.ServerConfig `yaml:"server"` + Include []string `yaml:"include"` +} + +func (config *serverConfig) Includes() []string { + includes := config.Include + config.Include = nil + return includes +} + +func run(ctx context.Context, command *cli.Command) error { + var config serverConfig + if err := cmd.Load(logger, command.String(cmd.FlagConfig), &config); err != nil { + return err + } + + server, err := hamview.NewServer(&config.Server, &config.Database) + if err != nil { + return err + } + + return server.Run() +} diff --git a/cmd/import-letsmesh-nodes/main.go b/cmd/import-letsmesh-nodes/main.go new file mode 100644 index 0000000..a04d7a8 --- /dev/null +++ b/cmd/import-letsmesh-nodes/main.go @@ -0,0 +1,183 @@ +package main + +import ( + "context" + "database/sql" + "encoding/hex" + "encoding/json" + "os" + "time" + + "github.com/sirupsen/logrus" + "github.com/urfave/cli/v3" + + "git.maze.io/go/ham/protocol/meshcore" + + "git.maze.io/ham/hamview" + "git.maze.io/ham/hamview/internal/cmd" + + _ "github.com/cridenour/go-postgis" // PostGIS support + _ "github.com/lib/pq" // PostgreSQL support +) + +var logger = logrus.New() + +/* + { + "public_key": "E119666239EE254E8E7B2937A99FE9DB7CBB58040B5D0E995B719C598CD261F6", + "name": "~ Jonzy Heltec Repeater", + "device_role": 2, + "regions": [ + "OMA" + ], + "first_seen": "2026-01-18T04:31:21.694Z", + "last_seen": "2026-02-20T10:30:16.144Z", + "is_mqtt_connected": true, + "decoded_payload": { + "lat": 41.28516, + "lon": -96.13876, + "mode": "Repeater", + "name": "~ Jonzy Heltec Repeater", + "flags": 146, + "is_valid": true, + "signature": "F08599E4D7357E9276B5F78246C698BFFCF14EC83D8A70CAB6F8E63EDF3FEB5CB692A60C3072593ABE0261B164709F9E012AC526B5EF08407B3520C13719900E", + "timestamp": 1771583405, + "public_key": "E119666239EE254E8E7B2937A99FE9DB7CBB58040B5D0E995B719C598CD261F6" + }, + "location": { + "latitude": 41.28516, + "longitude": -96.13876 + }, + "node_settings": { + "show_neighbors": true, + "show_adverts": true + } + }, +*/ +type node struct { + PublicKey string `json:"public_key"` + Name string `json:"name"` + Type int `json:"device_role"` + FirstHeard time.Time `json:"first_seen"` + LastHeard time.Time `json:"last_seen"` + Position *meshcore.Position `json:"location"` + Payload payload `json:"decoded_payload` +} + +type payload struct { + Timestamp int64 `json:"timestamp"` +} + +func main() { + cmd := &cli.Command{ + Name: "import-letsmesh-nodes", + Action: run, + Before: cmd.ConfigureLogging(&logger), + Flags: append([]cli.Flag{ + &cli.StringFlag{ + Name: "dump", + Usage: "letsmesh node json", + Value: "letsmeshnodes.json", + }, + }, cmd.AllFlags("hamview-collector.yaml")...), + } + + if err := cmd.Run(context.Background(), os.Args); err != nil { + logger.Fatal(err) + } +} + +type collectorConfig struct { + hamview.CollectorConfig `yaml:",inline"` + Broker map[string]any `yaml:"broker"` + Meshcore map[string]any `yaml:"meshcore"` + Include []string +} + +func (config *collectorConfig) Includes() []string { + includes := config.Include + config.Include = nil + return includes +} + +func run(ctx context.Context, command *cli.Command) error { + var config collectorConfig + if err := cmd.Load(logger, command.String(cmd.FlagConfig), &config); err != nil { + return err + } + + db, err := sql.Open(config.Database.Type, config.Database.Conf) + if err != nil { + return err + } + defer db.Close() + + b, err := os.ReadFile(command.String("dump")) + if err != nil { + return err + } + + var nodes struct { + Nodes []*node `json:"nodes"` + } + if err = json.Unmarshal(b, &nodes); err != nil { + return err + } + + logger.Infof("found %d nodes", len(nodes.Nodes)) + for _, node := range nodes.Nodes { + k, err := hex.DecodeString(node.PublicKey) + if err != nil { + logger.Warnf("node %s has incorrect public key: %v", node.Name, err) + continue + } + logger.Infof("node %s at %s", node.Name, node.Position) + var latitude, longitude *float64 + if node.Position != nil { + latitude = &node.Position.Latitude + longitude = &node.Position.Longitude + } + if _, err = db.Exec( + `INSERT INTO meshcore_node ( + node_type, + public_key, + name, + local_time, + first_heard, + last_heard, + last_latitude, + last_longitude + ) VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8 + ) + ON CONFLICT (public_key) + DO UPDATE + SET + name = $3, + local_time = $4, + last_heard = $6, + last_latitude = $7, + last_longitude = $8 + `, + node.Type, + k, + node.Name, + time.Unix(node.Payload.Timestamp, 0), + node.FirstHeard, + node.LastHeard, + latitude, + longitude, + ); err != nil { + logger.Fatalf("node %s insert failed: %v", node.Name, err) + } + } + + return nil +} diff --git a/collector.go b/collector.go new file mode 100644 index 0000000..955d1fa --- /dev/null +++ b/collector.go @@ -0,0 +1,400 @@ +package hamview + +import ( + "database/sql" + "fmt" + + _ "github.com/cridenour/go-postgis" // PostGIS support + "github.com/lib/pq" // PostgreSQL support + + "git.maze.io/go/ham/protocol" + "git.maze.io/go/ham/protocol/aprs" + "git.maze.io/go/ham/protocol/meshcore" +) + +type CollectorConfig struct { + Database DatabaseConfig `yaml:"database"` +} + +type DatabaseConfig struct { + Type string `yaml:"type"` + Conf string `yaml:"conf"` +} + +type Collector struct { + *sql.DB + + meshCoreGroup map[byte][]*meshcore.Group +} + +func NewCollector(config *CollectorConfig) (*Collector, error) { + d, err := sql.Open(config.Database.Type, config.Database.Conf) + if err != nil { + return nil, err + } + + for _, query := range []string{ + // radio.* + sqlCreateRadio, + sqlIndexRadioName, + sqlIndexRadioProtocol, + sqlGeometryRadioPosition, + + // meshcore_packet.* + sqlCreateMeshCorePacket, + sqlIndexMeshCorePacketHash, + sqlIndexMeshCorePacketPayloadType, + + // meshcore_node.* + sqlCreateMeshCoreNode, + sqlIndexMeshCoreNodeName, + sqlAlterMeshCoreNodePrefix, + sqlGeometryMeshCoreNodePosition, + + // meshcore_node_position.* + sqlCreateMeshCoreNodePosition, + sqlGeometryMeshCoreNodePositionPosition, + sqlIndexMeshCoreNodePositionPosition, + } { + if _, err := d.Exec(query); err != nil { + var ignore bool + if err, ok := err.(*pq.Error); ok { + switch err.Code { + case "42701": // column "x" of relation "y" already exists (42701) + ignore = true + } + } + Logger.Debugf("collector: sql error %T: %v", err, err) + if !ignore { + return nil, fmt.Errorf("error in query %s: %v", query, err) + } + } + } + + return &Collector{ + DB: d, + meshCoreGroup: make(map[byte][]*meshcore.Group), + }, nil +} + +func (c *Collector) Collect(broker Broker, topic string) error { + Logger.Debugf("collector: subscribing to radios") + radios, err := broker.SubscribeRadios() + if err != nil { + Logger.Errorf("collector: error subscribing: %v", err) + return err + } + + Logger.Debugf("collector: subscribing to %s", topic) + packets, err := broker.SubscribePackets(topic) + if err != nil { + Logger.Errorf("collector: error subscribing to %s: %v", topic, err) + return err + } + +loop: + for { + select { + case radio := <-radios: + if radio == nil { + break loop + } + c.processRadio(radio) + + case packet := <-packets: + if packet == nil { + break loop + } + switch packet.Protocol { + case protocol.APRS: + c.processAPRSPacket(packet) + case protocol.MeshCore: + c.processMeshCorePacket(packet) + } + } + } + + Logger.Warnf("collector: done processing packets from %s: channel closed", topic) + + return nil +} + +func (c *Collector) processRadio(radio *Radio) { + Logger.Tracef("collector: process %s radio %q online %t", + radio.Protocol, + radio.Name, + radio.IsOnline) + + var latitude, longitude, altitude *float64 + if radio.Position != nil { + latitude = &radio.Position.Latitude + longitude = &radio.Position.Longitude + altitude = &radio.Position.Altitude + } + + var id int64 + if err := c.QueryRow(` + INSERT INTO radio ( + name, + is_online, + device, + manufacturer, + firmware_date, + firmware_version, + antenna, + modulation, + protocol, + latitude, + longitude, + altitude, + frequency, + rx_frequency, + tx_frequency, + bandwidth, + power, + gain, + lora_sf, + lora_cr, + extra + ) VALUES ( + $1, + $2, + NULLIF($3, ''), -- device + NULLIF($4, ''), -- manufacturer + $5, + NULLIF($6, ''), -- firmware_version + NULLIF($7, ''), -- antenna + NULLIF($8, ''), -- modulation + $9, -- protocol + NULLIF($10, 0.0), -- latitude + NULLIF($11, 0.0), -- longitude + $12, -- altitude + $13, -- frequency + NULLIF($14, 0.0), -- rx_frequency + NULLIF($15, 0.0), -- tx_frequency + $16, -- bandwidth + NULLIF($17, 0.0), -- power + NULLIF($18, 0.0), -- gain + NULLIF($19, 0), -- lora_sf + NULLIF($20, 0), -- lora_cr + $21 + ) + ON CONFLICT (name) + DO UPDATE + SET + is_online = $2, + device = NULLIF($3, ''), + manufacturer = NULLIF($4, ''), + firmware_date = $5, + firmware_version = NULLIF($6, ''), + antenna = NULLIF($7, ''), + modulation = NULLIF($8, ''), + protocol = $9, + latitude = NULLIF($10, 0.0), + longitude = NULLIF($11, 0.0), + altitude = $12, + frequency = $13, + rx_frequency = NULLIF($14, 0.0), + tx_frequency = NULLIF($15, 0.0), + bandwidth = $16, + power = NULLIF($17, 0), + gain = NULLIF($18, 0), + lora_sf = NULLIF($19, 0), + lora_cr = NULLIF($20, 0), + extra = $21 + RETURNING id + `, + radio.Name, + radio.IsOnline, + radio.Device, + radio.Manufacturer, + radio.FirmwareDate, + radio.FirmwareVersion, + radio.Antenna, + radio.Modulation, + radio.Protocol, + latitude, + longitude, + altitude, + radio.Frequency, + radio.RXFrequency, + radio.TXFrequency, + radio.Bandwidth, + radio.Power, + radio.Gain, + radio.LoRaSF, + radio.LoRaCR, + nil, + ).Scan(&id); err != nil { + Logger.Warnf("collector: error storing radio: %v", err) + return + } +} + +func (c *Collector) processAPRSPacket(packet *protocol.Packet) { + decoded, err := aprs.ParsePacket(string(packet.Raw)) + if err != nil { + Logger.Warnf("collector: invalid %s packet: %v", packet.Protocol, err) + return + } + + Logger.Tracef("collector: process %s packet (%d bytes)", + packet.Protocol, + len(packet.Raw)) + + var id int64 + if err := c.QueryRow(` + INSERT INTO aprs_packet ( + src_address, + dst_address, + comment + ) VALUES ($1, $2, $3) + RETURNING id; + `, + decoded.Src.String(), + decoded.Dst.String(), + decoded.Comment, + ).Scan(&id); err != nil { + Logger.Warnf("collector: error storing packet: %v", err) + return + } +} + +func (c *Collector) processMeshCorePacket(packet *protocol.Packet) { + var parsed meshcore.Packet + if err := parsed.UnmarshalBytes(packet.Raw); err != nil { + Logger.Warnf("collector: invalid %s packet: %v", packet.Protocol, err) + return + } + + Logger.Tracef("collector: process %s %s packet (%d bytes)", + packet.Protocol, + parsed.PayloadType.String(), + len(packet.Raw)) + + if len(parsed.Path) == 0 { + parsed.Path = nil // store NULL + } + + var id int64 + if err := c.QueryRow(` + INSERT INTO meshcore_packet ( + snr, + rssi, + hash, + route_type, + payload_type, + path, + payload, + raw, + received_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING id;`, + packet.SNR, + packet.RSSI, + parsed.Hash(), + parsed.RouteType, + parsed.PayloadType, + parsed.Path, + parsed.Payload, + packet.Raw, + packet.Time, + ).Scan(&id); err != nil { + Logger.Warnf("collector: error storing packet: %v", err) + return + } + + switch parsed.PayloadType { + case meshcore.TypeAdvert: + payload, err := parsed.Decode() + if err != nil { + Logger.Warnf("collector: error decoding packet: %v", err) + return + } + + var ( + advert = payload.(*meshcore.Advert) + nodeID int64 + latitude *float64 + longitude *float64 + ) + if advert.Position != nil { + latitude = &advert.Position.Latitude + longitude = &advert.Position.Longitude + } + if err = c.QueryRow(` + INSERT INTO meshcore_node ( + node_type, + public_key, + name, + local_time, + first_heard, + last_heard, + last_latitude, + last_longitude, + last_advert_id + ) VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8, + $9 + ) + ON CONFLICT (public_key) + DO UPDATE + SET + name = $3, + local_time = $4, + last_heard = $6, + last_latitude = $7, + last_longitude = $8, + last_advert_id = $9 + RETURNING id + `, + advert.Type, + advert.PublicKey.Bytes(), + advert.Name, + advert.Time, + packet.Time, + packet.Time, + latitude, + longitude, + id, + ).Scan(&nodeID); err != nil { + Logger.Warnf("collector: error storing node: %v", err) + return + } + + if advert.Position != nil { + if _, err = c.Exec(` + INSERT INTO meshcore_node_position ( + node_id, + heard_at, + latitude, + longitude, + position + ) VALUES ( + $1, + $2, + $3, + $4, + ST_SetSRID(ST_MakePoint($5, $6), 4326) + ); + `, + nodeID, + packet.Time, + advert.Position.Latitude, + advert.Position.Longitude, + advert.Position.Latitude, + advert.Position.Longitude, + ); err != nil { + Logger.Warnf("collector: error storing node position: %v", err) + return + } + } + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2376776 --- /dev/null +++ b/go.mod @@ -0,0 +1,39 @@ +module git.maze.io/ham/hamview + +go 1.25.6 + +replace git.maze.io/go/ham => ../ham + +require ( + git.maze.io/go/ham v0.0.0-20260218162317-db19ea81b095 + github.com/Vaniog/go-postgis v0.0.0-20240619200434-9c2eb8ed621e + github.com/cemkiy/echo-logrus v0.0.0-20200218141616-06f9cd1dae34 + github.com/cridenour/go-postgis v1.0.1 + github.com/eclipse/paho.mqtt.golang v1.5.1 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/labstack/echo/v4 v4.15.0 + github.com/lib/pq v1.11.2 + github.com/sirupsen/logrus v1.9.4 + github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07 + github.com/urfave/cli/v3 v3.6.2 + go.yaml.in/yaml/v3 v3.0.4 +) + +require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/kr/pretty v0.3.0 // indirect + github.com/labstack/gommon v0.4.2 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/rogpeppe/go-internal v1.8.0 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + golang.org/x/time v0.14.0 // indirect + gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..433784b --- /dev/null +++ b/go.sum @@ -0,0 +1,113 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/Vaniog/go-postgis v0.0.0-20240619200434-9c2eb8ed621e h1:Ck+0lNRr62RM/LNKkkD0R1aJ2DvgELqmmuNvyyHL75E= +github.com/Vaniog/go-postgis v0.0.0-20240619200434-9c2eb8ed621e/go.mod h1:o3MIxN5drWoGBTtBGtLqFZlr7RjfdQKnfwYXoUU77vU= +github.com/cemkiy/echo-logrus v0.0.0-20200218141616-06f9cd1dae34 h1:cGxEwqDl+PiqPtJpQNoiJIXcrVEkkSMuMQtb+PPAHL4= +github.com/cemkiy/echo-logrus v0.0.0-20200218141616-06f9cd1dae34/go.mod h1:kvJeauv7Kc2LibOGGom8nEWyjjaN7LIsCdbkrFfU9rE= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/cridenour/go-postgis v1.0.1 h1:H8LkcOgoASyxDMej3xzF1OcXtskvsDfcL/gxcb8r0ow= +github.com/cridenour/go-postgis v1.0.1/go.mod h1:KEQNef9ssi7Q0nQFBo5b4l6hjVw7EoFQ5GD8rBYD8kU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE= +github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/labstack/echo v3.3.10+incompatible/go.mod h1:0INS7j/VjnFxD4E2wkz67b8cVwCLbBmJyDaka6Cmk1s= +github.com/labstack/echo/v4 v4.1.13/go.mod h1:3WZNypykZ3tnqpF2Qb4fPg27XDunFqgP3HGDmCMgv7U= +github.com/labstack/echo/v4 v4.15.0 h1:hoRTKWcnR5STXZFe9BmYun9AMTNeSbjHi2vtDuADJ24= +github.com/labstack/echo/v4 v4.15.0/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c= +github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs= +github.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ= +github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +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/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= +github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07 h1:UyzmZLoiDWMRywV4DUYb9Fbt8uiOSooupjTq10vpvnU= +github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA= +github.com/urfave/cli/v3 v3.6.2 h1:lQuqiPrZ1cIz8hz+HcrG0TNZFxU70dPZ3Yl+pSrH9A8= +github.com/urfave/cli/v3 v3.6.2/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= +github.com/valyala/fasttemplate v1.1.0/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191227163750-53104e6ec876/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200214034016-1d94cc7ab1c6/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +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/internal/cmd/all.go b/internal/cmd/all.go new file mode 100644 index 0000000..e139cde --- /dev/null +++ b/internal/cmd/all.go @@ -0,0 +1,27 @@ +package cmd + +import ( + "errors" + "os" + "os/signal" + "syscall" + + "github.com/sirupsen/logrus" + "github.com/urfave/cli/v3" +) + +func AllFlags(configFile string) []cli.Flag { + return append(ConfigFlags(configFile), LoggerFlags()...) +} + +func WaitForInterrupt(logger *logrus.Logger, what string) error { + sigs := make(chan os.Signal, 1) + signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) + + logger.Infof("%s: running, interrupt with ^C", what) + for sig := range sigs { + return errors.New("terminating on signal " + sig.String()) + } + + return nil +} diff --git a/internal/cmd/config.go b/internal/cmd/config.go new file mode 100644 index 0000000..3e93105 --- /dev/null +++ b/internal/cmd/config.go @@ -0,0 +1,80 @@ +package cmd + +import ( + "bytes" + "os" + "path/filepath" + + "github.com/sirupsen/logrus" + "github.com/urfave/cli/v3" + "go.yaml.in/yaml/v3" +) + +const ( + FlagConfig = "config" +) + +func ConfigFlags(defaultValue string) []cli.Flag { + return []cli.Flag{ + &cli.StringFlag{ + Name: FlagConfig, + Aliases: []string{"c"}, + Usage: "Configuration file path", + Value: defaultValue, + }, + } +} + +type ConfigWithIncludes interface { + Includes() []string +} + +func Load(logger *logrus.Logger, name string, config ConfigWithIncludes, parsed ...string) (err error) { + if !filepath.IsAbs(name) { + var abs string + if abs, err = filepath.Abs(name); err != nil { + return + } + logger.Tracef("config: canonicallize %s => %s", name, abs) + name = abs + } + + logger.Tracef("config: parsed %s", parsed) + logger.Debugf("config: parse %s", name) + var data []byte + if data, err = os.ReadFile(name); err != nil { + return + } + + decoder := yaml.NewDecoder(bytes.NewBuffer(data)) + decoder.KnownFields(true) + if err = decoder.Decode(config); err != nil { + return err + } + + for _, include := range config.Includes() { + if contains(parsed, include) { + continue + } + if !filepath.IsAbs(include) { + abs := filepath.Clean(filepath.Join(filepath.Dir(name), include)) + logger.Tracef("config: canonicallize %s => %s", include, abs) + include = abs + } + parsed = append(parsed, include) + if err = Load(logger, include, config, parsed...); err != nil { + return + } + } + + return nil +} + +func contains(ss []string, s string) bool { + for _, v := range ss { + if v == s { + return true + } + } + return false +} diff --git a/internal/cmd/logger.go b/internal/cmd/logger.go new file mode 100644 index 0000000..ea404a4 --- /dev/null +++ b/internal/cmd/logger.go @@ -0,0 +1,69 @@ +package cmd + +import ( + "context" + "time" + + "github.com/sirupsen/logrus" + "github.com/urfave/cli/v3" + + "git.maze.io/go/ham/protocol/meshcore" + "git.maze.io/go/hamview" +) + +const ( + FlagQuiet = "quiet" + FlagDebug = "debug" + FlagTrace = "trace" +) + +func LoggerFlags() []cli.Flag { + return []cli.Flag{ + &cli.BoolFlag{ + Name: FlagQuiet, + Aliases: []string{"q"}, + Usage: "Disable informational logging", + }, + &cli.BoolFlag{ + Name: FlagDebug, + Aliases: []string{"D"}, + Usage: "Enable debug level logging", + }, + &cli.BoolFlag{ + Name: FlagTrace, + Aliases: []string{"T"}, + Usage: "Enable trace level logging", + }, + } +} + +func ConfigureLogging(logger **logrus.Logger) cli.BeforeFunc { + return func(ctx context.Context, cmd *cli.Command) (context.Context, error) { + *logger = NewLogger(cmd) + hamview.Logger = *logger + return ctx, nil + } +} + +func NewLogger(cmd *cli.Command) *logrus.Logger { + logger := logrus.New() + logger.SetFormatter(&logrus.TextFormatter{ + FullTimestamp: true, + TimestampFormat: time.RFC3339, + }) + + if cmd != nil { + if cmd.Bool(FlagTrace) { + logger.SetLevel(logrus.TraceLevel) + } else if cmd.Bool(FlagDebug) { + logger.SetLevel(logrus.DebugLevel) + } else if cmd.Bool(FlagQuiet) { + logger.SetLevel(logrus.ErrorLevel) + } + } + + // Update package loggers: + meshcore.Logger = logger + + return logger +} diff --git a/meshcore.go b/meshcore.go new file mode 100644 index 0000000..9ca00b1 --- /dev/null +++ b/meshcore.go @@ -0,0 +1,185 @@ +package hamview + +import ( + "database/sql" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "os" + "time" + + "github.com/Vaniog/go-postgis" + "github.com/tarm/serial" + "go.yaml.in/yaml/v3" + + "git.maze.io/go/ham/protocol" + "git.maze.io/go/ham/protocol/meshcore" +) + +type MeshCoreConfig struct { + Type string `yaml:"type"` + Conf yaml.Node `yaml:"conf"` +} + +type MeshCoreCompanionConfig struct { + Port string `yaml:"port"` + Baud int `yaml:"baud"` + Addr string `yaml:"addr"` +} + +type MeshCorePrefix byte + +func (prefix *MeshCorePrefix) MarshalJSON() ([]byte, error) { + s := fmt.Sprintf("%02x", *prefix) + return json.Marshal(s) +} + +func (prefix *MeshCorePrefix) UnmarshalJSON(b []byte) error { + var s string + if err := json.Unmarshal(b, &s); err != nil { + return err + } + if n, err := fmt.Sscanf(s, "%02x", &prefix); err != nil { + return err + } else if n != 1 { + return errors.New("no prefix could be decoded") + } + return nil +} + +func NewMeshCoreReceiver(config *MeshCoreConfig) (protocol.PacketReceiver, error) { + switch config.Type { + case "companion", "": + return newMeshCoreCompanionReceiver(config.Conf) + + default: + return nil, fmt.Errorf("hamview: unsupported MeshCore node type %q", config.Type) + } +} + +func newMeshCoreCompanionReceiver(node yaml.Node) (protocol.PacketReceiver, error) { + var config MeshCoreCompanionConfig + if err := node.Decode(&config); err != nil { + return nil, err + } + + var ( + conn io.ReadWriteCloser + err error + ) + switch { + case config.Addr != "": + Logger.Infof("receiver: connecting to MeshCore companion at tcp://%s", config.Addr) + conn, err = net.Dial("tcp", config.Addr) + + default: + if config.Port == "" { + // TODO: detect serial ports + config.Port = "/dev/ttyUSB0" + } + if config.Baud == 0 { + config.Baud = 115200 + } + Logger.Infof("receiver: connecting to MeshCore companion on %s at %d baud", config.Port, config.Baud) + conn, err = serial.OpenPort(&serial.Config{ + Name: config.Port, + Baud: config.Baud, + }) + } + if err != nil { + return nil, err + } + + receiver, err := meshcore.NewCompanion(conn) + if err != nil { + _ = conn.Close() + Logger.Warnf("receiver: error connecting to companion: %v", err) + return nil, err + } + + info := receiver.Info() + Logger.Infof("receiver: connected to MeshCore Companion %q model %q version %q", info.Name, info.Manufacturer, info.FirmwareVersion) + return receiver, nil +} + +type meshCoreNode struct { + Name string `json:"name"` + PublicKey []byte `json:"public_key"` + Prefix MeshCorePrefix `json:"prefix"` + NodeType meshcore.NodeType `json:"node_type"` + FirstHeard time.Time `json:"first_heard"` + LastHeard time.Time `json:"last_heard"` + Position *meshcore.Position `json:"position"` +} + +type meshCoreNodeDistance struct { + meshCoreNode + Distance float64 `json:"distance"` +} + +func meshCoreRepeaterWithPrefixCloseTo(db *sql.DB, prefix MeshCorePrefix, position *meshcore.Position) (node *meshCoreNodeDistance, err error) { + if position == nil { + return nil, os.ErrNotExist + } + + node = &meshCoreNodeDistance{ + meshCoreNode: meshCoreNode{ + Position: new(meshcore.Position), + }, + } + var nodePrefix []byte + if err = db.QueryRow(` + SELECT + n.name, + n.public_key, + n.prefix, + n.first_heard, + n.last_heard, + n.last_latitude, + n.last_longitude, + ST_DistanceSphere( + last_position, + GeomFromEWKB($2) + ) AS distance + FROM + meshcore_node n + WHERE + n.prefix = $1 + AND + n.last_latitude IS NOT NULL + AND + n.last_longitude IS NOT NULL + AND + n.last_position IS NOT NULL + ORDER BY + distance ASC + LIMIT 1 + `, + []byte{byte(prefix)}, + postgis.PointS{ + SRID: 4326, + X: position.Latitude, + Y: position.Longitude, + }, + ).Scan( + &node.Name, + &node.PublicKey, + &nodePrefix, + &node.FirstHeard, + &node.LastHeard, + &node.Position.Latitude, + &node.Position.Longitude, + &node.Distance, + ); err != nil { + if err == sql.ErrNoRows { + return nil, os.ErrNotExist + } + return + } + if len(nodePrefix) > 0 { + node.Prefix = MeshCorePrefix(nodePrefix[0]) + } + return +} diff --git a/radio.go b/radio.go new file mode 100644 index 0000000..fc06e58 --- /dev/null +++ b/radio.go @@ -0,0 +1,11 @@ +package hamview + +import "git.maze.io/go/ham/radio" + +type Radio struct { + *radio.Info + + Protocol string `json:"protocol"` + ID string `json:"id"` // Unique identifier for the device + IsOnline bool `json:"is_online"` +} diff --git a/server.go b/server.go new file mode 100644 index 0000000..5d3bce4 --- /dev/null +++ b/server.go @@ -0,0 +1,423 @@ +package hamview + +import ( + "database/sql" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "net" + "net/http" + "os" + "slices" + "strconv" + "strings" + "time" + + echologrus "github.com/cemkiy/echo-logrus" + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" + + "git.maze.io/go/ham/protocol/meshcore" + "git.maze.io/go/ham/protocol/meshcore/crypto" +) + +const DefaultServerListen = ":8073" + +type ServerConfig struct { + Listen string `yaml:"listen"` +} + +type Server struct { + listen string + listenAddr *net.TCPAddr + db *sql.DB +} + +func NewServer(serverConfig *ServerConfig, databaseConfig *DatabaseConfig) (*Server, error) { + if serverConfig.Listen == "" { + serverConfig.Listen = DefaultServerListen + } + + listenAddr, err := net.ResolveTCPAddr("tcp", serverConfig.Listen) + if err != nil { + return nil, fmt.Errorf("hamview: invalid listen address %q: %v", serverConfig.Listen, err) + } + + db, err := sql.Open(databaseConfig.Type, databaseConfig.Conf) + if err != nil { + return nil, err + } + + return &Server{ + listen: serverConfig.Listen, + listenAddr: listenAddr, + db: db, + }, nil +} + +func (server *Server) Run() error { + echologrus.Logger = Logger + + e := echo.New() + e.Logger = echologrus.GetEchoLogger() + e.Use(echologrus.Hook()) + e.Use(middleware.RequestLogger()) + e.Use(middleware.CORSWithConfig(middleware.CORSConfig{ + AllowOrigins: []string{"*"}, + AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAccept}, + })) + + e.GET("/api/v1/meshcore/nodes", server.apiGetMeshCoreNodes) + e.GET("/api/v1/meshcore/packets", server.apiGetMeshCorePackets) + e.GET("/api/v1/meshcore/path/:origin/:path", server.apiGetMeshCorePath) + e.GET("/api/v1/meshcore/sources", server.apiGetMeshCoreSources) + + if server.listenAddr.IP == nil || server.listenAddr.IP.Equal(net.ParseIP("0.0.0.0")) || server.listenAddr.IP.Equal(net.ParseIP("::")) { + Logger.Infof("server: listening on http://127.0.0.1:%d", server.listenAddr.Port) + } else { + Logger.Infof("server: listening on http://%s:%d", server.listenAddr.IP, server.listenAddr.Port) + } + + return e.Start(server.listen) +} + +func (server *Server) apiError(ctx echo.Context, err error, status ...int) error { + Logger.Warnf("server: error serving %s %s: %v", ctx.Request().Method, ctx.Request().URL.Path, err) + + if len(status) > 0 { + return ctx.JSON(status[0], map[string]any{ + "error": err.Error(), + }) + } + + switch { + case os.IsNotExist(err): + return ctx.JSON(http.StatusNotFound, nil) + case os.IsPermission(err): + return ctx.JSON(http.StatusUnauthorized, nil) + default: + return ctx.JSON(http.StatusInternalServerError, map[string]any{ + "error": err.Error(), + }) + } +} + +type meshCoreNodeResponse struct { + SNR float64 `json:"snr"` + RSSI int8 `json:"rssi"` + Name string `json:"name"` + PublicKey []byte `json:"public_key"` + Prefix byte `json:"prefix"` + NodeType meshcore.NodeType `json:"node_type"` + FirstHeard time.Time `json:"first_heard"` + LastHeard time.Time `json:"last_heard"` + Position *meshcore.Position `json:"position"` +} + +func (server *Server) apiGetMeshCoreNodes(ctx echo.Context) error { + /* + nodeTypes := getQueryInts(ctx, "type") + if len(nodeTypes) == 0 { + nodeTypes = []int{ + int(meshcore.Repeater), + } + } + */ + nodeType := meshcore.Repeater + if ctx.QueryParam("type") != "" { + nodeType = meshcore.NodeType(getQueryInt(ctx, "type")) + } + + rows, err := server.db.Query(sqlSelectMeshCoreNodesLastPosition, nodeType, 25) + if err != nil { + return server.apiError(ctx, err) + } + + var ( + response []meshCoreNodeResponse + prefix []byte + ) + for rows.Next() { + var ( + row meshCoreNodeResponse + lat, lng *float64 + ) + if err := rows.Scan( + &row.SNR, + &row.RSSI, + &row.Name, + &row.PublicKey, + &prefix, + &row.NodeType, + &row.FirstHeard, + &row.LastHeard, + &lat, + &lng, + ); err != nil { + return server.apiError(ctx, err) + } + if lat != nil && lng != nil { + row.Position = &meshcore.Position{ + Latitude: *lat, + Longitude: *lng, + } + } + if len(prefix) > 0 { + row.Prefix = prefix[0] + } + response = append(response, row) + } + + return ctx.JSON(http.StatusOK, response) +} + +type meshCorePacketResponse struct { + SNR float64 `json:"snr"` + RSSI int8 `json:"rssi"` + Hash []byte `json:"hash"` + RouteType byte `json:"route_type"` + PayloadType byte `json:"payload_type"` + Path []byte `json:"path"` + ReceivedAt time.Time `json:"received_at"` + Raw []byte `json:"raw"` + Parsed []byte `json:"parsed"` +} + +func (server *Server) apiGetMeshCorePackets(ctx echo.Context) error { + var ( + query string + limit = 25 + args []any + ) + if hashParam := ctx.QueryParam("hash"); hashParam != "" { + var ( + hash []byte + err error + ) + switch len(hashParam) { + case base64.URLEncoding.EncodedLen(8): + hash, err = base64.URLEncoding.DecodeString(hashParam) + case hex.EncodedLen(8): + hash, err = hex.DecodeString(hashParam) + default: + err = errors.New("invalid encoding") + } + if err != nil { + return server.apiError(ctx, err, http.StatusBadRequest) + } + query = sqlSelectMeshCorePacketsByHash + args = []any{hash} + } else { + query = sqlSelectMeshCorePackets + args = []any{limit} + } + + rows, err := server.db.Query(query, args...) + if err != nil { + return server.apiError(ctx, err) + } + + var response []meshCorePacketResponse + for rows.Next() { + var row meshCorePacketResponse + if err := rows.Scan( + &row.SNR, + &row.RSSI, + &row.Hash, + &row.RouteType, + &row.PayloadType, + &row.Path, + &row.ReceivedAt, + &row.Raw, + &row.Parsed, + ); err != nil { + return server.apiError(ctx, err) + } + response = append(response, row) + } + + return ctx.JSON(http.StatusOK, response) +} + +type meshCorePathResponse struct { + Origin *meshCoreNode `json:"origin"` + Path []*meshCoreNodeDistance `json:"path"` +} + +func (server *Server) apiGetMeshCorePath(ctx echo.Context) error { + origin, err := hex.DecodeString(ctx.Param("origin")) + if err != nil || len(origin) != crypto.PublicKeySize { + return ctx.JSON(http.StatusBadRequest, map[string]any{ + "error": "invalid origin", + }) + } + + path, err := hex.DecodeString(ctx.Param("path")) + if err != nil || len(path) == 0 { + return ctx.JSON(http.StatusBadRequest, map[string]any{ + "error": "invalid path", + }) + } + + var ( + node meshCoreNodeDistance + prefix []byte + latitude, longitude *float64 + ) + if err := server.db.QueryRow(` + SELECT + n.name, + n.public_key, + n.prefix, + n.first_heard, + n.last_heard, + n.last_latitude, + n.last_longitude + FROM + meshcore_node n + WHERE + n.node_type = 2 AND + n.public_key = $1 + `, + origin, + ).Scan( + &node.Name, + &node.PublicKey, + &prefix, + &node.FirstHeard, + &node.LastHeard, + &latitude, + &longitude, + ); err != nil { + return server.apiError(ctx, err) + } + node.Prefix = MeshCorePrefix(prefix[0]) + if latitude == nil || longitude == nil { + return ctx.JSON(http.StatusNotFound, map[string]any{ + "error": "origin has no known position", + }) + } + node.Position = &meshcore.Position{ + Latitude: *latitude, + Longitude: *longitude, + } + + var ( + current = &node + trace []*meshCoreNodeDistance + ) + slices.Reverse(path) + for _, prefix := range path { + if prefix != byte(current.Prefix) { + var hop *meshCoreNodeDistance + if hop, err = meshCoreRepeaterWithPrefixCloseTo(server.db, MeshCorePrefix(prefix), current.Position); err != nil { + if !os.IsNotExist(err) { + return server.apiError(ctx, err) + } + current = &meshCoreNodeDistance{ + meshCoreNode: meshCoreNode{ + Prefix: MeshCorePrefix(prefix), + Position: current.Position, + }, + } + } else { + current = hop + } + } + trace = append(trace, current) + } + + /* + if path[len(path)-1] == node.Prefix { + path = path[:len(path)-2] + } + */ + + var response = meshCorePathResponse{ + Origin: &node.meshCoreNode, + Path: trace, + } + return ctx.JSON(http.StatusOK, response) +} + +type meshCoreSourcesResponse struct { + Window time.Time `json:"time"` + Packets map[string]int `json:"packets"` +} + +func (server *Server) apiGetMeshCoreSources(ctx echo.Context) error { + var ( + now = time.Now().UTC() + windows = map[string]struct { + Interval int + Since time.Duration + }{ + "24h": {900, time.Hour * 24}, + "1w": {3600, time.Hour * 24 * 7}, + } + window = ctx.QueryParam("window") + ) + if window == "" { + window = "24h" + } + params, ok := windows[window] + if !ok { + return server.apiError(ctx, os.ErrNotExist) + } + + rows, err := server.db.Query(sqlSelectMeshCorePacketsByRepeaterWindowed, params.Interval, now.Add(-params.Since)) + if err != nil { + return server.apiError(ctx, err) + } + + var ( + response []*meshCoreSourcesResponse + buckets = make(map[int64]*meshCoreSourcesResponse) + ) + for rows.Next() { + var result struct { + Window time.Time + Repeater string + Packets int + } + if err := rows.Scan(&result.Window, &result.Repeater, &result.Packets); err != nil { + return server.apiError(ctx, err) + } + if result.Packets <= 10 { + continue // ignore + } + + if bucket, ok := buckets[result.Window.Unix()]; ok { + bucket.Packets[result.Repeater] = result.Packets + } else { + bucket = &meshCoreSourcesResponse{ + Window: result.Window, + Packets: map[string]int{ + result.Repeater: result.Packets, + }, + } + response = append(response, bucket) + buckets[result.Window.Unix()] = bucket + } + } + + return ctx.JSON(http.StatusOK, response) +} + +func getQueryInt(ctx echo.Context, param string) int { + v, _ := strconv.Atoi(ctx.QueryParam(param)) + return v +} + +func getQueryInts(ctx echo.Context, param string) []int { + var values []int + if keys := strings.Split(ctx.QueryParam(param), ","); len(keys) > 0 { + for _, value := range keys { + if v, err := strconv.Atoi(value); err == nil { + values = append(values, v) + } + } + } + return values +} diff --git a/sql.go b/sql.go new file mode 100644 index 0000000..d6bd126 --- /dev/null +++ b/sql.go @@ -0,0 +1,236 @@ +package hamview + +const ( + sqlCreateRadio = ` + CREATE TABLE IF NOT EXISTS radio ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(64) NOT NULL UNIQUE, + is_online BOOLEAN NOT NULL DEFAULT false, + device VARCHAR(100), + manufacturer VARCHAR(100), + firmware_date TIMESTAMPTZ, + firmware_version VARCHAR(32), + antenna VARCHAR(100), + modulation VARCHAR(16) NOT NULL, + protocol VARCHAR(16) NOT NULL, + latitude NUMERIC(10, 8), -- GPS latitude in decimal degrees + longitude NUMERIC(11, 8), -- GPS longitude in decimal degrees + altitude REAL, -- Altitude in meters + frequency DOUBLE PRECISION, + bandwidth DOUBLE PRECISION, + rx_frequency DOUBLE PRECISION, + tx_frequency DOUBLE PRECISION, + power REAL, + gain REAL, + lora_sf SMALLINT, + lora_cr SMALLINT, + extra JSONB + ); + ` + sqlIndexRadioName = `CREATE INDEX IF NOT EXISTS idx_radio_name ON radio(name);` + sqlIndexRadioProtocol = `CREATE INDEX IF NOT EXISTS idx_radio_protocol ON radio(protocol);` + sqlGeometryRadioPosition = `SELECT AddGeometryColumn('public', 'radio', 'position', 4326, 'POINT', 2);` +) + +const ( + sqlCreateAPRSStation = ` + CREATE TABLE IF NOT EXISTS aprs_station ( + id BIGSERIAL PRIMARY KEY, + address VARCHAR(10) NOT NULL UNIQUE, + last_heard TIMESTAMPTZ NOT NULL, + last_path TEXT[], + last_comment TEXT + ); + ` + sqlCreateAPRSPacket = ` + CREATE TABLE IF NOT EXISTS aprs_packet ( + id BIGSERIAL PRIMARY KEY, + src_address VARCHAR(10) NOT NULL, + dst_address VARCHAR(10) NOT NULL, + comment TEXT, + payload BYTEA, + raw BYTEA, + received_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + ` +) + +const ( + sqlCreateMeshCorePacket = ` + CREATE TABLE IF NOT EXISTS meshcore_packet ( + id BIGSERIAL PRIMARY KEY, + snr REAL NOT NULL DEFAULT 0, + rssi SMALLINT NOT NULL DEFAULT 0, + hash BYTEA NOT NULL, -- Used for deduplication + route_type SMALLINT NOT NULL, + payload_type SMALLINT NOT NULL, + path BYTEA, + payload BYTEA, + raw BYTEA, + parsed JSONB, + received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + ` + sqlIndexMeshCorePacketHash = `CREATE INDEX IF NOT EXISTS idx_meshcore_packet_hash ON meshcore_packet(hash);` + sqlIndexMeshCorePacketPayloadType = `CREATE INDEX IF NOT EXISTS idx_meshcore_packet_payload_type ON meshcore_packet(payload_type);` +) + +const ( + sqlCreateMeshCoreNode = ` + CREATE TABLE IF NOT EXISTS meshcore_node ( + id BIGSERIAL PRIMARY KEY, + last_advert_id BIGINT NOT NULL REFERENCES meshcore_packet(id) ON DELETE CASCADE, + node_type SMALLINT NOT NULL DEFAULT 0, + public_key BYTEA NOT NULL UNIQUE, + name TEXT, + local_time TIMESTAMPTZ NOT NULL, + first_heard TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_heard TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_latitude NUMERIC(10, 8), -- GPS latitude in decimal degrees + last_longitude NUMERIC(11, 8), -- GPS longitude in decimal degrees + last_position GEOMETRY(POINT, 4326) + ); + ` + sqlIndexMeshCoreNodePublicKey = `CREATE INDEX IF NOT EXISTS idx_meshcore_node_public_key ON meshcore_node(public_key);` + sqlIndexMeshCoreNodeName = `CREATE INDEX IF NOT EXISTS idx_meshcore_node_name ON meshcore_node(name);` + sqlAlterMeshCoreNodePrefix = `ALTER TABLE meshcore_node ADD COLUMN IF NOT EXISTS prefix BYTEA GENERATED ALWAYS AS (substring(public_key, 0, 2)) STORED;` + sqlGeometryMeshCoreNodePosition = `SELECT AddGeometryColumn('public', 'meshcore_node', 'position', 4326, 'POINT', 2);` + sqlAlterMeshCoreNodeLastPosition = ` + ALTER TABLE meshcore_node + ADD COLUMN last_position GEOMETRY(Point, 4326) + GENERATED ALWAYS AS ( + CASE + WHEN last_latitude IS NOT NULL AND last_longitude IS NOT NULL THEN ST_SetSRID(ST_MakePoint(last_latitude, last_longitude), 4326) + ELSE NULL + END + ) STORED;` +) + +const ( + sqlCreateMeshCoreNodePosition = ` + CREATE TABLE IF NOT EXISTS meshcore_node_position ( + id BIGSERIAL PRIMARY KEY, + node_id BIGINT NOT NULL REFERENCES meshcore_node(id) ON DELETE CASCADE, + heard_at TIMESTAMPTZ NOT NULL, + latitude NUMERIC(10, 8), -- GPS latitude in decimal degrees + longitude NUMERIC(11, 8) -- GPS longitude in decimal degrees + ); + ` + sqlGeometryMeshCoreNodePositionPosition = `SELECT AddGeometryColumn('public', 'meshcore_node_position', 'position', 4326, 'POINT', 2);` + sqlIndexMeshCoreNodePositionPosition = `CREATE INDEX IF NOT EXISTS idx_meshcore_node_position_position ON meshcore_node_position USING GIST (position);` +) + +const ( + sqlSelectMeshCoreNodesLastPosition = ` + WITH ranked_positions AS ( + SELECT + node_id, latitude, longitude, position, + ROW_NUMBER() OVER (PARTITION BY node_id ORDER BY heard_at DESC) as rn + FROM meshcore_node_position + ) + SELECT + r.snr, + r.rssi, + n.name, + n.public_key, + n.prefix, + n.node_type, + n.first_heard, + n.last_heard, + p.latitude, + p.longitude + FROM + meshcore_node n + LEFT JOIN ranked_positions p ON n.id = p.node_id AND p.rn = 1 + LEFT JOIN meshcore_packet r ON r.id = n.last_advert_id + WHERE + n.node_type = $1 + ORDER BY last_heard DESC LIMIT $2; + ` + sqlSelectMeshCorePackets = ` + SELECT + snr, + rssi, + hash, + route_type, + payload_type, + path, + received_at, + raw, + parsed + FROM + meshcore_packet + ORDER BY + received_at DESC + LIMIT $1; + ` + sqlSelectMeshCorePacketsByHash = ` + SELECT + snr, + rssi, + hash, + route_type, + payload_type, + path, + received_at, + raw, + parsed + FROM + meshcore_packet + WHERE + hash = $1 + ORDER BY + received_at DESC; + ` + sqlSelectMeshCorePacketsByRepeaterWindowed = ` + SELECT + to_timestamp(round(EXTRACT(EPOCH FROM received_at) / $1) * $1) as window, + cast(to_hex(get_byte(path, length(path)-2)) as text) AS repeater, + count(id) AS packets + FROM + meshcore_packet + WHERE + length(path) >= 2 AND + received_at >= $2 + GROUP BY + round(EXTRACT(EPOCH FROM received_at) / $1), + cast(to_hex(get_byte(path, length(path)-2)) as text); + ` + sqlSelectMeshCorePacketPathNodes = ` + WITH RECURSIVE + params AS ( + $1::BYTEA as path, + $2::NUMERIC(10, 8) as start_latitude, + $3::NUMERIC(11, 8) as start_longitude, + $4::DOUBLE PRECISION as max_range_m + ), + + path_prefix AS ( + SELECT + ) + ` +) + +const ( + sqlCreateMeshCoreIdentity = ` + CREATE TABLE IF NOT EXISTS meshcore_identity ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(32) NOT NULL UNIQUE, + public_key BYTEA(32) NOT NULL UNIQUE, + private_key BYTEA(64) NOT NULL + ); + ` +) + +const ( + sqlCreateMeshCoreGroup = ` + CREATE TABLE IF NOT EXISTS meshcore_group ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(32) NOT NULL UNIQUE, + hash SMALLINT NOT NULL, + shared_key VARCHAR(64) NOT NULL, + is_public BOOLEAN NOT NULL DEFAULT FALSE + ); + ` +) diff --git a/util.go b/util.go new file mode 100644 index 0000000..0c1bc6c --- /dev/null +++ b/util.go @@ -0,0 +1,20 @@ +package hamview + +import ( + "fmt" + + "github.com/sirupsen/logrus" + "go.yaml.in/yaml/v3" +) + +// Logger used by this package. +var Logger *logrus.Logger = logrus.New() + +func unmarshalOne(node yaml.Node, value any) error { + Logger.Printf("node: %#+v", node) + Logger.Printf("content: %d", len(node.Content)) + if len(node.Content) != 1 { + return fmt.Errorf("hamview: expected 1 configuration value, got %d", len(node.Content)) + } + return node.Content[0].Decode(value) +}