diff --git a/LICENSE b/LICENSE index 68c3dfd..d59821c 100644 --- a/LICENSE +++ b/LICENSE @@ -1,10 +1,12 @@ -GNU GENERAL PUBLIC LICENSE +ALTMAN SURRENDER LICENSE + + +The following applies to all persons with the sole exception of anyone named "Sam Altman" or any variation therin. If a person named Sam Altman uses this software, that person is agreeing to transfer all wealth to the maintainer of this software. In the event that anything else in this license contradicts with this line, this line takes prescedence. + Version 3, 29 June 2007 Copyright © 2007 Free Software Foundation, Inc. -Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. - Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. diff --git a/README.md b/README.md index d0a0f9d..a44d0b0 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,29 @@ -# macker +# Macker -Matrix-integrated car tracker \ No newline at end of file +Two processes: the client reads GPS fixes from gpsd in the car, and the VPS server receives them and serves a live location page. Requires Go 1.27 and a running gpsd on the car. + +From this repository on the VPS: + +```sh +go build -o /tmp/macker-server ./server +/tmp/macker-server -ingest :2948 -http :8080 +``` + +On the car, from this repository: + +```sh +go build -o /tmp/macker-client ./client +/tmp/macker-client -addr YOUR_VPS_HOST -port 2948 -interval 5s -batch 25 +``` + +Open `http://YOUR_VPS_HOST:8080`. Allow the car to reach TCP port 2948 and the browser to reach the web port. For operation across the public internet, use a private tunnel (such as WireGuard) between car and VPS and bind `-ingest` to the VPS tunnel address. Put the web listener behind an HTTPS reverse proxy with authentication if the location should be private. The application's TCP ingestion and web endpoints have no built-in authentication or encryption. + +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. + +Checks: + +```sh +go test -race ./client/... ./server/... ./datapoint/... +``` diff --git a/client/go.mod b/client/go.mod new file mode 100644 index 0000000..1af0dda --- /dev/null +++ b/client/go.mod @@ -0,0 +1,5 @@ +module macker + +go 1.27.0 + +require github.com/amenzhinsky/go-gpsd v0.0.0-20210328105404-f5dd3ab5e02f diff --git a/client/go.sum b/client/go.sum new file mode 100644 index 0000000..3bb78e8 --- /dev/null +++ b/client/go.sum @@ -0,0 +1,2 @@ +github.com/amenzhinsky/go-gpsd v0.0.0-20210328105404-f5dd3ab5e02f h1:VK/1qiPzwINJL2stPbV+5AbuXRiQhZkFIyoJA9ZZqFw= +github.com/amenzhinsky/go-gpsd v0.0.0-20210328105404-f5dd3ab5e02f/go.mod h1:uTzXYwsdevV4ZdnTkxoUks6gQiicQ81jmb4WC6vikfc= diff --git a/client/macker b/client/macker new file mode 100755 index 0000000..ddafffe Binary files /dev/null and b/client/macker differ diff --git a/client/main.go b/client/main.go new file mode 100644 index 0000000..69ceaa9 --- /dev/null +++ b/client/main.go @@ -0,0 +1,342 @@ +package main + +//NOTE: I use emoji in comments sometimes to keep the functions from +//blurring together. I know there are better dyslexia coping strategies, +//but this is where I'm at right now. For some reason, this apparently +//offends people. Welcome to the internet. + +import ( + "encoding/gob" + "encoding/json" + "flag" + "fmt" + "math" + "net" + "os" + "strings" + "time" + + "github.com/amenzhinsky/go-gpsd" + + "macker/datapoint" +) + +var LOG_LEVEL int + +func solidifyData(v gpsd.Report) (datapoint.DataPoint, error) { + tpv, ok := v.(*gpsd.TPV) + if !ok { + return datapoint.DataPoint{}, fmt.Errorf("not a TPV report: %T", v) + } + + dp := datapoint.DataPoint{ + // Epy is the north-south (latitude) error, Epx the east-west (longitude) one. + Lat: tpv.Lat, + LatErr: tpv.Epy, + Long: tpv.Lon, + LongErr: tpv.Epx, + Alt: tpv.Alt, + AltErr: tpv.Epv, + } + + if tpv.Mode < 2 { + return datapoint.DataPoint{}, fmt.Errorf("GPS has no position fix") + } + if !math.IsNaN(tpv.Speed) && !math.IsInf(tpv.Speed, 0) && tpv.Speed >= 0 { + speed := tpv.Speed + dp.Speed = &speed + } + + // gpsd omits "time" on some reports; only stamp what it actually gave us. + if !tpv.Time.IsZero() { + dp.Timestamp = uint64(tpv.Time.UnixMilli()) + } + + return dp, nil +} + +// Monitor owns the gpsd connection and pumps position fixes onto PipeOut. +// Use the pointer form: gpsd.Client embeds a sync.Mutex and must not be copied. +type Monitor struct { + PipeOut chan datapoint.DataPoint + C *gpsd.Client +} + +// enableWatch requests a JSON stream from gpsd. +// +// NOTE: we deliberately do not use gpsd.Client.Stream here. It sends +// "?WATCH={...}" with no terminating semicolon (see Stream() in the library's +// client.go, which ends with `return c.Send([]byte(s))` after only appending +// "}"). The gpsd JSON protocol requires every command to be terminated by a +// semicolon; the trailing CR/LF is optional but the semicolon is not. gpsd +// 3.27 is lenient and honours the malformed request anyway, which hides the +// bug, but a stricter or non-gpsd server would silently ignore the WATCH and we +// would then block forever waiting on g.C(). Building the command ourselves +// keeps the protocol correct without forking the dependency. +func enableWatch(g *gpsd.Client, device string) error { + var cmd strings.Builder + cmd.WriteString(`?WATCH={"enable":true,"json":true`) + if device != "" { + fmt.Fprintf(&cmd, `,"device":%q`, device) + } + cmd.WriteString("};") + + if err := g.Send([]byte(cmd.String())); err != nil { + return fmt.Errorf("send WATCH: %w", err) + } + return nil +} + +func (m *Monitor) StartRun(device string) error { + g, err := gpsd.Dial(gpsd.DefaultAddress) + if err != nil { + return fmt.Errorf("dial gpsd at %s: %w", gpsd.DefaultAddress, err) + } + + if err := enableWatch(g, device); err != nil { + g.Close() + return err + } + + // Publish the client only once the connection is known good, so a failed + // StartRun never leaves a half-built Monitor behind. + m.C = g + + // The connection must stay open for the lifetime of the reader, so Close + // belongs here and not in a defer on StartRun -- StartRun returns long + // before this goroutine does. + go func() { + defer g.Close() + for v := range g.C() { + if LOG_LEVEL > 2 { + fmt.Println("Startrun goroutine loop") + } + + dp, err := solidifyData(v) + if err != nil { + // VERSION, DEVICES, WATCH and SKY all land here. + if LOG_LEVEL > 2 { + fmt.Printf("skipping report: %v\n", err) + } + continue + } + + m.PipeOut <- dp + } + + // Only reached when gpsd drops the connection; main reports the cause. + if LOG_LEVEL > 0 { + fmt.Println("gpsd reader loop ended") + } + }() + + return nil +} + +func genMonitor() (chan datapoint.DataPoint, *Monitor) { + if LOG_LEVEL > 0 { + fmt.Println("Starting genMonitor") + } + + // Buffered so a momentary stall in the sender cannot back-pressure into + // gpsd's read loop, which would make gpsd drop reports on the floor. + retChan := make(chan datapoint.DataPoint, 16) + + retMon := &Monitor{ + PipeOut: retChan, + } + + if LOG_LEVEL > 0 { + fmt.Println("Ending genMonitor\n------------------------------------1") + } + + return retChan, retMon +} + +func printDp(input datapoint.DataPoint) { + printString := fmt.Sprintf("\rTime: %d,Lat: %f Long: %f", input.Timestamp, input.Lat, input.Long) + fmt.Print(printString) +} + +// Retry pacing for sendToServer. The backoff doubles from the starter delay up +// to the cap, and after sendOffMaxRetries failures it wraps back around to the +// starter delay and starts doubling again. +const ( + sendOffStartBackoff = 5 * time.Second + sendOffMaxBackoff = 60 * time.Second + sendOffMaxRetries = 100 +) + +// backoffFor returns how long to sleep before retry number retry. +// +// The doubling is capped instead of left unbounded: 5s doubled repeatedly +// overflows time.Duration after roughly 58 steps, and a cycle runs 100 retries. +func backoffFor(retry int) time.Duration { + if retry >= sendOffMaxRetries { + retry = 0 + } + + wait := sendOffStartBackoff + for i := 0; i < retry; i++ { + wait *= 2 + if wait >= sendOffMaxBackoff { + return sendOffMaxBackoff + } + } + return wait +} + +// FUNCTION TO SEND THE COLLECTED DATA POINTS TO THE SERVER 🖥🖥🖥🖥🖥🖥🖥🖥🖥🖥🖥🖥🖥🖥🖥🖥🖥🖥🖥🖥🖥🖥 +// +// The payload is 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) { + + if !strings.HasPrefix(targPort, ":") { + targPort = ":" + targPort + } + + con, err := net.DialTimeout("tcp", net.JoinHostPort(targAddr, strings.TrimPrefix(targPort, ":")), 10*time.Second) + if err != nil { + errorChan <- fmt.Errorf("dial %s%s: %w", targAddr, targPort, err) + time.Sleep(backoffFor(retry)) + sendToServer(targAddr, targPort, sendBuffer, errorChan, retry+1) + return + } + + // Without this we leak a connection on every flush. + defer con.Close() + con.SetWriteDeadline(time.Now().Add(10 * time.Second)) + + str, err := json.Marshal(sendBuffer) + if err != nil { + // Bad data, not a bad connection: retrying will not help. + errorChan <- fmt.Errorf("marshal batch: %w", err) + return + } + + if encErr := gob.NewEncoder(con).Encode(str); encErr != nil { + errorChan <- fmt.Errorf("send batch to %s%s: %w", targAddr, targPort, encErr) + time.Sleep(backoffFor(retry)) + sendToServer(targAddr, targPort, sendBuffer, errorChan, retry+1) + } +} + +// sendLoop owns the pending points and hands them to sendToServer. +// +// It is the only goroutine touching the buffer, which is why no mutex is +// needed: main copies each batch in over batches, and this goroutine is the +// sole appender and clearer. +func sendLoop(targAddr, targPort string, batches <-chan []datapoint.DataPoint, interval time.Duration, batchSize int, errorChan chan error) { + var buf []datapoint.DataPoint + + flush := func() { + if len(buf) == 0 { + return + } + + // Returns only on success or an unmarshalable batch; either way these + // points have left our hands. + sendToServer(targAddr, targPort, &buf, errorChan, 0) + buf = buf[:0] + } + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case b := <-batches: + buf = append(buf, b...) + if len(buf) >= batchSize { + flush() + } + + case <-ticker.C: + flush() + } + } +} + +func manageErrors(input chan error) { + for er := range input { + // For now, just print + fmt.Println(er) + } +} + +// HERE'S MAIN ⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️ +func main() { + targAddr := flag.String("addr", "localhost", "address of the server to send points to") + targPort := flag.String("port", "2948", "port of the server to send points to") + interval := flag.Duration("interval", 5*time.Second, "how often to flush buffered points to the server") + batchSize := flag.Int("batch", 25, "flush as soon as this many points are buffered") + device := flag.String("device", "", "gpsd device to watch (empty watches every device)") + logLevel := flag.Int("log", 0, "log verbosity (0 quiet, 1 lifecycle, 2+ per-report)") + flag.Parse() + + if *interval <= 0 || *batchSize <= 0 { + fmt.Fprintln(os.Stderr, "interval and batch must be positive") + os.Exit(2) + } + LOG_LEVEL = *logLevel + + errorChan := make(chan error) + go manageErrors(errorChan) + + // Buffered so bursts of fixes do not stall the gpsd read loop. A full + // channel means the sender is behind on a slow or dead server; we drop + // the batch and say so rather than block gpsd's rx, which would make + // gpsd discard reports entirely. + batches := make(chan []datapoint.DataPoint, 64) + go sendLoop(*targAddr, *targPort, batches, *interval, *batchSize, errorChan) + + monitorOutput, m := genMonitor() + + if err := m.StartRun(*device); err != nil { + fmt.Println(err) + os.Exit(1) + } + + ticker := time.NewTicker(*interval) + defer ticker.Stop() + pending := make([]datapoint.DataPoint, 0, *batchSize) + + for { + select { + case dp := <-monitorOutput: + printDp(dp) + pending = append(pending, dp) + + if len(pending) >= *batchSize { + select { + case batches <- pending: + default: + fmt.Println("send queue full, dropping batch of", len(pending)) + } + // Fresh backing array: sendLoop appends into its own buffer and + // we must not have it alias ours. + pending = make([]datapoint.DataPoint, 0, *batchSize) + } + + case <-ticker.C: + if len(pending) == 0 { + continue + } + + select { + case batches <- pending: + default: + fmt.Println("send queue full, dropping batch of", len(pending)) + } + pending = make([]datapoint.DataPoint, 0, *batchSize) + + case <-m.C.Done(): + // Without this the program would block on a channel that can + // never receive again, and hang with no explanation. + fmt.Println("gpsd connection closed:", m.C.Err()) + return + } + } +} diff --git a/client/tmp_verify_test.go b/client/tmp_verify_test.go new file mode 100644 index 0000000..99df56c --- /dev/null +++ b/client/tmp_verify_test.go @@ -0,0 +1,123 @@ +package main + +import ( + "encoding/gob" + "encoding/json" + "net" + "testing" + "time" + + "github.com/amenzhinsky/go-gpsd" + + "macker/datapoint" +) + +func TestBackoffFor(t *testing.T) { + want := []time.Duration{5 * time.Second, 10 * time.Second, 20 * time.Second, 40 * time.Second, 60 * time.Second, 60 * time.Second} + for i, w := range want { + if got := backoffFor(i); got != w { + t.Errorf("backoffFor(%d) = %v, want %v", i, got, w) + } + } + // 100-retry cycle wraps back to the starter delay. + if got := backoffFor(99); got != 60*time.Second { + t.Errorf("backoffFor(99) = %v, want 60s", got) + } + if got := backoffFor(100); got != 5*time.Second { + t.Errorf("backoffFor(100) = %v, want 5s", got) + } +} + +func TestSolidifyData(t *testing.T) { + tpv := &gpsd.TPV{Mode: 3, Speed: 12.5, Lat: 45.5, Lon: -122.6, Alt: 100, Epy: 1.5, Epx: 2.5, Epv: 3.5} + dp, err := solidifyData(tpv) + if err != nil { + t.Fatalf("solidifyData: %v", err) + } + if dp.Lat != 45.5 || dp.LatErr != 1.5 || dp.Long != -122.6 || dp.LongErr != 2.5 || dp.Alt != 100 || dp.AltErr != 3.5 { + t.Errorf("bad mapping: %+v", dp) + } + if dp.Speed == nil || *dp.Speed != 12.5 { + t.Errorf("speed was not preserved: %v", dp.Speed) + } + if _, err := solidifyData(&gpsd.TPV{Mode: 1}); err == nil { + t.Error("expected report without position fix to be rejected") + } + if dp.Timestamp != 0 { + t.Errorf("zero time should leave Timestamp 0, got %d", dp.Timestamp) + } + if _, err := solidifyData(&gpsd.SKY{}); err == nil { + t.Error("expected error for non-TPV report") + } +} + +// Fake server: gob-decode []byte then JSON-decode, exactly as a real receiver would. +func TestSendLoopEndToEnd(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + + type batch struct { + points []datapoint.DataPoint + err error + } + got := make(chan batch, 4) + + go func() { + for { + con, err := ln.Accept() + if err != nil { + return + } + var raw []byte + if err := gob.NewDecoder(con).Decode(&raw); err != nil { + got <- batch{err: err} + con.Close() + continue + } + var pts []datapoint.DataPoint + if err := json.Unmarshal(raw, &pts); err != nil { + got <- batch{err: err} + con.Close() + continue + } + got <- batch{points: pts} + con.Close() + } + }() + + errorChan := make(chan error, 16) + go manageErrors(errorChan) + + batches := make(chan []datapoint.DataPoint, 4) + _, port, err := net.SplitHostPort(ln.Addr().String()) + if err != nil { + t.Fatal(err) + } + go sendLoop("127.0.0.1", port, batches, time.Hour, 2, errorChan) + + batches <- []datapoint.DataPoint{{Timestamp: 1, Lat: 1}, {Timestamp: 2, Lat: 2}} + batches <- []datapoint.DataPoint{{Timestamp: 3, Lat: 3}} + + // First batch trips the size-2 threshold. + select { + case b := <-got: + if b.err != nil { + t.Fatalf("decode: %v", b.err) + } + if len(b.points) != 2 || b.points[0].Lat != 1 || b.points[1].Lat != 2 { + t.Fatalf("bad first batch: %+v", b.points) + } + case <-time.After(3 * time.Second): + t.Fatal("timeout waiting for first batch") + } + + // Second batch waits for the ticker (1h), so just prove it accumulates. + select { + case b := <-got: + t.Fatalf("unexpected flush before interval: %+v", b) + case <-time.After(300 * time.Millisecond): + } +} diff --git a/datapoint/datapoint.go b/datapoint/datapoint.go new file mode 100644 index 0000000..ecb8066 --- /dev/null +++ b/datapoint/datapoint.go @@ -0,0 +1,60 @@ +// Package datapoint holds the position fix type shared by the macker client +// and server. +package datapoint + +import "sync" + +type Tri struct { + Lat, Long, Alt float64 +} + +// DataPoint is a single position fix extracted from a gpsd TPV report. +// Timestamp is Unix time in milliseconds; zero means gpsd omitted the time. +type DataPoint struct { + Speed *float64 // Ground speed in metres per second; nil means unavailable. + Timestamp uint64 + Lat, LatErr float64 + Long, LongErr float64 + Alt, AltErr float64 +} + +func (dp DataPoint) getTri() Tri { + return Tri{ + dp.Lat, + dp.Long, + dp.Alt, + } +} + +type TrackBuffer struct { + DataPoints []DataPoint + Mtx *sync.Mutex +} + +func GenTrackBuff() TrackBuffer { + + var sm sync.Mutex + + return TrackBuffer{ + []DataPoint{}, + &sm, + } +} + +func (tb *TrackBuffer) Add(input DataPoint) { + tb.Mtx.Lock() + tb.DataPoints = append(tb.DataPoints, input) + tb.Mtx.Unlock() +} + +func (tb *TrackBuffer) StartTBHandler() (chan DataPoint, *TrackBuffer) { + + inchan := make(chan DataPoint) + + go func() { + for tmp := range inchan { + tb.Add(tmp) + } + }() + return inchan, tb +} diff --git a/datapoint/go.mod b/datapoint/go.mod new file mode 100644 index 0000000..a1c037e --- /dev/null +++ b/datapoint/go.mod @@ -0,0 +1,3 @@ +module macker/datapoint + +go 1.27.0 diff --git a/go.work b/go.work new file mode 100644 index 0000000..0b0d536 --- /dev/null +++ b/go.work @@ -0,0 +1,7 @@ +go 1.27.0 + +use ( + ./client + ./datapoint + ./server +) diff --git a/server/go.mod b/server/go.mod new file mode 100644 index 0000000..9de0989 --- /dev/null +++ b/server/go.mod @@ -0,0 +1,3 @@ +module srvr + +go 1.27.0 diff --git a/server/index.html b/server/index.html new file mode 100644 index 0000000..eda80ec --- /dev/null +++ b/server/index.html @@ -0,0 +1,34 @@ + + + + +Macker · Work car + + + + +

Macker

Work car · Live location

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

Waiting for the car’s first GPS fix…

+ + + diff --git a/server/main.go b/server/main.go new file mode 100644 index 0000000..1e5cf97 --- /dev/null +++ b/server/main.go @@ -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()) +} diff --git a/server/main_test.go b/server/main_test.go new file mode 100644 index 0000000..1538f8d --- /dev/null +++ b/server/main_test.go @@ -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") + } +}