diff --git a/.air.toml b/.air.toml index 8905a6e..d9d23b4 100644 --- a/.air.toml +++ b/.air.toml @@ -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] diff --git a/Dockerfile b/Dockerfile index 10e368a..0370c4d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 . diff --git a/bmdevice.go b/bmdevice.go new file mode 100644 index 0000000..3f97bf8 --- /dev/null +++ b/bmdevice.go @@ -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() +} diff --git a/compose.dev.yml b/compose.dev.yml index f4a3312..874468c 100644 --- a/compose.dev.yml +++ b/compose.dev.yml @@ -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: diff --git a/compose.yml b/compose.yml index a875287..7cecd60 100644 --- a/compose.yml +++ b/compose.yml @@ -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: diff --git a/db.go b/db.go index 623cdc5..3132f42 100644 --- a/db.go +++ b/db.go @@ -2,84 +2,98 @@ 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"` - Band string `json:"band"` - Lat float64 `json:"lat"` - Lng float64 `json:"lng"` - City string `json:"city"` - 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"` + ID int `json:"id"` + Callsign string `json:"callsign"` + 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"` + City string `json:"city"` + State string `json:"state"` + Country string `json:"country"` + ColorCode int `json:"color_code"` + 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 { + 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 { - return nil, err - } - if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil { - db.Close() - return nil, err + 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 } diff --git a/go.mod b/go.mod index a942483..3937d0b 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index a94e672..dc549a6 100644 --- a/go.sum +++ b/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/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= diff --git a/handlers.go b/handlers.go index 1669bc1..4123c26 100644 --- a/handlers.go +++ b/handlers.go @@ -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 diff --git a/main.go b/main.go index 89234fa..d8fadc0 100644 --- a/main.go +++ b/main.go @@ -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)) diff --git a/migrate.go b/migrate.go new file mode 100644 index 0000000..0477aac --- /dev/null +++ b/migrate.go @@ -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 +} diff --git a/migrations/001_initial_schema.sql b/migrations/001_initial_schema.sql new file mode 100644 index 0000000..3350576 --- /dev/null +++ b/migrations/001_initial_schema.sql @@ -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; diff --git a/migrations/002_add_bm_device_fields.sql b/migrations/002_add_bm_device_fields.sql new file mode 100644 index 0000000..7220a65 --- /dev/null +++ b/migrations/002_add_bm_device_fields.sql @@ -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; diff --git a/seed.go b/seed.go index bae0872..005f16c 100644 --- a/seed.go +++ b/seed.go @@ -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++ diff --git a/static/app.js b/static/app.js index 29e3137..f662f4f 100644 --- a/static/app.js +++ b/static/app.js @@ -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 = '
| Freq | " + - r.frequency.toFixed(5) + - ' MHz ' + - escapeHtml(r.band) + - " |
| TX | " + r.freq_tx.toFixed(4) + " MHz |
| RX | " + r.freq_rx.toFixed(4) + " MHz |
| Offset | " + - escapeHtml(r.offset) + + escapeHtml(r.freq_offset) + " MHz |
| CC | " + r.color_code + " |
| Status | " + escapeHtml(r.status) + " |
| BM Status | " + escapeHtml(r.bm_status_text) + " |
| Last seen | " + escapeHtml(r.last_seen.replace("T", " ").substring(0, 19)) + " |
| Hardware | " + escapeHtml(r.hardware) + " |
| Power | " + r.pep + " W |
| Antenna | " + r.agl + " m AGL |
| Inactive |