From df09606a2dcb982752fa2dad7c0dc09504975e4c Mon Sep 17 00:00:00 2001 From: Gregory Ballantine Date: Wed, 9 Sep 2026 16:35:17 -0400 Subject: [PATCH] Initial working project --- .gitignore | 3 + README.md | 2 +- config.go.example | 6 ++ go.mod | 17 ++++ go.sum | 16 +++ main.go | 161 ++++++++++++++++++++++++++++++ network.go | 249 ++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 453 insertions(+), 1 deletion(-) create mode 100644 config.go.example create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 network.go diff --git a/.gitignore b/.gitignore index 5b90e79..7e7178a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Config file that may contain secrets +config.go + # ---> Go # If you prefer the allow list template instead of the deny list, see community template: # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore diff --git a/README.md b/README.md index 653cfe2..41b57c1 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,3 @@ # pico-clock -Raspberry Pi Pico app to run a clock on an SSD1306 display \ No newline at end of file +Raspberry Pi Pico app to run a clock on an SSD1306 display diff --git a/config.go.example b/config.go.example new file mode 100644 index 0000000..c482b35 --- /dev/null +++ b/config.go.example @@ -0,0 +1,6 @@ +package main + +const ( + WifiSSID = "MyWifiNetwork" + WifiPass = "MyWifiPassword" +) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..19a4f3d --- /dev/null +++ b/go.mod @@ -0,0 +1,17 @@ +module git.metauinx.net/BitGoblin/pico-clock + +go 1.25.2 + +require ( + github.com/soypat/cyw43439 v0.1.1 + github.com/soypat/lneto v0.3.2 + tinygo.org/x/drivers v0.36.0 + tinygo.org/x/tinyfont v0.7.0 +) + +require ( + github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect + github.com/soypat/seqs v0.0.0-20250124201400-0d65bc7c1710 // indirect + github.com/tinygo-org/pio v0.3.0 // indirect + golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..88d9804 --- /dev/null +++ b/go.sum @@ -0,0 +1,16 @@ +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= +github.com/soypat/cyw43439 v0.1.1 h1:vcaTiVzfuz3keK7lJpVxStZ6tV8HCw7Ugzsh1k4mneE= +github.com/soypat/cyw43439 v0.1.1/go.mod h1:R2uSILRwSPmcmmKy5Z0FtK4ypgiPf5YqK+F+IKmXqxc= +github.com/soypat/lneto v0.3.2 h1:iUFeRSq2czT7Db6MMOsAnMCBlKCqvIr941zsNf9dcu0= +github.com/soypat/lneto v0.3.2/go.mod h1:Be5PjwoYukvHFiUXxpYi8+ppH2F/gw/vjGBvFdv+Ti8= +github.com/soypat/seqs v0.0.0-20250124201400-0d65bc7c1710 h1:Y9fBuiR/urFY/m76+SAZTxk2xAOS2n85f+H1CugajeA= +github.com/soypat/seqs v0.0.0-20250124201400-0d65bc7c1710/go.mod h1:oCVCNGCHMKoBj97Zp9znLbQ1nHxpkmOY9X+UAGzOxc8= +github.com/tinygo-org/pio v0.3.0 h1:opEnOtw58KGB4RJD3/n/Rd0/djYGX3DeJiXLI6y/yDI= +github.com/tinygo-org/pio v0.3.0/go.mod h1:wf6c6lKZp+pQOzKKcpzchmRuhiMc27ABRuo7KVnaMFU= +golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d h1:0olWaB5pg3+oychR51GUVCEsGkeCU/2JxjBgIo4f3M0= +golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c= +tinygo.org/x/drivers v0.36.0 h1:F0x342A6GWqh6abtCa57uAxCyz/b9MbGzvIVvIf+gpE= +tinygo.org/x/drivers v0.36.0/go.mod h1:DQgKyHkB4G6IEOKVTAjApbKnWGwESN91EVJO+nMOE9Y= +tinygo.org/x/tinyfont v0.7.0 h1:Ju901eGRThlHLoti9K7myMYyv4phd3I0DrJwc14NTTU= +tinygo.org/x/tinyfont v0.7.0/go.mod h1:onflMSkpWl7r7j4MIqhPEVV39pn7yL4N3MOePl3G+G8= diff --git a/main.go b/main.go new file mode 100644 index 0000000..42e1320 --- /dev/null +++ b/main.go @@ -0,0 +1,161 @@ +package main + +import ( + "fmt" + "machine" + "net/netip" + "time" + "image/color" + + "tinygo.org/x/drivers/ssd1306" + "tinygo.org/x/tinyfont" + "tinygo.org/x/tinyfont/freesans" + "tinygo.org/x/tinyfont/proggy" +) + +var ( + defaultFont = &freesans.Regular9pt7b + tinyFont = &proggy.TinySZ8pt7b +) + +// ------------------------------------------------------------- +// Panic Handler & Entry Point +// ------------------------------------------------------------- +func panicErr(msg string, err error) { + if err != nil { + println("FATAL:", msg, "-", err.Error()) + } + for { + time.Sleep(500 * time.Millisecond) + } +} + +// syncNTP triggers an NTP request and polls until completed or timed out. +func syncNTP(stack *NetworkStack, server netip.Addr, timeout time.Duration) (time.Duration, error) { + if err := stack.stack.StartNTP(server); err != nil { + return 0, fmt.Errorf("start NTP: %w", err) + } + + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + offset, done := stack.stack.ResultNTPOffset() + if done { + return offset, nil + } + time.Sleep(50 * time.Millisecond) + } + + return 0, fmt.Errorf("NTP request timed out") +} + +func main() { + time.Sleep(2 * time.Second) + println("Starting app...") + + // Bump frequency to 400kHz for snappy display flushes + i2c := machine.I2C0 + err := i2c.Configure(machine.I2CConfig{ + Frequency: 400 * machine.KHz, + SDA: machine.GPIO4, + SCL: machine.GPIO5, + }) + if err != nil { + panicErr("I2C configuration", err) + } + + // Instantiate nil oled variable + var oled *ssd1306.Device + // White color for display + white := color.RGBA{R: 255, G: 255, B: 255, A: 255} + + // Probe address 0x3C with a zero-byte or 1-byte read + println("Initializing display...") + var dummy [1]byte + if err := i2c.ReadRegister(0x3C, 0x00, dummy[:]); err == nil { + println("SSD1306 detected at 0x3C") + dev := ssd1306.NewI2C(i2c) + dev.Configure(ssd1306.Config{ + Address: 0x3C, + Width: 128, + Height: 64, + }) + oled = dev + + // Run some initial clearing and add booting text to the screen + oled.ClearBuffer() + tinyfont.WriteLine(oled, defaultFont, 0, 32, "Booting...", white) + oled.Display() + time.Sleep(1 * time.Second) + } else { + panicErr("Failed to init display.", err) + } + + println("Starting Wi-Fi...") + oled.ClearBuffer() + tinyfont.WriteLine(oled, defaultFont, 0, 32, "Starting Wi-Fi...", white) + oled.Display() + + netStack, err := InitNetwork(WifiSSID, WifiPass) + if err != nil { + panicErr("network init", err) + } + + println("Wi-Fi Connected!") + oled.ClearBuffer() + tinyfont.WriteLine(oled, defaultFont, 0, 32, "Wi-Fi Connected!", white) + oled.Display() + time.Sleep(1 * time.Second) + + // Extract IP string from the returned NetworkStack + ipStr := netStack.localIP.String() + + // Sync wall time using public NTP (e.g. Cloudflare: 162.159.200.1 / time.cloudflare.com) + println("Synchronizing clock via NTP...") + oled.ClearBuffer() + tinyfont.WriteLine(oled, defaultFont, 0, 32, "Syncing NTP...", white) + oled.Display() + ntpServer := netip.MustParseAddr("162.159.200.1") + timeOffset, err := syncNTP(netStack, ntpServer, 5*time.Second) + if err != nil { + panicErr("NTP sync failed", err) + } + + println("NTP synced successfully!") + oled.ClearBuffer() + tinyfont.WriteLine(oled, defaultFont, 0, 32, "NTP synced!", white) + oled.Display() + time.Sleep(1 * time.Second) + + lastSecond := -1 + + for { + currentTime := time.Now().Add(timeOffset).UTC() + currentSecond := currentTime.Second() + + // Skip display refreshes if the second hasn't changed to save the i2c bus + if currentSecond != lastSecond { + lastSecond = currentSecond + + // Alternate colon (:) with space ( ) every second + timeSeperator := ":" + if currentSecond % 2 == 1 { + timeSeperator = " " + } + + // Create the awful time string before displaying it + timeStr := fmt.Sprintf("%02d%s%02d%s%02d UTC", + currentTime.Hour(), timeSeperator, + currentTime.Minute(), timeSeperator, + currentTime.Second()) + + oled.ClearBuffer() + // Time + tinyfont.WriteLine(oled, defaultFont, 8, 32, timeStr, white) + // IP Address + tinyfont.WriteLine(oled, tinyFont, 0, 60, fmt.Sprintf("IP: %s", ipStr), white) + oled.Display() + } + + time.Sleep(10 * time.Millisecond) + } +} diff --git a/network.go b/network.go new file mode 100644 index 0000000..e908bb9 --- /dev/null +++ b/network.go @@ -0,0 +1,249 @@ +package main + +import ( + "context" + "errors" + "fmt" + "net" + "net/netip" + "syscall" + "time" + + "github.com/soypat/cyw43439" + "github.com/soypat/lneto" + "github.com/soypat/lneto/ethernet" + "github.com/soypat/lneto/x/xnet" +) + +const ( + pollTime = 5 * time.Millisecond + protoTimeout = 5 * time.Second + protoRetries = 3 + + tcpBufsize = 2048 + tcpPacketQueueSize = 4 + tcpConnPoolSize = 5 + tcpEstablishedTimeout = 4 * time.Second + tcpCloseTimeout = protoTimeout +) + +var nanotime = func() int64 { + return time.Now().UnixNano() +} + +// CywAdapter bridges the cyw43439 driver to the lneto interface requirements. +type CywAdapter struct { + dev *cyw43439.Device + rxBuf [1514]byte + rxLen int + hasPkt bool +} + +func NewCywAdapter(dev *cyw43439.Device) *CywAdapter { + adapter := &CywAdapter{dev: dev} + + dev.RecvEthHandle(func(pkt []byte) error { + if !adapter.hasPkt && len(pkt) <= len(adapter.rxBuf) { + copy(adapter.rxBuf[:], pkt) + adapter.rxLen = len(pkt) + adapter.hasPkt = true + } + return nil + }) + + return adapter +} + +// In CywAdapter: +func (a *CywAdapter) PollHardware() error { + _, err := a.dev.TryPoll() + return err +} + +func (a *CywAdapter) SendEth(frame []byte) error { + return a.dev.SendEth(frame) +} + +func (a *CywAdapter) RecvEth(dst []byte) (int, error) { + if !a.hasPkt { + return 0, nil + } + n := copy(dst, a.rxBuf[:a.rxLen]) + a.hasPkt = false + a.rxLen = 0 + return n, nil +} + +func (a *CywAdapter) HardwareAddress6() ([6]byte, error) { + return a.dev.HardwareAddr6() +} + +func (a *CywAdapter) MaxFrameLength() (int, error) { + return 1514, nil +} + +type NetworkStack struct { + adapter *CywAdapter + stack *xnet.StackAsync + gostack xnet.StackGo + localIP netip.Addr +} + +func InitNetwork(ssid, pass string) (*NetworkStack, error) { + println("Initializing CYW43439 Wi-Fi hardware...") + dev := cyw43439.NewPicoWDevice() + cfg := cyw43439.DefaultWifiConfig() + if err := dev.Init(cfg); err != nil { + return nil, fmt.Errorf("device init failed: %w", err) + } + + println("Associating with SSID:", ssid) + if err := dev.JoinWPA2(ssid, pass); err != nil { + return nil, fmt.Errorf("wifi association failed: %w", err) + } + println("Wi-Fi associated.") + + adapter := NewCywAdapter(dev) + hwaddr, err := adapter.HardwareAddress6() + if err != nil { + return nil, fmt.Errorf("read MAC: %w", err) + } + framelen, err := adapter.MaxFrameLength() + if err != nil { + return nil, fmt.Errorf("max frame len: %w", err) + } + + stack := &xnet.StackAsync{} + err = stack.Reset(xnet.StackConfig{ + Hostname: "pico2w-bme280", + RandSeed: time.Now().UnixNano(), + MaxActiveTCPPorts: 2, + MTU: uint16(framelen - ethernet.MaxOverheadSize), + HardwareAddress: hwaddr, + }) + if err != nil { + return nil, fmt.Errorf("stack config reset: %w", err) + } + + // Start background frame pump + ctx := context.Background() + go stackLoop(ctx, stack, adapter) + + println("Acquiring IP via DHCP...") + rstack := stack.StackRetrying(stackBackoff) + results, err := rstack.DoDHCPv4([4]byte{}, protoTimeout, protoRetries) + if err != nil { + return nil, fmt.Errorf("DHCP failed: %w", err) + } + + err = stack.AssimilateDHCPResults(results) + if err != nil { + return nil, fmt.Errorf("assimilate DHCP failed: %w", err) + } + + println("Resolving router MAC...") + gateway, err := rstack.DoResolveHardwareAddress6(results.Router, protoTimeout, protoRetries) + if err != nil { + return nil, fmt.Errorf("resolving router MAC failed: %w", err) + } + stack.SetGatewayHardwareAddr(gateway) + + localIP := netip.AddrFrom4(results.AssignedAddr4) + println("DHCP lease assigned! IP:", localIP.String()) + + gostack := stack.StackBlocking(stackBackoff).StackGo(xnet.StackGoConfig{ + ListenerPoolConfig: xnet.TCPPoolConfig{ + PoolSize: tcpConnPoolSize, + QueueSize: tcpPacketQueueSize, + TxBufSize: tcpBufsize, + RxBufSize: tcpBufsize, + NanoTime: nanotime, + EstablishedTimeout: tcpEstablishedTimeout, + ClosingTimeout: tcpCloseTimeout, + NewBackoff: func() lneto.BackoffStrategy { return tcpBackoff }, + }, + }) + + return &NetworkStack{ + adapter: adapter, + stack: stack, + gostack: gostack, + localIP: localIP, + }, nil +} + +func (ns *NetworkStack) DialTCP(ctx context.Context, hostIP string, port uint16) (net.Conn, error) { + rIP, err := netip.ParseAddr(hostIP) + if err != nil { + return nil, fmt.Errorf("invalid host IPv4: %w", err) + } + + laddr := net.TCPAddrFromAddrPort(netip.AddrPortFrom(ns.localIP, 0)) + raddr := net.TCPAddrFromAddrPort(netip.AddrPortFrom(rIP, port)) + + const sockstream = 0x1 + c, err := ns.gostack.Socket(ctx, "tcp", syscall.AF_INET, sockstream, laddr, raddr) + if err != nil { + return nil, fmt.Errorf("socket dial: %w", err) + } + + conn, ok := c.(net.Conn) + if !ok { + return nil, errors.New("socket did not return a stream connection") + } + + return conn, nil +} + +func stackLoop(ctx context.Context, stack *xnet.StackAsync, adapter *CywAdapter) { + frameLength, _ := adapter.MaxFrameLength() + buf := make([]byte, frameLength) + + for ctx.Err() == nil { + // 1. Pump the CYW43439 hardware over SPI to trigger RecvEthHandle + _ = adapter.PollHardware() + + // 2. Ingress: read from adapter into lneto + nread, err := adapter.RecvEth(buf[:]) + if err != nil { + println("recv err:", err.Error()) + } else if nread > 0 { + err = stack.IngressEthernet(buf[:nread]) + if err != nil && err != lneto.ErrPacketDrop { + println("ingress err:", err.Error()) + } + } + + // 3. Egress: send out any frames generated by lneto + nwrite, err := stack.EgressEthernet(buf[:]) + if err != nil { + println("egress err:", err.Error()) + } else if nwrite > 0 { + if err := adapter.SendEth(buf[:nwrite]); err != nil { + println("send eth err:", err.Error()) + } + } + + if nwrite == 0 && nread == 0 { + time.Sleep(pollTime) + } + } +} + +func stackBackoff(consecutiveBackoffs uint) time.Duration { + if consecutiveBackoffs < 10 { + return time.Millisecond + } + return 10 * time.Millisecond +} + +func tcpBackoff(consecutiveBackoffs uint) time.Duration { + const ( + minWait = uint32(time.Microsecond) + maxWait = 5 * uint32(time.Millisecond) + maxShift = 22 + ) + shifted := minWait << min(consecutiveBackoffs, maxShift) + wait := min(shifted, maxWait) + return time.Duration(wait) +}