From 206db81f5cd8026c39f118a678e2aaf7ae055507 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 16:26:20 +0000 Subject: [PATCH 1/3] Replace periph.io with direct i2c-dev access; drop all dependencies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The driver's entire use of periph.io was opening /dev/i2c-1 and issuing plain writes to a fixed device address — one I2C_SLAVE ioctl plus write(2), which the kernel's i2c-dev interface provides directly. The periph host.Init() call also registered GPIO/SPI/sysfs drivers this project never touches. Talking to i2c-dev directly removes the project's only module dependencies (periph.io/x/conn, periph.io/x/host, and clockwork transitively), shrinking both the supply-chain surface of a binary that users install as a privileged service and the binary itself. The display's command framing and burst-chunk limits, previously exercised only on hardware, are now covered by tests against a fake bus. --- go.mod | 5 -- go.sum | 6 --- internal/st7735/driver.go | 73 +++++++++++++++++++---------- internal/st7735/driver_test.go | 85 ++++++++++++++++++++++++++++++++++ 4 files changed, 134 insertions(+), 35 deletions(-) delete mode 100644 go.sum diff --git a/go.mod b/go.mod index d15e812..50bb4dc 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,3 @@ module github.com/cafedomingo/SKU_RM0004 go 1.26.0 - -require ( - periph.io/x/conn/v3 v3.7.3 - periph.io/x/host/v3 v3.8.5 -) diff --git a/go.sum b/go.sum deleted file mode 100644 index fd46385..0000000 --- a/go.sum +++ /dev/null @@ -1,6 +0,0 @@ -github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= -github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= -periph.io/x/conn/v3 v3.7.3 h1:+8UblkC4omTB1M+jZTvTj3qoxQOTJy0ZRQm8DLUuVzc= -periph.io/x/conn/v3 v3.7.3/go.mod h1:tyV9YaYquOJ2Q2yAL0B5zk9ZvHGsbW56M6y92wjyPDQ= -periph.io/x/host/v3 v3.8.5 h1:g4g5xE1XZtDiGl1UAJaUur1aT7uNiFLMkyMEiZ7IHII= -periph.io/x/host/v3 v3.8.5/go.mod h1:hPq8dISZIc+UNfWoRj+bPH3XEBQqJPdFdx218W92mdc= diff --git a/internal/st7735/driver.go b/internal/st7735/driver.go index 9981d32..438c002 100644 --- a/internal/st7735/driver.go +++ b/internal/st7735/driver.go @@ -4,19 +4,18 @@ import ( "encoding/binary" "fmt" "log/slog" + "os" + "syscall" "time" - - "periph.io/x/conn/v3/i2c" - "periph.io/x/conn/v3/i2c/i2creg" - "periph.io/x/host/v3" ) const ( - i2cBus = "/dev/i2c-1" + i2cBusPath = "/dev/i2c-1" i2cAddress = 0x18 - burstMaxLen = 160 // hardware limit, do NOT increase - burstDelayUS = 450 // empirically tuned at 400kHz - yOffset = 24 // controller is 160x160, our 160x80 starts at row 24 + i2cSlave = 0x0703 // I2C_SLAVE ioctl from + burstMaxLen = 160 // hardware limit, do NOT increase + burstDelayUS = 450 // empirically tuned at 400kHz + yOffset = 24 // controller is 160x160, our 160x80 starts at row 24 regWriteData = 0x00 regBurstWrite = 0x01 @@ -33,32 +32,58 @@ type Display interface { Close() error } +// i2cDev is a write-only connection to a fixed-address I2C device. +type i2cDev interface { + write(p []byte) error + Close() error +} + +// i2cConn talks to an I2C device through the kernel's i2c-dev interface: +// one I2C_SLAVE ioctl to latch the address, then plain write(2) for each +// transaction. +type i2cConn struct { + f *os.File +} + +func openI2C(path string, addr uint8) (*i2cConn, error) { + f, err := os.OpenFile(path, os.O_RDWR, 0) + if err != nil { + return nil, err + } + if _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, f.Fd(), i2cSlave, uintptr(addr)); errno != 0 { + _ = f.Close() + return nil, fmt.Errorf("ioctl I2C_SLAVE 0x%02x: %w", addr, errno) + } + return &i2cConn{f: f}, nil +} + +func (c *i2cConn) write(p []byte) error { + _, err := c.f.Write(p) + return err +} + +func (c *i2cConn) Close() error { + return c.f.Close() +} + type display struct { - dev *i2c.Dev - bus i2c.BusCloser + dev i2cDev logger *slog.Logger } -// NewDisplay initializes the I2C bus and returns a Display backed by the +// NewDisplay opens the I2C bus and returns a Display backed by the // UCTRONICS SKU_RM0004 ST7735 controller at address 0x18. func NewDisplay(logger *slog.Logger) (Display, error) { - if _, err := host.Init(); err != nil { - return nil, fmt.Errorf("st7735: host init: %w", err) - } - - bus, err := i2creg.Open(i2cBus) + dev, err := openI2C(i2cBusPath, i2cAddress) if err != nil { - return nil, fmt.Errorf("st7735: open i2c bus %s: %w", i2cBus, err) + return nil, fmt.Errorf("st7735: open i2c bus %s: %w", i2cBusPath, err) } - - dev := &i2c.Dev{Bus: bus, Addr: i2cAddress} - - return &display{dev: dev, bus: bus, logger: logger}, nil + return &display{dev: dev, logger: logger}, nil } // writeCommand sends a 3-byte I2C command: [register, high, low]. func (d *display) writeCommand(reg, hi, lo byte) { - if err := d.dev.Tx([]byte{reg, hi, lo}, nil); err != nil { + if err := d.dev.write([]byte{reg, hi, lo}); err != nil { d.logger.Warn("i2c write failed", "register", reg, "error", err) } } @@ -90,7 +115,7 @@ func (d *display) burstSend(data []byte) { if chunk > burstMaxLen { chunk = burstMaxLen } - if err := d.dev.Tx(data[offset:offset+chunk], nil); err != nil { + if err := d.dev.write(data[offset : offset+chunk]); err != nil { d.logger.Warn("burst send failed", "offset", offset, "error", err) } offset += chunk @@ -125,5 +150,5 @@ func (d *display) SendFull(pixels []uint16) { // Close releases the I2C bus. func (d *display) Close() error { - return d.bus.Close() + return d.dev.Close() } diff --git a/internal/st7735/driver_test.go b/internal/st7735/driver_test.go index db5cdef..2d981d9 100644 --- a/internal/st7735/driver_test.go +++ b/internal/st7735/driver_test.go @@ -1,9 +1,94 @@ package st7735 import ( + "log/slog" "testing" ) +// fakeI2C records every write so tests can check protocol framing. +type fakeI2C struct { + writes [][]byte + closed bool +} + +func (f *fakeI2C) write(p []byte) error { + cp := make([]byte, len(p)) + copy(cp, p) + f.writes = append(f.writes, cp) + return nil +} + +func (f *fakeI2C) Close() error { + f.closed = true + return nil +} + +func TestSendRegionFraming(t *testing.T) { + fake := &fakeI2C{} + d := &display{dev: fake, logger: slog.Default()} + + // Two rows of 160 pixels starting at y=10: 640 bytes of pixel data, + // which must be split into 160-byte burst chunks. + pixels := make([]uint16, 2*Width) + d.SendRegion(0, 10, Width, 2, pixels) + + want := [][]byte{ + {regXCoord, 0, Width - 1}, // column window + {regYCoord, 10 + yOffset, 11 + yOffset}, // row window with panel offset + {regCharData, 0x00, 0x00}, + {regSync, 0x00, 0x01}, + {regBurstWrite, 0x00, 0x01}, // burst on + } + if len(fake.writes) < len(want) { + t.Fatalf("got %d writes, want at least %d", len(fake.writes), len(want)) + } + for i, w := range want { + got := fake.writes[i] + if len(got) != len(w) { + t.Fatalf("write[%d] = %v, want %v", i, got, w) + } + for j := range w { + if got[j] != w[j] { + t.Errorf("write[%d][%d] = 0x%02X, want 0x%02X", i, j, got[j], w[j]) + } + } + } + + // Pixel bursts: every chunk must respect the hardware limit and the + // total must equal the region's byte size. + burst := fake.writes[len(want) : len(fake.writes)-2] + total := 0 + for i, chunk := range burst { + if len(chunk) > burstMaxLen { + t.Errorf("burst chunk %d is %d bytes, exceeds hardware limit %d", i, len(chunk), burstMaxLen) + } + total += len(chunk) + } + if total != 2*Width*2 { + t.Errorf("burst total = %d bytes, want %d", total, 2*Width*2) + } + + // Trailing writes: burst off, then sync. + last := fake.writes[len(fake.writes)-2:] + if last[0][0] != regBurstWrite || last[0][2] != 0x00 { + t.Errorf("expected burst-off command, got %v", last[0]) + } + if last[1][0] != regSync { + t.Errorf("expected sync command, got %v", last[1]) + } +} + +func TestDisplayClose(t *testing.T) { + fake := &fakeI2C{} + d := &display{dev: fake, logger: slog.Default()} + if err := d.Close(); err != nil { + t.Fatalf("Close() = %v", err) + } + if !fake.closed { + t.Error("Close() did not close the underlying bus") + } +} + func TestPixelsToBytes(t *testing.T) { tests := []struct { name string From 2b69a5f619d86ce74fa9dd7c962016cf2c555fee Mon Sep 17 00:00:00 2001 From: Patrick Sunday <33767+cafedomingo@users.noreply.github.com> Date: Fri, 12 Jun 2026 23:23:28 -0700 Subject: [PATCH 2/3] Tighten TestSendRegionFraming: assert exact write count and full trailing bytes --- internal/st7735/driver_test.go | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/internal/st7735/driver_test.go b/internal/st7735/driver_test.go index 2d981d9..874df6a 100644 --- a/internal/st7735/driver_test.go +++ b/internal/st7735/driver_test.go @@ -54,9 +54,15 @@ func TestSendRegionFraming(t *testing.T) { } } + // Two rows × 160 pixels × 2 bytes = 640 bytes → exactly 4 burst chunks. + wantBurstCount := 4 + if got, want := len(fake.writes), len(want)+wantBurstCount+2; got != want { + t.Fatalf("got %d writes, want %d", got, want) + } + // Pixel bursts: every chunk must respect the hardware limit and the // total must equal the region's byte size. - burst := fake.writes[len(want) : len(fake.writes)-2] + burst := fake.writes[len(want) : len(want)+wantBurstCount] total := 0 for i, chunk := range burst { if len(chunk) > burstMaxLen { @@ -69,12 +75,15 @@ func TestSendRegionFraming(t *testing.T) { } // Trailing writes: burst off, then sync. - last := fake.writes[len(fake.writes)-2:] - if last[0][0] != regBurstWrite || last[0][2] != 0x00 { - t.Errorf("expected burst-off command, got %v", last[0]) + wantTail := [][]byte{ + {regBurstWrite, 0x00, 0x00}, + {regSync, 0x00, 0x01}, } - if last[1][0] != regSync { - t.Errorf("expected sync command, got %v", last[1]) + last := fake.writes[len(want)+wantBurstCount:] + for i, w := range wantTail { + if got := last[i]; len(got) != len(w) || got[0] != w[0] || got[1] != w[1] || got[2] != w[2] { + t.Errorf("trailing write[%d] = %v, want %v", i, got, w) + } } } From 0e3d68f87ec34cc42b683162bff5220d0869becd Mon Sep 17 00:00:00 2001 From: Patrick Sunday <33767+cafedomingo@users.noreply.github.com> Date: Fri, 12 Jun 2026 23:35:00 -0700 Subject: [PATCH 3/3] Use io.WriteCloser instead of custom i2cDev interface --- internal/st7735/driver.go | 18 ++++++------------ internal/st7735/driver_test.go | 4 ++-- 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/internal/st7735/driver.go b/internal/st7735/driver.go index 438c002..c81d5a0 100644 --- a/internal/st7735/driver.go +++ b/internal/st7735/driver.go @@ -3,6 +3,7 @@ package st7735 import ( "encoding/binary" "fmt" + "io" "log/slog" "os" "syscall" @@ -32,12 +33,6 @@ type Display interface { Close() error } -// i2cDev is a write-only connection to a fixed-address I2C device. -type i2cDev interface { - write(p []byte) error - Close() error -} - // i2cConn talks to an I2C device through the kernel's i2c-dev interface: // one I2C_SLAVE ioctl to latch the address, then plain write(2) for each // transaction. @@ -57,9 +52,8 @@ func openI2C(path string, addr uint8) (*i2cConn, error) { return &i2cConn{f: f}, nil } -func (c *i2cConn) write(p []byte) error { - _, err := c.f.Write(p) - return err +func (c *i2cConn) Write(p []byte) (int, error) { + return c.f.Write(p) } func (c *i2cConn) Close() error { @@ -67,7 +61,7 @@ func (c *i2cConn) Close() error { } type display struct { - dev i2cDev + dev io.WriteCloser logger *slog.Logger } @@ -83,7 +77,7 @@ func NewDisplay(logger *slog.Logger) (Display, error) { // writeCommand sends a 3-byte I2C command: [register, high, low]. func (d *display) writeCommand(reg, hi, lo byte) { - if err := d.dev.write([]byte{reg, hi, lo}); err != nil { + if _, err := d.dev.Write([]byte{reg, hi, lo}); err != nil { d.logger.Warn("i2c write failed", "register", reg, "error", err) } } @@ -115,7 +109,7 @@ func (d *display) burstSend(data []byte) { if chunk > burstMaxLen { chunk = burstMaxLen } - if err := d.dev.write(data[offset : offset+chunk]); err != nil { + if _, err := d.dev.Write(data[offset : offset+chunk]); err != nil { d.logger.Warn("burst send failed", "offset", offset, "error", err) } offset += chunk diff --git a/internal/st7735/driver_test.go b/internal/st7735/driver_test.go index 874df6a..ed7b013 100644 --- a/internal/st7735/driver_test.go +++ b/internal/st7735/driver_test.go @@ -11,11 +11,11 @@ type fakeI2C struct { closed bool } -func (f *fakeI2C) write(p []byte) error { +func (f *fakeI2C) Write(p []byte) (int, error) { cp := make([]byte, len(p)) copy(cp, p) f.writes = append(f.writes, cp) - return nil + return len(p), nil } func (f *fakeI2C) Close() error {