18 Commits

Author SHA1 Message Date
d2e710d179 /meshcore/nodes: update table
Some checks failed
Test and build / Test and lint (push) Failing after 35s
Test and build / Build collector (push) Failing after 35s
Test and build / Build receiver (push) Failing after 35s
2026-03-08 22:56:19 +01:00
9053ec65a6 Checkpoint 2026-03-08 22:22:51 +01:00
247c827291 Added SNR and refactored types
Some checks failed
Test and build / Test and lint (push) Failing after 35s
Test and build / Build collector (push) Failing after 37s
Test and build / Build receiver (push) Failing after 37s
2026-03-06 09:06:08 +01:00
e83df1c143 More APRS enhancements
Some checks failed
Test and build / Test and lint (push) Failing after 35s
Test and build / Build collector (push) Failing after 36s
Test and build / Build receiver (push) Failing after 36s
2026-03-05 22:24:09 +01:00
7a8d7b0275 Added received time and APRS API
Some checks failed
Test and build / Test and lint (push) Failing after 35s
Test and build / Build collector (push) Failing after 34s
Test and build / Build receiver (push) Failing after 35s
2026-03-05 16:11:24 +01:00
13afa08e8a Checkpoint
Some checks failed
Test and build / Test and lint (push) Failing after 36s
Test and build / Build collector (push) Failing after 43s
Test and build / Build receiver (push) Failing after 42s
2026-03-05 15:38:18 +01:00
3106b2cf45 Add bash
All checks were successful
Test and build / Test and lint (push) Successful in 1m14s
Test and build / Build collector (push) Successful in 1m32s
Test and build / Build receiver (push) Successful in 1m48s
2026-02-24 15:51:31 +01:00
95eb917871 Fix entrypoint
All checks were successful
Test and build / Test and lint (push) Successful in 1m12s
Test and build / Build collector (push) Successful in 1m32s
Test and build / Build receiver (push) Successful in 1m48s
2026-02-24 15:40:04 +01:00
da9dfda207 Installed shellcheck 2026-02-24 15:39:03 +01:00
42ca30c8e4 Allow override CONFIG and FLAGS
All checks were successful
Test and build / Test and lint (push) Successful in 1m15s
Test and build / Build collector (push) Successful in 1m32s
Test and build / Build receiver (push) Successful in 1m48s
2026-02-24 15:31:40 +01:00
6d716e6654 Typofix
All checks were successful
Test and build / Test and lint (push) Successful in 1m13s
Test and build / Build collector (push) Successful in 1m30s
Test and build / Build receiver (push) Successful in 1m49s
2026-02-23 22:02:28 +01:00
41ba6b3142 Revert: Force HTTP
All checks were successful
Test and build / Test and lint (push) Successful in 1m43s
Test and build / Build collector (push) Successful in 3m29s
Test and build / Build receiver (push) Successful in 3m42s
2026-02-23 21:45:32 +01:00
214b013ea7 Force HTTP
Some checks failed
Test and build / Test and lint (push) Successful in 48s
Test and build / Build collector (push) Failing after 59s
Test and build / Build receiver (push) Failing after 1m14s
2026-02-23 21:41:04 +01:00
4b29396549 Force HTTP
Some checks failed
Test and build / Build collector (push) Failing after 59s
Test and build / Test and lint (push) Successful in 1m14s
Test and build / Build receiver (push) Failing after 1m14s
2026-02-23 21:32:26 +01:00
5495fae127 Downgrade docker/* actions
Some checks failed
Test and build / Test and lint (push) Successful in 1m12s
Test and build / Build collector (push) Failing after 2m14s
Test and build / Build receiver (push) Failing after 2m28s
2026-02-23 21:28:59 +01:00
dc2e7f4563 Refactor
Some checks failed
Test and build / Build collector (push) Failing after 59s
Test and build / Test and lint (push) Successful in 1m14s
Test and build / Build receiver (push) Failing after 1m14s
2026-02-23 21:26:53 +01:00
f234497162 Refactor
Some checks failed
Test and build / Test and lint (push) Successful in 1m12s
Test and build / Build collector (push) Failing after 1m15s
Test and build / Build receiver (push) Failing after 1m29s
2026-02-23 21:21:23 +01:00
227477d17f Re-added hamview-receiver
Some checks failed
Test and build / Build receiver (push) Failing after 45s
Test and build / test (push) Successful in 59s
Test and build / Build collector (push) Failing after 1m16s
2026-02-23 21:15:48 +01:00
229 changed files with 30331 additions and 767 deletions

58
.air.toml Normal file
View File

@@ -0,0 +1,58 @@
#:schema https://json.schemastore.org/any.json
env_files = []
root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"
[build]
args_bin = []
bin = "./tmp/hamview-server"
cmd = "go build -o ./tmp/hamview-server ./cmd/hamview-server"
delay = 1000
entrypoint = ["./tmp/hamview-server", "-T", "--config", "./etc/hamview-server.yaml"]
exclude_dir = ["assets", "tmp", "vendor", "testdata", "ui"]
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
ignore_dangerous_root_dir = false
include_dir = []
include_ext = ["go", "tpl", "tmpl", "html"]
include_file = []
kill_delay = "0s"
log = "build-errors.log"
poll = false
poll_interval = 0
post_cmd = []
pre_cmd = []
rerun = false
rerun_delay = 500
send_interrupt = false
stop_on_error = false
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
main_only = false
silent = false
time = false
[misc]
clean_on_exit = false
[proxy]
app_port = 0
app_start_timeout = 0
enabled = false
proxy_port = 0
[screen]
clear_on_rebuild = false
keep_scroll = true

View File

@@ -7,39 +7,52 @@ permissions:
jobs:
test:
name: Test and lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
- name: Checkout
uses: actions/checkout@v6
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
check-latest: false
#- name: golangci-lint
# uses: golangci/golangci-lint-action@v9
# with:
# go-version: ${{ matrix.go }}
# version: v2.6
- name: Test
run: go test -v ./...
- name: Lint
uses: golangci/golangci-lint-action@v9
with:
go-version: ${{ matrix.go }}
version: v2.6
if: ${{ github.actor != 'maze' }}
build_collector:
name: Build collector
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
- name: Checkout
uses: actions/checkout@v6
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
check-latest: false
- name: Download modules
run: go mod download
- name: Make build directory
run: mkdir -p build && readlink -f build
run: mkdir -p build
- name: Build
run: go build -o build/hamview-collector ./cmd/hamview-collector && ls -al $(readlink -f build/hamview-collector)
- name: Debug env
run: env
run: |
go build -o build/hamview-collector ./cmd/hamview-collector && \
ls -al $(readlink -f build/hamview-collector)
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log into Container Registry
- name: Log in to Docker
uses: docker/login-action@v3
with:
username: ${{ vars.DOCKER_USER }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Log in to Gitea
uses: docker/login-action@v3
with:
registry: ${{ vars.REGISTRY }}
@@ -49,7 +62,7 @@ jobs:
uses: docker/build-push-action@v6
with:
push: true
tags: ham/hamview:collector
tags: ${{ vars.REGISTRY }}/ham/hamview/collector:dev
file: ./cmd/hamview-collector/Dockerfile
context: .
platforms: |
@@ -58,40 +71,52 @@ jobs:
build_receiver:
name: Build receiver
runs-on: ubuntu-latest
strategy:
matrix:
include:
- goos: linux
goarch: amd64
goarm: ""
- goos: linux
goarch: arm
goarm: "6"
- goos: linux
goarch: arm
goarm: "7"
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
- name: Checkout
uses: actions/checkout@v6
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
check-latest: false
- name: Set target variables
run: |
echo "GOOS=${{ matrix.goos }}" >> $GITHUB_ENV
echo "GOARCH=${{ matrix.goarch }}" >> $GITHUB_ENV
if [ -n "${{ matrix.goarm }}" ]; then
echo "GOARM=${{ matrix.goarm }}" >> $GITHUB_ENV
fi
- name: Download modules
run: go mod download
- name: Make build directory
run: mkdir -p build && readlink -f build
- name: Build
run: go build -o build/hamview-receiver.${{ matrix.goarch }}${{ matrix.goarm }} ./cmd/hamview-receiver && readlink -f build/hamview-receiver.${{ matrix.goarch }}${{ matrix.goarm }}
- name: Build amd64
env:
GOOS: linux
GOARCH: amd64
run: |
GOOS=$GOOS GOARCH=$GOARCH \
go build -o build/hamview-receiver-$GOARCH ./cmd/hamview-receiver && \
readlink -f build/hamview-receiver-$GOARCH
- name: Build arm6
env:
GOOS: linux
GOARCH: arm
GOARM: 6
run: |
GOOS=$GOOS GOARCH=$GOARCH GOARM=$GOARM \
go build -o build/hamview-receiver-$GOARCH$GOARM ./cmd/hamview-receiver && \
readlink -f build/hamview-receiver-$GOARCH$GOARM
- name: Build arm7
env:
GOOS: linux
GOARCH: arm
GOARM: 7
run: |
GOOS=$GOOS GOARCH=$GOARCH GOARM=$GOARM \
go build -o build/hamview-receiver-$GOARCH$GOARM ./cmd/hamview-receiver && \
readlink -f build/hamview-receiver-$GOARCH$GOARM
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log into Container Registry
- name: Log in to Docker
uses: docker/login-action@v3
with:
username: ${{ vars.DOCKER_USER }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Log in to Gitea
uses: docker/login-action@v3
with:
registry: ${{ vars.REGISTRY }}
@@ -101,7 +126,7 @@ jobs:
uses: docker/build-push-action@v6
with:
push: true
tags: ham/hamview:receiver
tags: ${{ vars.REGISTRY }}/ham/hamview/receiver:dev
file: ./cmd/hamview-receiver/Dockerfile
context: .
platforms: |

25
.gitignore vendored
View File

@@ -1,5 +1,30 @@
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
test.db
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
go.work.sum
# env file
.env
# Build artifacts
build/
tmp/
# Local configuration files
etc/*.key

0
.gitmodules vendored Normal file
View File

View File

@@ -2,11 +2,16 @@ repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: check-yaml
- id: end-of-file-fixer
- id: trailing-whitespace
- id: check-yaml
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/golangci/golangci-lint
rev: v2.10.1
hooks:
- id: golangci-lint
- id: golangci-lint
- repo: https://github.com/shellcheck-py/shellcheck-py
rev: v0.11.0.1
hooks:
- id: shellcheck

46
.vscode/settings.json vendored
View File

@@ -1,5 +1,45 @@
{
"gopls": {
"formatting.local": "git.maze.io"
}
"gopls": {
"formatting.local": "git.maze.io",
"ui.semanticTokens": true
},
// Global defaults for all other languages (4 spaces)
"editor.tabSize": 4,
"editor.insertSpaces": true,
"editor.detectIndentation": false,
// Go: Use tabs, with a tab size of 4
"[go]": {
"editor.insertSpaces": false,
"editor.tabSize": 4,
"editor.detectIndentation": false
},
// CSS, JavaScript, TypeScript, JSON: Use 2 spaces
"[css]": {
"editor.tabSize": 2,
},
"[javascript]": {
"editor.tabSize": 2,
},
"[typescript]": {
"editor.tabSize": 2,
},
"[typescriptreact]": {
"editor.tabSize": 2,
},
"[json]": {
"editor.tabSize": 2,
},
"[yaml]": {
"editor.tabSize": 2,
},
// For JSON with comments, often used in VSCode config files
"[jsonc]": {
"editor.insertSpaces": true,
"editor.tabSize": 2,
"editor.detectIndentation": false
}
}

48
AGENTS.md Normal file
View File

@@ -0,0 +1,48 @@
# AGENTS
This document provides context for AI agents working on this codebase.
## Project Overview
HAMView is an online Amateur Radio digital protocol live viewer. It features:
- Displaying online radio receivers in near real-time
- Streaming of popular Amateur Radio protocols such as APRS, MeshCore, etc.
- A live packet stream for each of the protocols
- Packet inspection
## Tech Stack
Used technologies:
- **Framework**: Go with echo 4
- **Logging**: logrus
- **Code Editor**: Visual Studio Code
- **Backend**: Xorm on PostgreSQL with PostGIS extensions
- **Broker**: Mosquitto broker
- **Testing**: use `go test -v`
## Testing Requirements
**Always run tests before completing a task.**
Run `go test -v` and `golangci-lint run`.
## Coding Guidelines
### General
- Use builtins from Go, echo and the libraries from `go.mod` where possible
- Follow existing code patterns in the code base
- Don't add new imports unless necessary
### Styling
- Use Go builtins where appropriate
- Order imports with `goimports`
## Protected files
Never add secrets to code, unless a secret is used as a test vector. In that case, ask
for confirmation before adding or changing.
## Addressing
Don't call me "the user", refer to me as "the developer".
Refrain from using hyperbolic expressions like "excellent" and "perfect", "ok" or "good" is good enough.

18
LICENSE Normal file
View File

@@ -0,0 +1,18 @@
MIT License
Copyright (c) 2026 ham
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE.

3
README.md Normal file
View File

@@ -0,0 +1,3 @@
# hamview
HAMView radio receiver

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View File

@@ -0,0 +1,423 @@
Copyright and licensing information
======================================
This is a collection of vectorized symbols for use on the APRS system.
The copyright status of this collection is a bit complicated, since the
symbols come from various sources, each having different copyright owners.
Most of the vectorized symbols are loosely based on the low-resolution
"standard" bitmap symbol set as distributed by Stephen Smith, WA8LMF. That
set is used by most APRS software around the world. The low resolution of
those symbols does not allow direct vector conversion, so I've drawn new
symbols in a similar layout. The vector versions try to mimic the original
appearance and colours, with the intention of keeping the set recognizable
and familiar to existing users. In some cases the vector versions are
probably similar enough to the originals, so that they cannot be considered
"original work" by myself. In some of these cases, the originals are
probably also mimicking someone else's design.
The original symbols do not come with any information on their licensing.
They've been distributed with a lot of APRS software over time, but I don't
know who designed which symbol originally. Most likely all of them are
drawn by one of:
* Roger Barker, G4IDE, "original set provided with UI-View" (SK)
* Steve Dimse, KH4G, "U.S. customary set"
* Stephen Smith, WA8LMF
The Adobe Illustrator (.ai) file contains a copy of the original bitmaps
as a hidden layer, just for reference.
Some symbols I obtained from other sources, such as Wikipedia. In those
cases I picked SVG versions which allow commercial reuse (source known, and
the work is placed on public domain, or with a CC license which allows
adaptation and commercial reuse).
Some symbols are vectorized versions of product or brand logos. The
copyright of those is owned by the respective companies (Apple, Microsoft,
Kenwood), and each of those may have some opinions on how the logos are
used. Please check for yourself if you can use them or not.
In the list below I try to summarize the licensing status for each symbol.
Shorthand notation for common licensing status
-------------------------------------------------
* *VEC-OH7LZB* - Vectorized by OH7LZB, based on original APRS symbol set
* Source of original bitmap: http://wa8lmf.net/aprs/APRS_symbols.htm
* Original designer of individual symbol unknown at this time, but one of:
* Roger Barker, G4IDE
* Steve Dimse, KH4G
* Stephen Smith, WA8LMF
* Vectorized versions are designed to look similar
* Licensing: Unknown
* *OH7LZB* - Original vector design by Heikki Hannikainen, OH7LZB
* Different enough (by author's opinion) to make it a new original work,
instead of a copy of the old symbol
* License: CC BY-SA 2.0
* https://creativecommons.org/licenses/by-sa/2.0/
Primary table
----------------
* /! - Police station
* VEC-OH7LZB
* /# - Digipeater / Green star with D in middle
* VEC-OH7LZB
* /$ - Telephone
* VEC-OH7LZB
* /% - DX cluster
* VEC-OH7LZB
* /& - HF gateway
* VEC-OH7LZB
* /' - Small aircraft
* https://openclipart.org/detail/27182/topdown-airplane-view
* Author: Wirelizard (Brian Burger)
* With color and some other small tuning added by OH7LZB
* PD: https://openclipart.org/share
* /( - Mobile satellite station
* OH7LZB
* /) - Wheelchair, handicapped
* PD wheelchair symbol
* Vectorized from bitmap by OH7LZB
* /* - Snowmobile
* https://openclipart.org/detail/15849/snowmobile
* Author: Mystica (https://openclipart.org/user-detail/mystica)
* PD: https://openclipart.org/share
* /+ - Red Cross
* VEC-OH7LZB
* /, - Boy Scouts
* VEC-OH7LZB
* /- - House
* VEC-OH7LZB
* /. - Red X
* VEC-OH7LZB
* // - Red dot
* VEC-OH7LZB
* /0 to /9 - Numbered circles
* VEC-OH7LZB
* Fire
* http://commons.wikimedia.org/wiki/File:FireIcon.svg
* Author: Piotr Jaworski
* PD: I, the copyright holder of this work, release this work into the public domain. This applies worldwide.
* Tent
* https://openclipart.org/detail/174933/green-tent-by-stamps-174933
* Author: stamps
* PD: https://openclipart.org/share
* Motorcycle
* http://commons.wikimedia.org/wiki/File:MUTCD_W8-15P.svg
* This file is in the public domain because it comes from the Manual on
Uniform Traffic Control Devices, sign number W8-15P, which states
specifically on page I-1 that: Any traffic control device design or
application provision contained in this Manual shall be considered to
be in the public domain. Traffic control devices contained in this
Manual shall not be protected by a patent, trademark, or copyright,
except for the Interstate Shield and any other items owned by FHWA.
* Colour version by OH7LZB
* /= - Railroad engine
* http://commons.wikimedia.org/wiki/File:Icon_train.svg
* Author: http://en.wikipedia.org/wiki/User:Richtom80
* CC-BY-SA-2.5,2.0,1.0
* /> - Car
* OH7LZB
* /? - File server
* https://openclipart.org/detail/163717/file-server-by-lyte
* Author: lyte
* PD: https://openclipart.org/share
* /@ - Hurricane predicted path
* VEC-OH7LZB
* /A - Aid station
* VEC-OH7LZB
* Mail (BBS)
* https://openclipart.org/detail/29268/yellow-mail-by-rg1024-29268
* Author: rg1024
* PD: https://openclipart.org/share
* /C - Canoe
* https://openclipart.org/detail/179047/red-canoe-by-rambo-tribble-179047
* https://openclipart.org/detail/179041/canoe-paddle-by-rambo-tribble-179041
* Author: Rambo Tribble
* PD: I, the copyright holder of this work, release this work into the public domain. This applies worldwide.
* /E - Eyeball
* http://commons.wikimedia.org/wiki/File:Blue_eye.svg
* PD: "This file is from the Open Clip Art Library, which released it explicitly into the public domain"
* PD: https://openclipart.org/share
* /F - Tractor
* https://openclipart.org/detail/191654/farm-tractor-by-tmjbeary-191654
* Author: tmjbeary
* PD: https://openclipart.org/share
* /G - Grid square, 3 by 3
* VEC-OH7LZB
* /H - Hotel
* VEC-OH7LZB
* /I - TCP/IP
* VEC-OH7LZB
* /K - School
* OH7LZB
* /L - PC user
* OH7LZB
* /M - Mac apple
* Apple
* /N - NTS
* VEC-OH7LZB
* /O - Hot air balloon
* OH7LZB
* /P - Police
* OH7LZB
* /R - RV
* OH7LZB
* /S - Space Shuttle
* https://openclipart.org/detail/814/space-shuttle-by-johnny_automatic
* PD: Published by the NASA, in "The Brain in Space"
* /T - SSTV
* https://openclipart.org/detail/48997/flat-screen-by-rg1024
* Author: rg1024
* Adjusted by OH7LZB
* PD: https://openclipart.org/share
* /U - Bus
* OH7LZB
* /V - ATV, amateur television
* https://openclipart.org/detail/48997/flat-screen-by-rg1024
* Author: rg1024
* Adjusted by OH7LZB
* PD: https://openclipart.org/share
* /W - Wx, Weather service site
* VEC-OH7LZB
* /X - Helicopter
* OH7LZB
* /Y - Sailboat
* OH7LZB
* /Z - Windows flag
* Microsoft
* /[ - Human
* VEC-OH7LZB
* /\ - DF triangle
* VEC-OH7LZB
* /] - Mailbox, post office, letter
* /^ - Large aircraft
* https://openclipart.org/detail/183204/plane-red-by-sketchartist-183204
* Author: SketchArtist
* PD: https://openclipart.org/share
* /_ - Weather station
* VEC-OH7LZB
* /` - Satellite dish
* OH7LZB
* /a - Ambulance
* OH7LZB
* /b - Bicycle
* http://commons.wikimedia.org/wiki/File:Bicycle_evolution-numbers.svg
* Author: Wikipedia user: Al2
* CC BY 3.0
* /c - Incident command post
* VEC-OH7LZB
* /d - Fire station
* VEC-OH7LZB
* /e - Horse, equestrian
* https://openclipart.org/detail/142627/horse-riding-lesson-by-olku
* Author: OlKu
* PD: https://openclipart.org/share
* /f - Fire truck
* OH7LZB
* /g - Hang glider
* OH7LZB
* /h - Hospital
* VEC-OH7LZB
* /i - IOTA, islands on the air
* http://commons.wikimedia.org/wiki/File:Palm_Island_R.svg
* PI
* /j - Jeep
* OH7LZB
* /k - Truck
* OH7LZB
* /l - Laptop
* OH7LZB
* /m - Mic-E repeater
* VEC-OH7LZB
* /n - Node, black bulls-eye
* VEC-OH7LZB
* /o - Emergency operations center
* VEC-OH7LZB
* /p - Dog(e)
* OH7LZB
* /q - Grid square, 2 by 2
* VEC-OH7LZB
* /r - Repeater tower
* OH7LZB
* /s - Ship, power boat
* OH7LZB
* /t - Truck stop
* VEC-OH7LZB
* /u - Semi-trailer truck, 18-wheeler
* OH7LZB
* /v - Van
* OH7LZB
* /w - Water station
* VEC-OH7LZB
* /x - X / Unix
* https://commons.wikimedia.org/wiki/File:X11.svg
* PD
* /y - House, yagi antenna
* VEC-OH7LZB
* /z - Shelter
* VEC-OH7LZB
Secondary table
------------------
* Emergency
* VEC-OH7LZB
* Numbered digipeater / Green star
* VEC-OH7LZB
* Bank
* VEC-OH7LZB
* Numbered gateway / Black diamond
* VEC-OH7LZB
* Crash site
* OH7LZB
* Cloudy
* OH7LZB
* MEO
* VEC-OH7LZB
* Snowflake
* http://commons.wikimedia.org/wiki/File:Snowflake_01.svg
* Author: Wikipedia user: Amada44
* Public Domain
* Church
* VEC-OH7LZB
* Girl Scout
* VEC-OH7LZB
* Looks slightly like the common USA girl scouts logos. Should be different
enough to not infringe on "Girl Scouts of the USA" copyrights.
* Home (HF antenna)
* VEC-OH7LZB
* Unknown position
* VEC-OH7LZB
* Destination
* VEC-OH7LZB
* Numbered circle
* VEC-OH7LZB
* Petrol Station
* OH7LZB
* Hail
* VEC-OH7LZB
* Park
* VEC-OH7LZB
* Gale Flag
* VEC-OH7LZB
* Red car from above
* OH7LZB
* Info Kiosk
* VEC-OH7LZB
* Hurricane
* OH7LZB
* Numbered white box
* VEC-OH7LZB
* Snow blowing
* VEC-OH7LZB
* Coast Guard
* VEC-OH7LZB
* Drizzle
* VEC-OH7LZB
* Smoke / Chimney
* VEC-OH7LZB
* Freezing rain
* VEC-OH7LZB
* Snow Shwr
* VEC-OH7LZB
* Haze
* VEC-OH7LZB
* Rain Shower
* VEC-OH7LZB
* Lightning
* OH7LZB
* "Kenwood radio"
* Kenwood logo, vectorized
* "Lighthouse"
* CC BY-SA 2.0
* http://wiki.openstreetmap.org/wiki/File:Lighthouse.svg
* Nav Buoy
* OH7LZB
* Rocket
* http://www.clker.com/clipart-gglkuglug.html
* PD according to clker.com license
* Parking
* VEC-OH7LZB
* Earthquake, Restaurant
* VEC-OH7LZB
* Satellite
* OH7LZB
* Thunderstorm
* OH7LZB
* Sunny
* OH7LZB
* VORTAC, Numbered WXS
* VEC-OH7LZB
* Pharmacy Rx
* OH7LZB
* Wall Cloud
* OH7LZB
* Numbered plane
* https://openclipart.org/detail/183204/plane-red-by-sketchartist-183204
* Author: SketchArtist
* PD: https://openclipart.org/share
* Numbered WX Station
* VEC-OH7LZB
* Rain
* Source: http://commons.wikimedia.org/wiki/File:Heavy-rain-shower-transparent.svg
* Author: Wikipedia user: Peepo
* Public Domain
* With modifications by OH7LZB
* Numbered diamond
* VEC-OH7LZB
* Dust blowing
* NA
* Numbered civil defence
* VEC-OH7LZB
* DX spot
* VEC-OH7LZB
* Sleet
* NA
* Funnel Cloud
* NA
* Gale
* VEC-OH7LZB
* Store
* https://openclipart.org/detail/89299/cart-medium-by-martins.bruvelis
* Author: martins.bruvelis
* Public Domain
* Adjustments by OH7LZB
* Numbered black box
* VEC-OH7LZB
* Work zone / Excavator
* Based on http://www.clker.com/clipart-292480.html PNG version
* Vectorized and colors adjusted by OH7LZB
* PD according to clker.com documentation, uploader KURSVEIAL
* SUV
* OH7LZB
* Milepost, Numbered triangle, Circle sm
* VEC-OH7LZB
* Partly cloudy
* OH7LZB
* Restrooms, Numbered boat
* VEC-OH7LZB
* Tornado (also used in Funnel cloud, Skywarn)
* https://openclipart.org/detail/104887/tornado-by-laabadon
* Author: Laabadon
* Public Domain
* Numbered truck
* OH7LZB
* Numbered van
* OH7LZB
* Flooding
* NA
* Sky warn, Numbered shelter, fog
* VEC-OH7LZB

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 521 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 825 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 449 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 676 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 521 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 449 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 416 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 414 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 489 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

View File

@@ -33,13 +33,18 @@ type Broker interface {
SubscribeRadios() (<-chan *Radio, error)
PublishPacket(topic string, packet *protocol.Packet) error
SubscribePackets(topic string) (<-chan *protocol.Packet, error)
SubscribePackets(topic string) (<-chan *Packet, error)
}
type Receiver interface {
Disconnected()
}
type Packet struct {
RadioID string
*protocol.Packet
}
type BrokerConfig struct {
Type string `yaml:"type"`
Config yaml.Node `yaml:"conf"`
@@ -197,7 +202,7 @@ func (broker *mqttBroker) SubscribeRadios() (<-chan *Radio, error) {
}
radios := make(chan *Radio, 8)
token := broker.client.Subscribe("radio/#", 0, func(_ mqtt.Client, message mqtt.Message) {
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 {
@@ -232,17 +237,24 @@ func (broker *mqttBroker) PublishPacket(topic string, packet *protocol.Packet) e
return nil
}
func (broker *mqttBroker) SubscribePackets(topic string) (<-chan *protocol.Packet, error) {
func (broker *mqttBroker) SubscribePackets(topic string) (<-chan *Packet, error) {
if broker.client == nil {
return nil, ErrBrokerNotStarted
}
packets := make(chan *protocol.Packet, 16)
packets := make(chan *Packet, 16)
token := broker.client.Subscribe(topic, 0, func(_ mqtt.Client, message mqtt.Message) {
var packet protocol.Packet
var (
part = strings.Split(message.Topic(), "/")
id = part[len(part)-1]
packet protocol.Packet
)
if err := json.Unmarshal(message.Payload(), &packet); err == nil {
select {
case packets <- &packet:
case packets <- &Packet{
RadioID: id,
Packet: &packet,
}:
default:
}
}

View File

@@ -1,6 +1,10 @@
FROM alpine:3
COPY ./etc /etc/hamview
COPY ./build/hamview-collector /opt/hamview/bin/hamview-collector
WORKDIR /opt/hamview
ENTRYPOINT ["bin/hamview-collector"]
CMD [ "--config", "/etc/hamview/hamview-collector.yaml" ]
RUN apk add --no-cache dumb-init=1.2.5-r3 bash=5.3.3-r1
WORKDIR /app
COPY ./etc /app/config
COPY ./build/hamview-collector /app/hamview-collector
COPY ./cmd/hamview-collector/entrypoint.sh /entrypoint.sh
ENTRYPOINT ["dumb-init", "/entrypoint.sh"]

View File

@@ -0,0 +1,22 @@
#!/bin/bash
COLLECTOR_FLAGS=()
COLLECTOR_CONFIG="${COLLECTOR_CONFIG:-/app/config/collector.yaml}"
[ -n "${COLLECTOR_DEBUG}" ] && COLLECTOR_FLAGS=("${COLLECTOR_FLAGS[@]}" "--debug")
[ -n "${COLLECTOR_TRACE}" ] && COLLECTOR_FLAGS=("${COLLECTOR_FLAGS[@]}" "--trace")
run() {
exec "/app/hamview-collector" \
"${COLLECTOR_FLAGS[@]}" \
--config "${COLLECTOR_CONFIG}"
}
case "$@" in
"")
run
;;
*)
exec "/bin/sh" -c "$@"
;;
esac

View File

@@ -64,7 +64,6 @@ func run(ctx context.Context, command *cli.Command) error {
if err != nil {
return err
}
defer collector.Close()
broker, err := hamview.NewBroker(&config.Broker)
if err != nil {
@@ -79,7 +78,11 @@ func run(ctx context.Context, command *cli.Command) error {
protocol.APRS,
protocol.MeshCore,
} {
go collector.Collect(broker, proto+"/packet")
go func() {
if err := collector.Collect(broker, proto+"/packet/+"); err != nil {
logger.Fatalf("Error collecting %s packets: %v", proto, err)
}
}()
}
return cmd.WaitForInterrupt(logger, "collector")

View File

@@ -0,0 +1,12 @@
FROM alpine:3
RUN apk add --no-cache dumb-init=1.2.5-r3 bash=5.3.3-r1
WORKDIR /app
ARG TARGETARCH
ARG TARGETVARIANT
COPY ./etc /app/config
COPY ./build/hamview-receiver-${TARGETARCH}${TARGETVARIANT#v} /app/hamview-receiver
COPY ./cmd/hamview-receiver/entrypoint.sh /entrypoint.sh
ENTRYPOINT ["dumb-init", "/entrypoint.sh"]

View File

@@ -0,0 +1,26 @@
#!/bin/bash
RECEIVER_FLAGS=()
RECEIVER_CONFIG="${RECEIVER_CONFIG:-/app/config/receiver-${RECEIVER_PROTOCOL}.yaml}"
[ -n "${RECEIVER_DEBUG}" ] && RECEIVER_FLAGS=("${RECEIVER_FLAGS[@]}" "--debug")
[ -n "${RECEIVER_TRACE}" ] && RECEIVER_FLAGS=("${RECEIVER_FLAGS[@]}" "--trace")
run() {
case "${RECEIVER_PROTOCOL}" in
"") exec "/app/hamview-receiver" help ;;
*) exec "/app/hamview-receiver" \
"${RECEIVER_FLAGS[@]}" \
--config "${RECEIVER_CONFIG}" \
"${RECEIVER_PROTOCOL}" ;;
esac
}
case "$@" in
"")
run
;;
*)
exec "/bin/sh" -c "$@"
;;
esac

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/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,146 @@
package main
import (
"context"
"encoding/base64"
"time"
"github.com/urfave/cli/v3"
"git.maze.io/go/ham/protocol/aprs/aprsis"
"git.maze.io/go/ham/radio"
"git.maze.io/ham/hamview"
"git.maze.io/ham/hamview/cmd"
)
type aprsisConfig struct {
Broker hamview.BrokerConfig `yaml:"broker"`
Receiver hamview.APRSISConfig `yaml:"receiver"`
Radio map[string]*radio.Info `yaml:"radio"`
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, config.Radio[callsign])
}
return waitForInterrupt()
}
func receiveAPRSIS(config *hamview.BrokerConfig, callsign string, client *aprsis.ProxyClient, extra *radio.Info) {
defer func() { _ = client.Close() }()
if extra == nil {
logger.Warnf("receiver: no radio info configured for %s!", callsign)
}
broker, err := hamview.NewBroker(config)
if err != nil {
logger.Errorf("receiver: can't setup to broker: %v", err)
return
}
defer func() { _ = broker.Close() }()
info := client.Info()
if extra != nil {
info.Manufacturer = pick(info.Manufacturer, extra.Manufacturer)
info.Device = pick(info.Device, extra.Device)
info.FirmwareDate = pickTime(info.FirmwareDate, extra.FirmwareDate)
info.FirmwareVersion = pick(info.FirmwareVersion, extra.FirmwareVersion)
info.Antenna = pick(info.Antenna, extra.Antenna)
info.Modulation = pick(info.Modulation, extra.Modulation)
info.Position = pickPosition(info.Position, extra.Position)
info.Frequency = pickFloat64(info.Frequency, extra.Frequency)
info.Bandwidth = pickFloat64(info.Bandwidth, extra.Bandwidth)
info.Power = pickFloat64(info.Power, extra.Power)
info.Gain = pickFloat64(info.Gain, extra.Gain)
info.LoRaSF = pickUint8(info.LoRaSF, extra.LoRaSF)
info.LoRaCR = pickUint8(info.LoRaCR, extra.LoRaCR)
}
if err = broker.StartRadio("aprs", info); err != nil {
logger.Fatalf("receiver: can't start broker: %v", err)
return
}
id := base64.RawURLEncoding.EncodeToString([]byte(callsign))
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/"+id, packet); err != nil {
logger.Error(err)
}
}
logger.Infof("receiver: stopped receiving packets from station: %s", callsign)
}
func pick(ss ...string) string {
for _, s := range ss {
if s != "" {
return s
}
}
return ""
}
func pickFloat64(vv ...float64) float64 {
for _, v := range vv {
if v != 0 {
return v
}
}
return 0
}
func pickPosition(vv ...*radio.Position) *radio.Position {
for _, v := range vv {
if v != nil {
return v
}
}
return nil
}
func pickTime(tt ...time.Time) time.Time {
for _, t := range tt {
if !t.Equal(time.Time{}) {
return t
}
}
return time.Time{}
}
func pickUint8(vv ...uint8) uint8 {
for _, v := range vv {
if v != 0 {
return v
}
}
return 0
}

View File

@@ -0,0 +1,83 @@
package main
import (
"context"
"encoding/base64"
"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/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 func() { _ = broker.Close() }()
receiver, err := hamview.NewMeshCoreReceiver(&config.Receiver)
if err != nil {
return err
}
defer func() { _ = 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
}
// Node id
id := base64.RawURLEncoding.EncodeToString([]byte(info.Name))
// 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/"+id, packet); err != nil {
logger.Errorf("receiver: failed to publish packet: %v", err)
}
}
logger.Warn("receiver: closing")
}()
return waitForInterrupt()
}

View File

@@ -1,15 +1,21 @@
package hamview
import (
"database/sql"
"context"
"encoding/hex"
"fmt"
"strings"
"time"
_ "github.com/cridenour/go-postgis" // PostGIS support
"github.com/lib/pq" // PostgreSQL support
_ "github.com/lib/pq" // PostgreSQL support
"xorm.io/builder"
"xorm.io/xorm"
"git.maze.io/go/ham/protocol"
"git.maze.io/go/ham/protocol/aprs"
"git.maze.io/go/ham/protocol/meshcore"
"git.maze.io/ham/hamview/schema"
)
type CollectorConfig struct {
@@ -22,58 +28,20 @@ type DatabaseConfig struct {
}
type Collector struct {
*sql.DB
radioByID map[string]*schema.Radio
meshCoreGroup map[byte][]*meshcore.Group
}
func NewCollector(config *CollectorConfig) (*Collector, error) {
d, err := sql.Open(config.Database.Type, config.Database.Conf)
if err != nil {
Logger.Debugf("collector: opening %q database", config.Database.Type)
schema.Logger = Logger
if err := schema.Open(config.Database.Type, config.Database.Conf); 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),
radioByID: make(map[string]*schema.Radio),
}, nil
}
@@ -92,6 +60,8 @@ func (c *Collector) Collect(broker Broker, topic string) error {
return err
}
ctx := context.Background()
loop:
for {
select {
@@ -99,7 +69,7 @@ loop:
if radio == nil {
break loop
}
c.processRadio(radio)
c.processRadio(ctx, radio)
case packet := <-packets:
if packet == nil {
@@ -107,9 +77,9 @@ loop:
}
switch packet.Protocol {
case protocol.APRS:
c.processAPRSPacket(packet)
c.processAPRSPacket(ctx, packet)
case protocol.MeshCore:
c.processMeshCorePacket(packet)
c.processMeshCorePacket(ctx, packet)
}
}
}
@@ -119,120 +89,97 @@ loop:
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
func (c *Collector) getRadioByID(ctx context.Context, id string) (*schema.Radio, error) {
id = strings.TrimRight(id, "=")
if radio, ok := c.radioByID[id]; ok {
return radio, nil
}
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)
radio, err := schema.GetRadioByEncodedID(ctx, id)
if err == nil {
c.radioByID[id] = radio
}
return radio, err
}
func (c *Collector) processRadio(ctx context.Context, received *Radio) {
Logger.Tracef("collector: process %s radio %q online %t",
received.Protocol,
received.Name,
received.IsOnline)
var (
now = time.Now()
engine = schema.Query(ctx).(*xorm.Session)
)
if err := engine.Begin(); err != nil {
Logger.Warnf("collector: can't start session: %v", err)
return
}
radio := new(schema.Radio)
has, err := engine.Where(builder.Eq{
"name": received.Name,
"protocol": received.Protocol,
}).Get(radio)
if err != nil {
Logger.Warnf("collector: can't query radio: %v", err)
return
}
if has {
radio.IsOnline = received.IsOnline
radio.UpdatedAt = now
if _, err = engine.Cols("is_online", "updated_at").Update(radio); err != nil {
_ = engine.Rollback()
Logger.Warnf("collector: can't update radio: %v", err)
return
}
} else {
radio = &schema.Radio{
Name: received.Name,
IsOnline: received.IsOnline,
Manufacturer: received.Manufacturer,
Device: schema.NULLString(received.Device),
FirmwareVersion: schema.NULLString(received.FirmwareVersion),
FirmwareDate: schema.NULLTime(received.FirmwareDate),
Antenna: schema.NULLString(received.Antenna),
Modulation: received.Modulation,
Protocol: received.Protocol,
Frequency: received.Frequency,
Bandwidth: received.Bandwidth,
Power: schema.NULLFloat64(received.Power),
Gain: schema.NULLFloat64(received.Gain),
CreatedAt: now,
UpdatedAt: now,
}
if received.Position != nil {
radio.Latitude = &received.Position.Latitude
radio.Longitude = &received.Position.Longitude
radio.Altitude = &received.Position.Altitude
}
if received.LoRaCR != 0 && received.LoRaSF != 0 {
radio.LoRaSF = &received.LoRaSF
radio.LoRaCR = &received.LoRaCR
}
if _, err = engine.Insert(radio); err != nil {
Logger.Warnf("collector: can't insert radio %#+v: %v", radio, err)
return
}
}
if err = engine.Commit(); err != nil {
Logger.Errorf("collector: can't commit radio session: %v", err)
}
}
func (c *Collector) processAPRSPacket(packet *protocol.Packet) {
decoded, err := aprs.ParsePacket(string(packet.Raw))
func (c *Collector) processAPRSPacket(ctx context.Context, packet *Packet) {
radio, err := c.getRadioByID(ctx, packet.RadioID)
if err != nil {
Logger.Warnf("collector: process %s packet: can't find radio %q: %v", packet.Protocol, packet.RadioID, err)
return
}
decoded, err := aprs.Parse(string(packet.Raw))
if err != nil {
Logger.Warnf("collector: invalid %s packet: %v", packet.Protocol, err)
return
@@ -242,27 +189,71 @@ func (c *Collector) processAPRSPacket(packet *protocol.Packet) {
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)
engine := schema.Query(ctx)
station := new(schema.APRSStation)
has, err := engine.Where(builder.Eq{"call": strings.ToUpper(decoded.Source.String())}).Get(station)
if err != nil {
Logger.Warnf("collector: can't query APRS station: %v", err)
return
} else if has {
cols := []string{"last_heard_at"}
station.LastHeardAt = packet.Time
if decoded.Latitude != 0 {
station.LastLatitude = &decoded.Latitude
station.LastLongitude = &decoded.Longitude
cols = append(cols, "last_latitude", "last_longitude")
}
if _, err = engine.ID(station.ID).Cols(cols...).Update(station); err != nil {
Logger.Warnf("collector: can't update APRS station: %v", err)
return
}
} else {
station = &schema.APRSStation{
Call: strings.ToUpper(decoded.Source.String()),
Symbol: decoded.Symbol,
FirstHeardAt: packet.Time,
LastHeardAt: packet.Time,
}
if decoded.Latitude != 0 {
station.LastLatitude = &decoded.Latitude
station.LastLongitude = &decoded.Longitude
}
if station.ID, err = engine.Insert(station); err != nil {
Logger.Warnf("collector: can't insert APRS station: %v", err)
return
}
}
save := &schema.APRSPacket{
RadioID: radio.ID,
StationID: station.ID,
Source: station.Call,
Destination: decoded.Destination.String(),
Path: decoded.Path.String(),
Comment: decoded.Comment,
Symbol: string(decoded.Symbol[:]),
Raw: string(packet.Raw),
ReceivedAt: packet.Time,
}
if decoded.Latitude != 0 {
save.Latitude = &decoded.Latitude
save.Longitude = &decoded.Longitude
}
if _, err = engine.Insert(save); err != nil {
Logger.Warnf("collector: can't insert APRS packet: %v", err)
}
}
func (c *Collector) processMeshCorePacket(packet *protocol.Packet) {
func (c *Collector) processMeshCorePacket(ctx context.Context, packet *Packet) {
radio, err := c.getRadioByID(ctx, packet.RadioID)
if err != nil {
Logger.Warnf("collector: process %s packet: can't find radio %q: %v", packet.Protocol, packet.RadioID, err)
return
}
var parsed meshcore.Packet
if err := parsed.UnmarshalBytes(packet.Raw); err != nil {
if err = parsed.UnmarshalBytes(packet.Raw); err != nil {
Logger.Warnf("collector: invalid %s packet: %v", packet.Protocol, err)
return
}
@@ -276,125 +267,135 @@ func (c *Collector) processMeshCorePacket(packet *protocol.Packet) {
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 {
var channelHash string
switch parsed.PayloadType {
case meshcore.TypeGroupText, meshcore.TypeGroupData:
if len(parsed.Payload) > 0 {
channelHash = fmt.Sprintf("%02x", parsed.Payload[0])
}
}
save := &schema.MeshCorePacket{
RadioID: radio.ID,
SNR: packet.SNR,
RSSI: packet.RSSI,
RouteType: uint8(parsed.RouteType),
PayloadType: uint8(parsed.PayloadType),
Version: parsed.Version,
Hash: hex.EncodeToString(parsed.Hash()),
Path: parsed.Path,
Payload: parsed.Payload,
ChannelHash: channelHash,
Raw: packet.Raw,
ReceivedAt: packet.Time,
}
if _, err = schema.Query(ctx).Insert(save); 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
}
}
c.processMeshCoreAdvert(ctx, save, &parsed)
}
}
func (c *Collector) processMeshCoreAdvert(ctx context.Context, packet *schema.MeshCorePacket, parsed *meshcore.Packet) {
payload, err := parsed.Decode()
if err != nil {
Logger.Warnf("collector: error decoding packet: %v", err)
return
}
advert, ok := payload.(*meshcore.Advert)
if !ok {
Logger.Warnf("collector: expected Advert, got %T!?", payload)
return
}
node := &schema.MeshCoreNode{
PacketHash: packet.Hash,
Name: advert.Name,
Type: uint8(advert.Type),
Prefix: fmt.Sprintf("%02x", advert.PublicKey.Bytes()[0]),
PublicKey: hex.EncodeToString(advert.PublicKey.Bytes()),
FirstHeardAt: packet.ReceivedAt,
LastHeardAt: packet.ReceivedAt,
}
if advert.Position != nil {
node.LastLatitude = &advert.Position.Latitude
node.LastLongitude = &advert.Position.Longitude
}
var (
engine = schema.Query(ctx)
existing = new(schema.MeshCoreNode)
)
if err = engine.Begin(); err != nil {
Logger.Warnf("collector: can't start session: %v", err)
return
}
var has bool
if has, err = engine.Where(builder.Eq{"`public_key`": node.PublicKey}).Get(existing); err != nil {
_ = engine.Rollback()
Logger.Warnf("collector: can't query session: %v", err)
return
}
if has {
cols := []string{"last_heard_at"}
existing.LastHeardAt = packet.ReceivedAt
if advert.Position != nil {
existing.LastLatitude = node.LastLatitude
existing.LastLongitude = node.LastLongitude
cols = append(cols, "last_latitude", "last_longitude")
}
existing.Name = node.Name
_, err = engine.ID(existing.ID).Cols(cols...).Update(existing)
} else {
_, err = engine.Insert(node)
}
if err != nil {
_ = engine.Rollback()
Logger.Warnf("collector: can't save (update: %t): %v", has, err)
return
}
if err = engine.Commit(); err != nil {
Logger.Warnf("collector: can't commit session: %v", err)
return
}
/*
if advert.Position != nil {
if _, err = c.DB.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
}
}
}
*/
}

12
go.mod
View File

@@ -1,9 +1,11 @@
module git.maze.io/ham/hamview
go 1.25.6
go 1.26
replace git.maze.io/go/ham => ../ham
require (
git.maze.io/go/ham v0.1.0
git.maze.io/go/ham v0.1.1-0.20260302213739-8f8a97300f5c
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
@@ -11,20 +13,26 @@ require (
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/mattn/go-sqlite3 v1.14.32
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
xorm.io/builder v0.3.13
xorm.io/xorm v1.3.11
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/golang/snappy v0.0.4 // 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/syndtr/goleveldb v1.0.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

74
go.sum
View File

@@ -1,9 +1,7 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
git.maze.io/go/ham v0.0.0-20260222201625-824bde5d5525 h1:No1WgUBujHY8tSMfIFuvcAJ5qe9RK6o2WgmZROp5+JM=
git.maze.io/go/ham v0.0.0-20260222201625-824bde5d5525/go.mod h1:+WuiawzNBqlWgklVoodUAJc0cV+NDW6RR8Tn+AW8hsU=
git.maze.io/go/ham v0.1.0 h1:ytqqkGux4E6h3QbCB3zJy/Ngc+fEqodyMpepbp9o/ts=
git.maze.io/go/ham v0.1.0/go.mod h1:+WuiawzNBqlWgklVoodUAJc0cV+NDW6RR8Tn+AW8hsU=
gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a h1:lSA0F4e9A2NcQSqGqTOXqu2aRi/XEQxDCBwM8yJtE6s=
gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:EXuID2Zs0pAQhH8yz+DNjUbjppKQzKFAn28TMYPB6IU=
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=
@@ -15,12 +13,29 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
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/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
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/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
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/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
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=
@@ -49,9 +64,20 @@ github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOA
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/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0 h1:WSHQ+IS43OoUrWtD1/bbclrwK8TTH5hzp+umCiuxHgs=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.4.3 h1:RE1xgDvH7imwFD45h+u2SgIfERHlS2yNG4DObb5BSKU=
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
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=
@@ -61,9 +87,12 @@ github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC4
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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
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/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE=
github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
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=
@@ -81,13 +110,18 @@ golang.org/x/crypto v0.0.0-20191227163750-53104e6ec876/go.mod h1:LzIPMQfyMNhhGPh
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/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
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.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
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-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
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=
@@ -107,11 +141,43 @@ 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=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
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/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
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=
lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw=
modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0=
modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw=
modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY=
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sqlite v1.20.4 h1:J8+m2trkN+KKoE7jglyHYYYiaq5xmz2HoHJIiBlRzbE=
modernc.org/sqlite v1.20.4/go.mod h1:zKcGyrICaxNTMEHSr1HQ2GUraP0j+845GYw37+EyT6A=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
xorm.io/builder v0.3.13 h1:a3jmiVVL19psGeXx8GIurTp7p0IIgqeDmwhcR6BAOAo=
xorm.io/builder v0.3.13/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE=
xorm.io/xorm v1.3.11 h1:i4tlVUASogb0ZZFJHA7dZqoRU2pUpUsutnNdaOlFyMI=
xorm.io/xorm v1.3.11/go.mod h1:cs0ePc8O4a0jD78cNvD+0VFwhqotTvLQZv372QsDw7Q=

View File

@@ -24,9 +24,10 @@ type MeshCoreConfig struct {
}
type MeshCoreCompanionConfig struct {
Port string `yaml:"port"`
Baud int `yaml:"baud"`
Addr string `yaml:"addr"`
Port string `yaml:"port"`
Baud int `yaml:"baud"`
Addr string `yaml:"addr"`
HasSNR bool `yaml:"has_snr"` // patch: adds SNR/RSSI data
}
type MeshCorePrefix byte
@@ -54,6 +55,9 @@ func NewMeshCoreReceiver(config *MeshCoreConfig) (protocol.PacketReceiver, error
case "companion", "":
return newMeshCoreCompanionReceiver(config.Conf)
case "repeater":
return newMeshCoreRepeaterReceiver(config.Conf)
default:
return nil, fmt.Errorf("hamview: unsupported MeshCore node type %q", config.Type)
}
@@ -104,6 +108,51 @@ func newMeshCoreCompanionReceiver(node yaml.Node) (protocol.PacketReceiver, erro
return receiver, nil
}
func newMeshCoreRepeaterReceiver(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 repeater 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 repeater 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.NewRepeater(conn, config.HasSNR)
if err != nil {
_ = conn.Close()
Logger.Warnf("receiver: error connecting to repeater: %v", err)
return nil, err
}
info := receiver.Info()
Logger.Infof("receiver: connected to MeshCore repeater %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"`

86
schema/aprs.go Normal file
View File

@@ -0,0 +1,86 @@
package schema
import (
"context"
"os"
"strings"
"time"
"xorm.io/builder"
)
func init() {
RegisterModel(new(APRSStation))
RegisterModel(new(APRSPacket))
}
type APRSStation struct {
ID int64 `xorm:"pk autoincr" json:"id"`
Call string `xorm:"varchar(10) unique not null" json:"call"`
Symbol string `xorm:"varchar(2)" json:"symbol"`
FirstHeardAt time.Time `xorm:"timestamp not null"`
LastHeardAt time.Time `xorm:"timestamp not null"`
LastLatitude *float64 `xorm:"numeric(10,8)" json:"latitude,omitempty"`
LastLongitude *float64 `xorm:"numeric(11,8)" json:"longitude,omitempty"`
}
func GetAPRSStation(ctx context.Context, call string) (*APRSStation, error) {
station := new(APRSStation)
has, err := Query(ctx).
Where(builder.Eq{`"call"`: strings.ToUpper(call)}).
Get(station)
if err != nil {
return nil, err
} else if !has {
return nil, os.ErrNotExist
}
return station, nil
}
func (station APRSStation) GetPackets(ctx context.Context) ([]*APRSPacket, error) {
packets := make([]*APRSPacket, 0, 10)
return packets, Query(ctx).
Where(builder.Eq{"`station_id`": station.ID}).
Find(&packets)
}
type APRSPacket struct {
ID int64 `xorm:"pk autoincr" json:"id"`
RadioID int64 `xorm:"index" json:"radio_id"`
Radio *Radio `xorm:"-" json:"radio"`
StationID int64 `json:"-"`
Source string `xorm:"varchar(10) not null" json:"src"`
Destination string `xorm:"varchar(10) not null" json:"dst"`
Path string `xorm:"varchar(88) not null default ''" json:"path"`
Comment string `xorm:"varchar(250)" json:"comment"`
Latitude *float64 `xorm:"numeric(10,8)" json:"latitude,omitempty"`
Longitude *float64 `xorm:"numeric(11,8)" json:"longitude,omitempty"`
Symbol string `xorm:"varchar(2)" json:"symbol"`
Raw string `json:"raw"`
ReceivedAt time.Time `json:"received_at"`
//Station *APRSStation `xorm:"-" json:"station"`
}
func GetAPRSPackets(ctx context.Context, limit int) ([]*APRSPacket, error) {
packets := make([]*APRSPacket, 0, limit)
return packets, Query(ctx).
OrderBy("`received_at` DESC").
Limit(limit).
Find(&packets)
}
func GetAPRSPacketsBySource(ctx context.Context, source string) ([]*APRSPacket, error) {
packets := make([]*APRSPacket, 0, 100)
return packets, Query(ctx).
Where(builder.Eq{"source": strings.ToUpper(source)}).
OrderBy("`received_at` DESC").
Find(&packets)
}
func GetAPRSPacketsByDestination(ctx context.Context, destination string) ([]*APRSPacket, error) {
packets := make([]*APRSPacket, 0, 100)
return packets, Query(ctx).
Where(builder.Eq{"destination": strings.ToUpper(destination)}).
OrderBy("`received_at` DESC").
Find(&packets)
}

221
schema/engine.go Normal file
View File

@@ -0,0 +1,221 @@
package schema
import (
"context"
"database/sql"
"fmt"
"time"
"github.com/sirupsen/logrus"
"xorm.io/xorm"
"xorm.io/xorm/log"
"xorm.io/xorm/names"
_ "github.com/lib/pq" // PostgreSQL support
_ "github.com/mattn/go-sqlite3" // SQLite support
)
// Logger used by this package
var Logger = logrus.New()
var xormEngine *xorm.Engine
type engineContextKeyType struct{}
var engineContextKey = engineContextKeyType{}
// Engine represents a xorm engine or session.
type Engine interface {
Table(tableNameOrBean any) *xorm.Session
Count(...any) (int64, error)
Decr(column string, arg ...any) *xorm.Session
Delete(...any) (int64, error)
Truncate(...any) (int64, error)
Exec(...any) (sql.Result, error)
Find(any, ...any) error
Get(beans ...any) (bool, error)
ID(any) *xorm.Session
In(string, ...any) *xorm.Session
Incr(column string, arg ...any) *xorm.Session
Insert(...any) (int64, error)
Iterate(any, xorm.IterFunc) error
Join(joinOperator string, tablename, condition any, args ...any) *xorm.Session
SQL(any, ...any) *xorm.Session
Where(any, ...any) *xorm.Session
Asc(colNames ...string) *xorm.Session
Desc(colNames ...string) *xorm.Session
Limit(limit int, start ...int) *xorm.Session
NoAutoTime() *xorm.Session
SumInt(bean any, columnName string) (res int64, err error)
Sync(...any) error
Select(string) *xorm.Session
SetExpr(string, any) *xorm.Session
NotIn(string, ...any) *xorm.Session
OrderBy(any, ...any) *xorm.Session
Exist(...any) (bool, error)
Distinct(...string) *xorm.Session
Query(...any) ([]map[string][]byte, error)
Cols(...string) *xorm.Session
Context(ctx context.Context) *xorm.Session
Ping() error
IsTableExist(tableNameOrBean any) (bool, error)
Begin() error
Rollback() error
Commit() error
}
// Query the engine from the context.
func Query(ctx context.Context) Engine {
if engine, ok := ctx.Value(engineContextKey).(Engine); ok {
return engine
}
return xormEngine.Context(ctx)
}
// Open a database connection.
func Open(driver, config string) error {
var err error
if xormEngine, err = xorm.NewEngine(driver, config); err != nil {
return err
}
gonicNames := []string{
"ID",
"SSL", "UID",
"SNR", "RSSI",
"APRS", "MeshCore",
"LoRa",
}
for _, name := range gonicNames {
names.LintGonicMapper[name] = true
}
xormEngine.SetMapper(names.GonicMapper{})
logger := &xormLogger{}
//logger.SetLevel(log.LOG_DEBUG)
xormEngine.SetLogger(logger)
xormEngine.ShowSQL(true)
for _, model := range registeredModels {
Logger.Debugf("schema: sync schema %T", model)
if err = xormEngine.Sync(model); err != nil {
_ = xormEngine.Close()
xormEngine = nil
return err
}
}
return nil
}
var (
registeredModels []any
registeredInitFuncs []func() error
)
func RegisterModel(model any, initFuncs ...func() error) {
registeredModels = append(registeredModels, model)
if len(initFuncs) > 0 && initFuncs[0] != nil {
registeredInitFuncs = append(registeredInitFuncs, initFuncs...)
}
}
func NULLFloat64(v float64) *float64 {
if v == 0 {
return nil
}
return &v
}
func NULLString(s string) *string {
if s == "" {
return nil
}
return &s
}
func NULLTime(t time.Time) *time.Time {
if t.Equal(time.Time{}) {
return nil
}
return &t
}
type xormLogger struct {
showSQL bool
}
func (l xormLogger) BeforeSQL(ctx log.LogContext) {
var sessionPart string
v := ctx.Ctx.Value(log.SessionIDKey)
if key, ok := v.(string); ok {
sessionPart = fmt.Sprintf(" [%s]", key)
}
Logger.Debugf("[SQL (before)]%s %s %v", sessionPart, ctx.SQL, ctx.Args)
}
func (l xormLogger) AfterSQL(ctx log.LogContext) {
var sessionPart string
v := ctx.Ctx.Value(log.SessionIDKey)
if key, ok := v.(string); ok {
sessionPart = fmt.Sprintf(" [%s]", key)
}
if ctx.ExecuteTime > 0 {
Logger.Infof("[SQL (after)]%s %s %v - %v", sessionPart, ctx.SQL, ctx.Args, ctx.ExecuteTime)
} else {
Logger.Infof("[SQL (after)]%s %s %v", sessionPart, ctx.SQL, ctx.Args)
}
} // only invoked when IsShowSQL is true
func (l xormLogger) Debug(args ...any) { Logger.Debug(append([]any{"engine: "}, args...)...) }
func (l xormLogger) Debugf(format string, args ...any) { Logger.Debugf("engine: "+format, args...) }
func (l xormLogger) Error(args ...any) { Logger.Error(append([]any{"engine: "}, args...)...) }
func (l xormLogger) Errorf(format string, args ...any) { Logger.Errorf("engine: "+format, args...) }
func (l xormLogger) Info(args ...any) { Logger.Info(append([]any{"engine: "}, args...)...) }
func (l xormLogger) Infof(format string, args ...any) { Logger.Infof("engine: "+format, args...) }
func (l xormLogger) Warn(args ...any) { Logger.Warn(append([]any{"engine: "}, args...)...) }
func (l xormLogger) Warnf(format string, args ...any) { Logger.Warnf("engine: "+format, args...) }
func (l xormLogger) Level() log.LogLevel {
switch Logger.Level {
case logrus.TraceLevel:
return log.LOG_DEBUG
case logrus.DebugLevel:
return log.LOG_DEBUG
case logrus.InfoLevel:
return log.LOG_INFO
case logrus.ErrorLevel:
return log.LOG_ERR
case logrus.WarnLevel:
return log.LOG_WARNING
case logrus.FatalLevel:
return log.LOG_OFF
default:
return log.LOG_UNKNOWN
}
}
func (l xormLogger) SetLevel(level log.LogLevel) {
switch level {
case log.LOG_DEBUG:
Logger.SetLevel(logrus.DebugLevel)
case log.LOG_INFO:
Logger.SetLevel(logrus.InfoLevel)
case log.LOG_ERR:
Logger.SetLevel(logrus.ErrorLevel)
case log.LOG_OFF:
Logger.SetLevel(logrus.FatalLevel)
}
}
func (l *xormLogger) ShowSQL(show ...bool) {
if len(show) > 0 {
l.showSQL = show[0]
}
}
func (l xormLogger) IsShowSQL() bool {
return l.showSQL
}
var _ log.ContextLogger = (*xormLogger)(nil)

273
schema/meshcore.go Normal file
View File

@@ -0,0 +1,273 @@
package schema
import (
"context"
"encoding/hex"
"errors"
"fmt"
"os"
"time"
"xorm.io/builder"
"git.maze.io/go/ham/protocol"
"git.maze.io/go/ham/protocol/meshcore"
)
func init() {
RegisterModel(new(MeshCorePacket))
RegisterModel(new(MeshCoreNode))
RegisterModel(new(MeshCoreNodePosition))
RegisterModel(new(MeshCoreGroup))
}
type MeshCorePacket struct {
ID int64 `xorm:"pk autoincr" json:"id"`
RadioID int64 `xorm:"index" json:"radio_id"`
Radio *Radio `xorm:"-" json:"radio"`
SNR float64 `xorm:"not null default 0" json:"snr"`
RSSI int `xorm:"not null default 0" json:"rssi"`
Version int `xorm:"not null default 1" json:"version"`
RouteType uint8 `xorm:"index not null" json:"route_type"`
PayloadType uint8 `xorm:"index not null" json:"payload_type"`
Hash string `xorm:"varchar(16) index not null" json:"hash"`
Path []byte `xorm:"bytea" json:"path"`
Payload []byte `xorm:"bytea not null" json:"payload"`
Raw []byte `xorm:"bytea not null" json:"raw"`
Parsed *string `xorm:"jsonb" json:"parsed"`
ChannelHash string `xorm:"varchar(2) index" json:"channel_hash,omitempty"`
ReceivedAt time.Time `json:"received_at"`
}
func (MeshCorePacket) TableName() string {
return "meshcore_packet"
}
func GetMeshCorePackets(ctx context.Context, limit int) ([]*MeshCorePacket, error) {
packets := make([]*MeshCorePacket, 0, limit)
return packets, Query(ctx).
OrderBy("`received_at` DESC").
Limit(limit).
Find(&packets)
}
func GetMeshCorePacketsByHash(ctx context.Context, hash string) ([]*MeshCorePacket, error) {
if len(hash) != 16 {
return nil, errors.New("invalid hash")
} else if _, err := hex.DecodeString(hash); err != nil {
return nil, err
}
packets := make([]*MeshCorePacket, 0, 10)
return packets, Query(ctx).
Where(builder.Eq{"hash": hash}).
OrderBy("`received_at` ASC").
Find(&packets)
}
func GetMeshCorePacketsByPayloadType(ctx context.Context, payloadType meshcore.PayloadType) ([]*MeshCorePacket, error) {
packets := make([]*MeshCorePacket, 0, 10)
return packets, Query(ctx).
Where(builder.Eq{"payload_type": int(payloadType)}).
OrderBy("`received_at` DESC").
Find(&packets)
}
func GetMeshCorePacketsByChannelHash(ctx context.Context, hash string) ([]*MeshCorePacket, error) {
packets := make([]*MeshCorePacket, 0, 10)
return packets, Query(ctx).
Where(builder.Eq{
"`payload_type`": int(meshcore.TypeGroupText),
"`channel_hash`": hash,
}).
OrderBy("`received_at` DESC").
Find(&packets)
}
type MeshCoreGroup struct {
ID int64 `xorm:"pk autoincr" json:"id"`
Name string `xorm:"varchar(32) not null unique" json:"name"`
Secret string `xorm:"varchar(32) not null" json:"secret"`
IsPublic bool `xorm:"boolean not null default false" json:"-"`
}
func (MeshCoreGroup) TableName() string {
return "meshcore_group"
}
func GetMeshCoreGroups(ctx context.Context) ([]*MeshCoreGroup, error) {
groups := make([]*MeshCoreGroup, 0, 10)
return groups, Query(ctx).
Where(builder.Eq{"is_public": true}).
OrderBy("name asc").
Find(&groups)
}
type MeshCoreNode struct {
ID int64 `xorm:"pk autoincr" json:"id"`
PacketHash string `xorm:"varchar(16) index 'meshcore_packet_hash'" json:"-"`
Packets []*MeshCorePacket `xorm:"-" json:"packet"`
Name string `xorm:"varchar(100) not null" json:"name"`
Type uint8 `xorm:"index not null" json:"type"`
Prefix string `xorm:"varchar(2) not null" json:"prefix"`
PublicKey string `xorm:"varchar(64) not null unique" json:"public_key"`
FirstHeardAt time.Time `xorm:"timestamp not null" json:"first_heard_at"`
LastHeardAt time.Time `xorm:"timestamp not null" json:"last_heard_at"`
LastLatitude *float64 `json:"last_latitude"`
LastLongitude *float64 `json:"last_longitude"`
Distance float64 `xorm:"-" json:"distance,omitempty"`
}
func (MeshCoreNode) TableName() string {
return "meshcore_node"
}
func GetMeshCoreNodeByPublicKey(ctx context.Context, publicKey string) (*MeshCoreNode, error) {
node := new(MeshCoreNode)
if ok, err := Query(ctx).
Where(builder.Eq{"public_key": publicKey}).
Get(node); err != nil {
return nil, err
} else if !ok {
return nil, os.ErrNotExist
}
return node, nil
}
func GetMeshCoreNodes(ctx context.Context) ([]*MeshCoreNode, error) {
nodes := make([]*MeshCoreNode, 0, 100)
return nodes, Query(ctx).
OrderBy("`last_heard_at` DESC").
Find(&nodes)
}
func GetMeshCoreNodesByType(ctx context.Context, nodeType meshcore.NodeType) ([]*MeshCoreNode, error) {
nodes := make([]*MeshCoreNode, 0, 100)
return nodes, Query(ctx).
Where(builder.Eq{"type": nodeType}).
OrderBy("`last_heard_at` DESC").
Find(&nodes)
}
type MeshCoreNodeWithDistance struct {
MeshCoreNode `xorm:"extends"`
Distance float64 `xorm:"distance"`
}
func GetMeshCoreNodesCloseTo(ctx context.Context, publicKey string, radius float64) (*MeshCoreNode, []*MeshCoreNode, error) {
node, err := GetMeshCoreNodeByPublicKey(ctx, publicKey)
if err != nil {
return nil, nil, err
} else if node.LastLatitude == nil || node.LastLongitude == nil {
return nil, nil, errors.New("node has no location")
}
nodesWithDistance := make([]*MeshCoreNodeWithDistance, 0, 100)
selectClause := fmt.Sprintf("*, "+
"ST_Distance("+
"ST_SetSRID(ST_MakePoint(%f, %f), 4326)::geography, "+
"ST_SetSRID(ST_MakePoint(last_longitude, last_latitude), 4326)::geography"+
") as distance",
*node.LastLongitude, *node.LastLatitude)
if err = Query(ctx).
Select(selectClause).
Where("id != ?", node.ID).
Where("ST_DWithin("+
"ST_SetSRID(ST_MakePoint(?, ?), 4326)::geography, "+
"ST_SetSRID(ST_MakePoint(last_longitude, last_latitude), 4326)::geography, ?)",
*node.LastLongitude, *node.LastLatitude, radius).
OrderBy("`distance` ASC").
Find(&nodesWithDistance); err != nil {
return nil, nil, err
}
nodes := make([]*MeshCoreNode, len(nodesWithDistance))
for i, node := range nodesWithDistance {
node.MeshCoreNode.Distance = node.Distance
nodes[i] = &node.MeshCoreNode
}
return node, nodes, nil
}
type MeshCoreNodePosition struct {
ID int64 `xorm:"pk autoincr" json:"id"`
MeshCoreNodeId int64 `xorm:"not null 'meshcore_node_id'" json:"-"`
MeshCoreNode *MeshCoreNode `xorm:"-" json:"node"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
ReceivedAt time.Time `xorm:"timestamp not null" json:"received_at"`
}
func (MeshCoreNodePosition) TableName() string {
return "meshcore_node_position"
}
type MeshCoreStats struct {
Messages int64 `json:"messages"`
Nodes int64 `json:"nodes"`
Receivers int64 `json:"receivers"`
Packets struct {
Timestamps []int64 `json:"timestamps"`
Packets []int64 `json:"packets"`
} `json:"packets"`
}
func GetMeshCoreStats(ctx context.Context) (*MeshCoreStats, error) {
var (
engine = Query(ctx)
stats = new(MeshCoreStats)
err error
)
if stats.Messages, err = engine.
In("`payload_type`", meshcore.TypeText, meshcore.TypeGroupText).
Count(&MeshCorePacket{}); err != nil {
return nil, err
}
if stats.Nodes, err = engine.
Count(&MeshCoreNode{}); err != nil {
return nil, err
}
if stats.Receivers, err = engine.
Where(builder.Eq{"`protocol`": protocol.MeshCore}).
Count(&Radio{}); err != nil {
return nil, err
}
return stats, nil
}
/*
Column | Type | Collation | Nullable | Default
--------------+--------------------------+-----------+----------+---------------------------------------------
id | bigint | | not null | nextval('meshcore_packet_id_seq'::regclass)
snr | real | | not null | 0
rssi | smallint | | not null | 0
hash | bytea | | not null |
route_type | smallint | | not null |
payload_type | smallint | | not null |
path | bytea | | |
payload | bytea | | |
raw | bytea | | |
parsed | jsonb | | |
received_at | timestamp with time zone | | not null | now()
created_at | timestamp with time zone | | not null | now()
*/
/*
id | bigint | | not null | nextval('meshcore_node_id_seq'::regclass)
last_advert_id | bigint | | |
node_type | smallint | | not null | 0
public_key | bytea | | not null |
name | text | | |
local_time | timestamp with time zone | | not null |
first_heard | timestamp with time zone | | not null | now()
last_heard | timestamp with time zone | | not null | now()
last_latitude | numeric(10,8) | | |
last_longitude | numeric(11,8) | | |
prefix | bytea | | | generated always as ("substring"(public_key, 0, 2)) stored
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::double precision, last_longitude::double precision), 4326)+
| | | | ELSE NULL::geometry +
| | | | END) stored
position | geometry(Point,4326) | | |
*/

75
schema/radio.go Normal file
View File

@@ -0,0 +1,75 @@
package schema
import (
"context"
"encoding/base64"
"os"
"strings"
"time"
"xorm.io/builder"
)
func init() {
RegisterModel(new(Radio))
}
type Radio struct {
ID int64 `xorm:"pk autoincr" json:"id"`
Name string `xorm:"not null unique" json:"name"`
IsOnline bool `xorm:"bool not null default false" json:"is_online"`
Manufacturer string `xorm:"varchar(64) not null" json:"manufacturer"`
Device *string `xorm:"varchar(64)" json:"device"`
FirmwareVersion *string `xorm:"varchar(32)" json:"firmware_version"`
FirmwareDate *time.Time `json:"firmware_date"`
Antenna *string `xorm:"varchar(100)" json:"antenna"`
Modulation string `xorm:"varchar(16) not null" json:"modulation"`
Protocol string `xorm:"varchar(16) not null index" json:"protocol"`
Latitude *float64 `json:"latitude,omitempty"`
Longitude *float64 `json:"longitude,omitempty"`
Altitude *float64 `json:"altitude,omitempty"`
Frequency float64 `xorm:"not null" json:"frequency"`
Bandwidth float64 `xorm:"not null" json:"bandwidth"`
Power *float64 `json:"power,omitempty"`
Gain *float64 `json:"gain,omitempty"`
LoRaSF *uint8 `xorm:"smallint 'lora_sf'" json:"lora_sf,omitempty"`
LoRaCR *uint8 `xorm:"smallint 'lora_cr'" json:"lora_cr,omitempty"`
Extra []byte `xorm:"jsonb" json:"extra,omitempty"`
CreatedAt time.Time `xorm:"timestamp not null default current_timestamp" json:"created_at"`
UpdatedAt time.Time `xorm:"timestamp not null default current_timestamp" json:"updated_at"`
}
func GetRadioByEncodedID(ctx context.Context, id string) (*Radio, error) {
name, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(id, "="))
if err != nil {
return nil, err
}
radio := new(Radio)
has, err := Query(ctx).Where(builder.Eq{"`name`": name}).Get(radio)
if err != nil {
return nil, err
} else if !has {
return nil, os.ErrNotExist
}
return radio, nil
}
func GetRadios(ctx context.Context) ([]*Radio, error) {
radios := make([]*Radio, 0, 5)
return radios, Query(ctx).Find(&radios)
}
func GetRadiosByProtocol(ctx context.Context, protocol string) ([]*Radio, error) {
radios := make([]*Radio, 0, 5)
return radios, Query(ctx).
Where(builder.Eq{"`protocol`": protocol}).
Find(&radios)
}
func GetRadiosRecentlyOnline(ctx context.Context) ([]*Radio, error) {
radios := make([]*Radio, 0, 5)
return radios, Query(ctx).
Where(builder.Eq{"`is_online`": true}).
Find(&radios)
}

425
server.go
View File

@@ -1,423 +1,30 @@
package hamview
import (
"database/sql"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"net"
"net/http"
"os"
"slices"
"strconv"
"strings"
"time"
"github.com/sirupsen/logrus"
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"
"git.maze.io/ham/hamview/server"
)
const DefaultServerListen = ":8073"
// Deprecated: Use server.Config instead
type ServerConfig = server.Config
type ServerConfig struct {
Listen string `yaml:"listen"`
}
type Server struct {
listen string
listenAddr *net.TCPAddr
db *sql.DB
}
// Deprecated: Use server.Server instead
type Server = server.Server
// NewServer creates a new server instance
// Deprecated: Use server.New instead
func NewServer(serverConfig *ServerConfig, databaseConfig *DatabaseConfig) (*Server, error) {
if serverConfig.Listen == "" {
serverConfig.Listen = DefaultServerListen
// Get logger from the global context or create a new one
logger := Logger
if logger == nil {
logger = logrus.New()
}
listenAddr, err := net.ResolveTCPAddr("tcp", serverConfig.Listen)
if err != nil {
return nil, fmt.Errorf("hamview: invalid listen address %q: %v", serverConfig.Listen, err)
dbConfig := &server.DatabaseConfig{
Type: databaseConfig.Type,
Conf: databaseConfig.Conf,
}
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
return server.New(serverConfig, dbConfig, logger)
}

713
server/API.md Normal file
View File

@@ -0,0 +1,713 @@
# HAMView API Reference
Version: 1.0
Base URL: `/api/v1`
Content-Type: `application/json`
## Table of Contents
1. [Authentication](#authentication)
2. [Error Handling](#error-handling)
3. [Endpoints](#endpoints)
- [Radios](#radios)
- [MeshCore](#meshcore)
- [APRS](#aprs)
4. [Data Models](#data-models)
5. [Examples](#examples)
---
## Authentication
Currently, the API does not require authentication. All endpoints are publicly accessible.
---
## Error Handling
All error responses follow this format:
```json
{
"error": "Human-readable error message"
}
```
### HTTP Status Codes
| Code | Description |
|------|-------------|
| 200 | Success |
| 400 | Bad Request - Invalid parameters |
| 404 | Not Found - Resource does not exist |
| 500 | Internal Server Error |
---
## Endpoints
### Radios
#### List All Radios
```
GET /api/v1/radios
```
Returns a list of all radio receivers/stations.
**Response:** `200 OK`
```typescript
Radio[]
```
**Example:**
```bash
curl http://localhost:8073/api/v1/radios
```
```json
[
{
"id": 1,
"name": "Station-Alpha",
"is_online": true,
"manufacturer": "Heltec",
"protocol": "meshcore",
"frequency": 868.1
}
]
```
---
#### List Radios by Protocol
```
GET /api/v1/radios/:protocol
```
Returns radios filtered by protocol.
**Parameters:**
| Name | Type | In | Description |
|----------|--------|------|-------------|
| protocol | string | path | Protocol name (e.g., "meshcore", "aprs") |
**Response:** `200 OK`
```typescript
Radio[]
```
**Example:**
```bash
curl http://localhost:8073/api/v1/radios/meshcore
```
---
### MeshCore
#### Get MeshCore Statistics
```
GET /api/v1/meshcore
```
Returns aggregated network statistics.
**Response:** `200 OK`
```typescript
{
messages: number;
nodes: number;
receivers: number;
packets: {
timestamps: number[];
packets: number[];
};
}
```
**Example:**
```bash
curl http://localhost:8073/api/v1/meshcore
```
```json
{
"messages": 150234,
"nodes": 127,
"receivers": 8,
"packets": {
"timestamps": [1709650800, 1709654400],
"packets": [142, 203]
}
}
```
---
#### List MeshCore Groups
```
GET /api/v1/meshcore/groups
```
Returns public MeshCore groups/channels.
**Response:** `200 OK`
```typescript
{
id: number;
name: string;
secret: string;
}[]
```
**Example:**
```bash
curl http://localhost:8073/api/v1/meshcore/groups
```
```json
[
{
"id": 5,
"name": "General Chat",
"secret": "0123456789abcdef0123456789abcdef"
}
]
```
---
#### List MeshCore Nodes
```
GET /api/v1/meshcore/nodes
```
Returns MeshCore network nodes.
**Query Parameters:**
| Name | Type | Required | Description |
|------|--------|----------|-------------|
| type | string | No | Node type: "chat", "room", "sensor", "repeater" |
**Response:** `200 OK`
```typescript
{
id: number;
packet: MeshCorePacket[];
name: string;
type: number;
prefix: string;
public_key: string;
first_heard_at: string;
last_heard_at: string;
last_latitude: number | null;
last_longitude: number | null;
distance?: number;
}[]
```
**Example:**
```bash
curl http://localhost:8073/api/v1/meshcore/nodes
curl http://localhost:8073/api/v1/meshcore/nodes?type=chat
```
```json
[
{
"id": 42,
"packet": [],
"name": "NODE-CHARLIE",
"type": 0,
"prefix": "mc",
"public_key": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
"first_heard_at": "2026-01-15T08:00:00Z",
"last_heard_at": "2026-03-05T14:25:00Z",
"last_latitude": 52.3667,
"last_longitude": 4.8945
}
]
```
---
#### Find Nodes Near Location
```
GET /api/v1/meshcore/nodes/close-to/:publickey
```
Returns nodes within a specified radius of a reference node.
**Path Parameters:**
| Name | Type | Description |
|-----------|--------|-------------|
| publickey | string | 64-character hex-encoded Ed25519 public key |
**Query Parameters:**
| Name | Type | Required | Default | Description |
|--------|--------|----------|---------|-------------|
| radius | number | No | 25000 | Search radius in meters |
**Response:** `200 OK`
```typescript
{
node: MeshCoreNode;
nodes: MeshCoreNode[]; // Sorted by distance, with distance field populated
}
```
**Example:**
```bash
curl "http://localhost:8073/api/v1/meshcore/nodes/close-to/1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef?radius=50000"
```
```json
{
"node": {
"id": 42,
"name": "NODE-CHARLIE",
"public_key": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
"last_latitude": 52.3667,
"last_longitude": 4.8945,
"distance": 0
},
"nodes": [
{
"id": 43,
"name": "NODE-DELTA",
"last_latitude": 52.3700,
"last_longitude": 4.9000,
"distance": 450.5
}
]
}
```
---
#### List MeshCore Packets
```
GET /api/v1/meshcore/packets
```
Returns MeshCore packets based on filter criteria.
**Query Parameters (mutually exclusive):**
| Name | Type | Required | Description |
|--------------|--------|----------|-------------|
| hash | string | No | 16-character hex hash |
| type | number | No | Payload type (0-255) |
| channel_hash | string | No | 2-character channel hash (requires type) |
**Response:** `200 OK`
```typescript
{
id: number;
radio_id: number;
radio: Radio | null;
snr: number;
rssi: number;
version: number;
route_type: number;
payload_type: number;
hash: string;
path: string; // Base64
payload: string; // Base64
raw: string; // Base64
parsed: object | null;
channel_hash: string;
received_at: string;
}[]
```
**Payload Types:**
| Value | Description |
|-------|-------------|
| 0 | Ping |
| 1 | Node announcement |
| 2 | Direct text message |
| 3 | Group text message |
| 4 | Position update |
**Examples:**
```bash
# Get 100 most recent packets
curl http://localhost:8073/api/v1/meshcore/packets
# Get packets by hash
curl http://localhost:8073/api/v1/meshcore/packets?hash=a1b2c3d4e5f67890
# Get packets by type
curl http://localhost:8073/api/v1/meshcore/packets?type=3
# Get group messages for a channel
curl http://localhost:8073/api/v1/meshcore/packets?type=3&channel_hash=ab
```
```json
[
{
"id": 12345,
"radio_id": 1,
"snr": 8.5,
"rssi": -95,
"version": 1,
"route_type": 0,
"payload_type": 3,
"hash": "a1b2c3d4e5f67890",
"path": "AQIDBA==",
"payload": "SGVsbG8gV29ybGQ=",
"raw": "AQIDBAUGBwg=",
"parsed": {"text": "Hello World"},
"channel_hash": "ab",
"received_at": "2026-03-05T14:30:00Z"
}
]
```
---
### APRS
#### List APRS Packets
```
GET /api/v1/aprs/packets
```
Returns APRS packets based on filter criteria.
**Query Parameters (evaluated in order):**
| Name | Type | Required | Default | Description |
|-------|--------|----------|---------|-------------|
| src | string | No | - | Source callsign (case-insensitive) |
| dst | string | No | - | Destination callsign (case-insensitive) |
| limit | number | No | 100 | Maximum number of packets when no src/dst filter is used |
**Response:** `200 OK`
```typescript
{
id: number;
radio_id: number;
radio: Radio;
src: string;
dst: string;
path: string;
comment: string;
latitude: number | null;
longitude: number | null;
symbol: string;
raw: string;
received_at: string;
}[]
```
**Examples:**
```bash
# Get 100 most recent packets
curl http://localhost:8073/api/v1/aprs/packets
# Get packets by source callsign
curl http://localhost:8073/api/v1/aprs/packets?src=OE1ABC
# Get packets by destination callsign
curl http://localhost:8073/api/v1/aprs/packets?dst=APRS
# Get recent packets with explicit limit
curl http://localhost:8073/api/v1/aprs/packets?limit=200
```
---
## Data Models
### Radio
```typescript
interface Radio {
id: number; // Unique identifier
name: string; // Station name
is_online: boolean; // Online status
manufacturer: string; // Hardware manufacturer
device: string | null; // Device model
firmware_version: string | null; // Firmware version
firmware_date: string | null; // ISO 8601 timestamp
antenna: string | null; // Antenna description
modulation: string; // e.g., "LoRa"
protocol: string; // e.g., "meshcore", "aprs"
latitude: number | null; // Decimal degrees
longitude: number | null; // Decimal degrees
altitude: number | null; // Meters
frequency: number; // Hz
bandwidth: number; // Hz
power: number | null; // dBm
gain: number | null; // dBi
lora_sf: number | null; // LoRa spreading factor (7-12)
lora_cr: number | null; // LoRa coding rate (5-8)
extra: object | null; // Additional metadata
created_at: string; // ISO 8601 timestamp
updated_at: string; // ISO 8601 timestamp
}
```
### MeshCorePacket
```typescript
interface MeshCorePacket {
id: number;
radio_id: number;
radio: Radio | null;
snr: number; // Signal-to-noise ratio (dB)
rssi: number; // Received signal strength (dBm)
version: number;
route_type: number;
payload_type: number;
hash: string; // 16-char hex
path: string; // Base64-encoded
payload: string; // Base64-encoded
raw: string; // Base64-encoded
parsed: object | null; // Depends on payload_type
channel_hash: string; // 2-char hex
received_at: string; // ISO 8601 timestamp
}
```
### APRSPacket
```typescript
interface APRSPacket {
id: number;
radio_id: number;
radio: Radio;
src: string; // Source callsign
dst: string; // Destination callsign
path: string; // Digipeater path
comment: string;
latitude: number | null;
longitude: number | null;
symbol: string; // APRS symbol table + code
raw: string; // Raw APRS packet
received_at: string; // ISO 8601 timestamp
}
```
### MeshCoreNode
```typescript
interface MeshCoreNode {
id: number;
packet: MeshCorePacket[];
name: string;
type: number; // 0=repeater, 1=chat, 2=room, 3=sensor
prefix: string; // 2-char network prefix
public_key: string; // 64-char hex Ed25519 public key
first_heard_at: string; // ISO 8601 timestamp
last_heard_at: string; // ISO 8601 timestamp
last_latitude: number | null;
last_longitude: number | null;
distance?: number; // Meters (proximity queries only)
}
```
### MeshCoreGroup
```typescript
interface MeshCoreGroup {
id: number;
name: string; // Max 32 characters
secret: string; // 32-char hex
}
```
### MeshCoreStats
```typescript
interface MeshCoreStats {
messages: number;
nodes: number;
receivers: number;
packets: {
timestamps: number[]; // Unix timestamps
packets: number[]; // Packet counts
};
}
```
---
## Examples
### JavaScript/TypeScript Client
```typescript
// Using fetch API
const API_BASE = 'http://localhost:8073/api/v1';
// Get all radios
async function getRadios(): Promise<Radio[]> {
const response = await fetch(`${API_BASE}/radios`);
if (!response.ok) throw new Error(await response.text());
return response.json();
}
// Get MeshCore nodes by type
async function getMeshCoreNodes(type?: string): Promise<MeshCoreNode[]> {
const url = type
? `${API_BASE}/meshcore/nodes?type=${type}`
: `${API_BASE}/meshcore/nodes`;
const response = await fetch(url);
if (!response.ok) throw new Error(await response.text());
return response.json();
}
// Find nearby nodes
async function getNodesNearby(publicKey: string, radius: number = 25000) {
const response = await fetch(
`${API_BASE}/meshcore/nodes/close-to/${publicKey}?radius=${radius}`
);
if (!response.ok) throw new Error(await response.text());
return response.json();
}
```
### Python Client
```python
import requests
from typing import List, Optional, Dict, Any
API_BASE = 'http://localhost:8073/api/v1'
def get_radios(protocol: Optional[str] = None) -> List[Dict[str, Any]]:
"""Get all radios or filter by protocol."""
url = f"{API_BASE}/radios/{protocol}" if protocol else f"{API_BASE}/radios"
response = requests.get(url)
response.raise_for_status()
return response.json()
def get_meshcore_packets(
hash: Optional[str] = None,
type: Optional[int] = None,
channel_hash: Optional[str] = None
) -> List[Dict[str, Any]]:
"""Get MeshCore packets with optional filters."""
params = {}
if hash:
params['hash'] = hash
if type is not None:
params['type'] = type
if channel_hash:
params['channel_hash'] = channel_hash
response = requests.get(f"{API_BASE}/meshcore/packets", params=params)
response.raise_for_status()
return response.json()
def get_meshcore_stats() -> Dict[str, Any]:
"""Get MeshCore network statistics."""
response = requests.get(f"{API_BASE}/meshcore")
response.raise_for_status()
return response.json()
```
### Go Client
```go
package hamview
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
)
const APIBase = "http://localhost:8073/api/v1"
type Client struct {
BaseURL string
HTTP *http.Client
}
func NewClient() *Client {
return &Client{
BaseURL: APIBase,
HTTP: &http.Client{},
}
}
func (c *Client) GetRadios() ([]Radio, error) {
var radios []Radio
err := c.get("/radios", &radios)
return radios, err
}
func (c *Client) GetMeshCoreNodes(nodeType string) ([]MeshCoreNode, error) {
path := "/meshcore/nodes"
if nodeType != "" {
path += "?type=" + url.QueryEscape(nodeType)
}
var nodes []MeshCoreNode
err := c.get(path, &nodes)
return nodes, err
}
func (c *Client) get(path string, result interface{}) error {
resp, err := c.HTTP.Get(c.BaseURL + path)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %d", resp.StatusCode)
}
return json.NewDecoder(resp.Body).Decode(result)
}
```
---
## Rate Limiting
Currently no rate limiting is implemented.
## Versioning
API version is specified in the URL path (`/api/v1`). Breaking changes will increment the version number.
## Support
For issues or questions, please refer to the project documentation at https://git.maze.io/ham/hamview

164
server/README.md Normal file
View File

@@ -0,0 +1,164 @@
# Server Package
This package contains the restructured HTTP server implementation for HAMView.
## Structure
```
server/
├── server.go # Main server setup and configuration
├── router.go # Route configuration with nested routers
├── handlers_radios.go # Radio endpoint handlers
├── handlers_meshcore.go # MeshCore endpoint handlers
├── error.go # Error handling utilities
└── server_test.go # Test infrastructure and test cases
```
## Design Principles
### Clean Separation of Concerns
- **server.go**: Server initialization, configuration, and lifecycle management
- **router.go**: Centralized route definition using Echo's Group feature for nested routing
- **handlers_*.go**: Domain-specific handler functions grouped by feature
- **error.go**: Consistent error handling across all endpoints
### Nested Routers
The routing structure uses Echo's Group feature to create a clean hierarchy:
```
/api/v1
├── /radios
│ ├── GET / -> handleGetRadios
│ └── GET /:protocol -> handleGetRadios
└── /meshcore
├── GET / -> handleGetMeshCore
├── GET /groups -> handleGetMeshCoreGroups
├── GET /packets -> handleGetMeshCorePackets
└── /nodes
├── GET / -> handleGetMeshCoreNodes
└── GET /close-to/:publickey -> handleGetMeshCoreNodesCloseTo
```
### Testing Infrastructure
The test suite uses an in-memory SQLite3 database for fast, isolated unit tests:
- **setupTestServer()**: Creates a test Echo instance with routes and in-memory DB
- **teardownTestServer()**: Cleans up test resources
- Test cases cover all endpoints with various query parameters
- Benchmarks for performance testing
- Example showing how to populate test data
## Usage
### Creating a Server
```go
import (
"github.com/sirupsen/logrus"
"git.maze.io/ham/hamview/server"
)
logger := logrus.New()
serverConfig := &server.Config{
Listen: ":8073",
}
dbConfig := &server.DatabaseConfig{
Type: "postgres",
Conf: "host=localhost user=ham dbname=hamview",
}
srv, err := server.New(serverConfig, dbConfig, logger)
if err != nil {
log.Fatal(err)
}
if err := srv.Run(); err != nil {
log.Fatal(err)
}
```
### Running Tests
```bash
# Run all tests
go test -v ./server/
# Run specific test
go test -v ./server/ -run TestRadiosEndpoints
# Run with coverage
go test -v -cover ./server/
# Run benchmarks
go test -v -bench=. ./server/
```
### Adding New Endpoints
1. **Add handler function** in the appropriate `handlers_*.go` file:
```go
func (s *Server) handleNewEndpoint(c echo.Context) error {
// Implementation
return c.JSON(http.StatusOK, result)
}
```
2. **Register route** in `router.go`:
```go
func setupSomethingRoutes(s *Server, api *echo.Group) {
group := api.Group("/something")
group.GET("/new", s.handleNewEndpoint)
}
```
3. **Add test** in `server_test.go`:
```go
func TestNewEndpoint(t *testing.T) {
e, _ := setupTestServer(t)
defer teardownTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/api/v1/something/new", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
// Assertions
}
```
## Backward Compatibility
The root `server.go` file maintains backward compatibility by re-exporting types and providing a compatibility wrapper for `NewServer()`. Existing code continues to work without changes.
## Best Practices
1. **Handler naming**: Use `handle` prefix (e.g., `handleGetRadios`)
2. **Context parameter**: Use short name `c` for `echo.Context`
3. **Error handling**: Always use `s.apiError()` for consistent error responses
4. **Query parameters**: Use helper functions like `getQueryInt()` for parsing
5. **Testing**: Write tests for both success and error cases
6. **Documentation**: Add godoc comments for exported functions
## Performance Considerations
- Use in-memory SQLite for tests (fast and isolated)
- Benchmark critical endpoints
- Use Echo's built-in middleware for CORS, logging, etc.
- Consider adding route-specific middleware for caching or rate limiting
## Future Enhancements
Potential improvements to consider:
- [ ] Add middleware for authentication/authorization
- [ ] Implement request validation using a schema library
- [ ] Add structured logging with trace IDs
- [ ] Implement health check and metrics endpoints
- [ ] Add integration tests with a real PostgreSQL instance
- [ ] Consider adding OpenAPI/Swagger documentation
- [ ] Add graceful shutdown handling

Some files were not shown because too many files have changed in this diff Show More