Initial import
Some checks failed
Run tests / test (1.25) (push) Has been cancelled
Run tests / test (stable) (push) Has been cancelled

This commit is contained in:
2026-02-22 20:27:07 +01:00
commit fb898bb058
77 changed files with 2719 additions and 0 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
*.go text eol=lf

23
.gitea/workflows/dev.yaml Normal file
View File

@@ -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

11
aprsis.go Normal file
View File

@@ -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"`
}

View File

@@ -0,0 +1 @@
<svg version="1.2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 300" width="300" height="300"><style>.a{fill:#e7352c}</style><path class="a" d="m99.4 223.2c0 3.1-0.6 6.2-2 9-1.3 2.8-3.2 5.3-5.6 7.3-2.5 2-5.3 3.4-8.3 4.1-3 0.8-6.1 0.9-9.2 0.3-2-0.4-4-1.1-5.8-2.1-1.8-1-3.5-2.2-5-3.7-1.4-1.4-2.7-3.1-3.6-4.9-1-1.9-1.7-3.8-2.1-5.9-0.9-4.4-0.3-9 1.6-13.1 1.9-4 5.1-7.4 9.1-9.5 4-2.1 8.5-2.9 13-2.3 4.5 0.7 8.6 2.8 11.8 6q1.4 1.4 2.6 3.1 1.1 1.8 1.9 3.7 0.8 1.9 1.2 3.9 0.4 2.1 0.4 4.1zm183.5-21.9c-3.3-23.1-10.3-45.6-20.8-66.5-10.5-21-24.3-40.1-40.8-56.6-16.5-16.6-35.6-30.3-56.6-40.8-20.9-10.5-43.3-17.5-66.5-20.9q-4.2 2.2-8.2 4.7-4 2.5-7.8 5.3-3.9 2.8-7.5 5.8-3.6 3-7.1 6.3v20.3c22.7 0 45.2 4.5 66.2 13.2 20.9 8.7 40 21.4 56 37.5 16.1 16 28.8 35.1 37.5 56 8.7 21 13.2 43.5 13.3 66.2h20.3q3.2-3.5 6.2-7.1 3-3.6 5.8-7.4 2.8-3.9 5.3-7.8 2.5-4 4.7-8.2z"/><path class="a" d="m299.4 137.5c0-13.5-2-26.9-6-39.8-3.9-12.9-9.7-25.2-17.2-36.4-7.5-11.2-16.7-21.2-27.1-29.8-10.5-8.5-22.1-15.4-34.6-20.5q-6.2-2.6-12.7-4.5-6.4-1.9-13.1-3.2-6.6-1.3-13.3-1.9-6.7-0.7-13.4-0.6c-4.9 0-9.6 0-14.2 0.7l-3.2 9.1c16.6 5.8 32.5 13.4 47.4 22.8 14.9 9.3 28.6 20.3 41.1 32.8 12.4 12.4 23.4 26.2 32.8 41 9.3 14.9 17 30.8 22.8 47.4l9.1-3.3c0-4.5 0.9-9.1 0.9-14.2m-135.2 162.2c-21.2 0-42.3-4.2-62-12.3-19.7-8.2-37.6-20.1-52.7-35.1-15-15-27-32.9-35.2-52.5-8.2-19.7-12.5-40.7-12.6-62 0-10.7 1-21.3 3-31.8 2.1-10.4 5.2-20.7 9.3-30.5 4-9.9 9.1-19.3 15-28.1 5.9-8.9 12.7-17.1 20.3-24.6l9.2 8.6c-14 14-25.2 30.6-32.8 48.9-7.7 18.3-11.6 37.9-11.6 57.8 0 19.8 3.9 39.4 11.6 57.7 7.6 18.3 18.8 34.9 32.8 48.9 14 14.1 30.6 25.2 48.9 32.9 18.3 7.6 37.9 11.5 57.7 11.5 19.8 0 39.4-3.9 57.7-11.5 18.3-7.7 34.9-18.8 48.9-32.9l8.6 8.6c-7.6 7.6-16 14.3-24.9 20.1-9 5.9-18.5 10.9-28.4 14.8-10 4-20.3 7-30.8 8.9-10.6 1.9-21.3 2.8-32 2.6z"/><path class="a" d="m161.7 241.1c0.7-7.9 0.5-15.9-0.8-23.8-1.3-7.9-3.6-15.6-6.8-23-3.3-7.3-7.4-14.2-12.4-20.4-5-6.3-10.7-11.9-17.1-16.8q-5.1-4-10.7-7.3-5.7-3.3-11.7-5.7-6-2.5-12.4-4-6.3-1.6-12.8-2.3-1.6-0.2-3-1-1.5-0.8-2.5-2.1-1.1-1.2-1.6-2.8-0.5-1.6-0.4-3.2 0.1-1.3 0.6-2.4 0.4-1.2 1.2-2.2 0.8-1 1.9-1.7 1-0.7 2.2-1.1 0.4-0.1 0.8-0.2 0.4 0 0.9-0.1 0.4 0 0.8 0 0.4 0 0.8 0c14.6 1.5 28.6 5.9 41.5 12.8 12.8 7 24.2 16.4 33.4 27.7 9.2 11.3 16.2 24.3 20.4 38.3 4.2 14 5.6 28.7 4.2 43.2q-0.2 2.4-0.6 4.9-0.3 2.4-0.8 4.8-0.4 2.4-1 4.8-0.6 2.3-1.3 4.7l24.7 7q2.8-0.8 5.5-1.7 2.7-0.9 5.3-2 2.7-1 5.3-2.1 2.7-1.1 5.2-2.3 0.7-3.6 1.2-7.3 0.6-3.6 0.9-7.3 0.3-3.6 0.5-7.3 0.2-3.6 0.1-7.3c0-18.5-3.3-36.9-9.7-54.2-6.5-17.4-15.9-33.5-28-47.5-12.1-14.1-26.5-25.9-42.7-34.8-16.2-9-33.8-15.1-52.1-17.9q-3.1-0.5-6.2-0.8-3.1-0.3-6.3-0.3-3.1 0-6.2 0.3-3.1 0.3-6.2 0.8-3.9 1.1-7.6 2.6-3.7 1.6-7.1 3.7-3.4 2.2-6.4 4.7-3.1 2.6-5.8 5.7c-5.7 6.6-9.7 14.5-11.6 23.1-1.9 8.5-1.7 17.4 0.7 25.9 2.3 8.4 6.7 16.1 12.8 22.5 6 6.3 13.5 11 21.9 13.8q1.6 0.3 3.3 0.7 1.6 0.3 3.3 0.6 1.7 0.3 3.4 0.6 1.6 0.3 3.3 0.5c5.8 1 11.3 2.9 16.4 5.8 5.1 2.9 9.6 6.7 13.4 11.2 3.7 4.4 6.7 9.5 8.7 15 2 5.5 3 11.3 3 17.2q0 3.5-0.5 7-0.5 3.6-1.5 7-1 3.4-2.5 6.6-1.5 3.2-3.4 6.2l17 11q3.1 0.8 6.3 1.5 3.2 0.7 6.4 1.3 3.2 0.5 6.4 0.9 3.3 0.3 6.5 0.5 1.9-3.8 3.4-7.8 1.6-4 2.8-8.1 1.2-4.1 2-8.3 0.8-4.2 1.2-8.5"/></svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -0,0 +1,31 @@
<svg version="1.2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 26" width="100%" height="100%">
<style>
.a {
fill: #fff
}
.b {
fill: #c00000
}
</style>
<path
d="m5 25.3c-2.1-0.4-3.9-2.3-4.1-4.3-0.1-0.3-0.1-4-0.1-8.2 0-8.3 0-8.2 0.5-9.2 0.3-0.6 0.4-0.9 1-1.4 0.6-0.6 0.7-0.6 1.4-1 0.7-0.3 0.8-0.4 1.5-0.5 0.2 0 20.5 0 45.1 0 45.8 0 45.1 0 45.5 0.3q0.1 0 0.1 0c0.1 0 1 0.5 1.1 0.5 0.1 0.1 0.2 0.2 0.4 0.3 0.2 0.2 0.6 0.6 0.8 0.9 0.2 0.3 0.5 0.7 0.4 0.7q0 0 0.1 0.1c0 0.1 0.1 0.2 0.1 0.2 0 0.1 0.1 0.3 0.1 0.5 0.1 0.2 0.2 0.6 0.2 0.9 0.1 0.7 0.1 15.8 0 16.3 0 0.1-0.1 0.3-0.1 0.4q-0.1 0.1-0.1 0.1c0.1 0 0 0.3-0.2 0.6-0.7 1.4-1.9 2.3-3.5 2.7-0.3 0.1-89.7 0.1-90.2 0.1zm38.6-2.3c0-0.1-0.4-0.9-0.9-1.9l-0.8-1.7v-6.7c0-5.8 0-6.8-0.2-7.2-0.2-0.8-0.7-1.6-1.6-2.1-0.8-0.5 0.3-0.5-17.5-0.5-17.9 0-16.7 0-17.6 0.5-0.8 0.5-1.5 1.4-1.6 2.3-0.1 0.3-0.1 3.1-0.1 7.4 0 7.6 0 7.4 0.5 8.2 0.2 0.5 0.9 1.1 1.3 1.3 0.1 0.1 0.4 0.3 0.6 0.3 0.4 0.2 1.3 0.2 19.2 0.2 14.9 0 18.7 0 18.7-0.1zm27.5-3.2q3.1-0.8 4.2-3.9c0.9-2.4 0.7-5.6-0.5-7.7-0.4-0.6-1.4-1.6-2.1-1.9-2.1-1.2-5.1-1.2-7.3-0.1-0.3 0.2-0.8 0.6-1.2 1-0.6 0.6-0.7 0.8-1.1 1.5-0.6 1.4-0.9 2.8-0.8 4.5 0.2 3.7 2 6.1 5.2 6.7 0.9 0.2 2.8 0.1 3.6-0.1zm-22.4-4.6c0-2.5-0.1-5-0.1-5.5v-1h0.3c0.2 0 0-0.4 2.8 4.5 0.5 0.8 1.5 2.6 2.3 4.1l1.5 2.5 1.8 0.1h1.8v-7.2-7.1h-1.2-1.2l0.1 4.8c0 2.7 0 5.2 0 5.6l0.1 0.6h-0.3-0.3l-3.2-5.5-3.2-5.5h-1.7-1.8v7.1 7.2h1.2 1.2zm36 2.2c1.2-4.9 1.6-6.4 1.8-7.5l0.2-1.2h0.2c0.3 0 0.3 0 0.4 0.6 0.2 1.1 0.7 3.2 1.6 6.9l0.9 3.6h1.6 1.6l1.7-7c1-3.9 1.8-7.1 1.9-7.2 0-0.1-0.3-0.1-1.2-0.1h-1.3l-0.1 0.4c-0.7 2.8-2.2 9.5-2.4 10.8l-0.1 0.6h-0.3c-0.2 0-0.2 0-0.3-0.4-0.1-1.1-0.6-3.1-1.6-7.1l-1.1-4.3h-1.3-1.2l-0.7 2.7c-1.5 5.7-1.8 6.8-1.9 7.9l-0.2 1.1h-0.3c-0.2 0-0.2 0-0.3-0.2 0-0.1-0.1-0.8-0.3-1.7-0.1-0.8-0.7-3.3-1.2-5.5-0.5-2.1-0.9-4-1-4.1 0-0.2 0-0.2-1.3-0.2-1.1 0-1.2 0-1.2 0.1 0.1 0.2 0.7 2.5 2.3 8.8 0.7 2.9 1.3 5.3 1.3 5.3 0.1 0.1 0.4 0.1 1.6 0.1h1.5z" />
<path
d="m67.7 17.8c-1.3-0.4-2.1-1.3-2.4-2.9-0.2-0.9-0.2-3.5 0-4.4 0.2-1.2 0.9-2.2 1.7-2.6 1.1-0.6 3-0.6 4.1 0 0.8 0.4 1.5 1.4 1.8 2.6 0.1 0.5 0.1 1 0.1 2.2 0 1.8-0.1 2.5-0.6 3.4-0.2 0.5-0.8 1.2-1.3 1.4-0.8 0.4-2.4 0.5-3.4 0.3z" />
<path class="a"
d="m19.5 20.5c-0.8-0.1-1.5-0.3-2.1-0.5l-0.4-0.2v-1.7-1.7h0.2c0.1 0 0.7 0.2 1.1 0.4 1.4 0.5 2.3 0.8 3.2 0.8 0.7 0 0.9-0.1 1.2-0.2 0.6-0.3 0.9-0.9 0.7-1.4-0.2-0.5-0.7-0.9-2-1.5-2.4-1.2-3-1.7-3.6-2.6-0.5-0.7-0.6-1.3-0.6-2.5-0.1-1.1 0.1-1.6 0.5-2.3q1.2-2.1 4.7-2.1c1.5 0 2.8 0.3 4.2 0.9 0.3 0.2 0.4 0.2 0.4 0.4 0 0.2-0.3 0.8-0.5 1.5l-0.5 1.2-0.4-0.1c-0.2 0-0.7-0.2-1.1-0.3-0.8-0.3-2.1-0.6-2.4-0.5-0.5 0.1-0.7 0.2-1 0.5q-0.8 0.7 0 1.5c0.3 0.3 0.8 0.6 1.8 1.1 2.6 1.3 3.5 2 3.9 3.3 0.2 0.6 0.3 2.3 0.1 3-0.4 1.5-1.5 2.5-3.2 2.9-0.8 0.2-3.3 0.3-4.2 0.1z" />
<path class="a"
d="m5.7 12.8v-7.6h4.4c2.4 0 4.4 0 4.5 0 0.1 0.1 0.1 0.5 0.1 1.6l-0.1 1.5h-2.6-2.7v1.4 1.3h2.5 2.5v1.6 1.5h-2.5-2.5v1.6 1.7h2.7 2.6v1.5 1.6h-4.4-4.5z" />
<path class="a"
d="m29.9 12.8v-7.6h2.4c3.8 0 5 0.1 6.1 0.7 1 0.5 1.8 1.5 2 2.6 0.1 0.3 0.1 0.9 0.1 1.6 0 1.8-0.3 2.7-1.2 3.6q-0.9 0.9-2.2 1.3c-0.5 0.1-1 0.1-2.1 0.2h-1.5v2.7 2.6h-1.8-1.8zm5.8-1c0.7-0.3 1-0.7 1.2-1.3 0.1-0.5 0-1-0.4-1.5-0.3-0.5-1.2-0.8-2.5-0.8h-0.5v2 1.9h0.9c0.7 0 0.9-0.1 1.3-0.3z" />
<path class="a"
d="m67.3 20.1c-2.1-0.3-3.7-1.6-4.5-3.3-0.5-1.1-0.7-2.1-0.7-3.6-0.1-2 0.1-3.3 0.8-4.7 0.4-0.7 0.5-0.9 1.1-1.5 1.4-1.4 3-2 5.5-1.9 1.4 0.1 2.4 0.3 3.5 0.9 0.6 0.4 1.6 1.4 2 2 0.8 1.4 1.2 3.2 1.1 5.3q-0.2 3.4-2 5.2c-0.8 0.8-1.6 1.2-2.8 1.5-0.8 0.3-3.1 0.3-4 0.1zm2.7-2.5c0.7-0.2 1.1-0.4 1.6-0.9 0.5-0.5 0.8-1.1 1-2 0.2-0.9 0.2-3 0-3.9-0.3-1.3-0.9-2.3-1.8-2.7-0.4-0.2-1.2-0.4-1.7-0.4-0.6 0-1.4 0.2-1.8 0.4-0.9 0.4-1.5 1.4-1.8 2.6-0.2 0.9-0.2 3.1 0 4 0.3 1.2 0.8 2.1 1.6 2.5 0.8 0.4 1.9 0.6 2.9 0.4z" />
<path class="a"
d="m46.2 12.7v-7.4l2 0.1h2l3.1 5.5c1.8 3 3.3 5.5 3.3 5.5 0 0 0-0.2 0-0.4 0-0.2-0.1-2.7-0.1-5.5v-5.2h1.4 1.4v7.4 7.4h-2-2l-1.5-2.6c-0.8-1.4-1.9-3.3-2.3-4.1-2.8-4.8-2.6-4.5-2.7-4.2 0 0.1 0.1 0.7 0.1 1.3 0 0.6 0.1 3 0.1 5.4v4.2h-1.4-1.4z" />
<path class="a"
d="m80.7 20c0 0-0.6-2.4-1.3-5.3-0.8-2.9-1.6-6.1-1.9-7.2-0.2-1.1-0.5-2-0.5-2.1 0 0 0.5-0.1 1.6-0.1 1.5 0 1.5 0 1.5 0.2 0 0.1 0.5 2 1 4.2 0.5 2.2 1 4.7 1.2 5.5 0.2 0.8 0.3 1.6 0.3 1.7 0.1 0.1 0.1-0.2 0.3-0.9 0.2-1.1 0.4-2.3 1.9-8l0.7-2.7h1.5 1.5l1.1 4.3c1.2 4.5 1.2 4.9 1.5 6.5l0.2 1.1 0.1-0.6c0.2-1.3 0.5-2.7 1.4-6.5 0.5-2.2 1-4.2 1-4.4l0.1-0.4h1.5c0.9 0 1.6 0.1 1.6 0.1 0 0-0.9 3.4-1.9 7.4l-1.9 7.3h-1.7-1.8l-1-3.9c-1-3.7-1.4-5.3-1.6-6.6l-0.1-0.6-0.2 1.2c-0.2 1.1-0.6 2.6-1.9 7.5l-0.6 2.4h-1.7c-1.4 0-1.8 0-1.9-0.1z" />
<path class="b"
d="m5.5 23.2c-0.2-0.1-0.5-0.2-0.6-0.3-0.5-0.2-1.1-0.9-1.4-1.3-0.5-0.9-0.5-0.7-0.5-8.5 0-4.5 0.1-7.3 0.1-7.6 0.2-0.9 0.8-1.8 1.7-2.3 0.9-0.5-0.3-0.5 17.8-0.5 18.1 0 16.9 0 17.8 0.5 0.8 0.5 1.4 1.2 1.6 2.1 0.1 0.4 0.1 1.4 0.1 7.2v6.6l1 2.1c0.5 1.1 1 2.1 1 2.1 0 0-8.6 0-19.1 0-18.2 0-19.1 0-19.5-0.1zm18-3c1.7-0.5 2.8-1.4 3.2-2.9 0.2-0.7 0.1-2-0.1-2.6-0.5-1.2-1.3-1.9-3.9-3.2-1.5-0.8-1.9-1.1-2.1-1.5-0.2-0.3-0.2-1 0-1.2 0.1-0.3 0.4-0.7 0.7-0.8 0.7-0.3 2.1-0.1 3.4 0.4 0.5 0.1 0.9 0.3 0.9 0.3 0.1 0 0.3-0.3 0.6-1.2 0.3-0.6 0.5-1.2 0.5-1.2-0.1-0.1-1.2-0.5-1.8-0.7-1-0.2-1.5-0.3-2.6-0.3-2.1 0-3.6 0.7-4.4 2-0.4 0.7-0.5 1.2-0.5 2.1 0 1.3 0.4 2.1 1.3 3.1 0.5 0.4 1.1 0.8 2.9 1.7 1.8 0.9 2.2 1.4 2.1 2.3q-0.1 0.8-0.8 1.1c-0.3 0.2-0.5 0.2-1.4 0.2-1.3 0-2-0.2-4.2-1.1-0.1 0-0.1 0.3-0.1 1.4v1.5l0.5 0.2c1.2 0.5 2.2 0.6 3.9 0.6 1 0 1.4-0.1 1.9-0.2zm-9.1-1.3v-1.3h-2.7-2.6v-1.8-1.9h2.5 2.5v-1.3-1.3h-2.5-2.5v-1.6-1.7h2.6 2.7v-1.2c0-0.7 0-1.3 0-1.3 0 0-2 0-4.3 0h-4.2v7.4 7.4l4.3-0.1h4.2zm18.9-1.3v-2.6l1.5-0.1c1.6 0 2.3-0.2 3.2-0.6 0.7-0.4 1.5-1.2 1.8-1.8 0.5-0.9 0.7-2.6 0.4-3.8-0.3-1.1-1-2-2-2.5-1.2-0.6-1.8-0.7-5.2-0.7l-2.8-0.1v7.4 7.5h1.5 1.6z" />
<path class="b"
d="m33.3 10.2v-2.2h0.7c2 0 2.8 0.4 3.1 1.6 0.1 0.4 0.1 1.2-0.1 1.6-0.3 0.5-0.9 0.9-1.6 1.1-0.2 0-0.8 0.1-1.2 0.1h-0.9z" />
</svg>

After

Width:  |  Height:  |  Size: 5.8 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 182 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 242 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 17 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.2 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 104 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 68 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 77 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 123 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 124 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 157 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 231 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 192 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 23 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 62 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 118 KiB

View File

@@ -0,0 +1 @@
<svg width="752pt" height="1408pt" viewBox="0 0 752 1408" xmlns="http://www.w3.org/2000/svg"><path fill="#888b94" d="M107.77 0h528.71c17.3 1.49 34.61 5.38 50.12 13.37 17.57 8.72 32.62 22.13 44.12 37.95 10.87 15 17.82 32.63 21.28 50.78v1204.22c-5.62 31.38-23.45 60.63-49.71 78.92-19.12 14.09-42.69 21.1-66.2 22.76H106.6c-18.76-2.28-37.44-7.76-53.22-18.4-15.3-9.66-28.12-23.02-37.77-38.29-7.76-12.35-12.48-26.33-15.61-40.5V96.78C2.81 84.89 6.53 73.11 12.48 62.4 26.44 36.77 50.02 16.6 77.44 6.66 87.2 3.1 97.48 1.18 107.77 0m.71 17.65c-14.96 1.63-29.39 6.86-42.34 14.46-30.82 18.54-51.06 53.76-50.61 89.84-.16 53.35-.02 106.7-.07 160.05.02 334.31-.01 668.63.01 1002.94-.25 15.23 3.03 30.43 9.27 44.31 5.58 13.35 14.91 24.78 25.28 34.72 13.19 12.28 29.89 20.4 47.32 24.62 11.02 2.67 22.44 2.71 33.71 2.73 164-.08 328-.06 492-.06 12.32.13 24.79-.83 36.59-4.6 24.31-7.14 45.99-23.09 59.44-44.62 11.24-17.8 16.97-39.01 16.22-60.05-.11-381.66 0-763.33-.06-1144.99-.09-9.1.43-18.25-.77-27.3-3.4-31.62-22.54-60.95-49.83-77.18-17-10.27-36.79-15.57-56.62-15.71-166.67-.09-333.34.03-500.01-.02-6.52-.1-13.05.18-19.53.86z"/><path fill="#cad3d4" d="M108.48 17.65c6.48-.68 13.01-.96 19.53-.86 166.67.05 333.34-.07 500.01.02 19.83.14 39.62 5.44 56.62 15.71 27.29 16.23 46.43 45.56 49.83 77.18 1.2 9.05.68 18.2.77 27.3.06 381.66-.05 763.33.06 1144.99.75 21.04-4.98 42.25-16.22 60.05-13.45 21.53-35.13 37.48-59.44 44.62-11.8 3.77-24.27 4.73-36.59 4.6-164 0-328-.02-492 .06-11.27-.02-22.69-.06-33.71-2.73-17.43-4.22-34.13-12.34-47.32-24.62-10.37-9.94-19.7-21.37-25.28-34.72-6.24-13.88-9.52-29.08-9.27-44.31-.02-334.31.01-668.63-.01-1002.94.05-53.35-.09-106.7.07-160.05-.45-36.08 19.79-71.3 50.61-89.84 12.95-7.6 27.38-12.83 42.34-14.46m-47.99 108.3c-.05 371.96-.07 743.94.01 1115.9 44.14-.37 88.29-.03 132.44-.15 165.88-.04 331.76.15 497.63-.1-.21-49.19-.05-98.38-.08-147.56-.03-322.66 0-645.32-.01-967.98-90.81.04-181.63-.11-272.43-.03-119.19-.17-238.37.17-357.56-.08M368.3 1263.34c-20.49 2.64-37.11 21.1-38.3 41.6-1.48 17.69 8.72 35.64 24.62 43.53 15.67 8.3 36.04 6.17 49.75-5.07 10.84-8.23 16.75-21.96 16.81-35.41.15-14.48-7.28-28.68-19.09-37.01-9.68-6.82-22.14-9.42-33.79-7.64z"/><path fill="#4e4e54" d="M60.49 125.95c119.19.25 238.37-.09 357.56.08 90.8-.08 181.62.07 272.43.03.01 322.66-.02 645.32.01 967.98.03 49.18-.13 98.37.08 147.56-165.87.25-331.75.06-497.63.1-44.15.12-88.3-.22-132.44.15-.08-371.96-.06-743.94-.01-1115.9m7.93 8.21c.31 12.63.09 25.25.04 37.88.02 199.66-.03 399.32.03 598.98-.08 154.25.1 308.51-.1 462.77 151.55.16 303.11.04 454.66.02 53.17-.17 106.35.26 159.52-.13.13-268.56.03-537.13.07-805.7-.08-97.86.13-195.73-.12-293.59-1.15-1.06-3.05-.35-4.47-.59-89.36.71-178.7-.06-268.03.26-109.02 0-218.04.13-327.05-.08-4.85-.16-9.72-.34-14.55.18z"/><path fill="#747474" d="M68.42 134.16c4.83-.52 9.7-.34 14.55-.18 109.01.21 218.03.08 327.05.08 89.33-.32 178.67.45 268.03-.26 1.42.24 3.32-.47 4.47.59.25 97.86.04 195.73.12 293.59-.04 268.57.06 537.14-.07 805.7-53.17.39-106.35-.04-159.52.13-151.55.02-303.11.14-454.66-.02.2-154.26.02-308.52.1-462.77-.06-199.66-.01-399.32-.03-598.98.05-12.63.27-25.25-.04-37.88z"/><path fill="#b1b9bb" d="M368.3 1263.34c11.65-1.78 24.11.82 33.79 7.64 11.81 8.33 19.24 22.53 19.09 37.01-.06 13.45-5.97 27.18-16.81 35.41-13.71 11.24-34.08 13.37-49.75 5.07-15.9-7.89-26.1-25.84-24.62-43.53 1.19-20.5 17.81-38.96 38.3-41.6z"/></svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 419 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 247 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 81 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 70 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 102 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 22 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 58 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 64 KiB

View File

@@ -0,0 +1,28 @@
<svg version="1.2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 405" width="100%" height="100%">
<style>
.a { fill: #fff }
</style>
<path class="a"
d="m206.6 22.5c8.7 0 17.4 0.6 26 2 8.5 1.3 17 3.2 25.3 5.8 8.3 2.5 16.4 5.7 24.2 9.5 7.9 3.8 15.4 8.1 22.6 13l13.8-18.6c-8.3-5.4-16.9-10.2-25.9-14.4-8.9-4.3-18.2-7.8-27.6-10.7-9.5-2.9-19.1-5.1-28.9-6.6-9.7-1.6-19.6-2.4-29.5-2.5-9.9 0.1-19.7 0.9-29.5 2.5-9.8 1.5-19.4 3.7-28.9 6.6-9.4 2.9-18.6 6.5-27.6 10.7-8.9 4.2-17.6 9-25.8 14.4l13.7 18.6c7.2-4.9 14.8-9.2 22.6-13 7.8-3.8 15.9-7 24.2-9.5 8.3-2.6 16.8-4.5 25.4-5.8 8.5-1.4 17.2-2 25.9-2z" />
<path class="a"
d="m260 116.6l8.2-11.1q-6.8-4.3-14.1-7.7-7.4-3.4-15.1-5.7-7.7-2.4-15.7-3.7-7.9-1.3-16-1.5h-1.2-0.1q-8.1 0.2-16.1 1.5-7.9 1.3-15.7 3.7-7.7 2.3-15 5.7-7.4 3.4-14.2 7.7l8.3 11.1q6-3.8 12.4-6.7 6.4-2.9 13.2-4.9 6.7-2 13.7-3.1 7-1.2 14-1.4 7.1 0.2 14 1.4 7 1.1 13.7 3.1 6.8 2 13.2 4.9 6.5 2.9 12.5 6.7z" />
<path class="a"
d="m206.6 43.9q-11.4 0.2-22.7 1.9-11.3 1.8-22.3 5.2-10.9 3.3-21.3 8.2-10.3 4.8-19.9 11l10.9 15c11.1-7.3 23.2-13 36-16.9 12.7-3.8 26-5.8 39.3-5.8 13.3 0 26.6 2 39.3 5.8 12.8 3.9 24.9 9.6 36 16.9l10.9-15q-9.6-6.2-19.9-11-10.4-4.9-21.3-8.2-11-3.4-22.3-5.2-11.2-1.7-22.7-1.9z" />
<path class="a"
d="m206.6 379.9c-8.7 0-17.4-0.6-25.9-1.9-8.6-1.3-17.1-3.3-25.4-5.9-8.3-2.5-16.4-5.7-24.2-9.5-7.8-3.8-15.4-8.1-22.6-13l-13.7 18.6c8.2 5.4 16.9 10.3 25.8 14.5 9 4.2 18.2 7.7 27.6 10.6 9.5 2.9 19.1 5.1 28.9 6.7 9.8 1.5 19.6 2.3 29.5 2.4 9.9-0.1 19.8-0.9 29.5-2.4 9.8-1.6 19.4-3.8 28.9-6.7 9.4-2.9 18.7-6.4 27.6-10.6 9-4.2 17.6-9.1 25.9-14.5l-13.8-18.6c-7.2 4.9-14.7 9.2-22.6 13-7.8 3.8-15.9 7-24.2 9.5-8.3 2.6-16.8 4.6-25.3 5.9-8.6 1.3-17.3 1.9-26 1.9z" />
<path class="a"
d="m153.3 285.9l-8.3 11q6.8 4.4 14.2 7.7 7.3 3.4 15 5.7 7.7 2.4 15.7 3.7 8 1.3 16 1.5h0.1 1.2q8.1-0.2 16.1-1.5 8-1.3 15.7-3.7 7.7-2.3 15.1-5.7 7.3-3.3 14.1-7.7l-8.2-11q-6 3.7-12.5 6.6-6.4 2.9-13.1 4.9-6.8 2-13.8 3.2-6.9 1.1-14 1.3-7-0.2-14-1.3-7-1.2-13.7-3.2-6.8-2-13.2-4.9-6.4-2.9-12.4-6.6z" />
<path class="a"
d="m120.4 332.2q9.6 6.3 19.9 11.1 10.4 4.8 21.3 8.2 11 3.3 22.3 5.1 11.3 1.8 22.7 2 11.5-0.2 22.7-2 11.3-1.8 22.3-5.1 10.9-3.4 21.3-8.2 10.3-4.8 19.9-11.1l-10.9-15c-11.1 7.4-23.2 13.1-36 16.9-12.7 3.9-26 5.8-39.3 5.8-13.3 0-26.6-1.9-39.3-5.8-12.8-3.8-24.9-9.5-36-16.9z" />
<path fill-rule="evenodd" class="a" d="m118.8 231.6h-72.2v-146.5h-46.6v185.3h118.8v-38.8z" />
<path class="a"
d="m445.2 188c3.5-2.4 6.7-5.2 9.5-8.4 2.8-3.3 5.1-6.8 7-10.7 1.9-3.8 3.3-7.8 4.1-12 0.8-4.2 1.1-8.5 0.9-12.7 0.3-4.2 0.1-8.4-0.5-12.6-0.7-4.1-1.8-8.2-3.4-12-1.6-3.9-3.6-7.6-6-11.1-2.4-3.4-5.2-6.5-8.3-9.3q-5-3.7-10.5-6.5-5.6-2.8-11.5-4.6-6-1.8-12.2-2.5-6.1-0.7-12.3-0.4h-94.5v185h46v-66.5h29.5l36.3 66.5h51.7l-42.3-74.2q2.2-0.6 4.3-1.4 2.2-0.8 4.3-1.8 2-1.1 4-2.2 2-1.2 3.9-2.6zm-47.1-18.3h-44.6v-50.5h42c3.4-0.4 6.9 0 10.1 1.1 3.3 1.1 6.2 3 8.6 5.4 2.4 2.4 4.3 5.4 5.4 8.6 1.1 3.2 1.5 6.7 1.1 10.1 0.4 3.2 0.1 6.4-0.8 9.5-1 3.1-2.6 5.9-4.7 8.3-2.2 2.4-4.8 4.3-7.7 5.6-3 1.3-6.2 1.9-9.4 1.9z" />
<path class="a"
d="m206.7 274c29 0 73.5-13.8 73.5-74.1 0-60.2-48.8-71.5-73.5-71.5-29.7 0-73.7 14.8-73.7 72.8 0 58 44.7 72.8 73.7 72.8zm-0.4-114.7c14.6 0 33.3 7.9 33.3 41.3 0 33.3-16.4 42.6-32.5 42.6-16.2 0-33.4-10.2-33.4-42 0-31.7 17.1-41.9 32.6-41.9z" />
<path class="a"
d="m610.3 170c0-20.4-7.7-41.8-58.9-41.8-51.3 0-59.6 24.1-59.6 43.4h39c0.1-2.7 0.7-5.3 1.8-7.7 1.2-2.5 2.8-4.6 4.8-6.4 2-1.7 4.4-3 7-3.8 2.5-0.7 5.2-1 7.9-0.6 8.2 0 18.6 2.9 18.6 13 0 10-0.2 13.1-0.2 13.1-78.9 9.7-85.5 30-85.5 55.7 0 25.7 23.7 39.2 45.6 39.2 4 0.2 8 0 12-0.8 3.9-0.7 7.7-1.8 11.4-3.4 3.7-1.6 7.1-3.7 10.3-6.1 3.2-2.4 6.1-5.2 8.7-8.2q0.2 1.8 0.4 3.7 0.3 1.8 0.7 3.6 0.4 1.9 0.8 3.7 0.5 1.8 1.1 3.5h37.4q-0.9-2.5-1.6-5.1-0.6-2.6-1-5.2-0.4-2.6-0.6-5.3-0.2-2.7-0.1-5.3zm-40 46.8c0.1 3.8-0.6 7.4-2 10.9-1.4 3.4-3.5 6.5-6.2 9.1-2.6 2.6-5.8 4.6-9.3 6-3.4 1.3-7.1 1.8-10.9 1.7-2.3 0.3-4.6 0.1-6.8-0.6-2.2-0.7-4.3-1.9-6-3.5-1.7-1.5-3-3.5-3.8-5.7-0.9-2.1-1.2-4.5-1-6.8 0-3.8 3.1-13.2 21.5-17.8q3.1-0.7 6.2-1.5 3.1-0.8 6.1-1.7 3.1-0.9 6.1-1.9 3.1-1 6-2.1z" />
<path class="a"
d="m629.7 97.8v-0.1q0.6-0.1 1-0.4 0.5-0.2 0.9-0.6 0.3-0.5 0.5-0.9 0.2-0.5 0.3-1.1 0-0.4-0.1-0.8 0-0.4-0.2-0.7-0.2-0.4-0.4-0.7-0.3-0.3-0.6-0.6-0.5-0.3-1-0.5-0.5-0.2-1.1-0.3-0.6-0.1-1.2-0.2-0.5 0-1.1 0.1-0.6-0.1-1.2 0-0.7 0-1.3 0-0.6 0.1-1.2 0.2-0.6 0-1.2 0.1v12.8h3v-5.1h1.4c1.6 0 2.4 0.6 2.6 2q0.1 0.4 0.2 0.8 0.1 0.4 0.2 0.8 0.1 0.4 0.3 0.8 0.1 0.4 0.3 0.7h3.2q-0.2-0.3-0.3-0.7-0.2-0.4-0.3-0.8-0.1-0.4-0.2-0.8-0.1-0.4-0.1-0.9-0.1-0.5-0.3-1-0.2-0.4-0.5-0.8-0.3-0.5-0.7-0.8-0.4-0.3-0.9-0.5zm-3.5-0.9h-1.4v-3.7q0.2 0 0.4 0 0.2-0.1 0.4-0.1 0.2 0 0.5 0 0.2 0 0.4 0c1.8 0 2.7 0.8 2.7 1.9 0 1.4-1.3 1.9-3 1.9z" />
<path class="a"
d="m626.8 84.7c-5.2 0-9.8 3.3-11.8 8.1-1.9 4.8-0.7 10.3 3 13.9 3.7 3.7 9.3 4.7 14 2.7 4.8-2.1 7.9-6.8 7.9-11.9-0.1-1.7-0.4-3.4-1.1-5-0.6-1.6-1.6-3-2.8-4.2-1.2-1.2-2.7-2.1-4.3-2.7-1.5-0.7-3.2-1-4.9-0.9zm0 23c-4.1 0.2-8-2.2-9.8-6-1.7-3.8-0.9-8.3 2-11.3 2.9-3.1 7.3-4 11.2-2.4 3.9 1.5 6.4 5.3 6.4 9.5 0 1.3-0.2 2.6-0.7 3.9-0.4 1.2-1.1 2.3-2 3.3-1 0.9-2 1.7-3.3 2.2-1.2 0.5-2.5 0.8-3.8 0.8z" />
</svg>

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 134 15" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="M3.277,0.053C2.829,0.053 2.401,0.41 2.321,0.851L0.013,13.623C-0.067,14.064 0.232,14.421 0.681,14.421L3.13,14.421C3.578,14.421 4.006,14.064 4.086,13.623L5.004,8.54L6.684,13.957C6.766,14.239 7.02,14.421 7.337,14.421L10.58,14.421C10.897,14.421 11.217,14.239 11.401,13.957L15.043,8.513L14.119,13.623C14.038,14.064 14.338,14.421 14.787,14.421L17.236,14.421C17.684,14.421 18.112,14.064 18.192,13.623L20.5,0.851C20.582,0.41 20.283,0.053 19.834,0.053L16.69,0.053C16.373,0.053 16.053,0.235 15.87,0.517L9.897,9.473C9.803,9.616 9.578,9.578 9.528,9.41L7.074,0.517C6.992,0.235 6.738,0.053 6.421,0.053L3.277,0.053Z" style="fill:white;fill-rule:nonzero;"/>
<path d="M21.146,14.421C21.146,14.421 33.257,14.421 33.257,14.421C33.526,14.421 33.784,14.205 33.831,13.942L34.337,11.128C34.385,10.863 34.206,10.649 33.936,10.649L25.519,10.649C25.429,10.649 25.37,10.576 25.385,10.488L25.635,9.105C25.65,9.017 25.736,8.944 25.826,8.944L32.596,8.944C32.865,8.944 33.123,8.728 33.171,8.465L33.621,5.974C33.669,5.709 33.49,5.495 33.221,5.495L26.45,5.495C26.361,5.495 26.301,5.423 26.317,5.335L26.584,3.852C26.599,3.764 26.685,3.691 26.775,3.691L35.192,3.691C35.462,3.691 35.719,3.476 35.767,3.21L36.258,0.498C36.306,0.235 36.126,0.019 35.857,0.019L23.746,0.019C23.297,0.019 22.867,0.378 22.788,0.819L20.474,13.621C20.396,14.062 20.695,14.421 21.146,14.421Z" style="fill:white;fill-rule:nonzero;"/>
<path d="M45.926,14.419L45.926,14.421L46.346,14.421C48.453,14.421 50.465,12.742 50.839,10.67L51.081,9.327C51.456,7.256 50.05,5.576 47.943,5.576L41.455,5.576C41.186,5.576 41.007,5.363 41.054,5.097L41.218,4.192C41.266,3.927 41.524,3.713 41.793,3.713L50.569,3.713C51.018,3.713 51.446,3.356 51.526,2.915L51.9,0.85C51.98,0.407 51.68,0.05 51.232,0.05L41.638,0.05C39.531,0.05 37.519,1.73 37.145,3.801L36.88,5.267C36.505,7.339 37.91,9.018 40.018,9.018L46.506,9.018C46.775,9.018 46.954,9.231 46.907,9.497L46.785,10.176C46.737,10.441 46.479,10.655 46.21,10.655L37.189,10.655C36.741,10.655 36.313,11.012 36.233,11.453L35.841,13.621C35.761,14.062 36.061,14.419 36.51,14.419L45.926,14.419Z" style="fill:white;fill-rule:nonzero;"/>
<path d="M68.008,0.046C68.008,0.046 65.296,0.046 65.296,0.046C64.847,0.046 64.42,0.403 64.34,0.844L63.532,5.31C63.517,5.398 63.431,5.469 63.341,5.469L58.085,5.469C57.995,5.469 57.936,5.398 57.951,5.31L58.758,0.844C58.837,0.403 58.539,0.046 58.09,0.046L55.378,0.046C54.93,0.046 54.502,0.403 54.422,0.844L52.112,13.623C52.032,14.064 52.331,14.421 52.78,14.421L55.492,14.421C55.941,14.421 56.369,14.064 56.449,13.623L57.272,9.074C57.287,8.986 57.373,8.914 57.462,8.914L62.719,8.914C62.809,8.914 62.868,8.985 62.853,9.074L62.032,13.623C61.952,14.064 62.252,14.421 62.7,14.421L65.413,14.421C65.861,14.421 66.289,14.064 66.369,13.623L68.678,0.844C68.755,0.403 68.457,0.046 68.008,0.046Z" style="fill:white;fill-rule:nonzero;"/>
<path d="M72.099,14.421C72.099,14.421 80.066,14.421 80.066,14.421C80.515,14.421 80.943,14.064 81.022,13.623L81.414,11.453C81.494,11.012 81.194,10.655 80.746,10.655L73.828,10.655C73.559,10.655 73.38,10.441 73.427,10.176L74.51,4.215C74.558,3.951 74.815,3.736 75.082,3.736L82,3.736C82.448,3.736 82.876,3.379 82.956,2.938L83.34,0.817C83.42,0.376 83.12,0.019 82.672,0.019L74.724,0.019C72.622,0.019 70.614,1.691 70.236,3.757L68.965,10.665C68.587,12.738 69.99,14.421 72.099,14.421Z" style="fill:white;fill-rule:nonzero;"/>
<path d="M97.176,-0C97.176,0 88.882,0 88.882,0C86.775,0 84.763,1.68 84.389,3.751L83.139,10.67C82.765,12.741 84.169,14.421 86.277,14.421L94.571,14.421C96.678,14.421 98.69,12.741 99.064,10.67L100.314,3.751C100.689,1.68 99.284,-0 97.176,-0ZM94.798,10.178C94.75,10.443 94.492,10.657 94.223,10.657L87.978,10.657C87.709,10.657 87.529,10.443 87.577,10.178L88.659,4.192C88.707,3.927 88.964,3.713 89.234,3.713L95.477,3.713C95.747,3.713 95.926,3.927 95.878,4.192L94.798,10.178Z" style="fill:white;fill-rule:nonzero;"/>
<path d="M101.284,14.421L103.995,14.421C104.443,14.421 104.871,14.065 104.951,13.624L105.43,10.97C105.446,10.882 105.531,10.81 105.621,10.81L108.902,10.806C109.064,10.806 109.2,10.886 109.267,11.018L110.813,14.035C110.992,14.392 111.319,14.434 112.303,14.419C112.88,14.426 113.756,14.382 115.169,14.382C115.623,14.382 115.902,13.907 115.678,13.51L113.989,10.569C113.945,10.491 113.993,10.386 114.086,10.34C115.39,9.707 116.423,8.477 116.681,7.055L117.27,3.785C117.646,1.713 116.242,0.033 114.134,0.033L103.884,0.033C103.436,0.033 103.008,0.39 102.928,0.831L100.616,13.623C100.536,14.064 100.836,14.421 101.284,14.421L101.284,14.421ZM106.73,3.791C106.745,3.703 106.831,3.631 106.921,3.631L112.225,3.631C112.626,3.631 112.891,3.949 112.821,4.343L112.431,6.494C112.359,6.885 111.979,7.204 111.58,7.204L106.276,7.204C106.186,7.204 106.127,7.133 106.142,7.043L106.73,3.791Z" style="fill:white;fill-rule:nonzero;"/>
<path d="M118.277,14.421C118.277,14.421 130.388,14.421 130.388,14.421C130.657,14.421 130.915,14.205 130.963,13.942L131.468,11.128C131.516,10.863 131.337,10.649 131.068,10.649L122.65,10.649C122.56,10.649 122.501,10.576 122.516,10.488L122.766,9.105C122.781,9.017 122.867,8.944 122.957,8.944L129.728,8.944C129.997,8.944 130.254,8.728 130.302,8.465L130.753,5.974C130.801,5.709 130.621,5.495 130.352,5.495L123.581,5.495C123.492,5.495 123.432,5.423 123.448,5.335L123.715,3.852C123.73,3.764 123.816,3.691 123.906,3.691L132.324,3.691C132.593,3.691 132.851,3.476 132.898,3.21L133.389,0.498C133.437,0.235 133.257,0.019 132.988,0.019L120.877,0.019C120.428,0.019 119.999,0.378 119.919,0.819L117.605,13.621C117.527,14.062 117.827,14.421 118.277,14.421Z" style="fill:white;fill-rule:nonzero;"/>
</svg>

After

Width:  |  Height:  |  Size: 5.9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -0,0 +1 @@
<svg version="1.2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 342 300" width="342" height="300"><style>.a{fill:none}.b{opacity:.6;fill:#00a9ce}.c{opacity:.8;fill:#00a9ce}.d{opacity:.5;fill:#00a9ce}.e{fill:#00a9ce}</style><path class="a" d=""/><path class="b" d="m243.6 1.9c0 0-0.5 0 0 0-1.4 0.9-1.9 1.4-2.8 1.4l-72.6 41.7 84.3 48.8v-93.8c-2.8 0-6.1 0.9-8.9 1.9z"/><path class="c" d="m95.1 295.8l76.8-44.5-84.3-48.8v95.2c2.4 0.4 4.7-0.5 7.5-1.9z"/><path class="d" d="m338.7 246.1l-172.9-99.9-7.9-4.6-156-90.5c-1 2.8-1.9 5.6-1.9 8.9 0 0.5 0 1.4 0 1.9v179c0 5.7 3.3 10.8 8 13.6l71.2 41.8c2.3 1.4 5.1 1.8 7.9 1.8v-91.4-3.7l161.7 93.3c0.4 0.4 0.9 0.4 1.4 0.4h0.4c4.7 1.9 8.5 0.5 12.7-2.3l27.2-15.5 43.1-25.8c2.3-1.4 4.2-4.2 5.1-7z"/><path class="e" d="m339.7 128v-68c0-8.9-4.7-16.9-12.2-21.1l-62.8-36.1c-3.7-1.9-8-2.8-12.2-2.8v94.2l-141-82-15.9-8.9c-7.1-3.8-13.1-4.2-20.2-1 0 0-0.4 0-0.4 0.5-1 0.5-1.4 1-2.4 1.4l-24.8 14.5-35.6 20.7c-4.7 2.8-8.5 7-10.8 12.2l156 90 8 4.6 172.9 99.9c0.9-3.3 0.9-8.9 0.9-8.9v-109.2z"/></svg>

After

Width:  |  Height:  |  Size: 1022 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 145 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 134 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.5 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 310 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 10 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 105 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 58 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 41 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 17 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 42 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 22 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 28 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 46 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 62 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 78 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 81 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

433
broker.go Normal file
View File

@@ -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])
}
}

View File

@@ -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")
}

View File

@@ -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")
}

View File

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

View File

@@ -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()
}

View File

@@ -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()
}

View File

@@ -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
}

400
collector.go Normal file
View File

@@ -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
}
}
}
}

39
go.mod Normal file
View File

@@ -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
)

113
go.sum Normal file
View File

@@ -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=

27
internal/cmd/all.go Normal file
View File

@@ -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
}

80
internal/cmd/config.go Normal file
View File

@@ -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
}

69
internal/cmd/logger.go Normal file
View File

@@ -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
}

185
meshcore.go Normal file
View File

@@ -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
}

11
radio.go Normal file
View File

@@ -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"`
}

423
server.go Normal file
View File

@@ -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
}

236
sql.go Normal file
View File

@@ -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
);
`
)

20
util.go Normal file
View File

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