This commit is contained in:
beans
2026-10-28 10:54:15 -05:00
parent e58f11ae24
commit 5f5be1b8d3
14 changed files with 793 additions and 5 deletions
+5
View File
@@ -0,0 +1,5 @@
module macker
go 1.27.0
require github.com/amenzhinsky/go-gpsd v0.0.0-20210328105404-f5dd3ab5e02f
+2
View File
@@ -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=
Executable
BIN
View File
Binary file not shown.
+342
View File
@@ -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
}
}
}
+123
View File
@@ -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):
}
}