INITIAL
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
module srvr
|
||||
|
||||
go 1.27.0
|
||||
@@ -0,0 +1,34 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Macker · Work car</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
|
||||
<style>
|
||||
*{box-sizing:border-box}body{margin:0;background:#f3f5f2;color:#182d26;font:16px system-ui,sans-serif}header{padding:24px 5vw;display:flex;justify-content:space-between;align-items:center}h1{font-size:24px;margin:0}header p{margin:4px 0;color:#607269}main{margin:0 5vw 28px}.stats{display:flex;gap:16px;margin-bottom:20px}.card{background:white;border:1px solid #dce4dd;border-radius:12px;padding:20px;flex:1}.label{font-size:12px;text-transform:uppercase;letter-spacing:.1em;color:#607269}.value{display:block;font-size:32px;margin:8px 0}small{color:#607269}#map{height:65vh;min-height:320px;border-radius:14px;border:1px solid #cbd8cd}button{background:#1c593f;color:white;border:0;border-radius:8px;padding:12px;cursor:pointer}#status{margin:12px 0;min-height:24px}@media(max-width:540px){.stats{flex-direction:column;gap:10px}.card{padding:14px}.value{font-size:26px}header{gap:12px}#map{height:50vh}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header><div><h1>Macker</h1><p>Work car · Live location</p></div><button id="follow" type="button">Follow car: on</button></header>
|
||||
<main><div class="stats"><section class="card"><span class="label">Current speed</span><strong class="value" id="speed">—</strong><small id="metric">Waiting for GPS</small></section><section class="card"><span class="label">Last update</span><strong class="value" id="age">—</strong><small id="time">No location received yet</small></section></div><p id="status" role="status">Waiting for the car’s first GPS fix…</p><div id="map" aria-label="Car location map"></div></main>
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script>
|
||||
const el=id=>document.getElementById(id);
|
||||
let map, marker, latest, follow=true, failed=false;
|
||||
if(window.L){map=L.map('map').setView([20,0],2);L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png',{maxZoom:19,attribution:'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'}).addTo(map);}
|
||||
el('follow').onclick=()=>{follow=!follow;el('follow').textContent=`Follow car: ${follow?'on':'off'}`;if(follow&&marker)map.panTo(marker.getLatLng());};
|
||||
function render(){
|
||||
if(!latest){el('status').textContent=failed?'Cannot reach server. Retrying…':'Waiting for the car’s first GPS fix…';return;}
|
||||
const seconds=Math.max(0,Math.floor((Date.now()-Date.parse(latest.updated_at))/1000));
|
||||
const stale=seconds>30,p=latest.point;
|
||||
el('age').textContent=seconds<60?`${seconds} s ago`:seconds<3600?`${Math.floor(seconds/60)} min ago`:`${Math.floor(seconds/3600)} h ago`;
|
||||
el('time').textContent=new Date(latest.updated_at).toLocaleString();
|
||||
el('speed').textContent=stale||p.Speed==null?'—':`${(p.Speed*2.236936).toFixed(1)} mph`;
|
||||
el('metric').textContent=stale?'Last fix is stale':p.Speed==null?'Speed unavailable':`${(p.Speed*3.6).toFixed(1)} km/h`;
|
||||
el('status').textContent=failed?'Connection lost · showing last known location':stale?'No recent GPS fix · showing last known location':`GPS active · ${p.Lat.toFixed(5)}, ${p.Long.toFixed(5)}`;
|
||||
if(!map)el('status').textContent+=' · Map failed to load; reload to retry';
|
||||
}
|
||||
async function poll(){try{const res=await fetch('/api/location',{cache:'no-store',signal:AbortSignal.timeout(8000)});if(!res.ok)throw Error(res.status);latest=await res.json();failed=false;if(latest&&map){const pos=[latest.point.Lat,latest.point.Long];if(!marker){marker=L.marker(pos).addTo(map);map.setView(pos,15);}else{marker.setLatLng(pos);if(follow)map.panTo(pos);}}}catch(e){failed=true;}render();setTimeout(poll,2000);}
|
||||
setInterval(render,1000);poll();
|
||||
</script>
|
||||
</body></html>
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/gob"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"io"
|
||||
"log"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"macker/datapoint"
|
||||
)
|
||||
|
||||
//go:embed index.html
|
||||
var page []byte
|
||||
|
||||
type location struct {
|
||||
Point datapoint.DataPoint `json:"point"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ReceivedAt time.Time `json:"received_at"`
|
||||
}
|
||||
|
||||
type tracker struct {
|
||||
mu sync.RWMutex
|
||||
latest *location
|
||||
}
|
||||
|
||||
func (t *tracker) accept(points []datapoint.DataPoint, now time.Time) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
for _, p := range points {
|
||||
if math.IsNaN(p.Lat) || math.IsInf(p.Lat, 0) || math.IsNaN(p.Long) || math.IsInf(p.Long, 0) || p.Lat < -90 || p.Lat > 90 || p.Long < -180 || p.Long > 180 {
|
||||
continue
|
||||
}
|
||||
if p.Speed != nil && (math.IsNaN(*p.Speed) || math.IsInf(*p.Speed, 0) || *p.Speed < 0) {
|
||||
p.Speed = nil
|
||||
}
|
||||
updated := now
|
||||
if p.Timestamp != 0 {
|
||||
if p.Timestamp > uint64(now.Add(time.Minute).UnixMilli()) {
|
||||
continue
|
||||
}
|
||||
updated = time.UnixMilli(int64(p.Timestamp))
|
||||
}
|
||||
if t.latest != nil && !updated.After(t.latest.UpdatedAt) {
|
||||
continue
|
||||
}
|
||||
t.latest = &location{Point: p, UpdatedAt: updated, ReceivedAt: now}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *tracker) receive(c net.Conn) {
|
||||
defer c.Close()
|
||||
_ = c.SetReadDeadline(time.Now().Add(10 * time.Second))
|
||||
var raw []byte
|
||||
if err := gob.NewDecoder(io.LimitReader(c, 4<<20)).Decode(&raw); err != nil {
|
||||
log.Printf("decode batch: %v", err)
|
||||
return
|
||||
}
|
||||
var points []datapoint.DataPoint
|
||||
if err := json.Unmarshal(raw, &points); err != nil {
|
||||
log.Printf("decode points: %v", err)
|
||||
return
|
||||
}
|
||||
t.accept(points, time.Now().UTC())
|
||||
}
|
||||
|
||||
func (t *tracker) handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /api/location", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
_ = json.NewEncoder(w).Encode(t.latest)
|
||||
})
|
||||
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write(page)
|
||||
})
|
||||
return mux
|
||||
}
|
||||
|
||||
func main() {
|
||||
ingest := flag.String("ingest", ":2948", "TCP address for car GPS batches")
|
||||
web := flag.String("http", ":8080", "HTTP address for the map")
|
||||
flag.Parse()
|
||||
ln, err := net.Listen("tcp", *ingest)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
t := &tracker{}
|
||||
go func() {
|
||||
// Bound concurrent decoders and their memory usage.
|
||||
slots := make(chan struct{}, 32)
|
||||
for {
|
||||
c, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case slots <- struct{}{}:
|
||||
go func() { defer func() { <-slots }(); t.receive(c) }()
|
||||
default:
|
||||
c.Close()
|
||||
}
|
||||
}
|
||||
}()
|
||||
log.Printf("GPS receiver on %s; map on %s", *ingest, *web)
|
||||
srv := &http.Server{Addr: *web, Handler: t.handler(), ReadHeaderTimeout: 5 * time.Second, WriteTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second}
|
||||
log.Fatal(srv.ListenAndServe())
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/gob"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"macker/datapoint"
|
||||
)
|
||||
|
||||
func TestReceiveAndServeLocation(t *testing.T) {
|
||||
tr := &tracker{}
|
||||
server, client := net.Pipe()
|
||||
done := make(chan struct{})
|
||||
go func() { tr.receive(server); close(done) }()
|
||||
speed := 12.5
|
||||
now := time.Now().UTC().Truncate(time.Millisecond)
|
||||
point := datapoint.DataPoint{Timestamp: uint64(now.UnixMilli()), Lat: 41.8, Long: -87.6, Speed: &speed}
|
||||
raw, err := json.Marshal([]datapoint.DataPoint{point})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = gob.NewEncoder(client).Encode(raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client.Close()
|
||||
<-done
|
||||
rr := httptest.NewRecorder()
|
||||
tr.handler().ServeHTTP(rr, httptest.NewRequest("GET", "/api/location", nil))
|
||||
var got location
|
||||
if err = json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rr.Code != 200 || got.Point.Lat != point.Lat || got.Point.Speed == nil || *got.Point.Speed != speed || !got.UpdatedAt.Equal(now) {
|
||||
t.Fatalf("unexpected location: %s", rr.Body.String())
|
||||
}
|
||||
// A delayed batch must not replace the current position.
|
||||
tr.accept([]datapoint.DataPoint{{Timestamp: uint64(now.Add(-time.Minute).UnixMilli()), Lat: 20}}, now)
|
||||
if tr.latest.Point.Lat != point.Lat {
|
||||
t.Fatal("older fix replaced current position")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyInvalidAndMissingTimestamp(t *testing.T) {
|
||||
tr := &tracker{}
|
||||
rr := httptest.NewRecorder()
|
||||
tr.handler().ServeHTTP(rr, httptest.NewRequest("GET", "/api/location", nil))
|
||||
if rr.Body.String() != "null\n" {
|
||||
t.Fatal(rr.Body.String())
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
tr.accept([]datapoint.DataPoint{{Lat: 91}, {Long: 181}, {Timestamp: uint64(now.Add(time.Hour).UnixMilli())}}, now)
|
||||
if tr.latest != nil {
|
||||
t.Fatal("accepted invalid point")
|
||||
}
|
||||
tr.accept([]datapoint.DataPoint{{Lat: 0, Long: 0}}, now)
|
||||
if tr.latest == nil || !tr.latest.UpdatedAt.Equal(now) {
|
||||
t.Fatal("missing timestamp should use receipt time")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user