From 4bd08c3bca94f626400b0885faea1d5bc5619580 Mon Sep 17 00:00:00 2001 From: Rusty Eddy Date: Fri, 16 Jan 2026 08:29:46 -0800 Subject: [PATCH 01/17] Added the GTU7 driver --- drivers/serial.go | 35 +++++++ drivers/serial_linux.go | 120 ++++++++++++++++++++++ drivers/serial_stub.go | 12 +++ sensors/.vh400.go.swp | Bin 16384 -> 0 bytes sensors/gtu7.go | 214 ++++++++++++++++++++++++++++++++++++++++ sensors/gtu7_test.go | 53 ++++++++++ 6 files changed, 434 insertions(+) create mode 100644 drivers/serial.go create mode 100644 drivers/serial_linux.go create mode 100644 drivers/serial_stub.go delete mode 100644 sensors/.vh400.go.swp create mode 100644 sensors/gtu7.go create mode 100644 sensors/gtu7_test.go diff --git a/drivers/serial.go b/drivers/serial.go new file mode 100644 index 0000000..591f02f --- /dev/null +++ b/drivers/serial.go @@ -0,0 +1,35 @@ +package drivers + +import ( + "fmt" + "io" +) + +// SerialConfig describes how to open a serial port. +type SerialConfig struct { + Port string + Baud int +} + +// SerialPort is an opened serial port. +// +// Implementations should be safe to read from with bufio.Scanner. +type SerialPort interface { + io.ReadWriteCloser + String() string +} + +// SerialFactory opens serial ports. +type SerialFactory interface { + OpenSerial(cfg SerialConfig) (SerialPort, error) +} + +func validateSerialConfig(cfg SerialConfig) error { + if cfg.Port == "" { + return fmt.Errorf("serial: Port is required") + } + if cfg.Baud <= 0 { + return fmt.Errorf("serial: Baud must be > 0") + } + return nil +} diff --git a/drivers/serial_linux.go b/drivers/serial_linux.go new file mode 100644 index 0000000..818a692 --- /dev/null +++ b/drivers/serial_linux.go @@ -0,0 +1,120 @@ +//go:build linux + +package drivers + +import ( + "fmt" + "os" + "time" + + "golang.org/x/sys/unix" +) + +// LinuxSerialFactory opens a configured serial port on Linux. +// +// It uses termios to set 8N1 and the requested baud rate. +type LinuxSerialFactory struct{} + +func (LinuxSerialFactory) OpenSerial(cfg SerialConfig) (SerialPort, error) { + if err := validateSerialConfig(cfg); err != nil { + return nil, err + } + + // Open non-blocking first, configure termios, then clear O_NONBLOCK. + fd, err := unix.Open(cfg.Port, unix.O_RDWR|unix.O_NOCTTY|unix.O_NONBLOCK, 0) + if err != nil { + return nil, fmt.Errorf("serial: open %s: %w", cfg.Port, err) + } + + // Ensure we don't leak the fd if configuration fails. + ok := false + defer func() { + if !ok { + _ = unix.Close(fd) + } + }() + + if err := configureTermios(fd, cfg.Baud); err != nil { + return nil, err + } + + // Clear O_NONBLOCK so reads behave as expected. + if err := unix.SetNonblock(fd, false); err != nil { + return nil, fmt.Errorf("serial: set blocking %s: %w", cfg.Port, err) + } + + f := os.NewFile(uintptr(fd), cfg.Port) + if f == nil { + return nil, fmt.Errorf("serial: os.NewFile returned nil for %s", cfg.Port) + } + + ok = true + return &linuxSerialPort{file: f, port: cfg.Port, baud: cfg.Baud}, nil +} + +type linuxSerialPort struct { + file *os.File + port string + baud int +} + +func (p *linuxSerialPort) Read(b []byte) (int, error) { return p.file.Read(b) } +func (p *linuxSerialPort) Write(b []byte) (int, error) { return p.file.Write(b) } +func (p *linuxSerialPort) Close() error { return p.file.Close() } +func (p *linuxSerialPort) String() string { return fmt.Sprintf("%s@%d", p.port, p.baud) } + +func configureTermios(fd int, baud int) error { + t, err := unix.IoctlGetTermios(fd, unix.TCGETS) + if err != nil { + return fmt.Errorf("serial: ioctl TCGETS: %w", err) + } + + speed, err := baudToUnix(baud) + if err != nil { + return err + } + + // Raw-ish 8N1. + t.Iflag = 0 + t.Oflag = 0 + t.Lflag = 0 + t.Cflag = unix.CS8 | unix.CREAD | unix.CLOCAL + + // Disable flow control. + t.Cflag &^= unix.CRTSCTS + + // VMIN/VTIME: block until at least 1 byte. + t.Cc[unix.VMIN] = 1 + t.Cc[unix.VTIME] = 0 + + // Set baud. + t.Ispeed = uint32(speed) + t.Ospeed = uint32(speed) + + if err := unix.IoctlSetTermios(fd, unix.TCSETS, t); err != nil { + return fmt.Errorf("serial: ioctl TCSETS: %w", err) + } + + // Give the line a moment to settle. + time.Sleep(10 * time.Millisecond) + return nil +} + +func baudToUnix(baud int) (uint32, error) { + switch baud { + case 4800: + return unix.B4800, nil + case 9600: + return unix.B9600, nil + case 19200: + return unix.B19200, nil + case 38400: + return unix.B38400, nil + case 57600: + return unix.B57600, nil + case 115200: + return unix.B115200, nil + default: + return 0, fmt.Errorf("serial: unsupported baud %d (supported: 4800,9600,19200,38400,57600,115200)", baud) + } +} diff --git a/drivers/serial_stub.go b/drivers/serial_stub.go new file mode 100644 index 0000000..52ceb58 --- /dev/null +++ b/drivers/serial_stub.go @@ -0,0 +1,12 @@ +//go:build !linux + +package drivers + +import "fmt" + +// LinuxSerialFactory is available only on Linux. +type LinuxSerialFactory struct{} + +func (LinuxSerialFactory) OpenSerial(cfg SerialConfig) (SerialPort, error) { + return nil, fmt.Errorf("serial: unsupported platform") +} diff --git a/sensors/.vh400.go.swp b/sensors/.vh400.go.swp deleted file mode 100644 index 71334cb2c2e57c2ac942899476ac46f22e39c9dc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16384 zcmeI2UyLM08NmBaKn6Kb(S(?s){WjVLw0*-cJGd3;UIgvd%7WaM~-6=31V+|&1`!; z-E-YNv%4Gz4}$t&G%@5r^uZTU0}1Mb`d}a+55@%J-)O|s1dW0Lx$qznUi^L4-Ltc^ zAcpXurYHN`nXaz->ifR>s=DjzUTQ2Z98y=>O^eUFE$iuI+268d@79~1y4Z?)NxCNG zZ}Lf_IQHM9j-0;J2%Jb_cf!+s-_^-PqQfMLlZpQF?nc9INB@D8vP$WJ(gSbJ1KX^r z$!0^^ez>|rUGm-=-(gXjK9!R@et-H{CYp(r$ z`S+HQ?|04DM%r3Xq6lpZKO@L%Ww z$Fra2afczg=iq&%+PkA$SmOhZE3+-EbjX02O%Z zUC4#6!x!LA2;ew;0v6z-a1cHOuUueRe}-SfFW^Zy4JW_@2X2E;!ampvTi|yU%lamK z4Za8`;AWVC_rPDzM=tyrz5`!{&wvL<;aV7jSKet^&%z(!4E!EWgNEziQaE#-W&IR> z2#>-ea2H$)=fXeEr7!pm{0e>vPr$d~Uidg{hi&lcIhOSTJO>ZKy>K%uz+R}qN8tT% z4!lb8=LL8ko`c`QQ}8G}3=hGB5W!VYhfCpo@G^1r68s&$1rleUg^^FCvJj>^?mNM> z%07MDZME&9bj+&6I_<@wI@$}bkAgtQx3q%DN%!u)wdSU)V`^95KHwyJO!e)kmyRi^ zG#iC2zdfjUlJGcXRSHS{7588qbrz!_O=^AlUmsJUA5cST71bJJO2;t;eoKC+9s8w{ zI!TFXWKOH;{dDYju6SBfCN##~AbVYIHMi{=D+UP3Zbydsl zGIFE1PEV*^qiv?vZK~*MHi!~!PN16UeMmV^w{)C!lZz0AMMc{4ADK!AiOvkk%cHIi zwMWIQ+Td{5ADJ@{WxAj5>k#*eb`{s?WBhN9`}TatPiy(RKDMbkAIDKVrm7-Z)KD$Q z542Y`)*AOq)6K5VR2lNMM8`qL<=vw-`v}5j=5A;Wli3F)M<9YGGd3g}M zJvOwm?p4Q65Chyy5m!GdZuWuL~jSgSYKy`RQ zPAzHFYsjz-Y$;YPb@hI%IcdpcSGB&neDPzv=2n+A?Dglq>5K*w6H-TYS;G>QE^9S2 zH#;UvMPJ8hqGa81+FFfYqZV)Cgv3&}w-orvvQAXubh?2~Y|Fx5;6FZ*TP6{~@)lSjf}O-?U5K~EoB>*{G$%~wCu>^eX63PEP- zn9{>ehl=LYx8KZqQ-7(KWYzPimBlBMUd7-5G13LkMvSR zUOg`UQu%18leqvaWqf7z(+@)cUSw+uWD!Mv$x$26;Z3aTd!f4NE5NBo_L$JggY}$-LfR0;V4G-Q&f5gQrrzqwTp~?4*7a)>U@Ws?gzj)u<~cogi3K5yr>9 z#~PGoVq7$mv#Q48!~GSWsxZQe31y_n@5H}d8W2E*d$?1w)tqiQy&z@$q>5foVNln3*1hu2z1hqdgy^MWc{dbmCNG3|8e0RvAfyQ+}q2EW9j;%5^-ANNJs~ z5Chc*$Hf9|Za=UlYzKN=ho1JRD+5<+j87TMWyMfEY?qj1kx(*EsGqxn!DSscm1LBA z2@+P8xlNLLAJHbi#Zi6VQN^tfnicm5L&2998NtPAk&?)Tq?lEzRt#OHotzntUq*SM zCATDFMQ>`w5Ac7kTN)Y7B$jf`Y}281fgdHr0vVRIap}5XemhpyBVOG_&-L?y5FbWHf$-*R8UB&FtK}&*+LCMt1Wdu@R7{twa9ey(Rnq zKe3m6oPDtD|BL7QvgiL1JPe-)+2ikn*DhkO2`|H0cnKbW`(PfUZA<1%>z5FzJoh4E-SJ?Zmn51Tcs+0Dl+O;b`BwaIQ zH-;LbwrM!I*PfajY1y>*ZET9DqUr7{q$AUG_ddI^Z&TB$4V{XpqG@xz>Es?s%WY7% zX*Wk&P7NALWz%KTIB&R}$?AVb$-L91uHYdCb93dy*nL#}88Bgx?!(z-xKJOwt^Z5;sqSB*|FJoz0op zDqUBn)rS1;R7OY57HyE4V{Hs(Rtp@fuZHOtj! zY3(BUHpp#D&T^1v6Mc`8@5s-Z^ta?cq= 0 { + line = line[:i] + } + + parts := strings.Split(line, ",") + if len(parts) < 10 { + return GPSFix{}, false + } + if parts[0] != "$GPGGA" && parts[0] != "$GNGGA" { + return GPSFix{}, false + } + + utc := parts[1] + latRaw, latHem := parts[2], parts[3] + lonRaw, lonHem := parts[4], parts[5] + quality, _ := strconv.Atoi(parts[6]) + sats, _ := strconv.Atoi(parts[7]) + hdop, _ := strconv.ParseFloat(parts[8], 64) + alt, _ := strconv.ParseFloat(parts[9], 64) + + lat, ok := nmeaDeg(latRaw) + if !ok { + return GPSFix{}, false + } + if strings.EqualFold(latHem, "S") { + lat = -lat + } + + lon, ok := nmeaDeg(lonRaw) + if !ok { + return GPSFix{}, false + } + if strings.EqualFold(lonHem, "W") { + lon = -lon + } + + // quality 0 means invalid fix. + if quality == 0 { + return GPSFix{}, false + } + + return GPSFix{ + Lat: lat, + Lon: lon, + AltitudeM: alt, + Quality: quality, + Satellites: sats, + HDOP: hdop, + UTCTime: utc, + }, true +} + +// nmeaDeg converts ddmm.mmmm (lat) or dddmm.mmmm (lon) into decimal degrees. +func nmeaDeg(v string) (float64, bool) { + v = strings.TrimSpace(v) + if v == "" { + return 0, false + } + f, err := strconv.ParseFloat(v, 64) + if err != nil { + return 0, false + } + deg := math.Floor(f / 100.0) + min := f - (deg * 100.0) + return deg + (min / 60.0), true +} diff --git a/sensors/gtu7_test.go b/sensors/gtu7_test.go new file mode 100644 index 0000000..640f641 --- /dev/null +++ b/sensors/gtu7_test.go @@ -0,0 +1,53 @@ +package sensors + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestGTU7_EmitsFixFromGGA(t *testing.T) { + input := ` +$GPGGA,160446.00,3340.34121,N,11800.11332,W,2,08,1.20,11.8,M,-33.1,M,,0000*58 +$GPGSA,A,3,09,16,46,03,07,31,26,04,,,,,3.08,1.20,2.84*0E +$GPGSV,4,1,13,01,02,193,,03,58,181,33,04,64,360,31,06,12,295,*7A +$GPGLL,3340.34121,N,11800.11332,W,160446.00,A,D*74 +` + + gps := NewGTU7(GTU7Config{Reader: strings.NewReader(input)}) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + done := make(chan error, 1) + go func() { done <- gps.Run(ctx) }() + + select { + case fix := <-gps.Out(): + cancel() + // lat 33 deg + 40.34121/60 + require.InDelta(t, 33.6723535, fix.Lat, 1e-6) + // lon -(118 deg + 0.11332/60) + require.InDelta(t, -118.0018887, fix.Lon, 1e-6) + require.Equal(t, 2, fix.Quality) + require.Equal(t, 8, fix.Satellites) + require.InDelta(t, 1.20, fix.HDOP, 1e-6) + require.InDelta(t, 11.8, fix.AltitudeM, 1e-6) + require.Equal(t, "160446.00", fix.UTCTime) + case err := <-done: + require.NoError(t, err) + require.FailNow(t, "expected at least one fix") + case <-time.After(1 * time.Second): + require.FailNow(t, "timed out waiting for fix") + } + + // Ensure Run exits cleanly after cancel. + select { + case err := <-done: + require.NoError(t, err) + case <-time.After(1 * time.Second): + require.FailNow(t, "timed out waiting for Run to exit") + } +} From 8129c17fdb682021461ea90bf619f6a2ee9235fd Mon Sep 17 00:00:00 2001 From: Rusty Eddy Date: Fri, 16 Jan 2026 08:48:21 -0800 Subject: [PATCH 02/17] Added and --- sensors/gtu7.go | 128 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 127 insertions(+), 1 deletion(-) diff --git a/sensors/gtu7.go b/sensors/gtu7.go index 13d1a4e..a240768 100644 --- a/sensors/gtu7.go +++ b/sensors/gtu7.go @@ -25,6 +25,18 @@ type GPSFix struct { Satellites int HDOP float64 UTCTime string // HHMMSS.SS as emitted by the receiver + + // Additional values typically provided by RMC/VTG. + // SpeedKnots is speed over ground in knots. + // SpeedMPS is speed over ground in meters/sec. + SpeedKnots float64 + SpeedMPS float64 + // CourseDeg is course over ground in degrees. + CourseDeg float64 + // Date is DDMMYY (per RMC). + Date string + // Status is RMC status (e.g., "A"=active, "V"=void). + Status string } // GTU7Config configures a Vegetronix GT-U7 GPS (NMEA) reader. @@ -80,6 +92,18 @@ func (g *GTU7) Run(ctx context.Context) error { defer close(g.out) defer g.CloseEvents() + // Maintain a best-effort merged view across sentences. + last := GPSFix{ + Lat: math.NaN(), + Lon: math.NaN(), + AltitudeM: math.NaN(), + HDOP: math.NaN(), + SpeedKnots: math.NaN(), + SpeedMPS: math.NaN(), + CourseDeg: math.NaN(), + } + haveFix := false + var ( r io.Reader sp drivers.SerialPort @@ -132,13 +156,115 @@ func (g *GTU7) Run(ctx context.Context) error { } if fix, ok := parseGPGGA(line); ok { + // Merge in fields sourced from GGA. + last.Lat = fix.Lat + last.Lon = fix.Lon + last.AltitudeM = fix.AltitudeM + last.Quality = fix.Quality + last.Satellites = fix.Satellites + last.HDOP = fix.HDOP + last.UTCTime = fix.UTCTime + haveFix = true + select { - case g.out <- fix: + case g.out <- last: default: // drop if slow consumer } + continue + } + + if fix, ok := parseGPRMC(line); ok { + // Merge in fields sourced from RMC. + last.Status = fix.Status + last.Date = fix.Date + last.UTCTime = fix.UTCTime + last.SpeedKnots = fix.SpeedKnots + last.SpeedMPS = fix.SpeedMPS + last.CourseDeg = fix.CourseDeg + // RMC also carries position; prefer it if present. + if !math.IsNaN(fix.Lat) && !math.IsNaN(fix.Lon) { + last.Lat = fix.Lat + last.Lon = fix.Lon + } + haveFix = haveFix || (!math.IsNaN(last.Lat) && !math.IsNaN(last.Lon)) + + // Only emit once we have a usable position. + if haveFix { + select { + case g.out <- last: + default: + // drop if slow consumer + } + } + } + } +} + +// parseGPRMC extracts speed/course (and optionally position) from a $GPRMC sentence. +// Returns ok=false for non-GPRMC lines or parse failures. +func parseGPRMC(line string) (GPSFix, bool) { + if i := strings.IndexByte(line, '*'); i >= 0 { + line = line[:i] + } + + parts := strings.Split(line, ",") + if len(parts) < 10 { + return GPSFix{}, false + } + if parts[0] != "$GPRMC" && parts[0] != "$GNRMC" { + return GPSFix{}, false + } + + utc := parts[1] + status := parts[2] + // A = valid, V = void + if status == "" { + return GPSFix{}, false + } + if strings.ToUpper(status) != "A" { + // We still surface status but treat as not-ok for emitting. + return GPSFix{UTCTime: utc, Status: status}, false + } + + latRaw, latHem := parts[3], parts[4] + lonRaw, lonHem := parts[5], parts[6] + sogKnots, _ := strconv.ParseFloat(parts[7], 64) + cogDeg, _ := strconv.ParseFloat(parts[8], 64) + date := parts[9] + + lat := math.NaN() + lon := math.NaN() + if latRaw != "" { + if v, ok := nmeaDeg(latRaw); ok { + lat = v + if strings.EqualFold(latHem, "S") { + lat = -lat + } } } + if lonRaw != "" { + if v, ok := nmeaDeg(lonRaw); ok { + lon = v + if strings.EqualFold(lonHem, "W") { + lon = -lon + } + } + } + + // 1 knot = 0.514444 m/s + sogMPS := sogKnots * 0.514444 + + return GPSFix{ + Lat: lat, + Lon: lon, + UTCTime: utc, + Status: status, + Date: date, + SpeedKnots: sogKnots, + SpeedMPS: sogMPS, + CourseDeg: cogDeg, + }, true } // parseGPGGA extracts a fix from a $GPGGA sentence. From 2bc3751300bc9c248eb4c42518d6a742ada66aaa Mon Sep 17 00:00:00 2001 From: Rusty Eddy Date: Fri, 16 Jan 2026 09:00:42 -0800 Subject: [PATCH 03/17] added vtg --- sensors/gtu7.go | 71 ++++++++++++++++++++++++++++++++++++++++++++ sensors/gtu7_test.go | 46 ++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+) diff --git a/sensors/gtu7.go b/sensors/gtu7.go index a240768..a1e3301 100644 --- a/sensors/gtu7.go +++ b/sensors/gtu7.go @@ -198,6 +198,26 @@ func (g *GTU7) Run(ctx context.Context) error { } } } + if fix, ok := parseGPVTG(line); ok { + // Merge in fields sourced from VTG (speed/course). + if !math.IsNaN(fix.SpeedKnots) { + last.SpeedKnots = fix.SpeedKnots + last.SpeedMPS = fix.SpeedMPS + } + if !math.IsNaN(fix.CourseDeg) { + last.CourseDeg = fix.CourseDeg + } + + // Only emit once we have a usable position. + if haveFix { + select { + case g.out <- last: + default: + // drop if slow consumer + } + } + } + } } @@ -267,6 +287,57 @@ func parseGPRMC(line string) (GPSFix, bool) { }, true } +// parseGPVTG extracts speed/course from a $GPVTG sentence. +// Returns ok=false for non-GPVTG lines or parse failures. +func parseGPVTG(line string) (GPSFix, bool) { + if i := strings.IndexByte(line, '*'); i >= 0 { + line = line[:i] + } + + parts := strings.Split(line, ",") + if len(parts) < 9 { + return GPSFix{}, false + } + if parts[0] != "$GPVTG" && parts[0] != "$GNVTG" { + return GPSFix{}, false + } + + courseDeg := math.NaN() + speedKnots := math.NaN() + speedMPS := math.NaN() + + // Field map (common): + // 1: course true, 2: "T", 3: course magnetic, 4: "M", + // 5: speed knots, 6: "N", 7: speed km/h, 8: "K" (then optional mode). + if parts[1] != "" { + if v, err := strconv.ParseFloat(parts[1], 64); err == nil { + courseDeg = v + } + } + if parts[5] != "" { + if v, err := strconv.ParseFloat(parts[5], 64); err == nil { + speedKnots = v + speedMPS = v * 0.514444 + } + } else if parts[7] != "" { + // If knots missing, derive from km/h. + if v, err := strconv.ParseFloat(parts[7], 64); err == nil { + speedMPS = v * (1000.0 / 3600.0) + speedKnots = speedMPS / 0.514444 + } + } + + if math.IsNaN(courseDeg) && math.IsNaN(speedKnots) { + return GPSFix{}, false + } + + return GPSFix{ + SpeedKnots: speedKnots, + SpeedMPS: speedMPS, + CourseDeg: courseDeg, + }, true +} + // parseGPGGA extracts a fix from a $GPGGA sentence. // Returns ok=false for non-GPGGA lines or parse failures. func parseGPGGA(line string) (GPSFix, bool) { diff --git a/sensors/gtu7_test.go b/sensors/gtu7_test.go index 640f641..be57776 100644 --- a/sensors/gtu7_test.go +++ b/sensors/gtu7_test.go @@ -51,3 +51,49 @@ $GPGLL,3340.34121,N,11800.11332,W,160446.00,A,D*74 require.FailNow(t, "timed out waiting for Run to exit") } } + +func TestGTU7_MergesVTGSpeedCourse(t *testing.T) { + input := ` +$GPGGA,160446.00,3340.34121,N,11800.11332,W,2,08,1.20,11.8,M,-33.1,M,,0000*58 +$GPVTG,54.70,T,,M,5.50,N,10.19,K,A*00 +` + + gps := NewGTU7(GTU7Config{Reader: strings.NewReader(input)}) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + done := make(chan error, 1) + go func() { done <- gps.Run(ctx) }() + + // First fix comes from GGA. + select { + case <-gps.Out(): + // ok + case err := <-done: + require.NoError(t, err) + require.FailNow(t, "expected at least one fix") + case <-time.After(1 * time.Second): + require.FailNow(t, "timed out waiting for first fix") + } + + // Second fix should include VTG speed/course. + select { + case fix := <-gps.Out(): + cancel() + require.InDelta(t, 5.50, fix.SpeedKnots, 1e-6) + require.InDelta(t, 5.50*0.514444, fix.SpeedMPS, 1e-6) + require.InDelta(t, 54.70, fix.CourseDeg, 1e-6) + case err := <-done: + require.NoError(t, err) + require.FailNow(t, "expected a second fix") + case <-time.After(1 * time.Second): + require.FailNow(t, "timed out waiting for second fix") + } + + select { + case err := <-done: + require.NoError(t, err) + case <-time.After(1 * time.Second): + require.FailNow(t, "timed out waiting for Run to exit") + } +} From 7c29f4fc554d6c5fce7d3196fa135fea7e2ae933 Mon Sep 17 00:00:00 2001 From: Rusty Eddy Date: Fri, 16 Jan 2026 09:16:44 -0800 Subject: [PATCH 04/17] updated GPS modules --- sensors/gtu7.go | 437 ++++++++++++++++--------------------------- sensors/gtu7_test.go | 88 +++------ 2 files changed, 184 insertions(+), 341 deletions(-) diff --git a/sensors/gtu7.go b/sensors/gtu7.go index a1e3301..26b63a3 100644 --- a/sensors/gtu7.go +++ b/sensors/gtu7.go @@ -3,7 +3,7 @@ package sensors import ( "bufio" "context" - "fmt" + "errors" "io" "math" "strconv" @@ -13,61 +13,57 @@ import ( "github.com/rustyeddy/devices/drivers" ) -// GPSFix is a normalized position fix derived from NMEA sentences. -// -// Lat/Lon are decimal degrees (W/S are negative). -// AltitudeM is meters above mean sea level (per GGA). type GPSFix struct { - Lat float64 - Lon float64 - AltitudeM float64 - Quality int + Lat float64 + Lon float64 + AltMeters float64 + HDOP float64 Satellites int - HDOP float64 - UTCTime string // HHMMSS.SS as emitted by the receiver + Quality int - // Additional values typically provided by RMC/VTG. - // SpeedKnots is speed over ground in knots. - // SpeedMPS is speed over ground in meters/sec. SpeedKnots float64 SpeedMPS float64 - // CourseDeg is course over ground in degrees. - CourseDeg float64 - // Date is DDMMYY (per RMC). - Date string - // Status is RMC status (e.g., "A"=active, "V"=void). - Status string + CourseDeg float64 + + Status string // RMC: A/V + Date string // DDMMYY } -// GTU7Config configures a Vegetronix GT-U7 GPS (NMEA) reader. -// -// Provide either Reader (for tests) or Factory+Serial (for real hardware). type GTU7Config struct { Name string - Reader io.Reader - Factory drivers.SerialFactory Serial drivers.SerialConfig - OutBuf int + Factory drivers.SerialFactory + + // Test injection + Reader io.Reader } -// GTU7 reads NMEA sentences from a serial port and emits GPS fixes. type GTU7 struct { - devices.Base - cfg GTU7Config - out chan GPSFix + name string + out chan GPSFix + r io.Reader } func NewGTU7(cfg GTU7Config) *GTU7 { - if cfg.Name == "" { - cfg.Name = "gtu7" + if cfg.Factory == nil { + cfg.Factory = drivers.LinuxSerialFactory{} } - if cfg.OutBuf <= 0 { - cfg.OutBuf = 8 + + var r io.Reader + if cfg.Reader != nil { + r = cfg.Reader + } else { + port, err := cfg.Factory.OpenSerial(cfg.Serial) + if err != nil { + panic(err) + } + r = port } + return >U7{ - Base: devices.NewBase(cfg.Name, 16), - cfg: cfg, - out: make(chan GPSFix, cfg.OutBuf), + name: cfg.Name, + out: make(chan GPSFix, 4), + r: r, } } @@ -75,337 +71,226 @@ func (g *GTU7) Out() <-chan GPSFix { return g.out } func (g *GTU7) Descriptor() devices.Descriptor { return devices.Descriptor{ - Name: g.Name(), + Name: g.name, Kind: "gps", - ValueType: "gps_fix", - Access: devices.ReadOnly, - Unit: "", - Tags: []string{"location", "nmea"}, - Attributes: map[string]string{ - "serial": g.cfg.Serial.Port, - "baud": fmt.Sprintf("%d", g.cfg.Serial.Baud), - }, + ValueType: "GPSFix", } } func (g *GTU7) Run(ctx context.Context) error { defer close(g.out) - defer g.CloseEvents() - // Maintain a best-effort merged view across sentences. - last := GPSFix{ - Lat: math.NaN(), - Lon: math.NaN(), - AltitudeM: math.NaN(), - HDOP: math.NaN(), - SpeedKnots: math.NaN(), - SpeedMPS: math.NaN(), - CourseDeg: math.NaN(), - } + var last GPSFix haveFix := false - var ( - r io.Reader - sp drivers.SerialPort - err error - ) - - if g.cfg.Reader != nil { - r = g.cfg.Reader - g.Emit(devices.EventInfo, "using provided reader", nil, nil) - } else { - factory := g.cfg.Factory - if factory == nil { - factory = drivers.LinuxSerialFactory{} - } - sp, err = factory.OpenSerial(g.cfg.Serial) - if err != nil { - g.EmitBlocking(devices.EventError, "failed to open serial", err, map[string]string{"port": g.cfg.Serial.Port}) - return err - } - defer sp.Close() - r = sp - g.Emit(devices.EventInfo, "serial opened", nil, map[string]string{"port": sp.String()}) - } - - scanner := bufio.NewScanner(r) - // NMEA lines are small; default 64K buffer is plenty, but allow a bit more. - buf := make([]byte, 0, 4096) - scanner.Buffer(buf, 64*1024) + // RMC precedence flags + haveRMCSpeed := false + haveRMCCourse := false - for { + sc := bufio.NewScanner(g.r) + for sc.Scan() { select { case <-ctx.Done(): - g.Emit(devices.EventInfo, "stopping", nil, nil) return nil default: } - if !scanner.Scan() { - if err := scanner.Err(); err != nil { - g.Emit(devices.EventError, "scanner error", err, nil) - return err - } - // EOF. - return nil - } - - line := strings.TrimSpace(scanner.Text()) - if line == "" || !strings.HasPrefix(line, "$") { + line := strings.TrimSpace(sc.Text()) + if line == "" { continue } if fix, ok := parseGPGGA(line); ok { - // Merge in fields sourced from GGA. last.Lat = fix.Lat last.Lon = fix.Lon - last.AltitudeM = fix.AltitudeM - last.Quality = fix.Quality - last.Satellites = fix.Satellites + last.AltMeters = fix.AltMeters last.HDOP = fix.HDOP - last.UTCTime = fix.UTCTime - haveFix = true + last.Satellites = fix.Satellites + last.Quality = fix.Quality - select { - case g.out <- last: - default: - // drop if slow consumer - } + haveFix = true + g.emit(last) continue } if fix, ok := parseGPRMC(line); ok { - // Merge in fields sourced from RMC. - last.Status = fix.Status - last.Date = fix.Date - last.UTCTime = fix.UTCTime - last.SpeedKnots = fix.SpeedKnots - last.SpeedMPS = fix.SpeedMPS - last.CourseDeg = fix.CourseDeg - // RMC also carries position; prefer it if present. - if !math.IsNaN(fix.Lat) && !math.IsNaN(fix.Lon) { + if !math.IsNaN(fix.Lat) { last.Lat = fix.Lat last.Lon = fix.Lon + haveFix = true + } + if !math.IsNaN(fix.SpeedKnots) { + last.SpeedKnots = fix.SpeedKnots + last.SpeedMPS = fix.SpeedMPS + haveRMCSpeed = true + } + if !math.IsNaN(fix.CourseDeg) { + last.CourseDeg = fix.CourseDeg + haveRMCCourse = true + } + if fix.Status != "" { + last.Status = fix.Status + } + if fix.Date != "" { + last.Date = fix.Date } - haveFix = haveFix || (!math.IsNaN(last.Lat) && !math.IsNaN(last.Lon)) - // Only emit once we have a usable position. if haveFix { - select { - case g.out <- last: - default: - // drop if slow consumer - } + g.emit(last) } + continue } + if fix, ok := parseGPVTG(line); ok { - // Merge in fields sourced from VTG (speed/course). - if !math.IsNaN(fix.SpeedKnots) { + if !math.IsNaN(fix.SpeedKnots) && (!haveRMCSpeed || math.IsNaN(last.SpeedKnots)) { last.SpeedKnots = fix.SpeedKnots last.SpeedMPS = fix.SpeedMPS } - if !math.IsNaN(fix.CourseDeg) { + if !math.IsNaN(fix.CourseDeg) && (!haveRMCCourse || math.IsNaN(last.CourseDeg)) { last.CourseDeg = fix.CourseDeg } - // Only emit once we have a usable position. if haveFix { - select { - case g.out <- last: - default: - // drop if slow consumer - } + g.emit(last) } } - } + + return sc.Err() } -// parseGPRMC extracts speed/course (and optionally position) from a $GPRMC sentence. -// Returns ok=false for non-GPRMC lines or parse failures. -func parseGPRMC(line string) (GPSFix, bool) { - if i := strings.IndexByte(line, '*'); i >= 0 { - line = line[:i] +func (g *GTU7) emit(f GPSFix) { + select { + case g.out <- f: + default: } +} +/* ---------- Parsing helpers ---------- */ + +func parseGPGGA(line string) (GPSFix, bool) { + line = stripChecksum(line) parts := strings.Split(line, ",") - if len(parts) < 10 { - return GPSFix{}, false - } - if parts[0] != "$GPRMC" && parts[0] != "$GNRMC" { + if len(parts) < 10 || (parts[0] != "$GPGGA" && parts[0] != "$GNGGA") { return GPSFix{}, false } - utc := parts[1] - status := parts[2] - // A = valid, V = void - if status == "" { + lat, lon, err := parseLatLon(parts[2], parts[3], parts[4], parts[5]) + if err != nil { return GPSFix{}, false } - if strings.ToUpper(status) != "A" { - // We still surface status but treat as not-ok for emitting. - return GPSFix{UTCTime: utc, Status: status}, false - } - - latRaw, latHem := parts[3], parts[4] - lonRaw, lonHem := parts[5], parts[6] - sogKnots, _ := strconv.ParseFloat(parts[7], 64) - cogDeg, _ := strconv.ParseFloat(parts[8], 64) - date := parts[9] - - lat := math.NaN() - lon := math.NaN() - if latRaw != "" { - if v, ok := nmeaDeg(latRaw); ok { - lat = v - if strings.EqualFold(latHem, "S") { - lat = -lat - } - } - } - if lonRaw != "" { - if v, ok := nmeaDeg(lonRaw); ok { - lon = v - if strings.EqualFold(lonHem, "W") { - lon = -lon - } - } - } - // 1 knot = 0.514444 m/s - sogMPS := sogKnots * 0.514444 + q, _ := strconv.Atoi(parts[6]) + sats, _ := strconv.Atoi(parts[7]) + hdop, _ := strconv.ParseFloat(parts[8], 64) + alt, _ := strconv.ParseFloat(parts[9], 64) return GPSFix{ Lat: lat, Lon: lon, - UTCTime: utc, - Status: status, - Date: date, - SpeedKnots: sogKnots, - SpeedMPS: sogMPS, - CourseDeg: cogDeg, + AltMeters: alt, + HDOP: hdop, + Satellites: sats, + Quality: q, }, true } -// parseGPVTG extracts speed/course from a $GPVTG sentence. -// Returns ok=false for non-GPVTG lines or parse failures. -func parseGPVTG(line string) (GPSFix, bool) { - if i := strings.IndexByte(line, '*'); i >= 0 { - line = line[:i] - } - +func parseGPRMC(line string) (GPSFix, bool) { + line = stripChecksum(line) parts := strings.Split(line, ",") - if len(parts) < 9 { - return GPSFix{}, false - } - if parts[0] != "$GPVTG" && parts[0] != "$GNVTG" { + if len(parts) < 10 || (parts[0] != "$GPRMC" && parts[0] != "$GNRMC") { return GPSFix{}, false } - courseDeg := math.NaN() - speedKnots := math.NaN() - speedMPS := math.NaN() + fix := GPSFix{ + Lat: math.NaN(), + Lon: math.NaN(), + SpeedKnots: math.NaN(), + SpeedMPS: math.NaN(), + CourseDeg: math.NaN(), + Status: parts[2], + Date: parts[9], + } - // Field map (common): - // 1: course true, 2: "T", 3: course magnetic, 4: "M", - // 5: speed knots, 6: "N", 7: speed km/h, 8: "K" (then optional mode). - if parts[1] != "" { - if v, err := strconv.ParseFloat(parts[1], 64); err == nil { - courseDeg = v + if parts[3] != "" { + if lat, lon, err := parseLatLon(parts[3], parts[4], parts[5], parts[6]); err == nil { + fix.Lat = lat + fix.Lon = lon } } - if parts[5] != "" { - if v, err := strconv.ParseFloat(parts[5], 64); err == nil { - speedKnots = v - speedMPS = v * 0.514444 - } - } else if parts[7] != "" { - // If knots missing, derive from km/h. + + if parts[7] != "" { if v, err := strconv.ParseFloat(parts[7], 64); err == nil { - speedMPS = v * (1000.0 / 3600.0) - speedKnots = speedMPS / 0.514444 + fix.SpeedKnots = v + fix.SpeedMPS = v * 0.514444 } } - if math.IsNaN(courseDeg) && math.IsNaN(speedKnots) { - return GPSFix{}, false + if parts[8] != "" { + if v, err := strconv.ParseFloat(parts[8], 64); err == nil { + fix.CourseDeg = v + } } - return GPSFix{ - SpeedKnots: speedKnots, - SpeedMPS: speedMPS, - CourseDeg: courseDeg, - }, true + return fix, true } -// parseGPGGA extracts a fix from a $GPGGA sentence. -// Returns ok=false for non-GPGGA lines or parse failures. -func parseGPGGA(line string) (GPSFix, bool) { - // Strip checksum, if present. - // We keep parsing even if checksum is present/absent; validation is out of scope for now. - if i := strings.IndexByte(line, '*'); i >= 0 { - line = line[:i] - } - +func parseGPVTG(line string) (GPSFix, bool) { + line = stripChecksum(line) parts := strings.Split(line, ",") - if len(parts) < 10 { + if len(parts) < 9 || (parts[0] != "$GPVTG" && parts[0] != "$GNVTG") { return GPSFix{}, false } - if parts[0] != "$GPGGA" && parts[0] != "$GNGGA" { - return GPSFix{}, false - } - - utc := parts[1] - latRaw, latHem := parts[2], parts[3] - lonRaw, lonHem := parts[4], parts[5] - quality, _ := strconv.Atoi(parts[6]) - sats, _ := strconv.Atoi(parts[7]) - hdop, _ := strconv.ParseFloat(parts[8], 64) - alt, _ := strconv.ParseFloat(parts[9], 64) - lat, ok := nmeaDeg(latRaw) - if !ok { - return GPSFix{}, false + fix := GPSFix{ + SpeedKnots: math.NaN(), + SpeedMPS: math.NaN(), + CourseDeg: math.NaN(), } - if strings.EqualFold(latHem, "S") { - lat = -lat + + if parts[1] != "" { + if v, err := strconv.ParseFloat(parts[1], 64); err == nil { + fix.CourseDeg = v + } } - lon, ok := nmeaDeg(lonRaw) - if !ok { - return GPSFix{}, false + if parts[5] != "" { + if v, err := strconv.ParseFloat(parts[5], 64); err == nil { + fix.SpeedKnots = v + fix.SpeedMPS = v * 0.514444 + } } - if strings.EqualFold(lonHem, "W") { - lon = -lon + + return fix, true +} + +func stripChecksum(s string) string { + if i := strings.IndexByte(s, '*'); i >= 0 { + return s[:i] } + return s +} - // quality 0 means invalid fix. - if quality == 0 { - return GPSFix{}, false +func parseLatLon(lat, ns, lon, ew string) (float64, float64, error) { + if lat == "" || lon == "" { + return 0, 0, errors.New("empty") } + la, _ := strconv.ParseFloat(lat, 64) + lo, _ := strconv.ParseFloat(lon, 64) - return GPSFix{ - Lat: lat, - Lon: lon, - AltitudeM: alt, - Quality: quality, - Satellites: sats, - HDOP: hdop, - UTCTime: utc, - }, true -} + latDeg := math.Floor(la / 100) + latMin := la - latDeg*100 + latVal := latDeg + latMin/60 + + lonDeg := math.Floor(lo / 100) + lonMin := lo - lonDeg*100 + lonVal := lonDeg + lonMin/60 -// nmeaDeg converts ddmm.mmmm (lat) or dddmm.mmmm (lon) into decimal degrees. -func nmeaDeg(v string) (float64, bool) { - v = strings.TrimSpace(v) - if v == "" { - return 0, false + if ns == "S" { + latVal = -latVal } - f, err := strconv.ParseFloat(v, 64) - if err != nil { - return 0, false + if ew == "W" { + lonVal = -lonVal } - deg := math.Floor(f / 100.0) - min := f - (deg * 100.0) - return deg + (min / 60.0), true + return latVal, lonVal, nil } diff --git a/sensors/gtu7_test.go b/sensors/gtu7_test.go index be57776..8fde078 100644 --- a/sensors/gtu7_test.go +++ b/sensors/gtu7_test.go @@ -9,91 +9,49 @@ import ( "github.com/stretchr/testify/require" ) -func TestGTU7_EmitsFixFromGGA(t *testing.T) { - input := ` -$GPGGA,160446.00,3340.34121,N,11800.11332,W,2,08,1.20,11.8,M,-33.1,M,,0000*58 -$GPGSA,A,3,09,16,46,03,07,31,26,04,,,,,3.08,1.20,2.84*0E -$GPGSV,4,1,13,01,02,193,,03,58,181,33,04,64,360,31,06,12,295,*7A -$GPGLL,3340.34121,N,11800.11332,W,160446.00,A,D*74 -` - - gps := NewGTU7(GTU7Config{Reader: strings.NewReader(input)}) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - done := make(chan error, 1) - go func() { done <- gps.Run(ctx) }() - - select { - case fix := <-gps.Out(): - cancel() - // lat 33 deg + 40.34121/60 - require.InDelta(t, 33.6723535, fix.Lat, 1e-6) - // lon -(118 deg + 0.11332/60) - require.InDelta(t, -118.0018887, fix.Lon, 1e-6) - require.Equal(t, 2, fix.Quality) - require.Equal(t, 8, fix.Satellites) - require.InDelta(t, 1.20, fix.HDOP, 1e-6) - require.InDelta(t, 11.8, fix.AltitudeM, 1e-6) - require.Equal(t, "160446.00", fix.UTCTime) - case err := <-done: - require.NoError(t, err) - require.FailNow(t, "expected at least one fix") - case <-time.After(1 * time.Second): - require.FailNow(t, "timed out waiting for fix") - } - - // Ensure Run exits cleanly after cancel. - select { - case err := <-done: - require.NoError(t, err) - case <-time.After(1 * time.Second): - require.FailNow(t, "timed out waiting for Run to exit") - } -} - -func TestGTU7_MergesVTGSpeedCourse(t *testing.T) { +func TestGTU7_PrefersRMCOverVTG(t *testing.T) { input := ` $GPGGA,160446.00,3340.34121,N,11800.11332,W,2,08,1.20,11.8,M,-33.1,M,,0000*58 $GPVTG,54.70,T,,M,5.50,N,10.19,K,A*00 +$GPRMC,160446.00,A,3340.34121,N,11800.11332,W,7.25,123.40,160126,,,A*00 ` - gps := NewGTU7(GTU7Config{Reader: strings.NewReader(input)}) + gps := NewGTU7(GTU7Config{ + Reader: strings.NewReader(input), + }) + ctx, cancel := context.WithCancel(context.Background()) defer cancel() done := make(chan error, 1) go func() { done <- gps.Run(ctx) }() - // First fix comes from GGA. - select { - case <-gps.Out(): - // ok - case err := <-done: - require.NoError(t, err) - require.FailNow(t, "expected at least one fix") - case <-time.After(1 * time.Second): - require.FailNow(t, "timed out waiting for first fix") + // Drain GGA + VTG + for i := 0; i < 2; i++ { + select { + case <-gps.Out(): + case <-time.After(time.Second): + require.FailNow(t, "timeout") + } } - // Second fix should include VTG speed/course. + // RMC must override VTG select { case fix := <-gps.Out(): cancel() - require.InDelta(t, 5.50, fix.SpeedKnots, 1e-6) - require.InDelta(t, 5.50*0.514444, fix.SpeedMPS, 1e-6) - require.InDelta(t, 54.70, fix.CourseDeg, 1e-6) - case err := <-done: - require.NoError(t, err) - require.FailNow(t, "expected a second fix") - case <-time.After(1 * time.Second): - require.FailNow(t, "timed out waiting for second fix") + require.InDelta(t, 7.25, fix.SpeedKnots, 1e-6) + require.InDelta(t, 7.25*0.514444, fix.SpeedMPS, 1e-6) + require.InDelta(t, 123.40, fix.CourseDeg, 1e-6) + require.Equal(t, "A", fix.Status) + require.Equal(t, "160126", fix.Date) + case <-time.After(time.Second): + require.FailNow(t, "timeout waiting for RMC fix") } select { case err := <-done: require.NoError(t, err) - case <-time.After(1 * time.Second): - require.FailNow(t, "timed out waiting for Run to exit") + case <-time.After(time.Second): + require.FailNow(t, "run did not exit") } } From fb66d6957b1e8b401781e702f5661092e3f28caf Mon Sep 17 00:00:00 2001 From: Rusty Eddy Date: Fri, 16 Jan 2026 09:17:58 -0800 Subject: [PATCH 05/17] removed archived gtu7 and examples --- _archive/examples/ads1115/.gitignore | 1 - _archive/examples/ads1115/Makefile | 12 - _archive/examples/ads1115/main.go | 60 ----- _archive/examples/ads1115/main_test.go | 309 ------------------------ _archive/examples/blink/.gitignore | 1 - _archive/examples/blink/Makefile | 9 - _archive/examples/blink/main.go | 70 ------ _archive/examples/bme280/.gitignore | 1 - _archive/examples/bme280/Makefile | 12 - _archive/examples/bme280/main.go | 29 --- _archive/examples/gtu7/.gitignore | 1 - _archive/examples/gtu7/Makefile | 9 - _archive/examples/gtu7/main.go | 16 -- _archive/examples/json/.gitignore | 1 - _archive/examples/json/Makefile | 9 - _archive/examples/json/main.go | 49 ---- _archive/examples/oled/.gitignore | 1 - _archive/examples/oled/Makefile | 12 - _archive/examples/oled/main.go | 108 --------- _archive/examples/relay/.gitignore | 1 - _archive/examples/relay/Makefile | 9 - _archive/examples/relay/app/index.html | 31 --- _archive/examples/relay/app/js/relay.js | 50 ---- _archive/examples/relay/main.go | 16 -- _archive/examples/switch/.gitignore | 1 - _archive/examples/switch/Makefile | 9 - _archive/examples/switch/main.go | 79 ------ _archive/examples/vh400/.gitignore | 1 - _archive/examples/vh400/Makefile | 12 - _archive/examples/vh400/main.go | 33 --- _archive/gtu7/gps.go | 127 ---------- _archive/gtu7/gps_test.go | 37 --- _archive/gtu7/gtu7.go | 109 --------- 33 files changed, 1225 deletions(-) delete mode 100644 _archive/examples/ads1115/.gitignore delete mode 100644 _archive/examples/ads1115/Makefile delete mode 100644 _archive/examples/ads1115/main.go delete mode 100644 _archive/examples/ads1115/main_test.go delete mode 100644 _archive/examples/blink/.gitignore delete mode 100644 _archive/examples/blink/Makefile delete mode 100644 _archive/examples/blink/main.go delete mode 100644 _archive/examples/bme280/.gitignore delete mode 100644 _archive/examples/bme280/Makefile delete mode 100644 _archive/examples/bme280/main.go delete mode 100644 _archive/examples/gtu7/.gitignore delete mode 100644 _archive/examples/gtu7/Makefile delete mode 100644 _archive/examples/gtu7/main.go delete mode 100644 _archive/examples/json/.gitignore delete mode 100644 _archive/examples/json/Makefile delete mode 100644 _archive/examples/json/main.go delete mode 100644 _archive/examples/oled/.gitignore delete mode 100644 _archive/examples/oled/Makefile delete mode 100644 _archive/examples/oled/main.go delete mode 100644 _archive/examples/relay/.gitignore delete mode 100644 _archive/examples/relay/Makefile delete mode 100644 _archive/examples/relay/app/index.html delete mode 100644 _archive/examples/relay/app/js/relay.js delete mode 100644 _archive/examples/relay/main.go delete mode 100644 _archive/examples/switch/.gitignore delete mode 100644 _archive/examples/switch/Makefile delete mode 100644 _archive/examples/switch/main.go delete mode 100644 _archive/examples/vh400/.gitignore delete mode 100644 _archive/examples/vh400/Makefile delete mode 100644 _archive/examples/vh400/main.go delete mode 100644 _archive/gtu7/gps.go delete mode 100644 _archive/gtu7/gps_test.go delete mode 100644 _archive/gtu7/gtu7.go diff --git a/_archive/examples/ads1115/.gitignore b/_archive/examples/ads1115/.gitignore deleted file mode 100644 index 7e035f3..0000000 --- a/_archive/examples/ads1115/.gitignore +++ /dev/null @@ -1 +0,0 @@ -ads1115 diff --git a/_archive/examples/ads1115/Makefile b/_archive/examples/ads1115/Makefile deleted file mode 100644 index d9a8510..0000000 --- a/_archive/examples/ads1115/Makefile +++ /dev/null @@ -1,12 +0,0 @@ -all: - go build -v - -pi: - env GOOS=linux GOARCH=arm GOARM=7 go build -v - -zero: - env GOOS=linux GOARCH=arm GOARM=6 go build -v - -clean: - rm -f $(CMD) *.log - go clean diff --git a/_archive/examples/ads1115/main.go b/_archive/examples/ads1115/main.go deleted file mode 100644 index 55f3c79..0000000 --- a/_archive/examples/ads1115/main.go +++ /dev/null @@ -1,60 +0,0 @@ -//go:build linux && (arm || arm64) -// +build linux -// +build arm arm64 - -package main - -import ( - "fmt" - "time" - - "github.com/rustyeddy/devices/drivers" - "periph.io/x/conn/v3/physic" -) - -const ( - adcRange = 26400 // ADS1115 gives you 32768 steps with 4.096v, with 3.3v which is the 80% of 4.096v we get 26400 steps - inputVoltageInChannel = 3300 * physic.MilliVolt -) - -func main() { - ads := drivers.NewADS1115("ADS1115", "/dev/i2c-1", 0x48) - ads.Open() - - var err error - var pins [4]drivers.AnalogPin - var chans4 [4]<-chan float64 - for i := 0; i < 4; i++ { - pname := fmt.Sprintf("pin%d", i) - pins[i], err = ads.SetPin(pname, i) - if err != nil { - fmt.Printf("Error creating pin: %d\n", i) - } - chans4[i] = pins[i].ReadContinuous() - } - - for j := 0; j < 4; j++ { - for i := 0; i < 4; i++ { - val, err := pins[i].Get() - if err != nil { - fmt.Printf("failed to read pin[%d] = %s\n", i, err) - continue - } - fmt.Printf("reading[%d]: %f\n", i, val) - } - time.Sleep(2 * time.Second) - } - - var val float64 - for { - select { - case val = <-chans4[0]: - case val = <-chans4[1]: - case val = <-chans4[2]: - case val = <-chans4[3]: - } - } - fmt.Printf("VAL: %5.2f\n", val) - ads.Close() - -} diff --git a/_archive/examples/ads1115/main_test.go b/_archive/examples/ads1115/main_test.go deleted file mode 100644 index 3fdc55b..0000000 --- a/_archive/examples/ads1115/main_test.go +++ /dev/null @@ -1,309 +0,0 @@ -package main - -import ( - "testing" - - "github.com/rustyeddy/devices" - "github.com/rustyeddy/devices/bme280" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func init() { - devices.SetMock(true) -} - -// TestableMain wraps main functionality for testing -func TestableMain() error { - // Set the BME i2c device and address Initialize the bme to use - // the i2c bus - bme, err := bme280.New("bme280", "/dev/i2c-1", 0x76) - if err != nil { - return err - } - - // Open the device to prepare it for usage - err = bme.Open() - if err != nil { - return err - } - - _, err = bme.Get() - if err != nil { - return err - } - - return nil -} - -func TestMain(t *testing.T) { - tests := []struct { - name string - wantErr bool - }{ - { - name: "successful execution", - wantErr: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := TestableMain() - if tt.wantErr { - assert.Error(t, err) - } else { - assert.NoError(t, err) - } - }) - } -} - -func TestBME280Integration(t *testing.T) { - tests := []struct { - name string - deviceName string - i2cBus string - i2cAddr int - expectOpenErr bool - expectReadErr bool - wantErr bool - }{ - { - name: "valid BME280 configuration", - deviceName: "bme280-test", - i2cBus: "/dev/i2c-1", - i2cAddr: 0x76, - expectOpenErr: false, - expectReadErr: false, - wantErr: false, - }, - { - name: "alternative I2C address", - deviceName: "bme280-alt", - i2cBus: "/dev/i2c-1", - i2cAddr: 0x77, - expectOpenErr: false, - expectReadErr: false, - wantErr: false, - }, - { - name: "different I2C bus", - deviceName: "bme280-bus2", - i2cBus: "/dev/i2c-2", - i2cAddr: 0x76, - expectOpenErr: false, - expectReadErr: false, - wantErr: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - bme, err := bme280.New(tt.deviceName, tt.i2cBus, tt.i2cAddr) - if err != nil { - t.Fatalf("Failed to create BME280 device: %v", err) - } - - err = bme.Open() - if tt.expectOpenErr && err == nil { - t.Error("Open() expected error but got none") - } else if !tt.expectOpenErr && err != nil { - t.Errorf("Open() unexpected error = %v", err) - } - - if !tt.expectOpenErr { - val, err := bme.Get() - if tt.expectReadErr && err == nil { - t.Error("Get() expected error but got none") - } else if !tt.expectReadErr && err != nil { - t.Errorf("Get() unexpected error = %v", err) - } - - if !tt.expectReadErr { - assert.NotNil(t, val) - t.Logf("BME280 reading: Temp=%.2f°C, Humidity=%.2f%%, Pressure=%.2fhPa", - val.Temperature, val.Humidity, val.Pressure) - } - } - }) - } -} - -func TestBME280ErrorHandling(t *testing.T) { - tests := []struct { - name string - deviceName string - i2cBus string - i2cAddr int - shouldPanic bool - }{ - { - name: "invalid device name - empty string", - deviceName: "", - i2cBus: "/dev/i2c-1", - i2cAddr: 0x76, - shouldPanic: false, // bme280.New should handle this gracefully - }, - { - name: "invalid I2C address - negative", - deviceName: "bme280-test", - i2cBus: "/dev/i2c-1", - i2cAddr: -1, - shouldPanic: false, - }, - { - name: "invalid I2C address - too high", - deviceName: "bme280-test", - i2cBus: "/dev/i2c-1", - i2cAddr: 0x80, - shouldPanic: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if tt.shouldPanic { - assert.Panics(t, func() { - bme, err := bme280.New(tt.deviceName, tt.i2cBus, tt.i2cAddr) - if err != nil { - panic(err) - } - err = bme.Open() - if err != nil { - panic(err) - } - _, err = bme.Get() - if err != nil { - panic(err) - } - }) - } else { - assert.NotPanics(t, func() { - bme, err := bme280.New(tt.deviceName, tt.i2cBus, tt.i2cAddr) - if err != nil { - t.Logf("Expected error creating device: %v", err) - return - } - err = bme.Open() - if err != nil { - t.Logf("Expected error opening device: %v", err) - return - } - _, err = bme.Get() - if err != nil { - t.Logf("Expected error reading device: %v", err) - } - }) - } - }) - } -} - -func TestBME280MockData(t *testing.T) { - bme, err := bme280.New("bme280-mock", "/dev/i2c-1", 0x76) - require.NoError(t, err) - - err = bme.Open() - require.NoError(t, err) - - // Test multiple readings to ensure consistency - for i := 0; i < 5; i++ { - val, err := bme.Get() - require.NoError(t, err) - require.NotNil(t, val) - - // Validate readings are within expected ranges - assert.GreaterOrEqual(t, val.Temperature, -40.0, "Temperature below minimum") - assert.LessOrEqual(t, val.Temperature, 85.0, "Temperature above maximum") - - assert.GreaterOrEqual(t, val.Humidity, 0.0, "Humidity below minimum") - assert.LessOrEqual(t, val.Humidity, 100.0, "Humidity above maximum") - - assert.GreaterOrEqual(t, val.Pressure, 300.0, "Pressure below minimum") - assert.LessOrEqual(t, val.Pressure, 1100.0, "Pressure above maximum") - - t.Logf("Reading %d: Temp=%.2f°C, Humidity=%.2f%%, Pressure=%.2fhPa", - i+1, val.Temperature, val.Humidity, val.Pressure) - } -} - -func TestBME280DeviceProperties(t *testing.T) { - tests := []struct { - name string - deviceName string - i2cBus string - i2cAddr int - }{ - { - name: "standard configuration", - deviceName: "bme280", - i2cBus: "/dev/i2c-1", - i2cAddr: 0x76, - }, - { - name: "alternative configuration", - deviceName: "weather-sensor", - i2cBus: "/dev/i2c-2", - i2cAddr: 0x77, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - bme, err := bme280.New(tt.deviceName, tt.i2cBus, tt.i2cAddr) - require.NoError(t, err) - - assert.Equal(t, tt.deviceName, bme.Name(), 0.10) - assert.Contains(t, bme.String(), tt.deviceName) - - err = bme.Open() - require.NoError(t, err) - - // Test that the device can be read multiple times - val1, err := bme.Get() - require.NoError(t, err) - - val2, err := bme.Get() - require.NoError(t, err) - - // In mock mode, readings should be consistent - assert.InDelta(t, val1.Temperature, val2.Temperature, 0.11) - assert.InDelta(t, val1.Humidity, val2.Humidity, 0.10) - assert.InDelta(t, val1.Pressure, val2.Pressure, 0.10) - }) - } -} - -func TestBME280NonMockMode(t *testing.T) { - // Temporarily disable mock mode to test real I2C behavior - devices.SetMock(false) - defer devices.SetMock(true) - - bme, err := bme280.New("bme280-real", "/dev/i2c-nonexistent", 0x76) - require.NoError(t, err) - - // This should fail since we're using a nonexistent bus - err = bme.Open() - assert.Error(t, err, "Open() should fail with nonexistent I2C bus") -} - -func BenchmarkBME280Reading(b *testing.B) { - bme, err := bme280.New("bme280-bench", "/dev/i2c-1", 0x76) - if err != nil { - b.Fatalf("Failed to create BME280 device: %v", err) - } - - err = bme.Open() - if err != nil { - b.Fatalf("Failed to open BME280 device: %v", err) - } - - b.ResetTimer() - for i := 0; i < b.N; i++ { - _, err := bme.Get() - if err != nil { - b.Fatalf("Failed to read BME280: %v", err) - } - } -} diff --git a/_archive/examples/blink/.gitignore b/_archive/examples/blink/.gitignore deleted file mode 100644 index ddd7627..0000000 --- a/_archive/examples/blink/.gitignore +++ /dev/null @@ -1 +0,0 @@ -blink diff --git a/_archive/examples/blink/Makefile b/_archive/examples/blink/Makefile deleted file mode 100644 index aaccef9..0000000 --- a/_archive/examples/blink/Makefile +++ /dev/null @@ -1,9 +0,0 @@ -all: - go build -v - -pi: - env GOOS=linux GOARCH=arm GOARM=7 go build -v - -clean: - rm -f $(CMD) *.log - go clean diff --git a/_archive/examples/blink/main.go b/_archive/examples/blink/main.go deleted file mode 100644 index 9db82b6..0000000 --- a/_archive/examples/blink/main.go +++ /dev/null @@ -1,70 +0,0 @@ -/* -Blink sets up pin 6 for an LED and goes into an endless -toggle mode. -*/ - -package main - -import ( - "flag" - "fmt" - "log/slog" - "time" - - "github.com/rustyeddy/devices/led" -) - -var ( - useMQTT bool - pinid int - mock string - count int -) - -func init() { - flag.BoolVar(&useMQTT, "mqtt", false, "Use mqtt or a timer") - flag.IntVar(&pinid, "pin", 6, "The GPIO pin the LED is attached to") - flag.StringVar(&mock, "mock", "", "mock gpio and/or mqtt") -} - -// TODO add a timerloop to this device (from otto) -func main() { - flag.Parse() - - // Create the led, name it "led" and add a publish topic - led, done := initLED("led", pinid) - dotimer(led, 1*time.Second, done) - fmt.Println("LED will blink every second") - <-done -} - -func initLED(name string, pin int) (*led.LED, chan any) { - led, err := led.New(name, pin) - if err != nil { - panic(err) - } - done := make(chan any) - return led, done -} - -func dotimer(led *led.LED, period time.Duration, done chan any) { - count = 0 - // led.TimerLoop(context.TODO(), period, func() error { - // count++ - // return nil - // }) - ticker := time.NewTicker(1 * time.Second) - go func() { - for { - select { - case t := <-ticker.C: - count++ - slog.Info("led blink at ", "time", t, "count", count) - - case <-done: - break - } - } - }() - -} diff --git a/_archive/examples/bme280/.gitignore b/_archive/examples/bme280/.gitignore deleted file mode 100644 index 5e0f393..0000000 --- a/_archive/examples/bme280/.gitignore +++ /dev/null @@ -1 +0,0 @@ -bme280 diff --git a/_archive/examples/bme280/Makefile b/_archive/examples/bme280/Makefile deleted file mode 100644 index d9a8510..0000000 --- a/_archive/examples/bme280/Makefile +++ /dev/null @@ -1,12 +0,0 @@ -all: - go build -v - -pi: - env GOOS=linux GOARCH=arm GOARM=7 go build -v - -zero: - env GOOS=linux GOARCH=arm GOARM=6 go build -v - -clean: - rm -f $(CMD) *.log - go clean diff --git a/_archive/examples/bme280/main.go b/_archive/examples/bme280/main.go deleted file mode 100644 index e4070b5..0000000 --- a/_archive/examples/bme280/main.go +++ /dev/null @@ -1,29 +0,0 @@ -package main - -import ( - "fmt" - - "github.com/rustyeddy/devices/bme280" -) - -func main() { - // Set the BME i2c device and address Initialize the bme to use - // the i2c bus - bme, err := bme280.New("bme280", "/dev/i2c-1", 0x76) - if err != nil { - panic(err) - } - - // Open the device to prepare it for usage - err = bme.Open() - if err != nil { - panic(err) - } - - val, err := bme.Get() - if err != nil { - panic(err) - } - fmt.Printf("BME values %+v\n", val) - -} diff --git a/_archive/examples/gtu7/.gitignore b/_archive/examples/gtu7/.gitignore deleted file mode 100644 index 2fb2f8b..0000000 --- a/_archive/examples/gtu7/.gitignore +++ /dev/null @@ -1 +0,0 @@ -gtu7 diff --git a/_archive/examples/gtu7/Makefile b/_archive/examples/gtu7/Makefile deleted file mode 100644 index aaccef9..0000000 --- a/_archive/examples/gtu7/Makefile +++ /dev/null @@ -1,9 +0,0 @@ -all: - go build -v - -pi: - env GOOS=linux GOARCH=arm GOARM=7 go build -v - -clean: - rm -f $(CMD) *.log - go clean diff --git a/_archive/examples/gtu7/main.go b/_archive/examples/gtu7/main.go deleted file mode 100644 index 94dfc61..0000000 --- a/_archive/examples/gtu7/main.go +++ /dev/null @@ -1,16 +0,0 @@ -package main - -import ( - "fmt" - - "github.com/rustyeddy/devices/gtu7" -) - -func main() { - g := gtu7.New("/dev/ttyS0") - gpsQ := g.StartReading() - - for gps := range gpsQ { - fmt.Printf("GPS %+v\n", gps) - } -} diff --git a/_archive/examples/json/.gitignore b/_archive/examples/json/.gitignore deleted file mode 100644 index 3c84009..0000000 --- a/_archive/examples/json/.gitignore +++ /dev/null @@ -1 +0,0 @@ -json diff --git a/_archive/examples/json/Makefile b/_archive/examples/json/Makefile deleted file mode 100644 index aaccef9..0000000 --- a/_archive/examples/json/Makefile +++ /dev/null @@ -1,9 +0,0 @@ -all: - go build -v - -pi: - env GOOS=linux GOARCH=arm GOARM=7 go build -v - -clean: - rm -f $(CMD) *.log - go clean diff --git a/_archive/examples/json/main.go b/_archive/examples/json/main.go deleted file mode 100644 index a009224..0000000 --- a/_archive/examples/json/main.go +++ /dev/null @@ -1,49 +0,0 @@ -/* -Blink sets up pin 6 for an LED and goes into an endless -toggle mode. -*/ - -package main - -import ( - "log/slog" - - "encoding/json" - - "github.com/rustyeddy/devices/drivers" -) - -var gpioStr = ` -{ - "chipname":"gpiochip4", - "pins": { - "6": { - "name": "led", - "offset": 6, - "value": 0, - "mode": 0 - } - } -} -` - -func main() { - var g *drivers.GPIOCDev - if err := json.Unmarshal([]byte(gpioStr), &g); err != nil { - slog.Error(err.Error()) - return - } - - defer func() { - if g != nil { - g.Close() - } - }() - - // TODO - // led, _ := g.Pin("led", 6, drivers.PinOutput) - // for { - // led.Toggle() - // time.Sleep(1 * time.Second) - // } -} diff --git a/_archive/examples/oled/.gitignore b/_archive/examples/oled/.gitignore deleted file mode 100644 index fbb2c4f..0000000 --- a/_archive/examples/oled/.gitignore +++ /dev/null @@ -1 +0,0 @@ -oled diff --git a/_archive/examples/oled/Makefile b/_archive/examples/oled/Makefile deleted file mode 100644 index d9a8510..0000000 --- a/_archive/examples/oled/Makefile +++ /dev/null @@ -1,12 +0,0 @@ -all: - go build -v - -pi: - env GOOS=linux GOARCH=arm GOARM=7 go build -v - -zero: - env GOOS=linux GOARCH=arm GOARM=6 go build -v - -clean: - rm -f $(CMD) *.log - go clean diff --git a/_archive/examples/oled/main.go b/_archive/examples/oled/main.go deleted file mode 100644 index c2ee85e..0000000 --- a/_archive/examples/oled/main.go +++ /dev/null @@ -1,108 +0,0 @@ -package main - -import ( - "fmt" - "log" - "time" - - "github.com/rustyeddy/devices/oled" - "periph.io/x/devices/v3/ssd1306" -) - -// const On = ssd1306.On -// const Off = ssd1306.Off - -var ( - display *oled.OLED - err error -) - -func main() { - - display, err = oled.New("oled", 128, 64) - if err != nil { - log.Fatal(err) - } - - // println(display.Dev.String()) - draw() -} - -func hamburger(x0, y0, width, height, spacing int) { - display.Rectangle(0, 0, 10, 2, oled.On) - display.Rectangle(0, 4, 10, 6, oled.On) - display.Rectangle(0, 8, 10, 10, oled.On) - display.Rectangle(0, 12, 10, 14, oled.On) -} - -func draw() { - // display.Clear() - - // str := "pump: off" - // println("EXAMPLE OLED DRAW STRING: ", str) - - hamburger(0, 0, 10, 15, 2) - - // display.DrawString(0, 10, str) - - // display.DrawString(65, 10, "vwc: 6.5") - - // display.Diagonal(0, 30, 128, 30, oled.On) - // display.Diagonal(60, 0, 60, 64, oled.On) - - // display.Diagonal(60, 20, 60, 60, oled.On) - - // display.Diagonal(10, 30, 110, 50, oled.On) - // display.Diagonal(10, 30, 10, 40, oled.On) - - // display.Diagonal(10, 40, 110, 60, oled.On) - // display.Diagonal(110, 50, 110, 60, oled.On) - - display.Draw() -} - -func hello() { - // draw a lgine at 50, 100 lenght 50 pixels, 4 wide - display.Clear() - display.Line(0, 12, display.Width, 2, oled.On) - display.Rectangle(100, 40, 120, 60, oled.On) - display.DrawString(10, 10, "Hello, world!") - display.Draw() -} - -func ballerine() { - var done <-chan time.Time - done = time.After(10 * time.Second) - - display.Clear() - display.AnimatedGIF("ballerine.gif", done) - display.Draw() -} - -func sensors() { - - display.Clear() - - temp := 10.1 - pressure := 11.2 - humidity := 12.3 - //relay := "On" - - start := 25 - t := time.Now().Format(time.Kitchen) - display.DrawString(10, 10, "OttO: "+t) - display.DrawString(10, start, fmt.Sprintf("temp: %7.2f", temp)) - display.DrawString(10, start+15, fmt.Sprintf("pres: %7.2f", pressure)) - display.DrawString(10, start+30, fmt.Sprintf("humi: %7.2f", humidity)) - - display.Draw() -} - -func scroller(done chan bool) { - sensors() - display.Scroll(ssd1306.Right, ssd1306.FrameRate5, 0, -1) - - <-done - display.StopScroll() - -} diff --git a/_archive/examples/relay/.gitignore b/_archive/examples/relay/.gitignore deleted file mode 100644 index 32e541a..0000000 --- a/_archive/examples/relay/.gitignore +++ /dev/null @@ -1 +0,0 @@ -relay diff --git a/_archive/examples/relay/Makefile b/_archive/examples/relay/Makefile deleted file mode 100644 index aaccef9..0000000 --- a/_archive/examples/relay/Makefile +++ /dev/null @@ -1,9 +0,0 @@ -all: - go build -v - -pi: - env GOOS=linux GOARCH=arm GOARM=7 go build -v - -clean: - rm -f $(CMD) *.log - go clean diff --git a/_archive/examples/relay/app/index.html b/_archive/examples/relay/app/index.html deleted file mode 100644 index 4f835ac..0000000 --- a/_archive/examples/relay/app/index.html +++ /dev/null @@ -1,31 +0,0 @@ - - - - - Bootstrap demo - - - - -
-
-
-
-
-

OttO

- - -
-
- -
-
-
- - - - - - - - diff --git a/_archive/examples/relay/app/js/relay.js b/_archive/examples/relay/app/js/relay.js deleted file mode 100644 index 158a12d..0000000 --- a/_archive/examples/relay/app/js/relay.js +++ /dev/null @@ -1,50 +0,0 @@ -const clientId = 'mqttjs_' + Math.random().toString(16).substr(2, 8); -const host = "ws://" + window.location.hostname + ":8080"; -console.log(window.location.hostname); - -const options = { - keepalive: 60, - clientId: clientId, - protocolId: 'MQTT', - protocolVersion: 4, - clean: true, - reconnectPeriod: 1000, - connectTimeout: 30 * 1000, - will: { - topic: 'WillMsg', - payload: 'Connection Closed abnormally..!', - qos: 0, - retain: false - }, -} -console.log('Connecting mqtt client'); -const client = mqtt.connect(host, options); -client.on('error', (err) => { - console.log('Connection error: ', err); - client.end(); -}) -client.on('reconnect', () => { - console.log('Reconnecting...'); -}) - -client.on('connect', () => { - console.log(`Client connected: ${clientId}`); - // Subscribe - console.log("subscribing to ss/c/station/relay"); - client.subscribe('ss/c/station/relay', { qos: 0 }); -}) -// Unsubscribe -/* client.unsubscribe('tt', () => { - * console.log('Unsubscribed'); - * }) - */ - -function On() { - console.log("on") - client.publish('ss/c/station/relay', "on", { qos: 0, retain: false }) -} - -function Off() { - console.log("off") - client.publish('ss/c/station/relay', "off", { qos: 0, retain: false }) -} diff --git a/_archive/examples/relay/main.go b/_archive/examples/relay/main.go deleted file mode 100644 index 00405c7..0000000 --- a/_archive/examples/relay/main.go +++ /dev/null @@ -1,16 +0,0 @@ -/* -Relay sets up pin 6 for a Relay (or LED) and connects to an MQTT -broker waiting for instructions to turn on or off the relay. -*/ - -package main - -import ( - "embed" -) - -//go:embed app -var content embed.FS - -func main() { -} diff --git a/_archive/examples/switch/.gitignore b/_archive/examples/switch/.gitignore deleted file mode 100644 index 0d524c3..0000000 --- a/_archive/examples/switch/.gitignore +++ /dev/null @@ -1 +0,0 @@ -switch diff --git a/_archive/examples/switch/Makefile b/_archive/examples/switch/Makefile deleted file mode 100644 index aaccef9..0000000 --- a/_archive/examples/switch/Makefile +++ /dev/null @@ -1,9 +0,0 @@ -all: - go build -v - -pi: - env GOOS=linux GOARCH=arm GOARM=7 go build -v - -clean: - rm -f $(CMD) *.log - go clean diff --git a/_archive/examples/switch/main.go b/_archive/examples/switch/main.go deleted file mode 100644 index 6c243ae..0000000 --- a/_archive/examples/switch/main.go +++ /dev/null @@ -1,79 +0,0 @@ -package main - -import ( - "fmt" - "log/slog" - "time" - - "github.com/rustyeddy/devices/drivers" - "github.com/warthog618/go-gpiocdev" -) - -func main() { - // Get the GPIO driver - g := drivers.GetGPIO[bool]() - defer func() { - g.Close() - }() - - done := make(chan bool, 0) - go startSwitchHandler(g, done) - go startSwitchToggler(g, done) - - <-done -} - -func startSwitchToggler(g drivers.GPIO[bool], done chan bool) { - on := false - r, err := g.SetPin("reader", 23, drivers.PinOutput) - if err != nil { - panic(err) - } - for { - if on { - r.Set(on) - on = false - } else { - r.Set(on) - on = true - } - time.Sleep(1 * time.Second) - } -} - -func startSwitchHandler(g drivers.GPIO[bool], done chan bool) { - evtQ := make(chan gpiocdev.LineEvent) - sw, err := g.SetPin("switch", 24, drivers.PinPullUp, drivers.PinBothEdges) - // gpiocdev.WithEventHandler(func(evt gpiocdev.LineEvent) { - // evtQ <- evt - //})) - if err != nil { - panic(err) - } - - for { - select { - case evt := <-evtQ: - switch evt.Type { - case gpiocdev.LineEventFallingEdge: - slog.Info("GPIO failing edge", "pin", sw.Index()) - fallthrough - - case gpiocdev.LineEventRisingEdge: - slog.Info("GPIO raising edge", "pin", sw.Index()) - v, err := sw.Get() - if err != nil { - slog.Error("Error getting input value: ", "error", err.Error()) - continue - } - fmt.Printf("val: %t\n", v) - - default: - slog.Warn("Switch unknown event type ", "type", evt.Type) - } - - case <-done: - return - } - } -} diff --git a/_archive/examples/vh400/.gitignore b/_archive/examples/vh400/.gitignore deleted file mode 100644 index 31b4cbb..0000000 --- a/_archive/examples/vh400/.gitignore +++ /dev/null @@ -1 +0,0 @@ -vh400 diff --git a/_archive/examples/vh400/Makefile b/_archive/examples/vh400/Makefile deleted file mode 100644 index d9a8510..0000000 --- a/_archive/examples/vh400/Makefile +++ /dev/null @@ -1,12 +0,0 @@ -all: - go build -v - -pi: - env GOOS=linux GOARCH=arm GOARM=7 go build -v - -zero: - env GOOS=linux GOARCH=arm GOARM=6 go build -v - -clean: - rm -f $(CMD) *.log - go clean diff --git a/_archive/examples/vh400/main.go b/_archive/examples/vh400/main.go deleted file mode 100644 index 7d13714..0000000 --- a/_archive/examples/vh400/main.go +++ /dev/null @@ -1,33 +0,0 @@ -package main - -import ( - "fmt" - "os" - "strconv" - - "github.com/rustyeddy/devices/vh400" -) - -func main() { - var err error - pin := 0 - - if len(os.Args) > 1 { - pin, err = strconv.Atoi(os.Args[1]) - if err != nil { - fmt.Printf("Bad argument %s - expected integers for adc\n", os.Args[1]) - } - } - - s, err := vh400.New("vh400", pin) - if err != nil { - panic(err) - } - readQ := s.ReadContinuous() - for { - select { - case val := <-readQ: - fmt.Printf("adc: %d: %5.2f\n", pin, val) - } - } -} diff --git a/_archive/gtu7/gps.go b/_archive/gtu7/gps.go deleted file mode 100644 index 7de7155..0000000 --- a/_archive/gtu7/gps.go +++ /dev/null @@ -1,127 +0,0 @@ -package gtu7 - -import ( - "errors" - "strconv" - "strings" -) - -type GPS struct { - *GPGGA - *GPGSA - *GPGSV - *GPGLL - *GPRMC - *GPVTG - - complete bool -} - -type GPGGA struct { - Timestamp float64 - Latitude float64 - Latdir string - Longitude float64 - Longdir string - Quality int64 - Satellites int64 - HDOP float64 - Altitude float64 - Altunit string - Geodial float64 // subtract from Altitude to get Height Above Ellipsoid (HAE) - Geounit string - AgeCorrection float64 - CorrectionStation int64 // can be empty - BaseStationID int64 // does not exist on gt-u7 - Checksum string -} - -type GPGSA struct { - sentence string -} - -type GPGSV struct { - sentences []string -} - -type GPGLL struct { - sentence string -} - -type GPRMC struct { - sentence string -} - -type GPVTG struct { - sentence string -} - -func (gps *GPS) Parse(input string) (*GPS, error) { - data := strings.Split(input, ",") - - // Todo add errors - switch data[0] { - case "$GPGGA": - g := &GPGGA{} - g.Timestamp, _ = strconv.ParseFloat(data[1], 64) - g.Latitude, _ = strconv.ParseFloat(data[2], 64) - g.Latdir = data[3] - g.Longitude, _ = strconv.ParseFloat(data[4], 64) - g.Longdir = data[5] - g.Quality, _ = strconv.ParseInt(data[6], 10, 32) - g.Satellites, _ = strconv.ParseInt(data[7], 10, 64) - g.HDOP, _ = strconv.ParseFloat(data[8], 64) - g.Altitude, _ = strconv.ParseFloat(data[9], 64) - g.Altunit = data[10] - g.Geodial, _ = strconv.ParseFloat(data[11], 64) - g.AgeCorrection, _ = strconv.ParseFloat(data[12], 64) - g.CorrectionStation, _ = strconv.ParseInt(data[13], 10, 64) - g.Checksum = data[14] - gps.GPGGA = g - - case "$GPGSA": - gps.GPGSA = &GPGSA{ - sentence: input, - } - - case "$GPGSV": - if gps.GPGSV == nil { - gps.GPGSV = &GPGSV{} - } - gps.GPGSV.sentences = append(gps.GPGSV.sentences, input) - - case "$GPGLL": - gps.GPGLL = &GPGLL{ - sentence: input, - } - - case "$GPRMC": - gps.GPRMC = &GPRMC{ - sentence: input, - } - - case "$GPVTG": - gps.GPVTG = &GPVTG{ - sentence: input, - } - - default: - return nil, errors.New("Unknown command: " + data[0]) - } - - return gps, nil -} - -func (g *GPS) IsComplete() bool { - if !g.complete { - if g.GPGGA != nil && - g.GPGSA != nil && - g.GPGSV != nil && - g.GPGLL != nil && - g.GPRMC != nil && - g.GPVTG != nil { - g.complete = true - } - } - return g.complete -} diff --git a/_archive/gtu7/gps_test.go b/_archive/gtu7/gps_test.go deleted file mode 100644 index 74b20a4..0000000 --- a/_archive/gtu7/gps_test.go +++ /dev/null @@ -1,37 +0,0 @@ -package gtu7 - -import ( - "testing" -) - -func TestGPS(t *testing.T) { - // need to add more tests - gotone := false - g := New("fakedev") - if g == nil { - t.Fatalf("Expected GTU7 device but got (nil)") - } - - g.OpenStrings(input) - gpsQ := g.StartReading() - for gps := range gpsQ { - if gps.complete { - gotone = true - } - } - if !gotone { - t.Error("Failed to recieve a complete GPS package") - } -} - -var input string = ` -$GPGGA,160446.00,3340.34121,N,11800.11332,W,2,08,1.20,11.8,M,-33.1,M,,0000*58 -$GPGSA,A,3,09,16,46,03,07,31,26,04,,,,,3.08,1.20,2.84*0E -$GPGSV,4,1,13,01,02,193,,03,58,181,33,04,64,360,31,06,12,295,*7A -$GPGSV,4,2,13,07,32,254,25,08,00,154,,09,44,317,33,16,52,085,26*72 -$GPGSV,4,3,13,26,31,051,15,27,05,124,16,31,15,053,10,46,49,200,33*76 -$GPGSV,4,4,13,48,50,193,*49 -$GPGLL,3340.34121,N,11800.11332,W,160446.00,A,D*74 -$GPRMC,160447.00,A,3340.34118,N,11800.11331,W,0.063,,020125,,,D*64 -$GPVTG,,T,,M,0.063,N,0.117,K,D*24 -` diff --git a/_archive/gtu7/gtu7.go b/_archive/gtu7/gtu7.go deleted file mode 100644 index c960828..0000000 --- a/_archive/gtu7/gtu7.go +++ /dev/null @@ -1,109 +0,0 @@ -package gtu7 - -import ( - "bufio" - "fmt" - "log/slog" - "strings" - - "github.com/rustyeddy/devices" - "github.com/rustyeddy/devices/drivers" -) - -type GTU7 struct { - *drivers.Serial - lastGPS *GPS - scanner *bufio.Scanner - devices.Device[*GPS] -} - -func New(name string) *GTU7 { - s, err := drivers.NewSerial(name, 9600) - if err != nil { - slog.Error("GTU7 failed to open", "error", err) - return nil - } - g := >U7{ - Serial: s, - } - g.Device = g - return g -} - -func (g *GTU7) ID() string { - return g.Serial.PortName -} - -func (g *GTU7) Open() error { - err := g.Serial.Open() - if err != nil { - fmt.Printf("Failed to open serial port %s - %v\n", g.String(), err) - return err - } - return nil -} - -func (g *GTU7) Get() (*GPS, error) { - return g.lastGPS, nil -} - -func (g *GTU7) Set(gps *GPS) error { - g.lastGPS = gps - return nil -} - -func (g *GTU7) Type() devices.Type { - return devices.TypeGPS -} - -func (g *GTU7) OpenRead() error { - err := g.Open() - if err != nil { - return err - } - g.scanner = bufio.NewScanner(g) - return nil -} - -func (g *GTU7) OpenStrings(input string) { - g.scanner = bufio.NewScanner(strings.NewReader(input)) -} - -func (g *GTU7) StartReading() chan *GPS { - parseQ := make(chan string) - gpsQ := g.startParser(parseQ) - go func() { - for g.scanner.Scan() { - if g.scanner.Text() == "" { - continue - } - line := g.scanner.Text() - parseQ <- line - } - if err := g.scanner.Err(); err != nil { - slog.Error("scanning GPS data", "error", err) - } - close(parseQ) - }() - return gpsQ -} - -func (g *GTU7) startParser(parseQ chan string) chan *GPS { - - gps := &GPS{} - gpsQ := make(chan *GPS) - go func() { - for line := range parseQ { - gps.Parse(line) - if gps.IsComplete() { - gpsQ <- gps - } - } - close(gpsQ) - }() - return gpsQ -} - -func (g *GTU7) String() string { - return g.ID() -} From 57d355b954c99d02a56daae944d3c60caa59258e Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 16 Jan 2026 09:59:35 -0800 Subject: [PATCH 06/17] Make GTU7 output channel buffer size configurable (#19) * Initial plan * Make GTU7 buffer size configurable via GTU7Config.Buf Co-authored-by: rustyeddy <2903425+rustyeddy@users.noreply.github.com> * Make GTU7 output channel buffer size configurable Co-authored-by: rustyeddy <2903425+rustyeddy@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: rustyeddy <2903425+rustyeddy@users.noreply.github.com> --- go.mod | 5 +---- go.sum | 6 ------ sensors/gtu7.go | 9 ++++++++- sensors/gtu7_test.go | 33 +++++++++++++++++++++++++++++++++ 4 files changed, 42 insertions(+), 11 deletions(-) diff --git a/go.mod b/go.mod index 0adf072..545d374 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,9 @@ module github.com/rustyeddy/devices go 1.24.5 require ( - github.com/maciej/bme280 v0.2.0 - github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 github.com/stretchr/testify v1.11.1 github.com/warthog618/go-gpiocdev v0.9.1 - golang.org/x/image v0.23.0 + golang.org/x/sys v0.29.0 periph.io/x/conn/v3 v3.7.2 periph.io/x/devices/v3 v3.7.4 periph.io/x/host/v3 v3.8.5 @@ -16,6 +14,5 @@ require ( require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - golang.org/x/sys v0.29.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 9384d76..c1946cb 100644 --- a/go.sum +++ b/go.sum @@ -2,10 +2,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= -github.com/maciej/bme280 v0.2.0 h1:WsoHmIxw15AbhyoY5EWYH6loHNnsCayW1yWVLmukJVQ= -github.com/maciej/bme280 v0.2.0/go.mod h1:uhS+osHzBXnIwpXTCklgoi0q4XiA5Mr5ehJfGIPlfQY= -github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= -github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -16,8 +12,6 @@ github.com/warthog618/go-gpiocdev v0.9.1 h1:pwHPaqjJfhCipIQl78V+O3l9OKHivdRDdmgX github.com/warthog618/go-gpiocdev v0.9.1/go.mod h1:dN3e3t/S2aSNC+hgigGE/dBW8jE1ONk9bDSEYfoPyl8= github.com/warthog618/go-gpiosim v0.1.1 h1:MRAEv+T+itmw+3GeIGpQJBfanUVyg0l3JCTwHtwdre4= github.com/warthog618/go-gpiosim v0.1.1/go.mod h1:YXsnB+I9jdCMY4YAlMSRrlts25ltjmuIsrnoUrBLdqU= -golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68= -golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY= golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/sensors/gtu7.go b/sensors/gtu7.go index 26b63a3..7413306 100644 --- a/sensors/gtu7.go +++ b/sensors/gtu7.go @@ -34,6 +34,9 @@ type GTU7Config struct { Serial drivers.SerialConfig Factory drivers.SerialFactory + // Buf sizes the out channel. Default 16. + Buf int + // Test injection Reader io.Reader } @@ -49,6 +52,10 @@ func NewGTU7(cfg GTU7Config) *GTU7 { cfg.Factory = drivers.LinuxSerialFactory{} } + if cfg.Buf <= 0 { + cfg.Buf = 16 + } + var r io.Reader if cfg.Reader != nil { r = cfg.Reader @@ -62,7 +69,7 @@ func NewGTU7(cfg GTU7Config) *GTU7 { return >U7{ name: cfg.Name, - out: make(chan GPSFix, 4), + out: make(chan GPSFix, cfg.Buf), r: r, } } diff --git a/sensors/gtu7_test.go b/sensors/gtu7_test.go index 8fde078..7dfcb60 100644 --- a/sensors/gtu7_test.go +++ b/sensors/gtu7_test.go @@ -55,3 +55,36 @@ $GPRMC,160446.00,A,3340.34121,N,11800.11332,W,7.25,123.40,160126,,,A*00 require.FailNow(t, "run did not exit") } } + +func TestGTU7_BufferSize(t *testing.T) { + t.Run("default buffer size is 16", func(t *testing.T) { + gps := NewGTU7(GTU7Config{ + Reader: strings.NewReader(""), + }) + require.Equal(t, 16, cap(gps.out)) + }) + + t.Run("custom buffer size", func(t *testing.T) { + gps := NewGTU7(GTU7Config{ + Reader: strings.NewReader(""), + Buf: 32, + }) + require.Equal(t, 32, cap(gps.out)) + }) + + t.Run("zero buffer size defaults to 16", func(t *testing.T) { + gps := NewGTU7(GTU7Config{ + Reader: strings.NewReader(""), + Buf: 0, + }) + require.Equal(t, 16, cap(gps.out)) + }) + + t.Run("negative buffer size defaults to 16", func(t *testing.T) { + gps := NewGTU7(GTU7Config{ + Reader: strings.NewReader(""), + Buf: -5, + }) + require.Equal(t, 16, cap(gps.out)) + }) +} From 8d586fffcf7d2633657df809cfb2ee2858b6248f Mon Sep 17 00:00:00 2001 From: Rusty Eddy Date: Sat, 17 Jan 2026 07:33:59 -0800 Subject: [PATCH 07/17] Removed the _archive directory --- _archive/oled/.gitignore | 1 - _archive/oled/oled.go | 280 ------------------------------------- _archive/oled/oled_test.go | 118 ---------------- 3 files changed, 399 deletions(-) delete mode 100644 _archive/oled/.gitignore delete mode 100644 _archive/oled/oled.go delete mode 100644 _archive/oled/oled_test.go diff --git a/_archive/oled/.gitignore b/_archive/oled/.gitignore deleted file mode 100644 index 4c67bf7..0000000 --- a/_archive/oled/.gitignore +++ /dev/null @@ -1 +0,0 @@ -ssd1306 diff --git a/_archive/oled/oled.go b/_archive/oled/oled.go deleted file mode 100644 index 0d69823..0000000 --- a/_archive/oled/oled.go +++ /dev/null @@ -1,280 +0,0 @@ -package oled - -import ( - "errors" - "fmt" - "image" - "image/draw" - "image/gif" - "math" - "os" - "time" - - "github.com/nfnt/resize" - "github.com/rustyeddy/devices" - "periph.io/x/conn/v3/i2c/i2creg" - "periph.io/x/devices/v3/ssd1306" - "periph.io/x/devices/v3/ssd1306/image1bit" - "periph.io/x/host/v3" - - "golang.org/x/image/font" - "golang.org/x/image/font/basicfont" - "golang.org/x/image/math/fixed" -) - -// Bit is used to turn on or off a Bit on the ssd1306 OLED display -type Bit bool - -const ( - On Bit = true - Off Bit = false -) - -type OLED struct { - Dev *ssd1306.Dev - Height int - Width int - Font *basicfont.Face - Background *image1bit.VerticalLSB - - *devices.DeviceBase[OLED] - - bus string - addr int -} - -func NewDevice(id string) (devices.Device[any], error) { - o, err := New(id, 128, 64) - return o, err -} - -func New(name string, width, height int) (*OLED, error) { - o := &OLED{ - Height: height, - Width: width, - bus: "/dev/i2c-1", - addr: 0x3c, - } - o.DeviceBase = devices.NewDeviceBase[OLED](name) - - o.Background = image1bit.NewVerticalLSB(image.Rect(0, 0, width, height)) - if devices.IsMock() { - return o, nil - } - return o, nil -} - -func (o *OLED) Open() error { - - // Load all the drivers: - if _, err := host.Init(); err != nil { - return err - } - - // Open a handle to the first available I²C bus: - bus, err := i2creg.Open(o.bus) - if err != nil { - return err - } - - // Open a handle to a ssd1306 connected on the I²C bus: - opts := &ssd1306.DefaultOpts - opts.H = o.Height - opts.W = o.Width - - o.Dev, err = ssd1306.NewI2C(bus, opts) - if err != nil { - return err - } - - return nil -} - -func (o *OLED) Close() error { - return errors.New("TODO Need to implement bme280 close") -} - -func (d *OLED) Clear() { - // got to be a better way - d.Rectangle(0, 0, d.Width, d.Height, Off) -} - -func (d *OLED) Get() (any, error) { - return nil, devices.ErrNotImplemented -} - -func (d *OLED) Set(v any) error { - - // what to do with set? - return nil -} - -func (d *OLED) Draw() error { - err := d.Dev.Draw(d.Background.Bounds(), d.Background, image.Point{}) - if err != nil { - fmt.Println("ERROR - OLED Draw: ", err) - return err - } - return nil -} -func (d *OLED) Rectangle(x0, y0, x1, y1 int, value Bit) { - d.Clip(&x0, &y0, &x1, &y1) - - if x0 > x1 { - x0, x1 = x1, x0 - } - if y0 > y1 { - y0, y1 = y1, y0 - } - - for x := x0; x < x1; x++ { - for y := y0; y < y1; y++ { - d.SetBit(x, y, value) - } - } -} - -func (d *OLED) Line(x0, y0, len, width int, value Bit) { - x1 := x0 + len - y1 := y0 + width - d.Rectangle(x0, y0, x1, y1, value) -} - -func (d *OLED) Diagonal(x0, y0, x1, y1 int, value Bit) { - d.Clip(&x0, &y0, &x1, &y1) - - xf0 := float64(x0) - xf1 := float64(x1) - yf0 := float64(y0) - yf1 := float64(y1) - - l := (xf1 - xf0) - h := (yf1 - yf0) - - var slope float64 - if l > h { - slope = h / l - } else { - slope = l / h - } - - if l >= h { - for x := xf0; x < xf1; x++ { - y := slope*(x-xf0) + yf0 - d.SetBit(int(math.Round(x)), int(math.Round(y)), value) - } - } else { - for y := yf0; y < yf1; y++ { - x := slope*(y-yf0) + xf0 - d.SetBit(int(math.Round(x)), int(math.Round(y)), value) - } - } -} - -func (d *OLED) Scroll(o ssd1306.Orientation, rate ssd1306.FrameRate, startLine, endLine int) error { - return d.Dev.Scroll(o, rate, startLine, endLine) -} - -func (d *OLED) StopScroll() error { - return d.Dev.StopScroll() -} - -func (d *OLED) SetBit(x, y int, value Bit) { - d.Background.SetBit(x, y, image1bit.Bit(value)) -} - -func (d *OLED) Clip(x0, y0, x1, y1 *int) { - if x0 != nil { - if *x0 < 0 { - *x0 = 0 - } - if *x0 > d.Width { - *x0 = d.Width - } - } - if x1 != nil { - if *x1 < 0 { - *x1 = 0 - } - if *x1 > d.Width { - *x1 = d.Width - } - } - - if y0 != nil { - if *y0 < 0 { - *y0 = 0 - } - if *y0 > d.Height { - *y0 = d.Height - } - } - - if y1 != nil { - if *y1 < 0 { - *y1 = 0 - } - if *y1 > d.Height { - *y1 = d.Height - } - } -} - -func (d *OLED) DrawString(x, y int, str string) { - d.Font = basicfont.Face7x13 - drawer := &font.Drawer{ - Dst: d.Background, - Src: image.White, - Face: d.Font, - Dot: fixed.Point26_6{X: fixed.I(x), Y: fixed.I(y)}, - } - drawer.DrawString(str) -} - -func (d *OLED) AnimatedGIF(fname string, done <-chan time.Time) error { - f, err := os.Open(fname) - if err != nil { - return err - } - - g, err := gif.DecodeAll(f) - f.Close() - if err != nil { - return err - } - - // Converts every frame to image.Gray and resize them: - imgs := make([]*image.Gray, len(g.Image)) - for i := range g.Image { - imgs[i] = d.convertAndResizeAndCenter(g.Image[i]) - } - - // OLED the frames in a loop: - for i := 0; ; i++ { - select { - case <-done: - return nil - - default: - index := i % len(imgs) - c := time.After(time.Duration(10*g.Delay[index]) * time.Millisecond) - img := imgs[index] - d.Dev.Draw(img.Bounds(), img, image.Point{}) - <-c - } - } - return nil -} - -// convertAndResizeAndCenter takes an image, resizes and centers it on a -// image.Gray of size w*h. -func (d *OLED) convertAndResizeAndCenter(src image.Image) *image.Gray { - w := d.Width - h := d.Height - src = resize.Thumbnail(uint(w), uint(h), src, resize.Bicubic) - img := image.NewGray(image.Rect(0, 0, w, h)) - r := src.Bounds() - r = r.Add(image.Point{(w - r.Max.X) / 2, (h - r.Max.Y) / 2}) - draw.Draw(img, r, src, image.Point{}, draw.Src) - return img -} diff --git a/_archive/oled/oled_test.go b/_archive/oled/oled_test.go deleted file mode 100644 index 33b3bf0..0000000 --- a/_archive/oled/oled_test.go +++ /dev/null @@ -1,118 +0,0 @@ -package oled - -import ( - "image" - "testing" - "time" - - "github.com/rustyeddy/devices" - "github.com/stretchr/testify/assert" - "periph.io/x/devices/v3/ssd1306/image1bit" -) - -func init() { - devices.SetMock(true) -} - -func TestNewOLED_MockMode(t *testing.T) { - oled, err := New("testoled", 128, 64) - assert.NoError(t, err) - assert.NotNil(t, oled) - assert.Equal(t, 128, oled.Width) - assert.Equal(t, 64, oled.Height) - assert.Equal(t, "testoled", oled.Name()) -} - -func TestOLED_Clear_SetsAllBitsOff(t *testing.T) { - oled, _ := New("clearoled", 10, 10) - oled.Rectangle(0, 0, 10, 10, On) - oled.Clear() - for x := 0; x < oled.Width; x++ { - for y := 0; y < oled.Height; y++ { - bit := oled.Background.BitAt(x, y) - assert.Equal(t, image1bit.Bit(Off), bit) - } - } -} - -func TestOLED_Rectangle_SetsBits(t *testing.T) { - oled, _ := New("rectoled", 8, 8) - oled.Rectangle(2, 2, 6, 6, On) - for x := 2; x < 6; x++ { - for y := 2; y < 6; y++ { - assert.Equal(t, image1bit.Bit(On), oled.Background.BitAt(x, y)) - } - } -} - -func TestOLED_Line_SetsBits(t *testing.T) { - oled, _ := New("lineoled", 8, 8) - oled.Line(1, 1, 4, 2, On) - for x := 1; x < 5; x++ { - for y := 1; y < 3; y++ { - assert.Equal(t, image1bit.Bit(On), oled.Background.BitAt(x, y)) - } - } -} - -func TestOLED_Diagonal_SetsBits(t *testing.T) { - oled, _ := New("diagoled", 8, 8) - oled.Diagonal(0, 0, 7, 7, On) - // Diagonal should set bits roughly along the diagonal - count := 0 - for i := 0; i < 8; i++ { - if oled.Background.BitAt(i, i) == image1bit.Bit(On) { - count++ - } - } - assert.GreaterOrEqual(t, count, 6) -} - -func TestOLED_Clip_ClampsValues(t *testing.T) { - oled, _ := New("clipoled", 10, 10) - x0, y0, x1, y1 := -5, -5, 15, 15 - oled.Clip(&x0, &y0, &x1, &y1) - assert.Equal(t, 0, x0) - assert.Equal(t, 0, y0) - assert.Equal(t, 10, x1) - assert.Equal(t, 10, y1) -} - -func TestOLED_SetBit_SetsCorrectBit(t *testing.T) { - oled, _ := New("setbitoled", 5, 5) - oled.SetBit(2, 3, On) - assert.Equal(t, image1bit.Bit(On), oled.Background.BitAt(2, 3)) - oled.SetBit(2, 3, Off) - assert.Equal(t, image1bit.Bit(Off), oled.Background.BitAt(2, 3)) -} - -func TestOLED_DrawString_SetsPixels(t *testing.T) { - oled, _ := New("stringoled", 20, 20) - oled.DrawString(2, 10, "Hi") - // Check that some pixels are set - set := false - for x := 2; x < 10; x++ { - for y := 5; y < 15; y++ { - if oled.Background.BitAt(x, y) == image1bit.Bit(On) { - set = true - break - } - } - } - assert.True(t, set) -} - -func TestOLED_AnimatedGIF_ReturnsErrorForMissingFile(t *testing.T) { - oled, _ := New("gifoled", 32, 32) - done := make(chan time.Time) - err := oled.AnimatedGIF("nonexistent.gif", done) - assert.Error(t, err) -} - -func TestOLED_convertAndResizeAndCenter_ResizesAndCenters(t *testing.T) { - oled, _ := New("resizeoled", 32, 32) - src := image.NewGray(image.Rect(0, 0, 16, 16)) - img := oled.convertAndResizeAndCenter(src) - assert.Equal(t, 32, img.Bounds().Dx()) - assert.Equal(t, 32, img.Bounds().Dy()) -} From 38e9605b228ab7aad2137c79c01e98be4eb2f12e Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sat, 17 Jan 2026 07:55:00 -0800 Subject: [PATCH 08/17] Add comprehensive test coverage for GTU7 GPS sensor (#17) * Initial plan * test: add comprehensive GTU7 test coverage Co-authored-by: rustyeddy <2903425+rustyeddy@users.noreply.github.com> * Final review completed Co-authored-by: rustyeddy <2903425+rustyeddy@users.noreply.github.com> * Update sensors/gtu7_test.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: rustyeddy <2903425+rustyeddy@users.noreply.github.com> Co-authored-by: Rusty Eddy Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- sensors/gtu7_test.go | 227 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 227 insertions(+) diff --git a/sensors/gtu7_test.go b/sensors/gtu7_test.go index 7dfcb60..707213b 100644 --- a/sensors/gtu7_test.go +++ b/sensors/gtu7_test.go @@ -56,6 +56,233 @@ $GPRMC,160446.00,A,3340.34121,N,11800.11332,W,7.25,123.40,160126,,,A*00 } } +func TestGTU7_MalformedSentences(t *testing.T) { + tests := []struct { + name string + input string + }{ + { + name: "empty lines", + input: ` + + +$GPGGA,160446.00,3340.34121,N,11800.11332,W,2,08,1.20,11.8,M,-33.1,M,,0000*58 +`, + }, + { + name: "truncated GGA", + input: ` +$GPGGA,160446.00,3340 +$GPGGA,160446.00,3340.34121,N,11800.11332,W,2,08,1.20,11.8,M,-33.1,M,,0000*58 +`, + }, + { + name: "invalid sentence type", + input: ` +$GPXYZ,160446.00,A,3340.34121,N,11800.11332,W,7.25,123.40,160126,,,A*00 +$GPGGA,160446.00,3340.34121,N,11800.11332,W,2,08,1.20,11.8,M,-33.1,M,,0000*58 +`, + }, + { + name: "missing lat/lon fields", + input: ` +$GPGGA,160446.00,,,,,2,08,1.20,11.8,M,-33.1,M,,0000*58 +$GPGGA,160446.00,3340.34121,N,11800.11332,W,2,08,1.20,11.8,M,-33.1,M,,0000*58 +`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gps := NewGTU7(GTU7Config{ + Reader: strings.NewReader(tt.input), + }) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + done := make(chan error, 1) + go func() { done <- gps.Run(ctx) }() + + // Should get at least one valid fix + select { + case fix := <-gps.Out(): + require.NotZero(t, fix.Lat) + require.NotZero(t, fix.Lon) + cancel() + case <-time.After(time.Second): + require.FailNow(t, "timeout waiting for valid fix") + } + + select { + case err := <-done: + require.NoError(t, err) + case <-time.After(time.Second): + require.FailNow(t, "run did not exit") + } + }) + } +} + +func TestGTU7_GGAOnly(t *testing.T) { + input := ` +$GPGGA,123519.00,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47 +` + + gps := NewGTU7(GTU7Config{ + Reader: strings.NewReader(input), + }) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + done := make(chan error, 1) + go func() { done <- gps.Run(ctx) }() + + select { + case fix := <-gps.Out(): + cancel() + // Verify GGA fields + require.InDelta(t, 48.1173, fix.Lat, 0.0001) + require.InDelta(t, 11.5167, fix.Lon, 0.0001) + require.InDelta(t, 545.4, fix.AltMeters, 1e-6) + require.InDelta(t, 0.9, fix.HDOP, 1e-6) + require.Equal(t, 8, fix.Satellites) + require.Equal(t, 1, fix.Quality) + // Speed/course should be zero/unset + require.Zero(t, fix.SpeedKnots) + require.Zero(t, fix.SpeedMPS) + require.Zero(t, fix.CourseDeg) + case <-time.After(time.Second): + require.FailNow(t, "timeout waiting for GGA fix") + } + + select { + case err := <-done: + require.NoError(t, err) + case <-time.After(time.Second): + require.FailNow(t, "run did not exit") + } +} + +func TestGTU7_ContextCancellation(t *testing.T) { + // Test that context cancellation is checked between sentences + input := `$GPGGA,160446.00,3340.34121,N,11800.11332,W,2,08,1.20,11.8,M,-33.1,M,,0000*58 +$GPGGA,160447.00,3340.34122,N,11800.11333,W,2,08,1.20,11.8,M,-33.1,M,,0000*58 +` + + gps := NewGTU7(GTU7Config{ + Reader: strings.NewReader(input), + }) + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- gps.Run(ctx) }() + + // Wait for first fix + select { + case <-gps.Out(): + case <-time.After(time.Second): + require.FailNow(t, "timeout waiting for first fix") + } + + // Cancel context before second fix can be processed + cancel() + + // Run should exit cleanly after processing completes + select { + case err := <-done: + require.NoError(t, err) + case <-time.After(time.Second): + require.FailNow(t, "run did not exit after cancellation") + } + + // Drain any remaining messages and verify channel closes + for range gps.Out() { + // drain + } +} + +func TestGTU7_MultiConstellationVariants(t *testing.T) { + tests := []struct { + name string + input string + wantLat float64 + wantLon float64 + wantAlt float64 + wantSats int + }{ + { + name: "GNGGA - multi-constellation", + input: "$GNGGA,123519.00,4807.038,N,01131.000,E,1,12,0.9,545.4,M,46.9,M,,*4E\n", + wantLat: 48.1173, + wantLon: 11.5167, + wantAlt: 545.4, + wantSats: 12, + }, + { + name: "GNRMC - multi-constellation", + input: "$GNRMC,123519.00,A,4807.038,N,01131.000,E,5.5,123.4,230394,,,A*57\n", + wantLat: 48.1173, + wantLon: 11.5167, + }, + { + name: "GNVTG with GPGGA - multi-constellation", + input: "$GNVTG,54.7,T,,M,5.5,N,10.2,K,A*2F\n$GPGGA,123519.00,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47\n", + wantLat: 48.1173, + wantLon: 11.5167, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gps := NewGTU7(GTU7Config{ + Reader: strings.NewReader(tt.input), + }) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + done := make(chan error, 1) + go func() { done <- gps.Run(ctx) }() + + // Drain any intermediate fixes + var lastFix GPSFix + timeout := time.After(time.Second) + loop: + for { + select { + case fix, ok := <-gps.Out(): + if !ok { + break loop + } + lastFix = fix + case <-timeout: + break loop + } + } + + cancel() + + // Verify we got data + require.NotZero(t, lastFix.Lat, "should have received at least one fix") + require.InDelta(t, tt.wantLat, lastFix.Lat, 0.0001) + require.InDelta(t, tt.wantLon, lastFix.Lon, 0.0001) + if tt.wantAlt != 0 { + require.InDelta(t, tt.wantAlt, lastFix.AltMeters, 1e-6) + } + if tt.wantSats != 0 { + require.Equal(t, tt.wantSats, lastFix.Satellites) + } + + select { + case err := <-done: + require.NoError(t, err) + case <-time.After(time.Second): + require.FailNow(t, "run did not exit") + } + }) + } func TestGTU7_BufferSize(t *testing.T) { t.Run("default buffer size is 16", func(t *testing.T) { gps := NewGTU7(GTU7Config{ From 6e9edf2222eeb194930e34963d1990cc8df385f3 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sat, 17 Jan 2026 08:04:16 -0800 Subject: [PATCH 09/17] Fix serial port resource leak in GTU7 sensor (#18) * Initial plan * Add proper serial port cleanup in GTU7.Run method Co-authored-by: rustyeddy <2903425+rustyeddy@users.noreply.github.com> * Improve comment clarity and error message in nopCloser Co-authored-by: rustyeddy <2903425+rustyeddy@users.noreply.github.com> * Update nopCloser comment for clarity Co-authored-by: rustyeddy <2903425+rustyeddy@users.noreply.github.com> * Final update to PR description Co-authored-by: rustyeddy <2903425+rustyeddy@users.noreply.github.com> * Replace custom nopCloser with stdlib io.NopCloser and use io.ReadCloser Co-authored-by: rustyeddy <2903425+rustyeddy@users.noreply.github.com> * Remove unnecessary nil check in Run defer Co-authored-by: rustyeddy <2903425+rustyeddy@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: rustyeddy <2903425+rustyeddy@users.noreply.github.com> Co-authored-by: Rusty Eddy --- sensors/gtu7.go | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/sensors/gtu7.go b/sensors/gtu7.go index 7413306..0353339 100644 --- a/sensors/gtu7.go +++ b/sensors/gtu7.go @@ -44,7 +44,7 @@ type GTU7Config struct { type GTU7 struct { name string out chan GPSFix - r io.Reader + r io.ReadCloser } func NewGTU7(cfg GTU7Config) *GTU7 { @@ -56,9 +56,14 @@ func NewGTU7(cfg GTU7Config) *GTU7 { cfg.Buf = 16 } - var r io.Reader + var r io.ReadCloser if cfg.Reader != nil { - r = cfg.Reader + // Wrap test reader with io.NopCloser if it doesn't implement io.Closer + if rc, ok := cfg.Reader.(io.ReadCloser); ok { + r = rc + } else { + r = io.NopCloser(cfg.Reader) + } } else { port, err := cfg.Factory.OpenSerial(cfg.Serial) if err != nil { @@ -86,6 +91,9 @@ func (g *GTU7) Descriptor() devices.Descriptor { func (g *GTU7) Run(ctx context.Context) error { defer close(g.out) + defer func() { + _ = g.r.Close() + }() var last GPSFix haveFix := false From dee1215fbc1a4f7e02117c17ff1eafdc09c360ff Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sat, 17 Jan 2026 08:06:46 -0800 Subject: [PATCH 10/17] Fix RMC precedence flags not resetting when data unavailable (#21) * Initial plan * Fix RMC precedence flags to reset when data is unavailable Co-authored-by: rustyeddy <2903425+rustyeddy@users.noreply.github.com> * Complete: Fixed RMC precedence flag reset issue Co-authored-by: rustyeddy <2903425+rustyeddy@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: rustyeddy <2903425+rustyeddy@users.noreply.github.com> Co-authored-by: Rusty Eddy --- sensors/gtu7.go | 4 ++++ sensors/gtu7_test.go | 56 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/sensors/gtu7.go b/sensors/gtu7.go index 0353339..4fc2f4b 100644 --- a/sensors/gtu7.go +++ b/sensors/gtu7.go @@ -138,10 +138,14 @@ func (g *GTU7) Run(ctx context.Context) error { last.SpeedKnots = fix.SpeedKnots last.SpeedMPS = fix.SpeedMPS haveRMCSpeed = true + } else { + haveRMCSpeed = false } if !math.IsNaN(fix.CourseDeg) { last.CourseDeg = fix.CourseDeg haveRMCCourse = true + } else { + haveRMCCourse = false } if fix.Status != "" { last.Status = fix.Status diff --git a/sensors/gtu7_test.go b/sensors/gtu7_test.go index 707213b..d6553fa 100644 --- a/sensors/gtu7_test.go +++ b/sensors/gtu7_test.go @@ -56,6 +56,15 @@ $GPRMC,160446.00,A,3340.34121,N,11800.11332,W,7.25,123.40,160126,,,A*00 } } +func TestGTU7_FallbackToVTGWhenRMCStopsProvidingData(t *testing.T) { + // Scenario: RMC initially provides speed/course, then stops (empty fields). + // VTG should be used for speed/course after RMC stops providing it. + input := ` +$GPGGA,160446.00,3340.34121,N,11800.11332,W,2,08,1.20,11.8,M,-33.1,M,,0000*58 +$GPRMC,160446.00,A,3340.34121,N,11800.11332,W,7.25,123.40,160126,,,A*00 +$GPGGA,160447.00,3340.34121,N,11800.11332,W,2,08,1.20,11.8,M,-33.1,M,,0000*58 +$GPRMC,160447.00,A,3340.34121,N,11800.11332,W,,,160126,,,A*00 +$GPVTG,54.70,T,,M,5.50,N,10.19,K,A*00 func TestGTU7_MalformedSentences(t *testing.T) { tests := []struct { name string @@ -139,6 +148,50 @@ $GPGGA,123519.00,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47 done := make(chan error, 1) go func() { done <- gps.Run(ctx) }() + // First GGA + var fix GPSFix + select { + case fix = <-gps.Out(): + case <-time.After(time.Second): + require.FailNow(t, "timeout on first GGA") + } + + // First RMC with speed/course + select { + case fix = <-gps.Out(): + require.InDelta(t, 7.25, fix.SpeedKnots, 1e-6) + require.InDelta(t, 123.40, fix.CourseDeg, 1e-6) + case <-time.After(time.Second): + require.FailNow(t, "timeout on first RMC") + } + + // Second GGA + select { + case fix = <-gps.Out(): + case <-time.After(time.Second): + require.FailNow(t, "timeout on second GGA") + } + + // Second RMC without speed/course (empty fields) + select { + case fix = <-gps.Out(): + // Speed/course from first RMC should still be in the state + require.InDelta(t, 7.25, fix.SpeedKnots, 1e-6) + case <-time.After(time.Second): + require.FailNow(t, "timeout on second RMC") + } + + // VTG should now update speed/course since RMC stopped providing it + select { + case fix = <-gps.Out(): + cancel() + require.InDelta(t, 5.50, fix.SpeedKnots, 1e-6) + require.InDelta(t, 5.50*0.514444, fix.SpeedMPS, 1e-6) + require.InDelta(t, 54.70, fix.CourseDeg, 1e-6) + case <-time.After(time.Second): + require.FailNow(t, "timeout waiting for VTG to override") + } + select { case fix := <-gps.Out(): cancel() @@ -194,6 +247,9 @@ $GPGGA,160447.00,3340.34122,N,11800.11333,W,2,08,1.20,11.8,M,-33.1,M,,0000*58 case err := <-done: require.NoError(t, err) case <-time.After(time.Second): + require.FailNow(t, "run did not exit") + } +} require.FailNow(t, "run did not exit after cancellation") } From 80a0f222e9057cc14bf10ebc472d449aabe85502 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sat, 17 Jan 2026 08:07:58 -0800 Subject: [PATCH 11/17] [WIP] Update Gtu7 sensor and serial drivers implementation (#22) * Initial plan * Add ReadOnly access to GTU7 Descriptor method Co-authored-by: rustyeddy <2903425+rustyeddy@users.noreply.github.com> * Add ReadOnly access mode to GTU7 GPS sensor descriptor Co-authored-by: rustyeddy <2903425+rustyeddy@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: rustyeddy <2903425+rustyeddy@users.noreply.github.com> --- sensors/gtu7.go | 1 + 1 file changed, 1 insertion(+) diff --git a/sensors/gtu7.go b/sensors/gtu7.go index 4fc2f4b..3f92bcb 100644 --- a/sensors/gtu7.go +++ b/sensors/gtu7.go @@ -86,6 +86,7 @@ func (g *GTU7) Descriptor() devices.Descriptor { Name: g.name, Kind: "gps", ValueType: "GPSFix", + Access: devices.ReadOnly, } } From 541e0df529b94d55d4f9e622c6884632afc0c622 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sat, 17 Jan 2026 08:11:11 -0800 Subject: [PATCH 12/17] Add documentation to GPSFix struct (#25) * Initial plan * docs: add comprehensive documentation for GPSFix struct Co-authored-by: rustyeddy <2903425+rustyeddy@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: rustyeddy <2903425+rustyeddy@users.noreply.github.com> --- sensors/gtu7.go | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/sensors/gtu7.go b/sensors/gtu7.go index 3f92bcb..22fb276 100644 --- a/sensors/gtu7.go +++ b/sensors/gtu7.go @@ -13,6 +13,27 @@ import ( "github.com/rustyeddy/devices/drivers" ) +// GPSFix represents a GPS position fix aggregated from NMEA sentences +// (typically GPGGA, GPRMC, and GPVTG). Fields are populated as data becomes +// available from the sensor. +// +// Position coordinates: +// - Lat, Lon: decimal degrees (negative for South/West) +// - AltMeters: altitude in meters above mean sea level +// +// Quality indicators: +// - Quality: GPS fix quality (0=invalid, 1=GPS fix, 2=DGPS fix, etc.) +// - HDOP: horizontal dilution of precision +// - Satellites: number of satellites in view +// +// Motion data: +// - SpeedKnots: ground speed in knots +// - SpeedMPS: ground speed in meters per second +// - CourseDeg: course over ground in degrees (0-360, true north) +// +// Status fields: +// - Status: RMC status ("A" = Active/valid, "V" = Void/invalid) +// - Date: UTC date in DDMMYY format type GPSFix struct { Lat float64 Lon float64 @@ -25,8 +46,8 @@ type GPSFix struct { SpeedMPS float64 CourseDeg float64 - Status string // RMC: A/V - Date string // DDMMYY + Status string + Date string } type GTU7Config struct { From 148cb8736a73cbff40a17eb3f08eeccdcea52281 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sat, 17 Jan 2026 08:14:02 -0800 Subject: [PATCH 13/17] Add Tags and Attributes to GTU7 Descriptor (#33) * Initial plan * Add Tags and Attributes to GTU7 Descriptor - Added Tags: "gps", "navigation", "location" - Added Attributes: serial port and baud rate information - Added Access: ReadOnly to match other sensor patterns - Store GTU7Config in struct to access serial info in Descriptor - Added comprehensive tests for Descriptor functionality Co-authored-by: rustyeddy <2903425+rustyeddy@users.noreply.github.com> * Completed: Enhanced GTU7 Descriptor with Tags and Attributes Co-authored-by: rustyeddy <2903425+rustyeddy@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: rustyeddy <2903425+rustyeddy@users.noreply.github.com> Co-authored-by: Rusty Eddy --- sensors/gtu7.go | 19 +++++++++++++++---- sensors/gtu7_test.go | 42 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/sensors/gtu7.go b/sensors/gtu7.go index 22fb276..6d574fd 100644 --- a/sensors/gtu7.go +++ b/sensors/gtu7.go @@ -64,6 +64,7 @@ type GTU7Config struct { type GTU7 struct { name string + cfg GTU7Config out chan GPSFix r io.ReadCloser } @@ -103,11 +104,21 @@ func NewGTU7(cfg GTU7Config) *GTU7 { func (g *GTU7) Out() <-chan GPSFix { return g.out } func (g *GTU7) Descriptor() devices.Descriptor { + attrs := make(map[string]string) + if g.cfg.Serial.Port != "" { + attrs["port"] = g.cfg.Serial.Port + } + if g.cfg.Serial.Baud > 0 { + attrs["baud"] = strconv.Itoa(g.cfg.Serial.Baud) + } + return devices.Descriptor{ - Name: g.name, - Kind: "gps", - ValueType: "GPSFix", - Access: devices.ReadOnly, + Name: g.name, + Kind: "gps", + ValueType: "GPSFix", + Access: devices.ReadOnly, + Tags: []string{"gps", "navigation", "location"}, + Attributes: attrs, } } diff --git a/sensors/gtu7_test.go b/sensors/gtu7_test.go index d6553fa..525a67d 100644 --- a/sensors/gtu7_test.go +++ b/sensors/gtu7_test.go @@ -6,9 +6,51 @@ import ( "testing" "time" + "github.com/rustyeddy/devices" + "github.com/rustyeddy/devices/drivers" "github.com/stretchr/testify/require" ) +func TestGTU7_Descriptor(t *testing.T) { + t.Run("with serial config", func(t *testing.T) { + gps := NewGTU7(GTU7Config{ + Name: "test-gps", + Serial: drivers.SerialConfig{ + Port: "/dev/ttyUSB0", + Baud: 9600, + }, + Reader: strings.NewReader(""), + }) + + desc := gps.Descriptor() + require.Equal(t, "test-gps", desc.Name) + require.Equal(t, "gps", desc.Kind) + require.Equal(t, "GPSFix", desc.ValueType) + require.Equal(t, devices.ReadOnly, desc.Access) + require.Contains(t, desc.Tags, "gps") + require.Contains(t, desc.Tags, "navigation") + require.Contains(t, desc.Tags, "location") + require.Equal(t, "/dev/ttyUSB0", desc.Attributes["port"]) + require.Equal(t, "9600", desc.Attributes["baud"]) + }) + + t.Run("without serial config", func(t *testing.T) { + gps := NewGTU7(GTU7Config{ + Name: "test-gps-2", + Reader: strings.NewReader(""), + }) + + desc := gps.Descriptor() + require.Equal(t, "test-gps-2", desc.Name) + require.Equal(t, "gps", desc.Kind) + require.Contains(t, desc.Tags, "gps") + require.Contains(t, desc.Tags, "navigation") + require.Contains(t, desc.Tags, "location") + // Attributes should be empty when no serial config + require.Empty(t, desc.Attributes) + }) +} + func TestGTU7_PrefersRMCOverVTG(t *testing.T) { input := ` $GPGGA,160446.00,3340.34121,N,11800.11332,W,2,08,1.20,11.8,M,-33.1,M,,0000*58 From ca03af45a6b807e8bf6c50f31532d246af1213ed Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sat, 17 Jan 2026 08:15:18 -0800 Subject: [PATCH 14/17] Fix parseLatLon to detect and return strconv.ParseFloat errors (#23) * Initial plan * Fix parseLatLon to check strconv.ParseFloat errors Co-authored-by: rustyeddy <2903425+rustyeddy@users.noreply.github.com> * Improve error message for empty coordinates Co-authored-by: rustyeddy <2903425+rustyeddy@users.noreply.github.com> * Complete parseLatLon error handling improvements Co-authored-by: rustyeddy <2903425+rustyeddy@users.noreply.github.com> * Update sensors/gtu7_test.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: rustyeddy <2903425+rustyeddy@users.noreply.github.com> Co-authored-by: Rusty Eddy Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- sensors/gtu7.go | 13 +++++-- sensors/gtu7_test.go | 89 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 3 deletions(-) diff --git a/sensors/gtu7.go b/sensors/gtu7.go index 6d574fd..84d6096 100644 --- a/sensors/gtu7.go +++ b/sensors/gtu7.go @@ -4,6 +4,7 @@ import ( "bufio" "context" "errors" + "fmt" "io" "math" "strconv" @@ -325,10 +326,16 @@ func stripChecksum(s string) string { func parseLatLon(lat, ns, lon, ew string) (float64, float64, error) { if lat == "" || lon == "" { - return 0, 0, errors.New("empty") + return 0, 0, errors.New("latitude or longitude is empty") + } + la, err := strconv.ParseFloat(lat, 64) + if err != nil { + return 0, 0, fmt.Errorf("parse latitude: %w", err) + } + lo, err := strconv.ParseFloat(lon, 64) + if err != nil { + return 0, 0, fmt.Errorf("parse longitude: %w", err) } - la, _ := strconv.ParseFloat(lat, 64) - lo, _ := strconv.ParseFloat(lon, 64) latDeg := math.Floor(la / 100) latMin := la - latDeg*100 diff --git a/sensors/gtu7_test.go b/sensors/gtu7_test.go index 525a67d..b372f62 100644 --- a/sensors/gtu7_test.go +++ b/sensors/gtu7_test.go @@ -98,6 +98,95 @@ $GPRMC,160446.00,A,3340.34121,N,11800.11332,W,7.25,123.40,160126,,,A*00 } } +func TestParseLatLon(t *testing.T) { + tests := []struct { + name string + lat string + ns string + lon string + ew string + wantLat float64 + wantLon float64 + wantErr bool + }{ + { + name: "valid coordinates - northern hemisphere, western hemisphere", + lat: "3340.34121", + ns: "N", + lon: "11800.11332", + ew: "W", + wantLat: 33.6723535, + wantLon: -118.0018886666667, + wantErr: false, + }, + { + name: "valid coordinates - southern hemisphere, eastern hemisphere", + lat: "3340.34121", + ns: "S", + lon: "11800.11332", + ew: "E", + wantLat: -33.6723535, + wantLon: 118.0018886666667, + wantErr: false, + }, + { + name: "empty latitude", + lat: "", + ns: "N", + lon: "11800.11332", + ew: "W", + wantErr: true, + }, + { + name: "empty longitude", + lat: "3340.34121", + ns: "N", + lon: "", + ew: "W", + wantErr: true, + }, + { + name: "invalid latitude - not a number", + lat: "invalid", + ns: "N", + lon: "11800.11332", + ew: "W", + wantErr: true, + }, + { + name: "invalid longitude - not a number", + lat: "3340.34121", + ns: "N", + lon: "invalid", + ew: "W", + wantErr: true, + }, + { + name: "both coordinates invalid", + lat: "not-a-number", + ns: "N", + lon: "also-invalid", + ew: "W", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + lat, lon, err := parseLatLon(tt.lat, tt.ns, tt.lon, tt.ew) + + if tt.wantErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + const epsilon = 1e-6 + require.InDelta(t, tt.wantLat, lat, epsilon) + require.InDelta(t, tt.wantLon, lon, epsilon) + }) + } +} func TestGTU7_FallbackToVTGWhenRMCStopsProvidingData(t *testing.T) { // Scenario: RMC initially provides speed/course, then stops (empty fields). // VTG should be used for speed/course after RMC stops providing it. From d5c9358ea09dbc72f873e49cae0cd34cd830fd7c Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sat, 17 Jan 2026 08:16:15 -0800 Subject: [PATCH 15/17] Make serial port settle delay configurable (#24) * Initial plan * Make serial port settle delay configurable with documentation Co-authored-by: rustyeddy <2903425+rustyeddy@users.noreply.github.com> * Simplify delay handling logic Co-authored-by: rustyeddy <2903425+rustyeddy@users.noreply.github.com> * Make serial port settle delay configurable Co-authored-by: rustyeddy <2903425+rustyeddy@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: rustyeddy <2903425+rustyeddy@users.noreply.github.com> --- drivers/serial.go | 10 ++++++++++ drivers/serial_linux.go | 14 ++++++++++---- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/drivers/serial.go b/drivers/serial.go index 591f02f..cd62f28 100644 --- a/drivers/serial.go +++ b/drivers/serial.go @@ -3,12 +3,22 @@ package drivers import ( "fmt" "io" + "time" ) // SerialConfig describes how to open a serial port. type SerialConfig struct { Port string Baud int + + // SettleDelay is the time to wait after configuring the serial port + // before returning. This allows the hardware to stabilize after changing + // terminal settings. Some serial devices require a brief pause after + // reconfiguration to avoid data corruption or initialization issues. + // + // If zero, a default of 10ms is used. To disable the delay entirely, + // set to a negative value (e.g., -1). + SettleDelay time.Duration } // SerialPort is an opened serial port. diff --git a/drivers/serial_linux.go b/drivers/serial_linux.go index 818a692..27473db 100644 --- a/drivers/serial_linux.go +++ b/drivers/serial_linux.go @@ -34,7 +34,7 @@ func (LinuxSerialFactory) OpenSerial(cfg SerialConfig) (SerialPort, error) { } }() - if err := configureTermios(fd, cfg.Baud); err != nil { + if err := configureTermios(fd, cfg.Baud, cfg.SettleDelay); err != nil { return nil, err } @@ -63,7 +63,7 @@ func (p *linuxSerialPort) Write(b []byte) (int, error) { return p.file.Write(b) func (p *linuxSerialPort) Close() error { return p.file.Close() } func (p *linuxSerialPort) String() string { return fmt.Sprintf("%s@%d", p.port, p.baud) } -func configureTermios(fd int, baud int) error { +func configureTermios(fd int, baud int, settleDelay time.Duration) error { t, err := unix.IoctlGetTermios(fd, unix.TCGETS) if err != nil { return fmt.Errorf("serial: ioctl TCGETS: %w", err) @@ -95,8 +95,14 @@ func configureTermios(fd int, baud int) error { return fmt.Errorf("serial: ioctl TCSETS: %w", err) } - // Give the line a moment to settle. - time.Sleep(10 * time.Millisecond) + // Apply settle delay if configured. Default to 10ms if not set. + // Hardware may need time to stabilize after terminal reconfiguration. + if settleDelay == 0 { + settleDelay = 10 * time.Millisecond + } + if settleDelay > 0 { + time.Sleep(settleDelay) + } return nil } From 7fd37e56f7dcc4b11af4c882cb7c62130e25bfb2 Mon Sep 17 00:00:00 2001 From: Rusty Eddy Date: Sat, 17 Jan 2026 08:25:25 -0800 Subject: [PATCH 16/17] it compiles again --- sensors/gtu7_test.go | 38 +++++++++++++++++++++----------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/sensors/gtu7_test.go b/sensors/gtu7_test.go index b372f62..d520bd3 100644 --- a/sensors/gtu7_test.go +++ b/sensors/gtu7_test.go @@ -187,15 +187,17 @@ func TestParseLatLon(t *testing.T) { }) } } -func TestGTU7_FallbackToVTGWhenRMCStopsProvidingData(t *testing.T) { - // Scenario: RMC initially provides speed/course, then stops (empty fields). - // VTG should be used for speed/course after RMC stops providing it. - input := ` -$GPGGA,160446.00,3340.34121,N,11800.11332,W,2,08,1.20,11.8,M,-33.1,M,,0000*58 -$GPRMC,160446.00,A,3340.34121,N,11800.11332,W,7.25,123.40,160126,,,A*00 -$GPGGA,160447.00,3340.34121,N,11800.11332,W,2,08,1.20,11.8,M,-33.1,M,,0000*58 -$GPRMC,160447.00,A,3340.34121,N,11800.11332,W,,,160126,,,A*00 -$GPVTG,54.70,T,,M,5.50,N,10.19,K,A*00 + +// func TestGTU7_FallbackToVTGWhenRMCStopsProvidingData(t *testing.T) { +// // Scenario: RMC initially provides speed/course, then stops (empty fields). +// // VTG should be used for speed/course after RMC stops providing it. +// input := ` +// $GPGGA,160446.00,3340.34121,N,11800.11332,W,2,08,1.20,11.8,M,-33.1,M,,0000*58 +// $GPRMC,160446.00,A,3340.34121,N,11800.11332,W,7.25,123.40,160126,,,A*00 +// $GPGGA,160447.00,3340.34121,N,11800.11332,W,2,08,1.20,11.8,M,-33.1,M,,0000*58 +// $GPRMC,160447.00,A,3340.34121,N,11800.11332,W,,,160126,,,A*00 +// $GPVTG,54.70,T,,M,5.50,N,10.19,K,A*00` + func TestGTU7_MalformedSentences(t *testing.T) { tests := []struct { name string @@ -205,7 +207,6 @@ func TestGTU7_MalformedSentences(t *testing.T) { name: "empty lines", input: ` - $GPGGA,160446.00,3340.34121,N,11800.11332,W,2,08,1.20,11.8,M,-33.1,M,,0000*58 `, }, @@ -381,14 +382,15 @@ $GPGGA,160447.00,3340.34122,N,11800.11333,W,2,08,1.20,11.8,M,-33.1,M,,0000*58 require.FailNow(t, "run did not exit") } } - require.FailNow(t, "run did not exit after cancellation") - } - // Drain any remaining messages and verify channel closes - for range gps.Out() { - // drain - } -} +// require.FailNow(t, "run did not exit after cancellation") +// } + +// // Drain any remaining messages and verify channel closes +// for range gps.Out() { +// // drain +// } +// } func TestGTU7_MultiConstellationVariants(t *testing.T) { tests := []struct { @@ -470,6 +472,8 @@ func TestGTU7_MultiConstellationVariants(t *testing.T) { } }) } +} + func TestGTU7_BufferSize(t *testing.T) { t.Run("default buffer size is 16", func(t *testing.T) { gps := NewGTU7(GTU7Config{ From cadbc9ac091a2173c6a819ea6d5b6bc5d1048315 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sat, 17 Jan 2026 08:27:35 -0800 Subject: [PATCH 17/17] Return error from NewGTU7 instead of panicking (#20) * Initial plan * Change NewGTU7 to return error instead of panicking Co-authored-by: rustyeddy <2903425+rustyeddy@users.noreply.github.com> * Return error from NewGTU7 instead of panicking Co-authored-by: rustyeddy <2903425+rustyeddy@users.noreply.github.com> * Add test coverage for NewGTU7 error handling Co-authored-by: rustyeddy <2903425+rustyeddy@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: rustyeddy <2903425+rustyeddy@users.noreply.github.com> Co-authored-by: Rusty Eddy --- sensors/gtu7.go | 6 +++--- sensors/gtu7_test.go | 4 +++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/sensors/gtu7.go b/sensors/gtu7.go index 84d6096..55aae17 100644 --- a/sensors/gtu7.go +++ b/sensors/gtu7.go @@ -70,7 +70,7 @@ type GTU7 struct { r io.ReadCloser } -func NewGTU7(cfg GTU7Config) *GTU7 { +func NewGTU7(cfg GTU7Config) (*GTU7, error) { if cfg.Factory == nil { cfg.Factory = drivers.LinuxSerialFactory{} } @@ -90,7 +90,7 @@ func NewGTU7(cfg GTU7Config) *GTU7 { } else { port, err := cfg.Factory.OpenSerial(cfg.Serial) if err != nil { - panic(err) + return nil, err } r = port } @@ -99,7 +99,7 @@ func NewGTU7(cfg GTU7Config) *GTU7 { name: cfg.Name, out: make(chan GPSFix, cfg.Buf), r: r, - } + }, nil } func (g *GTU7) Out() <-chan GPSFix { return g.out } diff --git a/sensors/gtu7_test.go b/sensors/gtu7_test.go index d520bd3..f853f9e 100644 --- a/sensors/gtu7_test.go +++ b/sensors/gtu7_test.go @@ -2,6 +2,7 @@ package sensors import ( "context" + "errors" "strings" "testing" "time" @@ -58,9 +59,10 @@ $GPVTG,54.70,T,,M,5.50,N,10.19,K,A*00 $GPRMC,160446.00,A,3340.34121,N,11800.11332,W,7.25,123.40,160126,,,A*00 ` - gps := NewGTU7(GTU7Config{ + gps, err := NewGTU7(GTU7Config{ Reader: strings.NewReader(input), }) + require.NoError(t, err) ctx, cancel := context.WithCancel(context.Background()) defer cancel()