breadcrumb/main.go
Astra c41a2c5411 Initial commit: Breadcrumb GPS trip viewer
Go server receiving Colota location data into SQLite, with a trip-segmentation
API and embedded web frontend for browsing trips on a map.
2026-06-03 19:34:08 +01:00

396 lines
12 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"database/sql"
"embed"
"encoding/json"
"flag"
"fmt"
"io/fs"
"log"
"math"
"net/http"
"os"
"strconv"
"time"
_ "github.com/mattn/go-sqlite3"
)
//go:embed static
var staticFiles embed.FS
// LocationPayload matches the Colota app's default POST JSON body.
// alt, vel, and bear are optional (Colota may omit them).
type LocationPayload struct {
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
Acc float64 `json:"acc"`
Alt *float64 `json:"alt,omitempty"`
Vel *float64 `json:"vel,omitempty"`
Batt *int `json:"batt,omitempty"`
Bs *int `json:"bs,omitempty"` // battery state: 0=unknown,1=unplugged,2=charging,3=full
Tst int64 `json:"tst"` // Unix timestamp (seconds)
Bear *float64 `json:"bear,omitempty"` // bearing in degrees
}
// LocationRecord is what we store and return via the query API.
type LocationRecord struct {
ID int64 `json:"id"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
Acc float64 `json:"acc"`
Alt *float64 `json:"alt,omitempty"`
Vel *float64 `json:"vel,omitempty"`
Batt *int `json:"batt,omitempty"`
Bs *int `json:"bs,omitempty"`
Tst int64 `json:"tst"`
Bear *float64 `json:"bear,omitempty"`
ReceivedAt string `json:"received_at"`
}
type Server struct {
db *sql.DB
token string // optional Bearer token; empty = no auth
logger *log.Logger
}
func main() {
port := flag.Int("port", 8080, "HTTP listen port")
dbPath := flag.String("db", "locations.db", "SQLite3 database file path")
token := flag.String("token", "", "Optional Bearer token for authentication (empty = disabled)")
flag.Parse()
logger := log.New(os.Stdout, "[breadcrumb] ", log.LstdFlags)
db, err := sql.Open("sqlite3", *dbPath+"?_journal_mode=WAL&_busy_timeout=5000")
if err != nil {
logger.Fatalf("failed to open database: %v", err)
}
defer db.Close()
if err := initDB(db); err != nil {
logger.Fatalf("failed to initialise database: %v", err)
}
srv := &Server{db: db, token: *token, logger: logger}
sub, err := fs.Sub(staticFiles, "static")
if err != nil {
logger.Fatalf("failed to create static sub-fs: %v", err)
}
mux := http.NewServeMux()
mux.HandleFunc("/api/location", srv.handleLocation) // POST receive from Colota app
mux.HandleFunc("/api/locations", srv.handleLocations) // GET query stored locations
mux.HandleFunc("/api/trips", srv.handleTrips) // GET trip list
mux.HandleFunc("/health", srv.handleHealth) // GET health check
mux.Handle("/", http.FileServer(http.FS(sub))) // static frontend
addr := fmt.Sprintf(":%d", *port)
logger.Printf("listening on %s db=%s auth=%v", addr, *dbPath, *token != "")
if err := http.ListenAndServe(addr, mux); err != nil {
logger.Fatalf("server error: %v", err)
}
}
// initDB creates the locations table if it doesn't already exist.
func initDB(db *sql.DB) error {
_, err := db.Exec(`
CREATE TABLE IF NOT EXISTS locations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
lat REAL NOT NULL,
lon REAL NOT NULL,
acc REAL NOT NULL,
alt REAL,
vel REAL,
batt INTEGER,
bs INTEGER,
tst INTEGER NOT NULL,
bear REAL,
received_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_locations_tst ON locations(tst);
`)
return err
}
// ── Middleware ────────────────────────────────────────────────────────────────
func (s *Server) authenticate(r *http.Request) bool {
if s.token == "" {
return true // auth disabled
}
auth := r.Header.Get("Authorization")
return auth == "Bearer "+s.token
}
// ── Handlers ──────────────────────────────────────────────────────────────────
// handleLocation accepts the Colota app's POST JSON payload (or GET query params).
func (s *Server) handleLocation(w http.ResponseWriter, r *http.Request) {
if !s.authenticate(r) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
var p LocationPayload
switch r.Method {
case http.MethodPost:
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
s.logger.Printf("bad JSON: %v", err)
http.Error(w, "bad request", http.StatusBadRequest)
return
}
case http.MethodGet:
q := r.URL.Query()
p.Lat = parseFloat(q.Get("lat"))
p.Lon = parseFloat(q.Get("lon"))
p.Acc = parseFloat(q.Get("acc"))
p.Tst = parseInt64(q.Get("tst"))
if v := q.Get("alt"); v != "" { f := parseFloat(v); p.Alt = &f }
if v := q.Get("vel"); v != "" { f := parseFloat(v); p.Vel = &f }
if v := q.Get("bear"); v != "" { f := parseFloat(v); p.Bear = &f }
if v := q.Get("batt"); v != "" { i := int(parseFloat(v)); p.Batt = &i }
if v := q.Get("bs"); v != "" { i := int(parseFloat(v)); p.Bs = &i }
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if p.Lat == 0 && p.Lon == 0 {
http.Error(w, "lat/lon required", http.StatusBadRequest)
return
}
if p.Tst == 0 {
p.Tst = time.Now().Unix()
}
receivedAt := time.Now().UTC().Format(time.RFC3339)
_, err := s.db.Exec(`
INSERT INTO locations (lat, lon, acc, alt, vel, batt, bs, tst, bear, received_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
p.Lat, p.Lon, p.Acc, p.Alt, p.Vel, p.Batt, p.Bs, p.Tst, p.Bear, receivedAt,
)
if err != nil {
s.logger.Printf("db insert error: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
s.logger.Printf("saved lat=%.6f lon=%.6f acc=%.1f tst=%d", p.Lat, p.Lon, p.Acc, p.Tst)
w.WriteHeader(http.StatusOK)
}
// handleLocations returns stored location records as JSON.
// Query params: limit (default 100), from / to (Unix timestamps), page (1-based).
func (s *Server) handleLocations(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if !s.authenticate(r) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
q := r.URL.Query()
limit := 100
if v := q.Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 500000 {
limit = n
}
}
page := 1
if v := q.Get("page"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
page = n
}
}
offset := (page - 1) * limit
// Optional time-range filter
args := []any{}
where := "WHERE 1=1"
if v := q.Get("from"); v != "" {
where += " AND tst >= ?"
args = append(args, parseInt64(v))
}
if v := q.Get("to"); v != "" {
where += " AND tst <= ?"
args = append(args, parseInt64(v))
}
countArgs := make([]any, len(args))
copy(countArgs, args)
var total int
row := s.db.QueryRow("SELECT COUNT(*) FROM locations "+where, countArgs...)
if err := row.Scan(&total); err != nil {
s.logger.Printf("count error: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
args = append(args, limit, offset)
rows, err := s.db.Query(
"SELECT id, lat, lon, acc, alt, vel, batt, bs, tst, bear, received_at "+
"FROM locations "+where+" ORDER BY tst DESC LIMIT ? OFFSET ?",
args...,
)
if err != nil {
s.logger.Printf("query error: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
defer rows.Close()
records := []LocationRecord{}
for rows.Next() {
var rec LocationRecord
if err := rows.Scan(
&rec.ID, &rec.Lat, &rec.Lon, &rec.Acc,
&rec.Alt, &rec.Vel, &rec.Batt, &rec.Bs,
&rec.Tst, &rec.Bear, &rec.ReceivedAt,
); err != nil {
s.logger.Printf("scan error: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
records = append(records, rec)
}
type response struct {
Total int `json:"total"`
Page int `json:"page"`
Limit int `json:"limit"`
Records []LocationRecord `json:"records"`
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response{
Total: total,
Page: page,
Limit: limit,
Records: records,
})
}
// handleHealth returns 200 OK with a JSON status blob.
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
var count int
s.db.QueryRow("SELECT COUNT(*) FROM locations").Scan(&count)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"status": "ok",
"time": time.Now().UTC().Format(time.RFC3339),
"total_locations": count,
})
}
// ── Trips ─────────────────────────────────────────────────────────────────────
type TripSummary struct {
StartTst int64 `json:"start_tst"`
EndTst int64 `json:"end_tst"`
PointCount int `json:"point_count"`
DistanceKm float64 `json:"distance_km"`
}
// handleTrips groups all location points into trips separated by gaps > 30 min.
func (s *Server) handleTrips(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if !s.authenticate(r) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
rows, err := s.db.Query(`SELECT tst, lat, lon FROM locations ORDER BY tst ASC`)
if err != nil {
s.logger.Printf("trips query error: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
defer rows.Close()
type point struct{ tst int64; lat, lon float64 }
var pts []point
for rows.Next() {
var p point
if err := rows.Scan(&p.tst, &p.lat, &p.lon); err != nil {
continue
}
pts = append(pts, p)
}
const gapSec = 30 * 60 // 30 minutes
var trips []TripSummary
if len(pts) > 0 {
cur := TripSummary{StartTst: pts[0].tst, EndTst: pts[0].tst, PointCount: 1}
prevLat, prevLon := pts[0].lat, pts[0].lon
distKm := 0.0
for i := 1; i < len(pts); i++ {
p := pts[i]
if p.tst-pts[i-1].tst > gapSec {
cur.DistanceKm = math.Round(distKm*10) / 10
trips = append(trips, cur)
cur = TripSummary{StartTst: p.tst, EndTst: p.tst, PointCount: 1}
distKm = 0
} else {
cur.EndTst = p.tst
cur.PointCount++
distKm += haversineKm(prevLat, prevLon, p.lat, p.lon)
}
prevLat, prevLon = p.lat, p.lon
}
cur.DistanceKm = math.Round(distKm*10) / 10
trips = append(trips, cur)
}
// Return newest first
for i, j := 0, len(trips)-1; i < j; i, j = i+1, j-1 {
trips[i], trips[j] = trips[j], trips[i]
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(trips)
}
// ── Helpers ───────────────────────────────────────────────────────────────────
func haversineKm(lat1, lon1, lat2, lon2 float64) float64 {
const R = 6371.0
dLat := (lat2 - lat1) * math.Pi / 180
dLon := (lon2 - lon1) * math.Pi / 180
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
math.Cos(lat1*math.Pi/180)*math.Cos(lat2*math.Pi/180)*
math.Sin(dLon/2)*math.Sin(dLon/2)
return R * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
}
func parseFloat(s string) float64 {
if s == "" {
return 0
}
v, _ := strconv.ParseFloat(s, 64)
return v
}
func parseInt64(s string) int64 {
if s == "" {
return 0
}
v, _ := strconv.ParseInt(s, 10, 64)
return v
}