Implement repeater checks from BM api
This commit is contained in:
parent
6580457ac8
commit
10da490762
16 changed files with 523 additions and 128 deletions
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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
210
bmdevice.go
Normal 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()
|
||||||
|
}
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
20
compose.yml
20
compose.yml
|
|
@ -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:
|
||||||
|
|
|
||||||
127
db.go
127
db.go
|
|
@ -2,43 +2,22 @@ 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"`
|
||||||
|
FreqRx float64 `json:"freq_rx"`
|
||||||
|
FreqOffset string `json:"freq_offset"`
|
||||||
Band string `json:"band"`
|
Band string `json:"band"`
|
||||||
Lat float64 `json:"lat"`
|
Lat float64 `json:"lat"`
|
||||||
Lng float64 `json:"lng"`
|
Lng float64 `json:"lng"`
|
||||||
|
|
@ -46,40 +25,75 @@ type Repeater struct {
|
||||||
State string `json:"state"`
|
State string `json:"state"`
|
||||||
Country string `json:"country"`
|
Country string `json:"country"`
|
||||||
ColorCode int `json:"color_code"`
|
ColorCode int `json:"color_code"`
|
||||||
Offset string `json:"offset"`
|
|
||||||
TsLinked string `json:"ts_linked"`
|
TsLinked string `json:"ts_linked"`
|
||||||
Trustee string `json:"trustee"`
|
Trustee string `json:"trustee"`
|
||||||
IpscNetwork string `json:"ipsc_network"`
|
IpscNetwork string `json:"ipsc_network"`
|
||||||
Network string `json:"network"`
|
Network string `json:"network"`
|
||||||
Hotspot int `json:"hotspot"`
|
Hotspot int `json:"hotspot"`
|
||||||
Status string `json:"status"`
|
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 {
|
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()
|
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
|
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
24
go.mod
|
|
@ -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
37
go.sum
|
|
@ -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=
|
||||||
|
|
|
||||||
|
|
@ -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
22
main.go
|
|
@ -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
19
migrate.go
Normal 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
|
||||||
|
}
|
||||||
28
migrations/001_initial_schema.sql
Normal file
28
migrations/001_initial_schema.sql
Normal 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;
|
||||||
29
migrations/002_add_bm_device_fields.sql
Normal file
29
migrations/002_add_bm_device_fields.sql
Normal 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
30
seed.go
|
|
@ -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++
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue