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 ." cmd = "go build -o ./tmp/dmrmap ."
bin = "./tmp/dmrmap" bin = "./tmp/dmrmap"
include_ext = ["go"] include_ext = ["go"]
exclude_dir = ["tmp", "static", "data"] exclude_dir = ["tmp", "static", "data", "migrations"]
delay = 500 delay = 500
[misc] [misc]

View file

@ -3,11 +3,13 @@ WORKDIR /build
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY *.go ./ COPY *.go ./
COPY migrations/ ./migrations/
RUN CGO_ENABLED=0 go build -o dmrmap . RUN CGO_ENABLED=0 go build -o dmrmap .
FROM alpine:3.19 FROM alpine:3.19
WORKDIR /app WORKDIR /app
COPY --from=builder /build/dmrmap . COPY --from=builder /build/dmrmap .
COPY --from=builder /build/migrations/ ./migrations/
COPY static/ ./static/ COPY static/ ./static/
COPY rptrs.json . COPY rptrs.json .
COPY bmrptrs.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: 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: dmrmap:
build: build:
context: . context: .
@ -9,13 +20,15 @@ services:
- .:/app - .:/app
- go-mod-cache:/go/pkg/mod - go-mod-cache:/go/pkg/mod
- go-build-cache:/root/.cache/go-build - go-build-cache:/root/.cache/go-build
tmpfs: depends_on:
- /app/data - postgres
environment: environment:
- DB_PATH=/app/data/repeaters.db - DATABASE_URL=postgres://dmrmap:dmrmap@postgres:5432/dmrmap?sslmode=disable
- JSON_PATH=/app/rptrs.json - JSON_PATH=/app/rptrs.json
- BMRPTRS_PATH=/app/bmrptrs.json
- LISTEN_ADDR=:8080 - LISTEN_ADDR=:8080
volumes: volumes:
go-mod-cache: go-mod-cache:
go-build-cache: go-build-cache:
pgdata-dev:

View file

@ -1,10 +1,24 @@
services: 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: dmrmap:
build: . build: .
tmpfs: depends_on:
- /app/data - postgres
environment: environment:
- DB_PATH=/app/data/repeaters.db - DATABASE_URL=postgres://dmrmap:dmrmap@postgres:5432/dmrmap?sslmode=disable
- JSON_PATH=/app/rptrs.json - JSON_PATH=/app/rptrs.json
- BMRPTRS_PATH=/app/bmrptrs.json
- LISTEN_ADDR=:8080 - LISTEN_ADDR=:8080
restart: unless-stopped restart: unless-stopped
volumes:
pgdata:

161
db.go
View file

@ -2,84 +2,98 @@ package main
import ( import (
"database/sql" "database/sql"
"fmt"
"log"
"math" "math"
"sort" "sort"
"strings" "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 { type Repeater struct {
ID int `json:"id"` ID int `json:"id"`
Callsign string `json:"callsign"` Callsign string `json:"callsign"`
Frequency float64 `json:"frequency"` FreqTx float64 `json:"freq_tx"`
Band string `json:"band"` FreqRx float64 `json:"freq_rx"`
Lat float64 `json:"lat"` FreqOffset string `json:"freq_offset"`
Lng float64 `json:"lng"` Band string `json:"band"`
City string `json:"city"` Lat float64 `json:"lat"`
State string `json:"state"` Lng float64 `json:"lng"`
Country string `json:"country"` City string `json:"city"`
ColorCode int `json:"color_code"` State string `json:"state"`
Offset string `json:"offset"` Country string `json:"country"`
TsLinked string `json:"ts_linked"` ColorCode int `json:"color_code"`
Trustee string `json:"trustee"` TsLinked string `json:"ts_linked"`
IpscNetwork string `json:"ipsc_network"` Trustee string `json:"trustee"`
Network string `json:"network"` IpscNetwork string `json:"ipsc_network"`
Hotspot int `json:"hotspot"` Network string `json:"network"`
Status string `json:"status"` 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) { func openDB(dsn string) (*sql.DB, error) {
db, err := sql.Open("sqlite", path) var db *sql.DB
var err error
for i := 0; i < 30; i++ {
db, err = sql.Open("pgx", dsn)
if err != nil {
log.Printf("Failed to open database (attempt %d/30): %v", i+1, err)
time.Sleep(time.Second)
continue
}
if err = db.Ping(); err != nil {
db.Close()
log.Printf("Failed to ping database (attempt %d/30): %v", i+1, err)
time.Sleep(time.Second)
continue
}
break
}
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("failed to connect to database after 30 attempts: %w", err)
}
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
db.Close()
return nil, err
} }
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(5 * time.Minute)
return db, nil return db, nil
} }
func queryRepeaters(db *sql.DB, minLat, maxLat, minLng, maxLng float64, band string, networks []string, showHotspots bool) ([]Repeater, error) { func queryRepeaters(db *sql.DB, minLat, maxLat, minLng, maxLng float64, band string, networks []string, showHotspots bool, showInactive bool) ([]Repeater, error) {
query := `SELECT id, callsign, frequency, band, lat, lng, city, state, country, paramIdx := 0
color_code, offset, ts_linked, trustee, ipsc_network, network, hotspot, status nextParam := func() string {
FROM repeaters WHERE lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?` 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} args := []interface{}{minLat, maxLat, minLng, maxLng}
switch band { switch band {
case "2m": case "2m":
query += " AND band = ?" query += " AND band = " + nextParam()
args = append(args, "2m") args = append(args, "2m")
case "70cm": case "70cm":
query += " AND band = ?" query += " AND band = " + nextParam()
args = append(args, "70cm") args = append(args, "70cm")
default: default:
query += " AND band IN ('2m', '70cm')" 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 { for _, n := range networks {
switch n { switch n {
case "BM": case "BM":
placeholders = append(placeholders, "?") placeholders = append(placeholders, nextParam())
args = append(args, "Brandmeister") args = append(args, "Brandmeister")
case "DMR+": case "DMR+":
placeholders = append(placeholders, "?") placeholders = append(placeholders, nextParam())
args = append(args, "DMR+") args = append(args, "DMR+")
case "TGIF": case "TGIF":
placeholders = append(placeholders, "?") placeholders = append(placeholders, nextParam())
args = append(args, "TGIF") args = append(args, "TGIF")
case "Other": case "Other":
placeholders = append(placeholders, "?", "?", "?", "?") placeholders = append(placeholders, nextParam(), nextParam(), nextParam(), nextParam())
args = append(args, "DMR-MARC", "FreeDMR", "Other", "") 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...) rows, err := db.Query(query, args...)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
threshold := time.Now().Add(-7 * 24 * time.Hour)
var results []Repeater var results []Repeater
for rows.Next() { for rows.Next() {
var r Repeater 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.Lat, &r.Lng, &r.City, &r.State, &r.Country,
&r.ColorCode, &r.Offset, &r.TsLinked, &r.Trustee, &r.ColorCode, &r.TsLinked, &r.Trustee,
&r.IpscNetwork, &r.Network, &r.Hotspot, &r.Status); err != nil { &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 return nil, err
} }
r.Inactive = r.LastSeen != nil && r.LastSeen.Before(threshold)
results = append(results, r) results = append(results, r)
} }
if err := rows.Err(); err != nil { 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. // 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 { if len(points) == 0 {
return []Repeater{}, nil return []Repeater{}, nil
} }
@ -169,7 +194,7 @@ func queryRepeatersAlongRoute(db *sql.DB, points [][2]float64, corridorKm float6
maxLng += lngPad maxLng += lngPad
// Fetch candidates from bounding box // 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 { if err != nil {
return nil, err return nil, err
} }
@ -190,11 +215,11 @@ type RepeaterWithDistance struct {
Distance float64 `json:"distance"` 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 latPad := radiusKm / 111.32
lngPad := radiusKm / (111.32 * math.Cos(lat*math.Pi/180)) 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 { if err != nil {
return nil, err return nil, err
} }

24
go.mod
View file

@ -3,15 +3,19 @@ module dmrmap
go 1.25.7 go 1.25.7
require ( require (
github.com/dustin/go-humanize v1.0.1 // indirect github.com/jackc/pgx/v5 v5.8.0
github.com/google/uuid v1.6.0 // indirect github.com/pressly/goose/v3 v3.26.0
github.com/mattn/go-isatty v0.0.20 // indirect golang.org/x/time v0.14.0
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 require (
golang.org/x/sys v0.37.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
modernc.org/libc v1.67.6 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
modernc.org/mathutil v1.7.1 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
modernc.org/memory v1.11.0 // 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 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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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 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/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 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 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 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= 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 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 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 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=

View file

@ -19,6 +19,7 @@ type routeRequest struct {
Corridor float64 `json:"corridor"` Corridor float64 `json:"corridor"`
Network []string `json:"network"` Network []string `json:"network"`
Hotspots bool `json:"hotspots"` Hotspots bool `json:"hotspots"`
Inactive bool `json:"inactive"`
} }
func handleRepeaters(db *sql.DB) http.HandlerFunc { func handleRepeaters(db *sql.DB) http.HandlerFunc {
@ -54,8 +55,9 @@ func handleRepeaters(db *sql.DB) http.HandlerFunc {
} }
showHotspots := q.Get("hotspots") == "1" 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 { if err != nil {
http.Error(w, `{"error":"database query failed"}`, http.StatusInternalServerError) http.Error(w, `{"error":"database query failed"}`, http.StatusInternalServerError)
return return
@ -118,8 +120,9 @@ func handleRadiusRepeaters(db *sql.DB) http.HandlerFunc {
} }
showHotspots := q.Get("hotspots") == "1" 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 { if err != nil {
http.Error(w, `{"error":"database query failed"}`, http.StatusInternalServerError) http.Error(w, `{"error":"database query failed"}`, http.StatusInternalServerError)
return return
@ -166,7 +169,7 @@ func handleRouteRepeaters(db *sql.DB) http.HandlerFunc {
req.Corridor = 10 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 { if err != nil {
http.Error(w, `{"error":"database query failed"}`, http.StatusInternalServerError) http.Error(w, `{"error":"database query failed"}`, http.StatusInternalServerError)
return return

22
main.go
View file

@ -7,25 +7,27 @@ import (
) )
func main() { 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") jsonPath := envOr("JSON_PATH", "rptrs.json")
bmrptrsPath := envOr("BMRPTRS_PATH", "bmrptrs.json") bmrptrsPath := envOr("BMRPTRS_PATH", "bmrptrs.json")
addr := envOr("LISTEN_ADDR", ":8080") addr := envOr("LISTEN_ADDR", ":8080")
if err := os.MkdirAll("data", 0755); err != nil { db, err := openDB(dsn)
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)
if err != nil { if err != nil {
log.Fatalf("Failed to open database: %v", err) log.Fatalf("Failed to open database: %v", err)
} }
defer db.Close() 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 := http.NewServeMux()
mux.HandleFunc("/api/repeaters", handleRepeaters(db)) mux.HandleFunc("/api/repeaters", handleRepeaters(db))
mux.HandleFunc("/api/repeaters/radius", handleRadiusRepeaters(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 package main
import ( import (
"database/sql"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@ -121,17 +122,7 @@ func loadRepeaters(path string) ([]rawRepeater, error) {
return raw.Rptrs, nil return raw.Rptrs, nil
} }
func seedDatabase(dbPath, jsonPath, bmrptrsPath string) error { func seedDatabase(db *sql.DB, 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)
}
var count int var count int
if err := db.QueryRow("SELECT COUNT(*) FROM repeaters").Scan(&count); err != nil { if err := db.QueryRow("SELECT COUNT(*) FROM repeaters").Scan(&count); err != nil {
return fmt.Errorf("count check: %w", err) 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 stmt, err := tx.Prepare(`INSERT INTO repeaters
(id, callsign, frequency, band, lat, lng, city, state, country, (id, callsign, freq_tx, freq_rx, freq_offset, band, lat, lng, city, state, country,
color_code, offset, ts_linked, trustee, ipsc_network, network, hotspot, status) color_code, ts_linked, trustee, ipsc_network, network, hotspot, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) 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 { if err != nil {
tx.Rollback() tx.Rollback()
return fmt.Errorf("prepare stmt: %w", err) return fmt.Errorf("prepare stmt: %w", err)
@ -177,6 +169,12 @@ func seedDatabase(dbPath, jsonPath, bmrptrsPath string) error {
} }
band := classifyBand(freq) 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) network := classifyNetwork(r.IpscNetwork)
hotspot := 0 hotspot := 0
if isHotspot(r.Offset, r.City, r.MapInfo, r.Callsign, r.Country, r.Trustee) { 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 hotspot = 1
hotspots++ hotspots++
} }
if _, err := stmt.Exec(r.ID, r.Callsign, freq, band, lat, lng, if _, err := stmt.Exec(r.ID, r.Callsign, freq, freqRx, r.Offset, band, lat, lng,
r.City, r.State, r.Country, r.ColorCode, r.Offset, r.City, r.State, r.Country, r.ColorCode,
r.TsLinked, r.Trustee, r.IpscNetwork, network, hotspot, r.Status); err != nil { 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) log.Printf("Warning: skipping repeater %d (%s): %v", r.ID, r.Callsign, err)
skipped++ skipped++

View file

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

View file

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