From fb898bb058a9e8907812775cc65ff5ae3d69bd2e Mon Sep 17 00:00:00 2001 From: maze Date: Sun, 22 Feb 2026 20:27:07 +0100 Subject: [PATCH] Initial import --- .gitattributes | 1 + .gitea/workflows/dev.yaml | 23 ++ aprsis.go | 11 + asset/image/device/esp32.svg | 1 + asset/image/device/esp_now.svg | 31 ++ asset/image/device/faketec.svg | 1 + asset/image/device/heltec_mesh_solar.svg | 1 + asset/image/device/heltec_meshpocket.svg | 1 + asset/image/device/heltec_paper.svg | 1 + asset/image/device/heltec_t114.svg | 1 + asset/image/device/heltec_v2.svg | 1 + asset/image/device/heltec_v3.svg | 1 + asset/image/device/heltec_v4.svg | 1 + asset/image/device/heltec_wp.svg | 1 + asset/image/device/heltec_wsl3.svg | 1 + asset/image/device/heltec_wt3.svg | 1 + asset/image/device/ikoka_nano.svg | 1 + asset/image/device/ikoka_stick.svg | 1 + asset/image/device/keepteen_lt1.svg | 1 + asset/image/device/lilygo_pager.svg | 1 + asset/image/device/lilygo_t3s3.svg | 1 + asset/image/device/lilygo_t5_pro.svg | 1 + asset/image/device/lilygo_tbeam.svg | 1 + asset/image/device/lilygo_tbeam_supreme.svg | 1 + asset/image/device/lilygo_tdeck.svg | 1 + asset/image/device/lilygo_tdeck_pro.svg | 1 + asset/image/device/lilygo_tdisplay.svg | 1 + asset/image/device/lilygo_techo.svg | 1 + asset/image/device/lilygo_techo_lite.svg | 1 + asset/image/device/lilygo_tlora_1.6.svg | 1 + asset/image/device/lilygo_tlora_c6.svg | 1 + asset/image/device/lora.svg | 28 ++ asset/image/device/meshcore.svg | 12 + asset/image/device/nano_g2.svg | 1 + asset/image/device/nrf52.svg | 1 + asset/image/device/rak_11300.svg | 1 + asset/image/device/rak_4631.svg | 1 + asset/image/device/rak_wismesh_tag.svg | 1 + asset/image/device/rpi.svg | 1 + asset/image/device/rpi_picow.svg | 1 + asset/image/device/sensecap_solar.svg | 1 + asset/image/device/sensecap_t1000e.svg | 1 + asset/image/device/station_g2.svg | 1 + asset/image/device/thinknode_m1.svg | 1 + asset/image/device/thinknode_m2.svg | 1 + asset/image/device/thinknode_m3.svg | 1 + asset/image/device/thinknode_m5.svg | 1 + asset/image/device/thinknode_m6.svg | 1 + asset/image/device/wio_tracker_l1.svg | 1 + asset/image/device/wio_tracker_l1_eink.svg | 1 + asset/image/device/xiao_esp32c3.svg | 1 + asset/image/device/xiao_esp32c6.svg | 1 + asset/image/device/xiao_esp32s3.svg | 1 + asset/image/device/xiao_nrf52.svg | 1 + asset/image/device/yeasu_ft817.jpg | Bin 0 -> 14246 bytes asset/image/protocol/meshcore.org.png | Bin 0 -> 25624 bytes asset/image/protocol/meshcore.png | Bin 0 -> 15021 bytes asset/image/protocol/meshtastic.org.png | Bin 0 -> 16059 bytes asset/image/protocol/meshtastic.png | Bin 0 -> 7438 bytes broker.go | 433 ++++++++++++++++++++ cmd/hamview-collector/main.go | 86 ++++ cmd/hamview-receiver/main.go | 49 +++ cmd/hamview-receiver/run_aprsis.go | 77 ++++ cmd/hamview-receiver/run_meshcore.go | 79 ++++ cmd/hamview-server/main.go | 55 +++ cmd/import-letsmesh-nodes/main.go | 183 +++++++++ collector.go | 400 ++++++++++++++++++ go.mod | 39 ++ go.sum | 113 +++++ internal/cmd/all.go | 27 ++ internal/cmd/config.go | 80 ++++ internal/cmd/logger.go | 69 ++++ meshcore.go | 185 +++++++++ radio.go | 11 + server.go | 423 +++++++++++++++++++ sql.go | 236 +++++++++++ util.go | 20 + 77 files changed, 2719 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitea/workflows/dev.yaml create mode 100644 aprsis.go create mode 100644 asset/image/device/esp32.svg create mode 100644 asset/image/device/esp_now.svg create mode 100644 asset/image/device/faketec.svg create mode 100644 asset/image/device/heltec_mesh_solar.svg create mode 100644 asset/image/device/heltec_meshpocket.svg create mode 100644 asset/image/device/heltec_paper.svg create mode 100644 asset/image/device/heltec_t114.svg create mode 100644 asset/image/device/heltec_v2.svg create mode 100644 asset/image/device/heltec_v3.svg create mode 100644 asset/image/device/heltec_v4.svg create mode 100644 asset/image/device/heltec_wp.svg create mode 100644 asset/image/device/heltec_wsl3.svg create mode 100644 asset/image/device/heltec_wt3.svg create mode 100644 asset/image/device/ikoka_nano.svg create mode 100644 asset/image/device/ikoka_stick.svg create mode 100644 asset/image/device/keepteen_lt1.svg create mode 100644 asset/image/device/lilygo_pager.svg create mode 100644 asset/image/device/lilygo_t3s3.svg create mode 100644 asset/image/device/lilygo_t5_pro.svg create mode 100644 asset/image/device/lilygo_tbeam.svg create mode 100644 asset/image/device/lilygo_tbeam_supreme.svg create mode 100644 asset/image/device/lilygo_tdeck.svg create mode 100644 asset/image/device/lilygo_tdeck_pro.svg create mode 100644 asset/image/device/lilygo_tdisplay.svg create mode 100644 asset/image/device/lilygo_techo.svg create mode 100644 asset/image/device/lilygo_techo_lite.svg create mode 100644 asset/image/device/lilygo_tlora_1.6.svg create mode 100644 asset/image/device/lilygo_tlora_c6.svg create mode 100644 asset/image/device/lora.svg create mode 100644 asset/image/device/meshcore.svg create mode 100644 asset/image/device/nano_g2.svg create mode 100644 asset/image/device/nrf52.svg create mode 100644 asset/image/device/rak_11300.svg create mode 100644 asset/image/device/rak_4631.svg create mode 100644 asset/image/device/rak_wismesh_tag.svg create mode 100644 asset/image/device/rpi.svg create mode 100644 asset/image/device/rpi_picow.svg create mode 100644 asset/image/device/sensecap_solar.svg create mode 100644 asset/image/device/sensecap_t1000e.svg create mode 100644 asset/image/device/station_g2.svg create mode 100644 asset/image/device/thinknode_m1.svg create mode 100644 asset/image/device/thinknode_m2.svg create mode 100644 asset/image/device/thinknode_m3.svg create mode 100644 asset/image/device/thinknode_m5.svg create mode 100644 asset/image/device/thinknode_m6.svg create mode 100644 asset/image/device/wio_tracker_l1.svg create mode 100644 asset/image/device/wio_tracker_l1_eink.svg create mode 100644 asset/image/device/xiao_esp32c3.svg create mode 100644 asset/image/device/xiao_esp32c6.svg create mode 100644 asset/image/device/xiao_esp32s3.svg create mode 100644 asset/image/device/xiao_nrf52.svg create mode 100644 asset/image/device/yeasu_ft817.jpg create mode 100644 asset/image/protocol/meshcore.org.png create mode 100644 asset/image/protocol/meshcore.png create mode 100644 asset/image/protocol/meshtastic.org.png create mode 100644 asset/image/protocol/meshtastic.png create mode 100644 broker.go create mode 100644 cmd/hamview-collector/main.go create mode 100644 cmd/hamview-receiver/main.go create mode 100644 cmd/hamview-receiver/run_aprsis.go create mode 100644 cmd/hamview-receiver/run_meshcore.go create mode 100644 cmd/hamview-server/main.go create mode 100644 cmd/import-letsmesh-nodes/main.go create mode 100644 collector.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/cmd/all.go create mode 100644 internal/cmd/config.go create mode 100644 internal/cmd/logger.go create mode 100644 meshcore.go create mode 100644 radio.go create mode 100644 server.go create mode 100644 sql.go create mode 100644 util.go 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 0000000000000000000000000000000000000000..80cc3c7e02635300af9daac1cc9a0d458f886674 GIT binary patch literal 14246 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 GC;taNqW$Cm literal 0 HcmV?d00001 diff --git a/asset/image/protocol/meshcore.org.png b/asset/image/protocol/meshcore.org.png new file mode 100644 index 0000000000000000000000000000000000000000..e7c2fce139178aeb754f260791163d0db78c6c6f GIT binary patch literal 25624 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

s|9Du?J1!d+b-+`aAR1bra2E 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 S?7#ol(9tx|s8oCY`hNf{%Ia|d literal 0 HcmV?d00001 diff --git a/asset/image/protocol/meshcore.png b/asset/image/protocol/meshcore.png new file mode 100644 index 0000000000000000000000000000000000000000..ed58f675dfd84ef31f4b2035da9b044896288d57 GIT binary patch literal 15021 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 F{Vz{JttkKi literal 0 HcmV?d00001 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) +}