Added uncertainty circle and zipping
This commit is contained in:
@@ -20,7 +20,9 @@ Open `http://YOUR_VPS_HOST:8080`. Allow the car to reach TCP port 2948 and the b
|
||||
|
||||
The browser polls every two seconds. Speed comes from gpsd in metres per second and is shown in mph and km/h. The current gpsd dependency represents omitted speed as zero, so a GPS report without speed can appear stationary. Reports without a position fix are ignored. “Last update” uses the GPS fix time, falling back to server receipt time when absent. After 30 seconds, the page marks the fix stale and hides current speed. Delayed timestamped fixes cannot replace newer fixes.
|
||||
|
||||
The server retains only the latest fix in memory; a restart clears it. There is no route history or durable client queue. The existing gob-wrapped JSON wire format is retained; both binaries should be rebuilt for speed support. Browser access to unpkg.com and tile.openstreetmap.org is required for the map. Map integration follows the [Leaflet quick start](https://leafletjs.com/examples/quick-start/); OpenStreetMap attribution is included.
|
||||
The map shades an estimated GPS uncertainty circle around each fix. Its radius is `hypot(LatErr, LongErr)` in metres, enclosing the reported north–south and east–west error ranges. The circle moves and resizes with each fix and is hidden when either estimate is missing, zero, negative, or non-finite. A stale fix retains its last reported circle; it does not predict movement since the fix. This is an estimate, not a guaranteed confidence boundary: [gpsd notes that error confidence varies by receiver](https://gpsd.io/gpsd_json.html).
|
||||
|
||||
The server retains only the latest fix in memory; a restart clears it. There is no route history or durable client queue. The client compresses each JSON batch into an in-memory ZIP containing `points.json`, then sends the ZIP bytes inside the existing gob framing. The server unzips in memory, validates the archive and checksum, and accepts at most 4 MiB compressed and 16 MiB expanded. No temporary files are created. Upgrade the server first: it also accepts legacy uncompressed JSON batches. Then rebuild and restart the client to enable compression. Browser access to unpkg.com and tile.openstreetmap.org is required for the map. Map integration follows the [Leaflet quick start](https://leafletjs.com/examples/quick-start/); OpenStreetMap attribution is included.
|
||||
|
||||
Checks:
|
||||
|
||||
|
||||
Binary file not shown.
+3
-4
@@ -7,7 +7,6 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/gob"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"math"
|
||||
@@ -188,7 +187,7 @@ func backoffFor(retry int) time.Duration {
|
||||
|
||||
// FUNCTION TO SEND THE COLLECTED DATA POINTS TO THE SERVER 🖥🖥🖥🖥🖥🖥🖥🖥🖥🖥🖥🖥🖥🖥🖥🖥🖥🖥🖥🖥🖥🖥
|
||||
//
|
||||
// The payload is JSON, gob-encoded as an opaque []byte. sendBuffer is left
|
||||
// The payload is a ZIP of JSON, gob-encoded as an opaque []byte. sendBuffer is left
|
||||
// untouched on failure so the caller can retry the same points; the caller
|
||||
// clears them once we return without error.
|
||||
func sendToServer(targAddr, targPort string, sendBuffer *[]datapoint.DataPoint, errorChan chan error, retry int) {
|
||||
@@ -209,10 +208,10 @@ func sendToServer(targAddr, targPort string, sendBuffer *[]datapoint.DataPoint,
|
||||
defer con.Close()
|
||||
con.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||
|
||||
str, err := json.Marshal(sendBuffer)
|
||||
str, err := datapoint.ZipBatch(*sendBuffer)
|
||||
if err != nil {
|
||||
// Bad data, not a bad connection: retrying will not help.
|
||||
errorChan <- fmt.Errorf("marshal batch: %w", err)
|
||||
errorChan <- fmt.Errorf("zip batch: %w", err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/gob"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -51,7 +50,7 @@ func TestSolidifyData(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Fake server: gob-decode []byte then JSON-decode, exactly as a real receiver would.
|
||||
// Fake server: gob-decode []byte then unzip JSON, as the real receiver does.
|
||||
func TestSendLoopEndToEnd(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
@@ -77,8 +76,8 @@ func TestSendLoopEndToEnd(t *testing.T) {
|
||||
con.Close()
|
||||
continue
|
||||
}
|
||||
var pts []datapoint.DataPoint
|
||||
if err := json.Unmarshal(raw, &pts); err != nil {
|
||||
pts, err := datapoint.UnzipBatch(raw)
|
||||
if err != nil {
|
||||
got <- batch{err: err}
|
||||
con.Close()
|
||||
continue
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package datapoint
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
const (
|
||||
MaxBatchBytes = 4 << 20 // Maximum ZIP payload size on the wire.
|
||||
MaxBatchJSONBytes = 16 << 20 // Maximum expanded JSON size.
|
||||
)
|
||||
|
||||
// ZipBatch packages GPS fixes into an in-memory ZIP containing points.json.
|
||||
func ZipBatch(points []DataPoint) ([]byte, error) {
|
||||
raw, err := json.Marshal(points)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode points: %w", err)
|
||||
}
|
||||
if len(raw) > MaxBatchJSONBytes {
|
||||
return nil, fmt.Errorf("batch JSON exceeds %d bytes", MaxBatchJSONBytes)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
file, err := zw.Create("points.json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err = file.Write(raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = zw.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if buf.Len() > MaxBatchBytes {
|
||||
return nil, fmt.Errorf("ZIP batch exceeds %d bytes", MaxBatchBytes)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// UnzipBatch reads points in memory; archive entries are never written to disk.
|
||||
// Legacy JSON batches are accepted so the server can be upgraded first.
|
||||
func UnzipBatch(payload []byte) ([]DataPoint, error) {
|
||||
if len(payload) > MaxBatchBytes {
|
||||
return nil, fmt.Errorf("batch exceeds %d bytes", MaxBatchBytes)
|
||||
}
|
||||
raw := payload
|
||||
if !bytes.HasPrefix(bytes.TrimSpace(payload), []byte("[")) && !bytes.Equal(bytes.TrimSpace(payload), []byte("null")) {
|
||||
zr, err := zip.NewReader(bytes.NewReader(payload), int64(len(payload)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open ZIP batch: %w", err)
|
||||
}
|
||||
if len(zr.File) != 1 || zr.File[0].Name != "points.json" {
|
||||
return nil, fmt.Errorf("ZIP batch must contain only points.json")
|
||||
}
|
||||
file := zr.File[0]
|
||||
if file.UncompressedSize64 > MaxBatchJSONBytes {
|
||||
return nil, fmt.Errorf("expanded batch exceeds %d bytes", MaxBatchJSONBytes)
|
||||
}
|
||||
r, err := file.Open()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open points.json: %w", err)
|
||||
}
|
||||
defer r.Close()
|
||||
raw, err = io.ReadAll(io.LimitReader(r, MaxBatchJSONBytes+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read points.json: %w", err)
|
||||
}
|
||||
if len(raw) > MaxBatchJSONBytes {
|
||||
return nil, fmt.Errorf("expanded batch exceeds %d bytes", MaxBatchJSONBytes)
|
||||
}
|
||||
}
|
||||
var points []DataPoint
|
||||
if err := json.Unmarshal(raw, &points); err != nil {
|
||||
return nil, fmt.Errorf("decode points: %w", err)
|
||||
}
|
||||
return points, nil
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package datapoint
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"math"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBatchRoundTrip(t *testing.T) {
|
||||
speed := 14.5
|
||||
points := make([]DataPoint, 25)
|
||||
for i := range points {
|
||||
points[i] = DataPoint{Timestamp: uint64(1000 + i), Lat: 41.8, Long: -87.6, LatErr: 3, LongErr: 4, Speed: &speed}
|
||||
}
|
||||
raw, err := json.Marshal(points)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zipped, err := ZipBatch(points)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(zipped) >= len(raw) {
|
||||
t.Fatalf("batch did not compress: %d >= %d", len(zipped), len(raw))
|
||||
}
|
||||
for name, payload := range map[string][]byte{"zip": zipped, "legacy": raw} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
got, err := UnzipBatch(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, points) {
|
||||
t.Fatalf("round trip changed points: %+v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, points := range [][]DataPoint{nil, {}} {
|
||||
payload, err := ZipBatch(points)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := UnzipBatch(payload)
|
||||
if err != nil || !reflect.DeepEqual(got, points) {
|
||||
t.Fatalf("empty batch round trip: %v, %v", got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func archive(t *testing.T, names []string, raw []byte) []byte {
|
||||
t.Helper()
|
||||
var b bytes.Buffer
|
||||
w := zip.NewWriter(&b)
|
||||
for _, name := range names {
|
||||
f, err := w.Create(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = f.Write(raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
func TestUnzipRejectsBadBatches(t *testing.T) {
|
||||
valid := archive(t, []string{"points.json"}, []byte("[]"))
|
||||
// Alter the stored CRC, leaving a structurally readable archive.
|
||||
corrupt := bytes.Clone(valid)
|
||||
central := bytes.Index(corrupt, []byte{'P', 'K', 1, 2})
|
||||
if central < 0 {
|
||||
t.Fatal("missing ZIP directory")
|
||||
}
|
||||
corrupt[central+16] ^= 0xff
|
||||
cases := map[string][]byte{
|
||||
"garbage": []byte("not a zip"),
|
||||
"truncated": valid[:len(valid)-10],
|
||||
"invalid json": archive(t, []string{"points.json"}, []byte("bad JSON")),
|
||||
"wrong entry": archive(t, []string{"../points.json"}, []byte("[]")),
|
||||
"multiple entries": archive(t, []string{"points.json", "other.json"}, []byte("[]")),
|
||||
"oversized compressed": make([]byte, MaxBatchBytes+1),
|
||||
"oversized expanded": archive(t, []string{"points.json"}, bytes.Repeat([]byte(" "), MaxBatchJSONBytes+1)),
|
||||
"checksum": corrupt,
|
||||
}
|
||||
for name, payload := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := UnzipBatch(payload); err == nil {
|
||||
t.Fatal("accepted invalid batch")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestZipRejectsInvalidPoints(t *testing.T) {
|
||||
if _, err := ZipBatch([]DataPoint{{Lat: math.NaN()}}); err == nil {
|
||||
t.Fatal("accepted non-JSON data")
|
||||
}
|
||||
}
|
||||
+22
-3
@@ -10,13 +10,32 @@
|
||||
</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>
|
||||
<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><p id="uncertainty"><small>GPS uncertainty unavailable</small></p><div id="map" aria-label="Car location map with GPS uncertainty circle"></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;
|
||||
let map, marker, uncertaintyCircle, 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());};
|
||||
// gpsd axis errors are already in metres. Enclose their horizontal error box.
|
||||
// Zero also represents an omitted estimate in the client's gpsd dependency.
|
||||
function uncertaintyRadius(point){
|
||||
const {LatErr,LongErr}=point;
|
||||
if(!Number.isFinite(LatErr)||!Number.isFinite(LongErr)||LatErr<=0||LongErr<=0)return null;
|
||||
const radius=Math.hypot(LatErr,LongErr);
|
||||
return Number.isFinite(radius)?radius:null;
|
||||
}
|
||||
function updateUncertainty(point,pos){
|
||||
const radius=uncertaintyRadius(point);
|
||||
el('uncertainty').textContent=radius===null?'GPS uncertainty unavailable':`Estimated GPS uncertainty: ${radius.toFixed(1)} m radius · shaded circle`;
|
||||
if(radius===null){
|
||||
if(uncertaintyCircle){map.removeLayer(uncertaintyCircle);uncertaintyCircle=null;}
|
||||
return;
|
||||
}
|
||||
if(!uncertaintyCircle){
|
||||
uncertaintyCircle=L.circle(pos,{radius,color:'#287a57',weight:2,fillColor:'#409b73',fillOpacity:0.16,interactive:false}).addTo(map);
|
||||
}else{uncertaintyCircle.setLatLng(pos).setRadius(radius);}
|
||||
}
|
||||
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));
|
||||
@@ -28,7 +47,7 @@ function render(){
|
||||
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);}
|
||||
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];updateUncertainty(latest.point,pos);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>
|
||||
|
||||
+3
-3
@@ -58,12 +58,12 @@ 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 {
|
||||
if err := gob.NewDecoder(io.LimitReader(c, datapoint.MaxBatchBytes+1024)).Decode(&raw); err != nil {
|
||||
log.Printf("decode batch: %v", err)
|
||||
return
|
||||
}
|
||||
var points []datapoint.DataPoint
|
||||
if err := json.Unmarshal(raw, &points); err != nil {
|
||||
points, err := datapoint.UnzipBatch(raw)
|
||||
if err != nil {
|
||||
log.Printf("decode points: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ func TestReceiveAndServeLocation(t *testing.T) {
|
||||
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})
|
||||
raw, err := datapoint.ZipBatch([]datapoint.DataPoint{point})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
Executable
BIN
Binary file not shown.
Reference in New Issue
Block a user