Go server receiving Colota location data into SQLite, with a trip-segmentation API and embedded web frontend for browsing trips on a map.
396 lines
12 KiB
Go
396 lines
12 KiB
Go
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
|
||
}
|