INITIAL
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
module macker
|
||||
|
||||
go 1.27.0
|
||||
|
||||
require github.com/amenzhinsky/go-gpsd v0.0.0-20210328105404-f5dd3ab5e02f
|
||||
@@ -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
Binary file not shown.
+342
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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):
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user