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.
This commit is contained in:
commit
c41a2c5411
6 changed files with 1268 additions and 0 deletions
396
main.go
Normal file
396
main.go
Normal file
|
|
@ -0,0 +1,396 @@
|
|||
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue