diff --git a/go.mod b/go.mod index 8962fcf..59bc2be 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,15 @@ module git.metaunix.net/bitgoblin/pico-time 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 +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 index 751d109..88d9804 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,15 @@ 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= diff --git a/main.go b/main.go index 6852fd5..f059774 100644 --- a/main.go +++ b/main.go @@ -4,28 +4,31 @@ import ( "fmt" "image/color" "machine" + "sync" "time" "tinygo.org/x/drivers/ssd1306" "tinygo.org/x/tinyfont" + "tinygo.org/x/tinyfont/freemono" "tinygo.org/x/tinyfont/proggy" - "tinygo.org/x/tinyfont/freemono" ) var ( white = color.RGBA{R: 255, G: 255, B: 255, A: 255} black = color.RGBA{R: 0, G: 0, B: 0, A: 255} + + mu sync.Mutex + currentTime time.Time ) func main() { - // Configure I2C0 using GP4 (SDA) and GP5 (SCL) at 400kHz + // Configure I2C0 with your pins: GP16 (SDA) and GP17 (SCL) machine.I2C0.Configure(machine.I2CConfig{ Frequency: 400 * machine.KHz, - SDA: machine.GP16, - SCL: machine.GP17, + SDA: machine.GP16, + SCL: machine.GP17, }) - // Initialize the display (default I2C address for SSD1306 is usually 0x3C) display := ssd1306.NewI2C(machine.I2C0) display.Configure(ssd1306.Config{ Address: 0x3C, @@ -33,28 +36,65 @@ func main() { Height: 64, }) - display.ClearDisplay() + // 1. Splash / Connection screen + display.ClearBuffer() + tinyfont.WriteLine(display, &proggy.TinySZ8pt7b, 8, 32, "Connecting Wi-Fi...", white) + display.Display() - // Base time for clock tracking (update to current time or set via NTP) - currentTime := time.Date(2026, time.September, 7, 12, 0, 0, 0, time.UTC) + // 2. Initialize Wi-Fi hardware and lneto stack + netStack, err := InitNetwork(WifiSSID, WifiPass) + if err != nil { + display.ClearBuffer() + tinyfont.WriteLine(display, &proggy.TinySZ8pt7b, 8, 32, "Wi-Fi Error!", white) + display.Display() + println("Wi-Fi init error:", err.Error()) + for {} + } + + // 3. NTP Fetch Status + display.ClearBuffer() + tinyfont.WriteLine(display, &proggy.TinySZ8pt7b, 8, 32, "Syncing NTP...", white) + display.Display() + + syncedTime, err := netStack.FetchNTP(NtpServer) + if err != nil { + println("Initial NTP sync error:", err.Error()) + currentTime = time.Date(2026, time.September, 7, 12, 0, 0, 0, time.UTC) + } else { + currentTime = syncedTime + } + + // 4. Background NTP re-sync routine (every 1 hour) + go func() { + resyncTicker := time.NewTicker(1 * time.Hour) + for range resyncTicker.C { + if t, err := netStack.FetchNTP(NtpServer); err == nil { + mu.Lock() + currentTime = t + mu.Unlock() + } + } + }() + + // 5. 1Hz UI rendering loop ticker := time.NewTicker(1 * time.Second) - for range ticker.C { + mu.Lock() currentTime = currentTime.Add(1 * time.Second) + now := currentTime + mu.Unlock() - // Format time and date strings - timeStr := fmt.Sprintf("%02d:%02d:%02d", currentTime.Hour(), currentTime.Minute(), currentTime.Second()) - dateStr := currentTime.Format("Mon, Jan 02 2006") + // Optional: Apply timezone offset if needed (e.g. now = now.Add(-4 * time.Hour)) + timeStr := fmt.Sprintf("%02d:%02d:%02d", now.Hour(), now.Minute(), now.Second()) + dateStr := now.Format("Mon, Jan 02 2006") - // Clear screen buffer display.ClearBuffer() - // Render text to display buffer: - // WriteLine(target, font, x, y, text, color) + // Top: Date with Proggy tinyfont.WriteLine(display, &proggy.TinySZ8pt7b, 16, 14, dateStr, white) + // Center: Time with Freemono Bold 12pt tinyfont.WriteLine(display, &freemono.Bold12pt7b, 6, 44, timeStr, white) - // Push frame buffer to the OLED panel display.Display() } } diff --git a/network.go b/network.go new file mode 100644 index 0000000..b1cddb7 --- /dev/null +++ b/network.go @@ -0,0 +1,309 @@ +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) +} + +// FetchNTP requests the current time from an NTP server IPv4 address over UDP (port 123). +func (ns *NetworkStack) FetchNTP(serverIP string) (time.Time, error) { + ip, err := netip.ParseAddr(serverIP) + if err != nil || !ip.Is4() { + return time.Time{}, fmt.Errorf("invalid IPv4 address: %w", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), protoTimeout) + defer cancel() + + laddr := net.UDPAddrFromAddrPort(netip.AddrPortFrom(ns.localIP, 0)) + raddr := net.UDPAddrFromAddrPort(netip.AddrPortFrom(ip, 123)) + + const sockDgram = 0x2 + c, err := ns.gostack.Socket(ctx, "udp", syscall.AF_INET, sockDgram, laddr, raddr) + if err != nil { + return time.Time{}, fmt.Errorf("udp socket dial: %w", err) + } + conn, ok := c.(net.Conn) + if !ok { + return time.Time{}, errors.New("socket did not return a net.Conn") + } + defer conn.Close() + + _ = conn.SetDeadline(time.Now().Add(protoTimeout)) + + // Standard NTP request packet (48 bytes, leap indicator 0, version 4, mode 3 client = 0x23) + req := make([]byte, 48) + req[0] = 0x23 + + if _, err := conn.Write(req); err != nil { + return time.Time{}, fmt.Errorf("ntp write error: %w", err) + } + + resp := make([]byte, 48) + n, err := conn.Read(resp) + if err != nil { + return time.Time{}, fmt.Errorf("ntp read error: %w", err) + } + if n < 48 { + return time.Time{}, fmt.Errorf("ntp response too short: %d bytes", n) + } + + // Transmit timestamp seconds are at bytes 40-43 (big-endian) + secs := uint32(resp[40])<<24 | uint32(resp[41])<<16 | uint32(resp[42])<<8 | uint32(resp[43]) + fraction := uint32(resp[44])<<24 | uint32(resp[45])<<16 | uint32(resp[46])<<8 | uint32(resp[47]) + + // NTP epoch begins Jan 1, 1900. Unix epoch begins Jan 1, 1970. + // Difference is 2,208,988,800 seconds (70 years). + const ntpEpochOffset = 2208988800 + if secs < ntpEpochOffset { + return time.Time{}, errors.New("invalid NTP timestamp received") + } + + unixSecs := int64(secs - ntpEpochOffset) + nanos := (int64(fraction) * 1e9) >> 32 + + return time.Unix(unixSecs, nanos), nil +}