Initial working project
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
# pico-clock
|
||||
|
||||
Raspberry Pi Pico app to run a clock on an SSD1306 display
|
||||
Raspberry Pi Pico app to run a clock on an SSD1306 display
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package main
|
||||
|
||||
const (
|
||||
WifiSSID = "MyWifiNetwork"
|
||||
WifiPass = "MyWifiPassword"
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+249
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user