Implement repeater checks from BM api

This commit is contained in:
Marcus Kida 2026-02-09 23:52:50 +01:00
parent 6580457ac8
commit 10da490762
16 changed files with 523 additions and 128 deletions

View file

@ -4,7 +4,7 @@ root = "."
cmd = "go build -o ./tmp/dmrmap ."
bin = "./tmp/dmrmap"
include_ext = ["go"]
exclude_dir = ["tmp", "static", "data"]
exclude_dir = ["tmp", "static", "data", "migrations"]
delay = 500
[misc]

View file

@ -3,11 +3,13 @@ WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY *.go ./
COPY migrations/ ./migrations/
RUN CGO_ENABLED=0 go build -o dmrmap .
FROM alpine:3.19
WORKDIR /app
COPY --from=builder /build/dmrmap .
COPY --from=builder /build/migrations/ ./migrations/
COPY static/ ./static/
COPY rptrs.json .
COPY bmrptrs.json .

210
bmdevice.go Normal file
View file

@ -0,0 +1,210 @@
package main
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log"
"math"
"net/http"
"strconv"
"strings"
"sync"
"time"
"golang.org/x/time/rate"
)
type bmDeviceResponse struct {
ID int `json:"id"`
LastSeen string `json:"last_seen"`
Tx string `json:"tx"`
Rx string `json:"rx"`
Status int `json:"status"`
StatusText string `json:"statusText"`
Hardware string `json:"hardware"`
Firmware string `json:"firmware"`
Pep int `json:"pep"`
Agl int `json:"agl"`
Website string `json:"website"`
PriorityDescription string `json:"priorityDescription"`
Description string `json:"description"`
}
func startBMDeviceSync(db *sql.DB) {
go func() {
runBMDeviceSync(db)
ticker := time.NewTicker(3 * 24 * time.Hour)
defer ticker.Stop()
for range ticker.C {
runBMDeviceSync(db)
}
}()
}
func runBMDeviceSync(db *sql.DB) {
log.Println("Starting BM device sync...")
type repeaterFreq struct {
ID int
Callsign string
FreqTx float64
FreqRx float64
}
// Only poll repeaters that haven't been polled in the last 3 days
rows, err := db.Query(`SELECT id, callsign, freq_tx, freq_rx FROM repeaters
WHERE network = 'Brandmeister' AND hotspot = 0
AND (last_polled IS NULL OR last_polled < NOW() - INTERVAL '3 days')`)
if err != nil {
log.Printf("BM device sync: failed to query repeaters: %v", err)
return
}
defer rows.Close()
var rptrs []repeaterFreq
for rows.Next() {
var rf repeaterFreq
if err := rows.Scan(&rf.ID, &rf.Callsign, &rf.FreqTx, &rf.FreqRx); err != nil {
continue
}
rptrs = append(rptrs, rf)
}
if err := rows.Err(); err != nil {
log.Printf("BM device sync: row iteration error: %v", err)
return
}
log.Printf("BM device sync: checking %d BrandMeister repeaters", len(rptrs))
// Rate limit: 3600 requests/hour = 1/second
limiter := rate.NewLimiter(rate.Every(time.Second), 1)
sem := make(chan struct{}, 5) // max 5 concurrent
var wg sync.WaitGroup
client := &http.Client{Timeout: 15 * time.Second}
ctx := context.Background()
var mu sync.Mutex
updated, errors, inactive := 0, 0, 0
inactiveThreshold := time.Now().Add(-7 * 24 * time.Hour)
for _, rf := range rptrs {
wg.Add(1)
go func(rf repeaterFreq) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
if err := limiter.Wait(ctx); err != nil {
return
}
url := fmt.Sprintf("https://api.brandmeister.network/v2/device/%d", rf.ID)
resp, err := client.Get(url)
if err != nil {
mu.Lock()
errors++
mu.Unlock()
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
mu.Lock()
errors++
mu.Unlock()
return
}
var device bmDeviceResponse
if err := json.NewDecoder(resp.Body).Decode(&device); err != nil {
mu.Lock()
errors++
mu.Unlock()
return
}
lastSeen, err := time.Parse("2006-01-02 15:04:05", device.LastSeen)
if err != nil {
mu.Lock()
errors++
mu.Unlock()
return
}
if lastSeen.Before(inactiveThreshold) {
log.Printf("BM device sync: inactive repeater %d (%s), last seen %s", rf.ID, rf.Callsign, lastSeen.Format("2006-01-02"))
mu.Lock()
inactive++
mu.Unlock()
}
// Combine priority description and description
desc := device.Description
if device.PriorityDescription != "" {
if desc != "" {
desc = device.PriorityDescription + "\n" + desc
} else {
desc = device.PriorityDescription
}
}
// Strip HTML tags for clean storage
desc = stripHTML(desc)
// Check frequency consistency between RadioID and BrandMeister
freqInconsistent := false
if bmTx, err := strconv.ParseFloat(device.Tx, 64); err == nil {
if bmRx, err := strconv.ParseFloat(device.Rx, 64); err == nil {
if math.Abs(bmTx-rf.FreqTx) > 0.001 || math.Abs(bmRx-rf.FreqRx) > 0.001 {
freqInconsistent = true
}
}
}
_, err = db.Exec(
`UPDATE repeaters SET last_seen=$1, bm_status=$2, bm_status_text=$3,
hardware=$4, firmware=$5, pep=$6, agl=$7, website=$8, description=$9,
import_freq_inconsistent=$10, last_polled=NOW()
WHERE id=$11`,
lastSeen, device.Status, device.StatusText,
device.Hardware, device.Firmware, device.Pep, device.Agl,
device.Website, desc, freqInconsistent, rf.ID,
)
mu.Lock()
if err != nil {
errors++
} else {
updated++
}
mu.Unlock()
}(rf)
}
wg.Wait()
log.Printf("BM device sync complete: %d updated, %d inactive, %d errors", updated, inactive, errors)
}
// stripHTML removes HTML tags from a string.
func stripHTML(s string) string {
var b strings.Builder
inTag := false
for _, r := range s {
if r == '<' {
inTag = true
continue
}
if r == '>' {
inTag = false
continue
}
if !inTag {
b.WriteRune(r)
}
}
return b.String()
}

View file

@ -1,4 +1,15 @@
services:
postgres:
image: postgres:17-alpine
environment:
POSTGRES_USER: dmrmap
POSTGRES_PASSWORD: dmrmap
POSTGRES_DB: dmrmap
volumes:
- pgdata-dev:/var/lib/postgresql/data
ports:
- "5432:5432"
dmrmap:
build:
context: .
@ -9,13 +20,15 @@ services:
- .:/app
- go-mod-cache:/go/pkg/mod
- go-build-cache:/root/.cache/go-build
tmpfs:
- /app/data
depends_on:
- postgres
environment:
- DB_PATH=/app/data/repeaters.db
- DATABASE_URL=postgres://dmrmap:dmrmap@postgres:5432/dmrmap?sslmode=disable
- JSON_PATH=/app/rptrs.json
- BMRPTRS_PATH=/app/bmrptrs.json
- LISTEN_ADDR=:8080
volumes:
go-mod-cache:
go-build-cache:
pgdata-dev:

View file

@ -1,10 +1,24 @@
services:
postgres:
image: postgres:17-alpine
environment:
POSTGRES_USER: dmrmap
POSTGRES_PASSWORD: dmrmap
POSTGRES_DB: dmrmap
volumes:
- pgdata:/var/lib/postgresql/data
restart: unless-stopped
dmrmap:
build: .
tmpfs:
- /app/data
depends_on:
- postgres
environment:
- DB_PATH=/app/data/repeaters.db
- DATABASE_URL=postgres://dmrmap:dmrmap@postgres:5432/dmrmap?sslmode=disable
- JSON_PATH=/app/rptrs.json
- BMRPTRS_PATH=/app/bmrptrs.json
- LISTEN_ADDR=:8080
restart: unless-stopped
volumes:
pgdata:

127
db.go
View file

@ -2,43 +2,22 @@ package main
import (
"database/sql"
"fmt"
"log"
"math"
"sort"
"strings"
"time"
_ "modernc.org/sqlite"
_ "github.com/jackc/pgx/v5/stdlib"
)
const schemaSQL = `
CREATE TABLE IF NOT EXISTS repeaters (
id INTEGER PRIMARY KEY,
callsign TEXT NOT NULL,
frequency REAL NOT NULL,
band TEXT NOT NULL,
lat REAL NOT NULL,
lng REAL NOT NULL,
city TEXT NOT NULL DEFAULT '',
state TEXT NOT NULL DEFAULT '',
country TEXT NOT NULL DEFAULT '',
color_code INTEGER NOT NULL DEFAULT 1,
offset TEXT NOT NULL DEFAULT '',
ts_linked TEXT NOT NULL DEFAULT '',
trustee TEXT NOT NULL DEFAULT '',
ipsc_network TEXT NOT NULL DEFAULT '',
network TEXT NOT NULL DEFAULT '',
hotspot INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'ACTIVE'
);
CREATE INDEX IF NOT EXISTS idx_repeaters_lat_band ON repeaters (lat, band);
CREATE INDEX IF NOT EXISTS idx_repeaters_lng ON repeaters (lng);
CREATE INDEX IF NOT EXISTS idx_repeaters_network ON repeaters (network);
`
type Repeater struct {
ID int `json:"id"`
Callsign string `json:"callsign"`
Frequency float64 `json:"frequency"`
FreqTx float64 `json:"freq_tx"`
FreqRx float64 `json:"freq_rx"`
FreqOffset string `json:"freq_offset"`
Band string `json:"band"`
Lat float64 `json:"lat"`
Lng float64 `json:"lng"`
@ -46,40 +25,75 @@ type Repeater struct {
State string `json:"state"`
Country string `json:"country"`
ColorCode int `json:"color_code"`
Offset string `json:"offset"`
TsLinked string `json:"ts_linked"`
Trustee string `json:"trustee"`
IpscNetwork string `json:"ipsc_network"`
Network string `json:"network"`
Hotspot int `json:"hotspot"`
Status string `json:"status"`
LastSeen *time.Time `json:"last_seen"`
BmStatus *int `json:"bm_status"`
BmStatusText string `json:"bm_status_text"`
Hardware string `json:"hardware"`
Firmware string `json:"firmware"`
Pep int `json:"pep"`
Agl int `json:"agl"`
Website string `json:"website"`
Description string `json:"description"`
ImportFreqInconsistent bool `json:"import_freq_inconsistent"`
Inactive bool `json:"inactive"`
}
func openDB(path string) (*sql.DB, error) {
db, err := sql.Open("sqlite", path)
func openDB(dsn string) (*sql.DB, error) {
var db *sql.DB
var err error
for i := 0; i < 30; i++ {
db, err = sql.Open("pgx", dsn)
if err != nil {
return nil, err
log.Printf("Failed to open database (attempt %d/30): %v", i+1, err)
time.Sleep(time.Second)
continue
}
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
if err = db.Ping(); err != nil {
db.Close()
return nil, err
log.Printf("Failed to ping database (attempt %d/30): %v", i+1, err)
time.Sleep(time.Second)
continue
}
break
}
if err != nil {
return nil, fmt.Errorf("failed to connect to database after 30 attempts: %w", err)
}
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(5 * time.Minute)
return db, nil
}
func queryRepeaters(db *sql.DB, minLat, maxLat, minLng, maxLng float64, band string, networks []string, showHotspots bool) ([]Repeater, error) {
query := `SELECT id, callsign, frequency, band, lat, lng, city, state, country,
color_code, offset, ts_linked, trustee, ipsc_network, network, hotspot, status
FROM repeaters WHERE lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?`
func queryRepeaters(db *sql.DB, minLat, maxLat, minLng, maxLng float64, band string, networks []string, showHotspots bool, showInactive bool) ([]Repeater, error) {
paramIdx := 0
nextParam := func() string {
paramIdx++
return fmt.Sprintf("$%d", paramIdx)
}
query := `SELECT id, callsign, freq_tx, freq_rx, freq_offset, band, lat, lng, city, state, country,
color_code, ts_linked, trustee, ipsc_network, network, hotspot, status,
last_seen, bm_status, bm_status_text, hardware, firmware, pep, agl, website, description,
import_freq_inconsistent
FROM repeaters WHERE lat BETWEEN ` + nextParam() + ` AND ` + nextParam() + ` AND lng BETWEEN ` + nextParam() + ` AND ` + nextParam()
args := []interface{}{minLat, maxLat, minLng, maxLng}
switch band {
case "2m":
query += " AND band = ?"
query += " AND band = " + nextParam()
args = append(args, "2m")
case "70cm":
query += " AND band = ?"
query += " AND band = " + nextParam()
args = append(args, "70cm")
default:
query += " AND band IN ('2m', '70cm')"
@ -95,16 +109,16 @@ func queryRepeaters(db *sql.DB, minLat, maxLat, minLng, maxLng float64, band str
for _, n := range networks {
switch n {
case "BM":
placeholders = append(placeholders, "?")
placeholders = append(placeholders, nextParam())
args = append(args, "Brandmeister")
case "DMR+":
placeholders = append(placeholders, "?")
placeholders = append(placeholders, nextParam())
args = append(args, "DMR+")
case "TGIF":
placeholders = append(placeholders, "?")
placeholders = append(placeholders, nextParam())
args = append(args, "TGIF")
case "Other":
placeholders = append(placeholders, "?", "?", "?", "?")
placeholders = append(placeholders, nextParam(), nextParam(), nextParam(), nextParam())
args = append(args, "DMR-MARC", "FreeDMR", "Other", "")
}
}
@ -113,21 +127,32 @@ func queryRepeaters(db *sql.DB, minLat, maxLat, minLng, maxLng float64, band str
}
}
if !showInactive {
threshold := time.Now().Add(-7 * 24 * time.Hour)
query += " AND (last_seen IS NULL OR last_seen >= " + nextParam() + ")"
args = append(args, threshold)
}
rows, err := db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
threshold := time.Now().Add(-7 * 24 * time.Hour)
var results []Repeater
for rows.Next() {
var r Repeater
if err := rows.Scan(&r.ID, &r.Callsign, &r.Frequency, &r.Band,
if err := rows.Scan(&r.ID, &r.Callsign, &r.FreqTx, &r.FreqRx, &r.FreqOffset, &r.Band,
&r.Lat, &r.Lng, &r.City, &r.State, &r.Country,
&r.ColorCode, &r.Offset, &r.TsLinked, &r.Trustee,
&r.IpscNetwork, &r.Network, &r.Hotspot, &r.Status); err != nil {
&r.ColorCode, &r.TsLinked, &r.Trustee,
&r.IpscNetwork, &r.Network, &r.Hotspot, &r.Status,
&r.LastSeen, &r.BmStatus, &r.BmStatusText, &r.Hardware,
&r.Firmware, &r.Pep, &r.Agl, &r.Website, &r.Description,
&r.ImportFreqInconsistent); err != nil {
return nil, err
}
r.Inactive = r.LastSeen != nil && r.LastSeen.Before(threshold)
results = append(results, r)
}
if err := rows.Err(); err != nil {
@ -138,7 +163,7 @@ func queryRepeaters(db *sql.DB, minLat, maxLat, minLng, maxLng float64, band str
}
// Route corridor query: find repeaters within corridorKm of a polyline.
func queryRepeatersAlongRoute(db *sql.DB, points [][2]float64, corridorKm float64, band string, networks []string, showHotspots bool) ([]Repeater, error) {
func queryRepeatersAlongRoute(db *sql.DB, points [][2]float64, corridorKm float64, band string, networks []string, showHotspots bool, showInactive bool) ([]Repeater, error) {
if len(points) == 0 {
return []Repeater{}, nil
}
@ -169,7 +194,7 @@ func queryRepeatersAlongRoute(db *sql.DB, points [][2]float64, corridorKm float6
maxLng += lngPad
// Fetch candidates from bounding box
candidates, err := queryRepeaters(db, minLat, maxLat, minLng, maxLng, band, networks, showHotspots)
candidates, err := queryRepeaters(db, minLat, maxLat, minLng, maxLng, band, networks, showHotspots, showInactive)
if err != nil {
return nil, err
}
@ -190,11 +215,11 @@ type RepeaterWithDistance struct {
Distance float64 `json:"distance"`
}
func queryRepeatersInRadius(db *sql.DB, lat, lng, radiusKm float64, band string, networks []string, showHotspots bool) ([]RepeaterWithDistance, error) {
func queryRepeatersInRadius(db *sql.DB, lat, lng, radiusKm float64, band string, networks []string, showHotspots bool, showInactive bool) ([]RepeaterWithDistance, error) {
latPad := radiusKm / 111.32
lngPad := radiusKm / (111.32 * math.Cos(lat*math.Pi/180))
candidates, err := queryRepeaters(db, lat-latPad, lat+latPad, lng-lngPad, lng+lngPad, band, networks, showHotspots)
candidates, err := queryRepeaters(db, lat-latPad, lat+latPad, lng-lngPad, lng+lngPad, band, networks, showHotspots, showInactive)
if err != nil {
return nil, err
}

24
go.mod
View file

@ -3,15 +3,19 @@ module dmrmap
go 1.25.7
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/sys v0.37.0 // indirect
modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
github.com/jackc/pgx/v5 v5.8.0
github.com/pressly/goose/v3 v3.26.0
golang.org/x/time v0.14.0
)
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/text v0.29.0 // indirect
modernc.org/sqlite v1.44.3 // indirect
)

37
go.sum
View file

@ -1,18 +1,53 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/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/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/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
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/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
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/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM=
github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
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/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=

View file

@ -19,6 +19,7 @@ type routeRequest struct {
Corridor float64 `json:"corridor"`
Network []string `json:"network"`
Hotspots bool `json:"hotspots"`
Inactive bool `json:"inactive"`
}
func handleRepeaters(db *sql.DB) http.HandlerFunc {
@ -54,8 +55,9 @@ func handleRepeaters(db *sql.DB) http.HandlerFunc {
}
showHotspots := q.Get("hotspots") == "1"
showInactive := q.Get("inactive") == "1"
repeaters, err := queryRepeaters(db, minLat, maxLat, minLng, maxLng, band, networks, showHotspots)
repeaters, err := queryRepeaters(db, minLat, maxLat, minLng, maxLng, band, networks, showHotspots, showInactive)
if err != nil {
http.Error(w, `{"error":"database query failed"}`, http.StatusInternalServerError)
return
@ -118,8 +120,9 @@ func handleRadiusRepeaters(db *sql.DB) http.HandlerFunc {
}
showHotspots := q.Get("hotspots") == "1"
showInactive := q.Get("inactive") == "1"
repeaters, err := queryRepeatersInRadius(db, lat, lng, radius, band, networks, showHotspots)
repeaters, err := queryRepeatersInRadius(db, lat, lng, radius, band, networks, showHotspots, showInactive)
if err != nil {
http.Error(w, `{"error":"database query failed"}`, http.StatusInternalServerError)
return
@ -166,7 +169,7 @@ func handleRouteRepeaters(db *sql.DB) http.HandlerFunc {
req.Corridor = 10
}
repeaters, err := queryRepeatersAlongRoute(db, req.Points, req.Corridor, req.Band, req.Network, req.Hotspots)
repeaters, err := queryRepeatersAlongRoute(db, req.Points, req.Corridor, req.Band, req.Network, req.Hotspots, req.Inactive)
if err != nil {
http.Error(w, `{"error":"database query failed"}`, http.StatusInternalServerError)
return

22
main.go
View file

@ -7,25 +7,27 @@ import (
)
func main() {
dbPath := envOr("DB_PATH", "data/repeaters.db")
dsn := envOr("DATABASE_URL", "postgres://dmrmap:dmrmap@localhost:5432/dmrmap?sslmode=disable")
jsonPath := envOr("JSON_PATH", "rptrs.json")
bmrptrsPath := envOr("BMRPTRS_PATH", "bmrptrs.json")
addr := envOr("LISTEN_ADDR", ":8080")
if err := os.MkdirAll("data", 0755); err != nil {
log.Fatalf("Failed to create data directory: %v", err)
}
if err := seedDatabase(dbPath, jsonPath, bmrptrsPath); err != nil {
log.Fatalf("Failed to seed database: %v", err)
}
db, err := openDB(dbPath)
db, err := openDB(dsn)
if err != nil {
log.Fatalf("Failed to open database: %v", err)
}
defer db.Close()
if err := runMigrations(db); err != nil {
log.Fatalf("Failed to run migrations: %v", err)
}
if err := seedDatabase(db, jsonPath, bmrptrsPath); err != nil {
log.Fatalf("Failed to seed database: %v", err)
}
startBMDeviceSync(db)
mux := http.NewServeMux()
mux.HandleFunc("/api/repeaters", handleRepeaters(db))
mux.HandleFunc("/api/repeaters/radius", handleRadiusRepeaters(db))

19
migrate.go Normal file
View file

@ -0,0 +1,19 @@
package main
import (
"database/sql"
"log"
"github.com/pressly/goose/v3"
)
func runMigrations(db *sql.DB) error {
if err := goose.SetDialect("postgres"); err != nil {
return err
}
if err := goose.Up(db, "migrations"); err != nil {
return err
}
log.Println("Database migrations applied successfully")
return nil
}

View file

@ -0,0 +1,28 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS repeaters (
id INTEGER PRIMARY KEY,
callsign TEXT NOT NULL,
freq_tx DOUBLE PRECISION NOT NULL,
freq_rx DOUBLE PRECISION NOT NULL DEFAULT 0,
freq_offset TEXT NOT NULL DEFAULT '',
band TEXT NOT NULL,
lat DOUBLE PRECISION NOT NULL,
lng DOUBLE PRECISION NOT NULL,
city TEXT NOT NULL DEFAULT '',
state TEXT NOT NULL DEFAULT '',
country TEXT NOT NULL DEFAULT '',
color_code INTEGER NOT NULL DEFAULT 1,
ts_linked TEXT NOT NULL DEFAULT '',
trustee TEXT NOT NULL DEFAULT '',
ipsc_network TEXT NOT NULL DEFAULT '',
network TEXT NOT NULL DEFAULT '',
hotspot INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'ACTIVE'
);
CREATE INDEX IF NOT EXISTS idx_repeaters_lat_band ON repeaters (lat, band);
CREATE INDEX IF NOT EXISTS idx_repeaters_lng ON repeaters (lng);
CREATE INDEX IF NOT EXISTS idx_repeaters_network ON repeaters (network);
-- +goose Down
DROP TABLE IF EXISTS repeaters;

View file

@ -0,0 +1,29 @@
-- +goose Up
ALTER TABLE repeaters ADD COLUMN last_seen TIMESTAMP;
ALTER TABLE repeaters ADD COLUMN bm_status INTEGER;
ALTER TABLE repeaters ADD COLUMN bm_status_text TEXT NOT NULL DEFAULT '';
ALTER TABLE repeaters ADD COLUMN hardware TEXT NOT NULL DEFAULT '';
ALTER TABLE repeaters ADD COLUMN firmware TEXT NOT NULL DEFAULT '';
ALTER TABLE repeaters ADD COLUMN pep INTEGER NOT NULL DEFAULT 0;
ALTER TABLE repeaters ADD COLUMN agl INTEGER NOT NULL DEFAULT 0;
ALTER TABLE repeaters ADD COLUMN website TEXT NOT NULL DEFAULT '';
ALTER TABLE repeaters ADD COLUMN description TEXT NOT NULL DEFAULT '';
ALTER TABLE repeaters ADD COLUMN import_freq_inconsistent BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE repeaters ADD COLUMN last_polled TIMESTAMP;
CREATE INDEX idx_repeaters_last_seen ON repeaters (last_seen);
CREATE INDEX idx_repeaters_last_polled ON repeaters (last_polled);
-- +goose Down
DROP INDEX IF EXISTS idx_repeaters_last_polled;
DROP INDEX IF EXISTS idx_repeaters_last_seen;
ALTER TABLE repeaters DROP COLUMN IF EXISTS last_polled;
ALTER TABLE repeaters DROP COLUMN IF EXISTS import_freq_inconsistent;
ALTER TABLE repeaters DROP COLUMN IF EXISTS description;
ALTER TABLE repeaters DROP COLUMN IF EXISTS website;
ALTER TABLE repeaters DROP COLUMN IF EXISTS agl;
ALTER TABLE repeaters DROP COLUMN IF EXISTS pep;
ALTER TABLE repeaters DROP COLUMN IF EXISTS firmware;
ALTER TABLE repeaters DROP COLUMN IF EXISTS hardware;
ALTER TABLE repeaters DROP COLUMN IF EXISTS bm_status_text;
ALTER TABLE repeaters DROP COLUMN IF EXISTS bm_status;
ALTER TABLE repeaters DROP COLUMN IF EXISTS last_seen;

30
seed.go
View file

@ -1,6 +1,7 @@
package main
import (
"database/sql"
"encoding/json"
"fmt"
"io"
@ -121,17 +122,7 @@ func loadRepeaters(path string) ([]rawRepeater, error) {
return raw.Rptrs, nil
}
func seedDatabase(dbPath, jsonPath, bmrptrsPath string) error {
db, err := openDB(dbPath)
if err != nil {
return fmt.Errorf("open db: %w", err)
}
defer db.Close()
if _, err := db.Exec(schemaSQL); err != nil {
return fmt.Errorf("create schema: %w", err)
}
func seedDatabase(db *sql.DB, jsonPath, bmrptrsPath string) error {
var count int
if err := db.QueryRow("SELECT COUNT(*) FROM repeaters").Scan(&count); err != nil {
return fmt.Errorf("count check: %w", err)
@ -154,9 +145,10 @@ func seedDatabase(dbPath, jsonPath, bmrptrsPath string) error {
}
stmt, err := tx.Prepare(`INSERT INTO repeaters
(id, callsign, frequency, band, lat, lng, city, state, country,
color_code, offset, ts_linked, trustee, ipsc_network, network, hotspot, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
(id, callsign, freq_tx, freq_rx, freq_offset, band, lat, lng, city, state, country,
color_code, ts_linked, trustee, ipsc_network, network, hotspot, status)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)
ON CONFLICT (id) DO NOTHING`)
if err != nil {
tx.Rollback()
return fmt.Errorf("prepare stmt: %w", err)
@ -177,6 +169,12 @@ func seedDatabase(dbPath, jsonPath, bmrptrsPath string) error {
}
band := classifyBand(freq)
// Calculate freq_rx from freq_tx + offset
freqRx := 0.0
if off, err := strconv.ParseFloat(strings.TrimSpace(r.Offset), 64); err == nil {
freqRx = freq + off
}
network := classifyNetwork(r.IpscNetwork)
hotspot := 0
if isHotspot(r.Offset, r.City, r.MapInfo, r.Callsign, r.Country, r.Trustee) {
@ -186,8 +184,8 @@ func seedDatabase(dbPath, jsonPath, bmrptrsPath string) error {
hotspot = 1
hotspots++
}
if _, err := stmt.Exec(r.ID, r.Callsign, freq, band, lat, lng,
r.City, r.State, r.Country, r.ColorCode, r.Offset,
if _, err := stmt.Exec(r.ID, r.Callsign, freq, freqRx, r.Offset, band, lat, lng,
r.City, r.State, r.Country, r.ColorCode,
r.TsLinked, r.Trustee, r.IpscNetwork, network, hotspot, r.Status); err != nil {
log.Printf("Warning: skipping repeater %d (%s): %v", r.ID, r.Callsign, err)
skipped++

View file

@ -29,6 +29,7 @@
var netTgif = document.getElementById("net-tgif");
var netOther = document.getElementById("net-other");
var showHotspots = document.getElementById("show-hotspots");
var showInactive = document.getElementById("show-inactive");
var countEl = document.getElementById("count");
var fromInput = document.getElementById("route-from");
var toInput = document.getElementById("route-to");
@ -224,20 +225,15 @@
function buildPopup(r) {
var bandClass = r.band === "2m" ? "band-2m" : "band-70cm";
var html = '<div class="rptr-popup">';
html += '<h3><a href="https://brandmeister.network/?page=repeater&id=' + r.id + '" target="_blank" rel="noopener">' + escapeHtml(r.callsign) + "</a></h3>";
html += '<h3><a href="https://brandmeister.network/?page=repeater&id=' + r.id + '" target="_blank" rel="noopener">' + escapeHtml(r.callsign) + '</a> <span class="band-tag ' + bandClass + '">' + escapeHtml(r.band) + "</span></h3>";
html += "<table>";
html +=
"<tr><td>Freq</td><td>" +
r.frequency.toFixed(5) +
' MHz <span class="band-tag ' +
bandClass +
'">' +
escapeHtml(r.band) +
"</span></td></tr>";
if (r.offset)
html += "<tr><td>TX</td><td>" + r.freq_tx.toFixed(4) + " MHz</td></tr>";
if (r.freq_rx)
html += "<tr><td>RX</td><td>" + r.freq_rx.toFixed(4) + " MHz</td></tr>";
if (r.freq_offset)
html +=
"<tr><td>Offset</td><td>" +
escapeHtml(r.offset) +
escapeHtml(r.freq_offset) +
" MHz</td></tr>";
html += "<tr><td>CC</td><td>" + r.color_code + "</td></tr>";
var loc = escapeHtml(r.city);
@ -263,6 +259,18 @@
"<tr><td>Status</td><td>" +
escapeHtml(r.status) +
"</td></tr>";
if (r.bm_status_text)
html += "<tr><td>BM Status</td><td>" + escapeHtml(r.bm_status_text) + "</td></tr>";
if (r.last_seen)
html += "<tr><td>Last seen</td><td>" + escapeHtml(r.last_seen.replace("T", " ").substring(0, 19)) + "</td></tr>";
if (r.hardware)
html += "<tr><td>Hardware</td><td>" + escapeHtml(r.hardware) + "</td></tr>";
if (r.pep)
html += "<tr><td>Power</td><td>" + r.pep + " W</td></tr>";
if (r.agl)
html += "<tr><td>Antenna</td><td>" + r.agl + " m AGL</td></tr>";
if (r.inactive)
html += '<tr><td></td><td style="color:#ef5350;font-weight:600">Inactive</td></tr>';
html += "</table></div>";
return html;
}
@ -277,7 +285,7 @@
fillColor: color,
color: "#fff",
weight: 1,
fillOpacity: 0.85,
fillOpacity: r.inactive ? 0.35 : 0.85,
});
marker.bindPopup(buildPopup(r), { maxWidth: 280 });
markerLayer.addLayer(marker);
@ -306,6 +314,7 @@
band: getSelectedBand(),
network: getSelectedNetworks(),
hotspots: showHotspots.checked ? "1" : "0",
inactive: showInactive.checked ? "1" : "0",
});
fetch("/api/repeaters?" + params, { signal: controller.signal })
@ -387,6 +396,7 @@
corridor: 10,
network: getSelectedNetworks() === "all" ? [] : getSelectedNetworks().split(","),
hotspots: showHotspots.checked,
inactive: showInactive.checked,
}),
})
.then(function (resp) {
@ -481,6 +491,7 @@
band: getSelectedBand(),
network: getSelectedNetworks(),
hotspots: showHotspots.checked ? "1" : "0",
inactive: showInactive.checked ? "1" : "0",
});
fetch("/api/repeaters/radius?" + params)
@ -507,7 +518,7 @@
var bandColor = r.band === "2m" ? "#1976D2" : "#D32F2F";
item.innerHTML =
'<a class="callsign" href="https://brandmeister.network/?page=repeater&id=' + r.id + '" target="_blank" rel="noopener">' + escapeHtml(r.callsign) + "</a>" +
'<span class="freq" style="color:' + bandColor + '">' + r.frequency.toFixed(4) + "</span>" +
'<span class="freq" style="color:' + bandColor + '">' + r.freq_tx.toFixed(4) + "</span>" +
'<span class="dist">' + r.distance + " km</span>";
item.addEventListener("click", function (e) {
if (e.target.closest("a")) return;
@ -580,6 +591,7 @@
});
showHotspots.addEventListener("change", refetchActive);
showInactive.addEventListener("change", refetchActive);
pinClearBtn.addEventListener("click", clearPin);

View file

@ -24,6 +24,7 @@
<details class="experimental">
<summary>Experimental</summary>
<label class="hotspot-label"><input type="checkbox" id="show-hotspots"> Don't hide personal hotspots</label>
<label class="hotspot-label"><input type="checkbox" id="show-inactive"> Show inactive repeaters</label>
</details>
<div class="controls-row route-row autocomplete-wrap">
<input type="text" id="route-from" placeholder="From address" autocomplete="off" />