diff --git a/README.md b/README.md index a44d0b0..5606de3 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/client/macker b/client/macker index ddafffe..49a5b64 100755 Binary files a/client/macker and b/client/macker differ diff --git a/client/main.go b/client/main.go index 69ceaa9..f2e72a1 100644 --- a/client/main.go +++ b/client/main.go @@ -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 } diff --git a/client/tmp_verify_test.go b/client/tmp_verify_test.go index 99df56c..372cbb4 100644 --- a/client/tmp_verify_test.go +++ b/client/tmp_verify_test.go @@ -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 diff --git a/datapoint/batch.go b/datapoint/batch.go new file mode 100644 index 0000000..c69ade1 --- /dev/null +++ b/datapoint/batch.go @@ -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 +} diff --git a/datapoint/batch_test.go b/datapoint/batch_test.go new file mode 100644 index 0000000..2a69bbb --- /dev/null +++ b/datapoint/batch_test.go @@ -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") + } +} diff --git a/server/index.html b/server/index.html index eda80ec..f1e5ef0 100644 --- a/server/index.html +++ b/server/index.html @@ -10,13 +10,32 @@

Macker

Work car · Live location

-
Current speed—Waiting for GPS
Last update—No location received yet

Waiting for the car’s first GPS fix…

+
Current speed—Waiting for GPS
Last update—No location received yet

Waiting for the car’s first GPS fix…

GPS uncertainty unavailable

diff --git a/server/main.go b/server/main.go index 1e5cf97..eabdc35 100644 --- a/server/main.go +++ b/server/main.go @@ -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 } diff --git a/server/main_test.go b/server/main_test.go index 1538f8d..a358e4a 100644 --- a/server/main_test.go +++ b/server/main_test.go @@ -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) } diff --git a/server/srvr b/server/srvr new file mode 100755 index 0000000..19e3afc Binary files /dev/null and b/server/srvr differ