Compare commits
18 Commits
v0.1.0
...
refactor-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
d2e710d179
|
|||
|
9053ec65a6
|
|||
|
247c827291
|
|||
|
e83df1c143
|
|||
|
7a8d7b0275
|
|||
|
13afa08e8a
|
|||
|
3106b2cf45
|
|||
|
95eb917871
|
|||
|
da9dfda207
|
|||
|
42ca30c8e4
|
|||
|
6d716e6654
|
|||
|
41ba6b3142
|
|||
|
214b013ea7
|
|||
|
4b29396549
|
|||
|
5495fae127
|
|||
|
dc2e7f4563
|
|||
|
f234497162
|
|||
|
227477d17f
|
58
.air.toml
Normal 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
|
||||||
@@ -7,39 +7,52 @@ permissions:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
|
name: Test and lint
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- name: Checkout
|
||||||
- uses: actions/setup-go@v6
|
uses: actions/checkout@v6
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version-file: 'go.mod'
|
go-version-file: 'go.mod'
|
||||||
check-latest: false
|
check-latest: false
|
||||||
#- name: golangci-lint
|
- name: Test
|
||||||
# uses: golangci/golangci-lint-action@v9
|
run: go test -v ./...
|
||||||
# with:
|
- name: Lint
|
||||||
# go-version: ${{ matrix.go }}
|
uses: golangci/golangci-lint-action@v9
|
||||||
# version: v2.6
|
with:
|
||||||
|
go-version: ${{ matrix.go }}
|
||||||
|
version: v2.6
|
||||||
|
if: ${{ github.actor != 'maze' }}
|
||||||
|
|
||||||
build_collector:
|
build_collector:
|
||||||
name: Build collector
|
name: Build collector
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- name: Checkout
|
||||||
- uses: actions/setup-go@v6
|
uses: actions/checkout@v6
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version-file: 'go.mod'
|
go-version-file: 'go.mod'
|
||||||
check-latest: false
|
check-latest: false
|
||||||
- name: Download modules
|
- name: Download modules
|
||||||
run: go mod download
|
run: go mod download
|
||||||
- name: Make build directory
|
- name: Make build directory
|
||||||
run: mkdir -p build && readlink -f build
|
run: mkdir -p build
|
||||||
- name: Build
|
- name: Build
|
||||||
run: go build -o build/hamview-collector ./cmd/hamview-collector && ls -al $(readlink -f build/hamview-collector)
|
run: |
|
||||||
- name: Debug env
|
go build -o build/hamview-collector ./cmd/hamview-collector && \
|
||||||
run: env
|
ls -al $(readlink -f build/hamview-collector)
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
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
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ${{ vars.REGISTRY }}
|
registry: ${{ vars.REGISTRY }}
|
||||||
@@ -49,7 +62,7 @@ jobs:
|
|||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
push: true
|
push: true
|
||||||
tags: ham/hamview:collector
|
tags: ${{ vars.REGISTRY }}/ham/hamview/collector:dev
|
||||||
file: ./cmd/hamview-collector/Dockerfile
|
file: ./cmd/hamview-collector/Dockerfile
|
||||||
context: .
|
context: .
|
||||||
platforms: |
|
platforms: |
|
||||||
@@ -58,40 +71,52 @@ jobs:
|
|||||||
build_receiver:
|
build_receiver:
|
||||||
name: Build receiver
|
name: Build receiver
|
||||||
runs-on: ubuntu-latest
|
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:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- name: Checkout
|
||||||
- uses: actions/setup-go@v6
|
uses: actions/checkout@v6
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version-file: 'go.mod'
|
go-version-file: 'go.mod'
|
||||||
check-latest: false
|
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
|
- name: Download modules
|
||||||
run: go mod download
|
run: go mod download
|
||||||
- name: Make build directory
|
- name: Make build directory
|
||||||
run: mkdir -p build && readlink -f build
|
run: mkdir -p build && readlink -f build
|
||||||
- name: Build
|
- name: Build amd64
|
||||||
run: go build -o build/hamview-receiver.${{ matrix.goarch }}${{ matrix.goarm }} ./cmd/hamview-receiver && readlink -f build/hamview-receiver.${{ matrix.goarch }}${{ matrix.goarm }}
|
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
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
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
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ${{ vars.REGISTRY }}
|
registry: ${{ vars.REGISTRY }}
|
||||||
@@ -101,7 +126,7 @@ jobs:
|
|||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
push: true
|
push: true
|
||||||
tags: ham/hamview:receiver
|
tags: ${{ vars.REGISTRY }}/ham/hamview/receiver:dev
|
||||||
file: ./cmd/hamview-receiver/Dockerfile
|
file: ./cmd/hamview-receiver/Dockerfile
|
||||||
context: .
|
context: .
|
||||||
platforms: |
|
platforms: |
|
||||||
|
|||||||
25
.gitignore
vendored
@@ -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 artifacts
|
||||||
build/
|
build/
|
||||||
|
tmp/
|
||||||
|
|
||||||
# Local configuration files
|
# Local configuration files
|
||||||
etc/*.key
|
etc/*.key
|
||||||
|
|||||||
0
.gitmodules
vendored
Normal file
@@ -10,3 +10,8 @@ repos:
|
|||||||
rev: v2.10.1
|
rev: v2.10.1
|
||||||
hooks:
|
hooks:
|
||||||
- id: golangci-lint
|
- id: golangci-lint
|
||||||
|
|
||||||
|
- repo: https://github.com/shellcheck-py/shellcheck-py
|
||||||
|
rev: v0.11.0.1
|
||||||
|
hooks:
|
||||||
|
- id: shellcheck
|
||||||
|
|||||||
42
.vscode/settings.json
vendored
@@ -1,5 +1,45 @@
|
|||||||
{
|
{
|
||||||
"gopls": {
|
"gopls": {
|
||||||
"formatting.local": "git.maze.io"
|
"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
@@ -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
@@ -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.
|
||||||
BIN
asset/image/device/unknown.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
asset/image/protocol/aprs.org.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
asset/image/protocol/aprs.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
423
asset/image/protocol/aprs/COPYRIGHT.md
Normal 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
|
||||||
BIN
asset/image/protocol/aprs/aprs-symbols-128-0.png
Normal file
|
After Width: | Height: | Size: 308 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-128-0@2x.png
Normal file
|
After Width: | Height: | Size: 521 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-128-0@3x.png
Normal file
|
After Width: | Height: | Size: 825 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-128-1.png
Normal file
|
After Width: | Height: | Size: 279 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-128-1@2x.png
Normal file
|
After Width: | Height: | Size: 449 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-128-1@3x.png
Normal file
|
After Width: | Height: | Size: 676 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-128-2.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-128-2@2x.png
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-128-2@3x.png
Normal file
|
After Width: | Height: | Size: 156 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-24-0.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-24-0@2x.png
Normal file
|
After Width: | Height: | Size: 105 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-24-0@3x.png
Normal file
|
After Width: | Height: | Size: 162 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-24-1.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-24-1@2x.png
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-24-1@3x.png
Normal file
|
After Width: | Height: | Size: 140 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-24-2.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-24-2@2x.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-24-2@3x.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-256-0.png
Normal file
|
After Width: | Height: | Size: 521 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-256-0@2x.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
asset/image/protocol/aprs/aprs-symbols-256-0@3x.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
asset/image/protocol/aprs/aprs-symbols-256-1.png
Normal file
|
After Width: | Height: | Size: 449 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-256-1@2x.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
asset/image/protocol/aprs/aprs-symbols-256-1@3x.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
asset/image/protocol/aprs/aprs-symbols-256-2.png
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-256-2@2x.png
Normal file
|
After Width: | Height: | Size: 251 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-256-2@3x.png
Normal file
|
After Width: | Height: | Size: 416 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-32-0.png
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-32-0@2x.png
Normal file
|
After Width: | Height: | Size: 144 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-32-0@3x.png
Normal file
|
After Width: | Height: | Size: 224 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-32-1.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-32-1@2x.png
Normal file
|
After Width: | Height: | Size: 136 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-32-1@3x.png
Normal file
|
After Width: | Height: | Size: 188 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-32-2.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-32-2@2x.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-32-2@3x.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-48-0.png
Normal file
|
After Width: | Height: | Size: 105 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-48-0@2x.png
Normal file
|
After Width: | Height: | Size: 224 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-48-0@3x.png
Normal file
|
After Width: | Height: | Size: 350 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-48-1.png
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-48-1@2x.png
Normal file
|
After Width: | Height: | Size: 188 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-48-1@3x.png
Normal file
|
After Width: | Height: | Size: 301 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-48-2.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-48-2@2x.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-48-2@3x.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-56-0.png
Normal file
|
After Width: | Height: | Size: 125 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-56-0@2x.png
Normal file
|
After Width: | Height: | Size: 265 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-56-0@3x.png
Normal file
|
After Width: | Height: | Size: 414 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-56-1.png
Normal file
|
After Width: | Height: | Size: 119 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-56-1@2x.png
Normal file
|
After Width: | Height: | Size: 241 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-56-1@3x.png
Normal file
|
After Width: | Height: | Size: 352 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-56-2.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-56-2@2x.png
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-56-2@3x.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-64-0.png
Normal file
|
After Width: | Height: | Size: 144 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-64-0@2x.png
Normal file
|
After Width: | Height: | Size: 308 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-64-0@3x.png
Normal file
|
After Width: | Height: | Size: 489 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-64-1.png
Normal file
|
After Width: | Height: | Size: 136 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-64-1@2x.png
Normal file
|
After Width: | Height: | Size: 279 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-64-1@3x.png
Normal file
|
After Width: | Height: | Size: 401 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-64-2.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-64-2@2x.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-64-2@3x.png
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-64-droid.png
Normal file
|
After Width: | Height: | Size: 320 KiB |
BIN
asset/image/protocol/unknown.org.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
asset/image/protocol/unknown.png
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
24
broker.go
@@ -33,13 +33,18 @@ type Broker interface {
|
|||||||
SubscribeRadios() (<-chan *Radio, error)
|
SubscribeRadios() (<-chan *Radio, error)
|
||||||
|
|
||||||
PublishPacket(topic string, packet *protocol.Packet) error
|
PublishPacket(topic string, packet *protocol.Packet) error
|
||||||
SubscribePackets(topic string) (<-chan *protocol.Packet, error)
|
SubscribePackets(topic string) (<-chan *Packet, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type Receiver interface {
|
type Receiver interface {
|
||||||
Disconnected()
|
Disconnected()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Packet struct {
|
||||||
|
RadioID string
|
||||||
|
*protocol.Packet
|
||||||
|
}
|
||||||
|
|
||||||
type BrokerConfig struct {
|
type BrokerConfig struct {
|
||||||
Type string `yaml:"type"`
|
Type string `yaml:"type"`
|
||||||
Config yaml.Node `yaml:"conf"`
|
Config yaml.Node `yaml:"conf"`
|
||||||
@@ -197,7 +202,7 @@ func (broker *mqttBroker) SubscribeRadios() (<-chan *Radio, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
radios := make(chan *Radio, 8)
|
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
|
var radio Radio
|
||||||
if err := json.Unmarshal(message.Payload(), &radio); err == nil {
|
if err := json.Unmarshal(message.Payload(), &radio); err == nil {
|
||||||
select {
|
select {
|
||||||
@@ -232,17 +237,24 @@ func (broker *mqttBroker) PublishPacket(topic string, packet *protocol.Packet) e
|
|||||||
return nil
|
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 {
|
if broker.client == nil {
|
||||||
return nil, ErrBrokerNotStarted
|
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) {
|
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 {
|
if err := json.Unmarshal(message.Payload(), &packet); err == nil {
|
||||||
select {
|
select {
|
||||||
case packets <- &packet:
|
case packets <- &Packet{
|
||||||
|
RadioID: id,
|
||||||
|
Packet: &packet,
|
||||||
|
}:
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
FROM alpine:3
|
FROM alpine:3
|
||||||
COPY ./etc /etc/hamview
|
RUN apk add --no-cache dumb-init=1.2.5-r3 bash=5.3.3-r1
|
||||||
COPY ./build/hamview-collector /opt/hamview/bin/hamview-collector
|
|
||||||
WORKDIR /opt/hamview
|
WORKDIR /app
|
||||||
ENTRYPOINT ["bin/hamview-collector"]
|
|
||||||
CMD [ "--config", "/etc/hamview/hamview-collector.yaml" ]
|
COPY ./etc /app/config
|
||||||
|
COPY ./build/hamview-collector /app/hamview-collector
|
||||||
|
COPY ./cmd/hamview-collector/entrypoint.sh /entrypoint.sh
|
||||||
|
|
||||||
|
ENTRYPOINT ["dumb-init", "/entrypoint.sh"]
|
||||||
|
|||||||
22
cmd/hamview-collector/entrypoint.sh
Executable 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
|
||||||
@@ -64,7 +64,6 @@ func run(ctx context.Context, command *cli.Command) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer collector.Close()
|
|
||||||
|
|
||||||
broker, err := hamview.NewBroker(&config.Broker)
|
broker, err := hamview.NewBroker(&config.Broker)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -79,7 +78,11 @@ func run(ctx context.Context, command *cli.Command) error {
|
|||||||
protocol.APRS,
|
protocol.APRS,
|
||||||
protocol.MeshCore,
|
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")
|
return cmd.WaitForInterrupt(logger, "collector")
|
||||||
|
|||||||
12
cmd/hamview-receiver/Dockerfile
Normal 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"]
|
||||||
26
cmd/hamview-receiver/entrypoint.sh
Executable 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
|
||||||
49
cmd/hamview-receiver/main.go
Normal 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")
|
||||||
|
}
|
||||||
146
cmd/hamview-receiver/run_aprsis.go
Normal 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
|
||||||
|
}
|
||||||
83
cmd/hamview-receiver/run_meshcore.go
Normal 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()
|
||||||
|
}
|
||||||
501
collector.go
@@ -1,15 +1,21 @@
|
|||||||
package hamview
|
package hamview
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"context"
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
_ "github.com/cridenour/go-postgis" // PostGIS support
|
_ "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"
|
||||||
"git.maze.io/go/ham/protocol/aprs"
|
"git.maze.io/go/ham/protocol/aprs"
|
||||||
"git.maze.io/go/ham/protocol/meshcore"
|
"git.maze.io/go/ham/protocol/meshcore"
|
||||||
|
"git.maze.io/ham/hamview/schema"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CollectorConfig struct {
|
type CollectorConfig struct {
|
||||||
@@ -22,58 +28,20 @@ type DatabaseConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Collector struct {
|
type Collector struct {
|
||||||
*sql.DB
|
radioByID map[string]*schema.Radio
|
||||||
|
|
||||||
meshCoreGroup map[byte][]*meshcore.Group
|
meshCoreGroup map[byte][]*meshcore.Group
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCollector(config *CollectorConfig) (*Collector, error) {
|
func NewCollector(config *CollectorConfig) (*Collector, error) {
|
||||||
d, err := sql.Open(config.Database.Type, config.Database.Conf)
|
Logger.Debugf("collector: opening %q database", config.Database.Type)
|
||||||
if err != nil {
|
schema.Logger = Logger
|
||||||
|
if err := schema.Open(config.Database.Type, config.Database.Conf); err != nil {
|
||||||
return nil, err
|
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{
|
return &Collector{
|
||||||
DB: d,
|
|
||||||
meshCoreGroup: make(map[byte][]*meshcore.Group),
|
meshCoreGroup: make(map[byte][]*meshcore.Group),
|
||||||
|
radioByID: make(map[string]*schema.Radio),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,6 +60,8 @@ func (c *Collector) Collect(broker Broker, topic string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
loop:
|
loop:
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
@@ -99,7 +69,7 @@ loop:
|
|||||||
if radio == nil {
|
if radio == nil {
|
||||||
break loop
|
break loop
|
||||||
}
|
}
|
||||||
c.processRadio(radio)
|
c.processRadio(ctx, radio)
|
||||||
|
|
||||||
case packet := <-packets:
|
case packet := <-packets:
|
||||||
if packet == nil {
|
if packet == nil {
|
||||||
@@ -107,9 +77,9 @@ loop:
|
|||||||
}
|
}
|
||||||
switch packet.Protocol {
|
switch packet.Protocol {
|
||||||
case protocol.APRS:
|
case protocol.APRS:
|
||||||
c.processAPRSPacket(packet)
|
c.processAPRSPacket(ctx, packet)
|
||||||
case protocol.MeshCore:
|
case protocol.MeshCore:
|
||||||
c.processMeshCorePacket(packet)
|
c.processMeshCorePacket(ctx, packet)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -119,120 +89,97 @@ loop:
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Collector) processRadio(radio *Radio) {
|
func (c *Collector) getRadioByID(ctx context.Context, id string) (*schema.Radio, error) {
|
||||||
Logger.Tracef("collector: process %s radio %q online %t",
|
id = strings.TrimRight(id, "=")
|
||||||
radio.Protocol,
|
if radio, ok := c.radioByID[id]; ok {
|
||||||
radio.Name,
|
return radio, nil
|
||||||
radio.IsOnline)
|
|
||||||
|
|
||||||
var latitude, longitude, altitude *float64
|
|
||||||
if radio.Position != nil {
|
|
||||||
latitude = &radio.Position.Latitude
|
|
||||||
longitude = &radio.Position.Longitude
|
|
||||||
altitude = &radio.Position.Altitude
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var id int64
|
radio, err := schema.GetRadioByEncodedID(ctx, id)
|
||||||
if err := c.QueryRow(`
|
if err == nil {
|
||||||
INSERT INTO radio (
|
c.radioByID[id] = radio
|
||||||
name,
|
}
|
||||||
is_online,
|
return radio, err
|
||||||
device,
|
}
|
||||||
manufacturer,
|
|
||||||
firmware_date,
|
func (c *Collector) processRadio(ctx context.Context, received *Radio) {
|
||||||
firmware_version,
|
Logger.Tracef("collector: process %s radio %q online %t",
|
||||||
antenna,
|
received.Protocol,
|
||||||
modulation,
|
received.Name,
|
||||||
protocol,
|
received.IsOnline)
|
||||||
latitude,
|
|
||||||
longitude,
|
var (
|
||||||
altitude,
|
now = time.Now()
|
||||||
frequency,
|
engine = schema.Query(ctx).(*xorm.Session)
|
||||||
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)
|
if err := engine.Begin(); err != nil {
|
||||||
DO UPDATE
|
Logger.Warnf("collector: can't start session: %v", err)
|
||||||
SET
|
return
|
||||||
is_online = $2,
|
}
|
||||||
device = NULLIF($3, ''),
|
|
||||||
manufacturer = NULLIF($4, ''),
|
radio := new(schema.Radio)
|
||||||
firmware_date = $5,
|
has, err := engine.Where(builder.Eq{
|
||||||
firmware_version = NULLIF($6, ''),
|
"name": received.Name,
|
||||||
antenna = NULLIF($7, ''),
|
"protocol": received.Protocol,
|
||||||
modulation = NULLIF($8, ''),
|
}).Get(radio)
|
||||||
protocol = $9,
|
if err != nil {
|
||||||
latitude = NULLIF($10, 0.0),
|
Logger.Warnf("collector: can't query radio: %v", err)
|
||||||
longitude = NULLIF($11, 0.0),
|
return
|
||||||
altitude = $12,
|
}
|
||||||
frequency = $13,
|
if has {
|
||||||
rx_frequency = NULLIF($14, 0.0),
|
radio.IsOnline = received.IsOnline
|
||||||
tx_frequency = NULLIF($15, 0.0),
|
radio.UpdatedAt = now
|
||||||
bandwidth = $16,
|
if _, err = engine.Cols("is_online", "updated_at").Update(radio); err != nil {
|
||||||
power = NULLIF($17, 0),
|
_ = engine.Rollback()
|
||||||
gain = NULLIF($18, 0),
|
Logger.Warnf("collector: can't update radio: %v", err)
|
||||||
lora_sf = NULLIF($19, 0),
|
return
|
||||||
lora_cr = NULLIF($20, 0),
|
}
|
||||||
extra = $21
|
} else {
|
||||||
RETURNING id
|
radio = &schema.Radio{
|
||||||
`,
|
Name: received.Name,
|
||||||
radio.Name,
|
IsOnline: received.IsOnline,
|
||||||
radio.IsOnline,
|
Manufacturer: received.Manufacturer,
|
||||||
radio.Device,
|
Device: schema.NULLString(received.Device),
|
||||||
radio.Manufacturer,
|
FirmwareVersion: schema.NULLString(received.FirmwareVersion),
|
||||||
radio.FirmwareDate,
|
FirmwareDate: schema.NULLTime(received.FirmwareDate),
|
||||||
radio.FirmwareVersion,
|
Antenna: schema.NULLString(received.Antenna),
|
||||||
radio.Antenna,
|
Modulation: received.Modulation,
|
||||||
radio.Modulation,
|
Protocol: received.Protocol,
|
||||||
radio.Protocol,
|
Frequency: received.Frequency,
|
||||||
latitude,
|
Bandwidth: received.Bandwidth,
|
||||||
longitude,
|
Power: schema.NULLFloat64(received.Power),
|
||||||
altitude,
|
Gain: schema.NULLFloat64(received.Gain),
|
||||||
radio.Frequency,
|
CreatedAt: now,
|
||||||
radio.RXFrequency,
|
UpdatedAt: now,
|
||||||
radio.TXFrequency,
|
}
|
||||||
radio.Bandwidth,
|
if received.Position != nil {
|
||||||
radio.Power,
|
radio.Latitude = &received.Position.Latitude
|
||||||
radio.Gain,
|
radio.Longitude = &received.Position.Longitude
|
||||||
radio.LoRaSF,
|
radio.Altitude = &received.Position.Altitude
|
||||||
radio.LoRaCR,
|
}
|
||||||
nil,
|
if received.LoRaCR != 0 && received.LoRaSF != 0 {
|
||||||
).Scan(&id); err != nil {
|
radio.LoRaSF = &received.LoRaSF
|
||||||
Logger.Warnf("collector: error storing radio: %v", err)
|
radio.LoRaCR = &received.LoRaCR
|
||||||
|
}
|
||||||
|
if _, err = engine.Insert(radio); err != nil {
|
||||||
|
Logger.Warnf("collector: can't insert radio %#+v: %v", radio, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Collector) processAPRSPacket(packet *protocol.Packet) {
|
if err = engine.Commit(); err != nil {
|
||||||
decoded, err := aprs.ParsePacket(string(packet.Raw))
|
Logger.Errorf("collector: can't commit radio session: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
if err != nil {
|
||||||
Logger.Warnf("collector: invalid %s packet: %v", packet.Protocol, err)
|
Logger.Warnf("collector: invalid %s packet: %v", packet.Protocol, err)
|
||||||
return
|
return
|
||||||
@@ -242,27 +189,71 @@ func (c *Collector) processAPRSPacket(packet *protocol.Packet) {
|
|||||||
packet.Protocol,
|
packet.Protocol,
|
||||||
len(packet.Raw))
|
len(packet.Raw))
|
||||||
|
|
||||||
var id int64
|
engine := schema.Query(ctx)
|
||||||
if err := c.QueryRow(`
|
station := new(schema.APRSStation)
|
||||||
INSERT INTO aprs_packet (
|
has, err := engine.Where(builder.Eq{"call": strings.ToUpper(decoded.Source.String())}).Get(station)
|
||||||
src_address,
|
if err != nil {
|
||||||
dst_address,
|
Logger.Warnf("collector: can't query APRS station: %v", err)
|
||||||
comment
|
return
|
||||||
) VALUES ($1, $2, $3)
|
} else if has {
|
||||||
RETURNING id;
|
cols := []string{"last_heard_at"}
|
||||||
`,
|
station.LastHeardAt = packet.Time
|
||||||
decoded.Src.String(),
|
if decoded.Latitude != 0 {
|
||||||
decoded.Dst.String(),
|
station.LastLatitude = &decoded.Latitude
|
||||||
decoded.Comment,
|
station.LastLongitude = &decoded.Longitude
|
||||||
).Scan(&id); err != nil {
|
cols = append(cols, "last_latitude", "last_longitude")
|
||||||
Logger.Warnf("collector: error storing packet: %v", err)
|
}
|
||||||
|
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
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Collector) processMeshCorePacket(packet *protocol.Packet) {
|
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(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
|
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)
|
Logger.Warnf("collector: invalid %s packet: %v", packet.Protocol, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -276,101 +267,110 @@ func (c *Collector) processMeshCorePacket(packet *protocol.Packet) {
|
|||||||
parsed.Path = nil // store NULL
|
parsed.Path = nil // store NULL
|
||||||
}
|
}
|
||||||
|
|
||||||
var id int64
|
var channelHash string
|
||||||
if err := c.QueryRow(`
|
switch parsed.PayloadType {
|
||||||
INSERT INTO meshcore_packet (
|
case meshcore.TypeGroupText, meshcore.TypeGroupData:
|
||||||
snr,
|
if len(parsed.Payload) > 0 {
|
||||||
rssi,
|
channelHash = fmt.Sprintf("%02x", parsed.Payload[0])
|
||||||
hash,
|
}
|
||||||
route_type,
|
}
|
||||||
payload_type,
|
|
||||||
path,
|
save := &schema.MeshCorePacket{
|
||||||
payload,
|
RadioID: radio.ID,
|
||||||
raw,
|
SNR: packet.SNR,
|
||||||
received_at
|
RSSI: packet.RSSI,
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
RouteType: uint8(parsed.RouteType),
|
||||||
RETURNING id;`,
|
PayloadType: uint8(parsed.PayloadType),
|
||||||
packet.SNR,
|
Version: parsed.Version,
|
||||||
packet.RSSI,
|
Hash: hex.EncodeToString(parsed.Hash()),
|
||||||
parsed.Hash(),
|
Path: parsed.Path,
|
||||||
parsed.RouteType,
|
Payload: parsed.Payload,
|
||||||
parsed.PayloadType,
|
ChannelHash: channelHash,
|
||||||
parsed.Path,
|
Raw: packet.Raw,
|
||||||
parsed.Payload,
|
ReceivedAt: packet.Time,
|
||||||
packet.Raw,
|
}
|
||||||
packet.Time,
|
if _, err = schema.Query(ctx).Insert(save); err != nil {
|
||||||
).Scan(&id); err != nil {
|
|
||||||
Logger.Warnf("collector: error storing packet: %v", err)
|
Logger.Warnf("collector: error storing packet: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
switch parsed.PayloadType {
|
switch parsed.PayloadType {
|
||||||
case meshcore.TypeAdvert:
|
case meshcore.TypeAdvert:
|
||||||
|
c.processMeshCoreAdvert(ctx, save, &parsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Collector) processMeshCoreAdvert(ctx context.Context, packet *schema.MeshCorePacket, parsed *meshcore.Packet) {
|
||||||
payload, err := parsed.Decode()
|
payload, err := parsed.Decode()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Logger.Warnf("collector: error decoding packet: %v", err)
|
Logger.Warnf("collector: error decoding packet: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
advert, ok := payload.(*meshcore.Advert)
|
||||||
advert = payload.(*meshcore.Advert)
|
if !ok {
|
||||||
nodeID int64
|
Logger.Warnf("collector: expected Advert, got %T!?", payload)
|
||||||
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
|
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 {
|
if advert.Position != nil {
|
||||||
if _, err = c.Exec(`
|
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 (
|
INSERT INTO meshcore_node_position (
|
||||||
node_id,
|
node_id,
|
||||||
heard_at,
|
heard_at,
|
||||||
@@ -397,4 +397,5 @@ func (c *Collector) processMeshCorePacket(packet *protocol.Packet) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
}
|
}
|
||||||
|
|||||||
12
go.mod
@@ -1,9 +1,11 @@
|
|||||||
module git.maze.io/ham/hamview
|
module git.maze.io/ham/hamview
|
||||||
|
|
||||||
go 1.25.6
|
go 1.26
|
||||||
|
|
||||||
|
replace git.maze.io/go/ham => ../ham
|
||||||
|
|
||||||
require (
|
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/Vaniog/go-postgis v0.0.0-20240619200434-9c2eb8ed621e
|
||||||
github.com/cemkiy/echo-logrus v0.0.0-20200218141616-06f9cd1dae34
|
github.com/cemkiy/echo-logrus v0.0.0-20200218141616-06f9cd1dae34
|
||||||
github.com/cridenour/go-postgis v1.0.1
|
github.com/cridenour/go-postgis v1.0.1
|
||||||
@@ -11,20 +13,26 @@ require (
|
|||||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
github.com/labstack/echo/v4 v4.15.0
|
github.com/labstack/echo/v4 v4.15.0
|
||||||
github.com/lib/pq v1.11.2
|
github.com/lib/pq v1.11.2
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.32
|
||||||
github.com/sirupsen/logrus v1.9.4
|
github.com/sirupsen/logrus v1.9.4
|
||||||
github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07
|
github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07
|
||||||
github.com/urfave/cli/v3 v3.6.2
|
github.com/urfave/cli/v3 v3.6.2
|
||||||
go.yaml.in/yaml/v3 v3.0.4
|
go.yaml.in/yaml/v3 v3.0.4
|
||||||
|
xorm.io/builder v0.3.13
|
||||||
|
xorm.io/xorm v1.3.11
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
filippo.io/edwards25519 v1.1.0 // indirect
|
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/gorilla/websocket v1.5.3 // indirect
|
||||||
github.com/kr/pretty v0.3.0 // indirect
|
github.com/kr/pretty v0.3.0 // indirect
|
||||||
github.com/labstack/gommon v0.4.2 // indirect
|
github.com/labstack/gommon v0.4.2 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/rogpeppe/go-internal v1.8.0 // 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/bytebufferpool v1.0.0 // indirect
|
||||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||||
golang.org/x/crypto v0.48.0 // indirect
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
|
|||||||
74
go.sum
@@ -1,9 +1,7 @@
|
|||||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
git.maze.io/go/ham v0.0.0-20260222201625-824bde5d5525 h1:No1WgUBujHY8tSMfIFuvcAJ5qe9RK6o2WgmZROp5+JM=
|
gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a h1:lSA0F4e9A2NcQSqGqTOXqu2aRi/XEQxDCBwM8yJtE6s=
|
||||||
git.maze.io/go/ham v0.0.0-20260222201625-824bde5d5525/go.mod h1:+WuiawzNBqlWgklVoodUAJc0cV+NDW6RR8Tn+AW8hsU=
|
gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:EXuID2Zs0pAQhH8yz+DNjUbjppKQzKFAn28TMYPB6IU=
|
||||||
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=
|
|
||||||
github.com/Vaniog/go-postgis v0.0.0-20240619200434-9c2eb8ed621e h1:Ck+0lNRr62RM/LNKkkD0R1aJ2DvgELqmmuNvyyHL75E=
|
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/Vaniog/go-postgis v0.0.0-20240619200434-9c2eb8ed621e/go.mod h1:o3MIxN5drWoGBTtBGtLqFZlr7RjfdQKnfwYXoUU77vU=
|
||||||
github.com/cemkiy/echo-logrus v0.0.0-20200218141616-06f9cd1dae34 h1:cGxEwqDl+PiqPtJpQNoiJIXcrVEkkSMuMQtb+PPAHL4=
|
github.com/cemkiy/echo-logrus v0.0.0-20200218141616-06f9cd1dae34 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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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/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 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE=
|
||||||
github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU=
|
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 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
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 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
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.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/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
github.com/kr/pretty v0.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.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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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.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 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
|
||||||
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
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.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.1.1/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.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.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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
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 h1:UyzmZLoiDWMRywV4DUYb9Fbt8uiOSooupjTq10vpvnU=
|
||||||
github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA=
|
github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA=
|
||||||
github.com/urfave/cli/v3 v3.6.2 h1:lQuqiPrZ1cIz8hz+HcrG0TNZFxU70dPZ3Yl+pSrH9A8=
|
github.com/urfave/cli/v3 v3.6.2 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.0.0-20200214034016-1d94cc7ab1c6/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
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-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-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.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 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
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 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
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-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-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-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 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
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.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 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-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 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
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/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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
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=
|
||||||
|
|||||||
49
meshcore.go
@@ -27,6 +27,7 @@ type MeshCoreCompanionConfig struct {
|
|||||||
Port string `yaml:"port"`
|
Port string `yaml:"port"`
|
||||||
Baud int `yaml:"baud"`
|
Baud int `yaml:"baud"`
|
||||||
Addr string `yaml:"addr"`
|
Addr string `yaml:"addr"`
|
||||||
|
HasSNR bool `yaml:"has_snr"` // patch: adds SNR/RSSI data
|
||||||
}
|
}
|
||||||
|
|
||||||
type MeshCorePrefix byte
|
type MeshCorePrefix byte
|
||||||
@@ -54,6 +55,9 @@ func NewMeshCoreReceiver(config *MeshCoreConfig) (protocol.PacketReceiver, error
|
|||||||
case "companion", "":
|
case "companion", "":
|
||||||
return newMeshCoreCompanionReceiver(config.Conf)
|
return newMeshCoreCompanionReceiver(config.Conf)
|
||||||
|
|
||||||
|
case "repeater":
|
||||||
|
return newMeshCoreRepeaterReceiver(config.Conf)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("hamview: unsupported MeshCore node type %q", config.Type)
|
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
|
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 {
|
type meshCoreNode struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
PublicKey []byte `json:"public_key"`
|
PublicKey []byte `json:"public_key"`
|
||||||
|
|||||||
86
schema/aprs.go
Normal 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
@@ -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
@@ -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
@@ -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
@@ -1,423 +1,30 @@
|
|||||||
package hamview
|
package hamview
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"github.com/sirupsen/logrus"
|
||||||
"encoding/base64"
|
|
||||||
"encoding/hex"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"slices"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
echologrus "github.com/cemkiy/echo-logrus"
|
"git.maze.io/ham/hamview/server"
|
||||||
"github.com/labstack/echo/v4"
|
|
||||||
"github.com/labstack/echo/v4/middleware"
|
|
||||||
|
|
||||||
"git.maze.io/go/ham/protocol/meshcore"
|
|
||||||
"git.maze.io/go/ham/protocol/meshcore/crypto"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const DefaultServerListen = ":8073"
|
// Deprecated: Use server.Config instead
|
||||||
|
type ServerConfig = server.Config
|
||||||
|
|
||||||
type ServerConfig struct {
|
// Deprecated: Use server.Server instead
|
||||||
Listen string `yaml:"listen"`
|
type Server = server.Server
|
||||||
}
|
|
||||||
|
|
||||||
type Server struct {
|
|
||||||
listen string
|
|
||||||
listenAddr *net.TCPAddr
|
|
||||||
db *sql.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// NewServer creates a new server instance
|
||||||
|
// Deprecated: Use server.New instead
|
||||||
func NewServer(serverConfig *ServerConfig, databaseConfig *DatabaseConfig) (*Server, error) {
|
func NewServer(serverConfig *ServerConfig, databaseConfig *DatabaseConfig) (*Server, error) {
|
||||||
if serverConfig.Listen == "" {
|
// Get logger from the global context or create a new one
|
||||||
serverConfig.Listen = DefaultServerListen
|
logger := Logger
|
||||||
|
if logger == nil {
|
||||||
|
logger = logrus.New()
|
||||||
}
|
}
|
||||||
|
|
||||||
listenAddr, err := net.ResolveTCPAddr("tcp", serverConfig.Listen)
|
dbConfig := &server.DatabaseConfig{
|
||||||
if err != nil {
|
Type: databaseConfig.Type,
|
||||||
return nil, fmt.Errorf("hamview: invalid listen address %q: %v", serverConfig.Listen, err)
|
Conf: databaseConfig.Conf,
|
||||||
}
|
}
|
||||||
|
|
||||||
db, err := sql.Open(databaseConfig.Type, databaseConfig.Conf)
|
return server.New(serverConfig, dbConfig, logger)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
713
server/API.md
Normal 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
@@ -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
|
||||||