Compare commits
7 Commits
af8a614480
...
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
| d09369c394 | |||
| 5e00c5c8ed | |||
| 8798092cc8 | |||
| cc96fbccc0 | |||
| e478758c49 | |||
| a06f269d48 | |||
| 9a097b05c6 |
@@ -12,14 +12,20 @@ Made in Go with [TinyGo](https://tinygo.org/).
|
||||
The following hardware is what I used for a deployment:
|
||||
|
||||
* Raspberry Pi Pico 2 W: https://www.amazon.com/dp/B0DRJXPPWL
|
||||
* The **W** is VERY important, as without networking it can't reach a time server!
|
||||
* Also a Raspberry Pi Pico W *should* work too, but I haven't tested it yet
|
||||
* The **W** is VERY important, as without networking it can't reach a time server!
|
||||
* Elegoo SSD1306 LCD: https://www.amazon.com/dp/B0FSRQG23K
|
||||
|
||||
Most (or all?) of these components can be swapped for equivalent components. You just need to make sure the code is compatible with whatever board you choose (not likely), and an SSD1306 display.
|
||||
|
||||
Support for other hardware types may come in the future, but if there's a specific board or display module you're interested in then submit an issue or pull request!
|
||||
|
||||
### Tested On
|
||||
|
||||
This app has been tested to work on the following microcontrollers:
|
||||
|
||||
* Raspberry Pi Pico 2W
|
||||
* Raspberry Pi Pico W
|
||||
|
||||
### Prototyping
|
||||
|
||||
To help with debugging/testing deployments, I also used these components:
|
||||
@@ -34,6 +40,26 @@ To help with debugging/testing deployments, I also used these components:
|
||||
|
||||
* [TinyGo](https://tinygo.org/getting-started/install/) - TinyGo installation guide
|
||||
|
||||
### Configuration
|
||||
|
||||
While **Pico Clock** supports timezones, the TinyGo environment doesn't include the IANA database for timezones. This means that each timezone has to be manually added in the code. *If you would like a timezone added, open an issue and I will look into adding it*.
|
||||
|
||||
Currently the list of supported timezones is:
|
||||
|
||||
* `UTC` - No timezone offset
|
||||
* `US/Eastern` - Dynamic DST timezone for US Eastern; alias `ET`
|
||||
* `US/Central` - Dynamic DST timezone for US Eastern; alias `CT`
|
||||
* `US/Mountain` - Dynamic DST timezone for US Eastern; alias `MT`
|
||||
* `US/Pacific` - Dynamic DST timezone for US Eastern; alias `PT`
|
||||
* `EST` - Static set -5 offset (no DST change).
|
||||
* `EDT` - Static set -4 offset (no DST change).
|
||||
* `CST` - Static set -6 offset (no DST change).
|
||||
* `CDT` - Static set -4 offset (no DST change).
|
||||
* `MST` - Static set -7 offset (no DST change).
|
||||
* `MDT` - Static set -5 offset (no DST change).
|
||||
* `PST` - Static set -8 offset (no DST change).
|
||||
* `PDT` - Static set -7 offset (no DST change).
|
||||
|
||||
### Steps
|
||||
|
||||
1. Clone this git repository `git clone https://git.metaunix.net/BitGoblin/pico-weather`.
|
||||
@@ -42,7 +68,7 @@ To help with debugging/testing deployments, I also used these components:
|
||||
4. Plug in your Pico to a USB port on your PC.
|
||||
5. Wait 2-3 seconds then release the **BOOTSEL** button.
|
||||
6. Flash the program to the Pico: `tinygo flash -target=pico2-w .`. Or `tinygo flash -target=pico-w .`
|
||||
a. **Note**: On Pop!_OS I needed to open the file browser and mount the RP2350 partition manually while tinygo was attempting the flash, otherwise it would fail.
|
||||
1. **Note**: On Pop!_OS I needed to open the file browser and mount the RP2350 partition manually while tinygo was attempting the flash, otherwise it would fail.
|
||||
7. Monitor the app: `tinygo monitor`.
|
||||
|
||||
At this point, you should be able to deploy this. Unplug the Pico, connect your SSD1306 display and power it back on - you should see some messages about it booting up and connecting to Wi-Fi, then it should display the time.
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
package main
|
||||
|
||||
const (
|
||||
// Wi-Fi settings
|
||||
WifiSSID = "MyWifiNetwork"
|
||||
WifiPass = "MyWifiPassword"
|
||||
|
||||
// Network settings
|
||||
Hostname = "pico2w-clock"
|
||||
EnableICMP = true
|
||||
|
||||
// Time settings
|
||||
NTPServer = "pool.ntp.org"
|
||||
ActiveTimezone = "UTC"
|
||||
)
|
||||
|
||||
@@ -109,16 +109,27 @@ func main() {
|
||||
// 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("Resolving NTP server:", NTPServer)
|
||||
oled.ClearBuffer()
|
||||
tinyfont.WriteLine(oled, defaultFont, 0, 32, "Resolving NTP...", white)
|
||||
oled.Display()
|
||||
|
||||
ntpServer, err := resolveHostToIP(netStack, NTPServer, 5*time.Second)
|
||||
if err != nil {
|
||||
panicErr("NTP DNS lookup failed", err)
|
||||
}
|
||||
println("NTP server IP: ", ntpServer.String())
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
println("Synchronizing clock via NTP...")
|
||||
oled.ClearBuffer()
|
||||
tinyfont.WriteLine(oled, defaultFont, 0, 32, "Syncing NTP...", white)
|
||||
oled.Display()
|
||||
|
||||
timeOffset, err := syncNTP(netStack, ntpServer, 5*time.Second)
|
||||
if err != nil {
|
||||
panicErr("NTP sync failed", err)
|
||||
}
|
||||
|
||||
println("NTP synced successfully!")
|
||||
oled.ClearBuffer()
|
||||
@@ -128,30 +139,31 @@ func main() {
|
||||
|
||||
lastSecond := -1
|
||||
|
||||
// Remove static call before the loop, then in the loop:
|
||||
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 {
|
||||
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())
|
||||
// Dynamically determine DST vs Standard time for this second
|
||||
loc, zoneName := ResolveTimezone(ActiveTimezone, currentTime)
|
||||
localTime := currentTime.In(loc)
|
||||
|
||||
timeStr := fmt.Sprintf("%02d%s%02d%s%02d %s",
|
||||
localTime.Hour(), timeSeperator,
|
||||
localTime.Minute(), timeSeperator,
|
||||
localTime.Second(),
|
||||
zoneName)
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
+36
-27
@@ -2,11 +2,8 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/soypat/cyw43439"
|
||||
@@ -115,16 +112,20 @@ func InitNetwork(ssid, pass string) (*NetworkStack, error) {
|
||||
|
||||
stack := &xnet.StackAsync{}
|
||||
err = stack.Reset(xnet.StackConfig{
|
||||
Hostname: "pico2w-bme280",
|
||||
Hostname: Hostname,
|
||||
RandSeed: time.Now().UnixNano(),
|
||||
MaxActiveTCPPorts: 2,
|
||||
MTU: uint16(framelen - ethernet.MaxOverheadSize),
|
||||
HardwareAddress: hwaddr,
|
||||
ICMPQueueLimit: 4,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stack config reset: %w", err)
|
||||
}
|
||||
|
||||
// Enable network pings
|
||||
_ = stack.EnableICMP(EnableICMP)
|
||||
|
||||
// Start background frame pump
|
||||
ctx := context.Background()
|
||||
go stackLoop(ctx, stack, adapter)
|
||||
@@ -172,29 +173,6 @@ func InitNetwork(ssid, pass string) (*NetworkStack, error) {
|
||||
}, 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)
|
||||
@@ -247,3 +225,34 @@ func tcpBackoff(consecutiveBackoffs uint) time.Duration {
|
||||
wait := min(shifted, maxWait)
|
||||
return time.Duration(wait)
|
||||
}
|
||||
|
||||
// resolveHostToIP checks if host is already an IP literal, or queries the DNS server.
|
||||
func resolveHostToIP(stack *NetworkStack, host string, timeout time.Duration) (netip.Addr, error) {
|
||||
// 1. If it's already an IP string (e.g. "162.159.200.1"), parse and return directly
|
||||
if ip, err := netip.ParseAddr(host); err == nil {
|
||||
return ip, nil
|
||||
}
|
||||
|
||||
// 2. Start asynchronous DNS A-record query
|
||||
if err := stack.stack.StartLookupIP(host); err != nil {
|
||||
return netip.Addr{}, fmt.Errorf("start DNS lookup: %w", err)
|
||||
}
|
||||
|
||||
// 3. Poll ResultLookupIP until done or timed out
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
addrs, done, err := stack.stack.ResultLookupIP(host)
|
||||
if done {
|
||||
if err != nil {
|
||||
return netip.Addr{}, fmt.Errorf("DNS lookup error: %w", err)
|
||||
}
|
||||
if len(addrs) == 0 {
|
||||
return netip.Addr{}, fmt.Errorf("no addresses returned for %s", host)
|
||||
}
|
||||
return addrs[0], nil
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
|
||||
return netip.Addr{}, fmt.Errorf("DNS lookup timed out for %s", host)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type TimezoneConfig struct {
|
||||
StandardName string
|
||||
StandardOffset int
|
||||
DaylightName string
|
||||
DaylightOffset int
|
||||
ObservesUSDST bool
|
||||
}
|
||||
|
||||
// Map timezone aliases to standard/daylight offsets
|
||||
var timezones = map[string]TimezoneConfig{
|
||||
// Dynamic US zones (switch automatically between Standard and Daylight)
|
||||
"US/Eastern": {"EST", -5 * 3600, "EDT", -4 * 3600, true},
|
||||
"ET": {"EST", -5 * 3600, "EDT", -4 * 3600, true},
|
||||
"US/Central": {"CST", -6 * 3600, "CDT", -5 * 3600, true},
|
||||
"CT": {"CST", -6 * 3600, "CDT", -5 * 3600, true},
|
||||
"US/Mountain": {"MST", -7 * 3600, "MDT", -6 * 3600, true},
|
||||
"MT": {"MST", -7 * 3600, "MDT", -6 * 3600, true},
|
||||
"US/Pacific": {"PST", -8 * 3600, "PDT", -7 * 3600, true},
|
||||
"PT": {"PST", -8 * 3600, "PDT", -7 * 3600, true},
|
||||
|
||||
// Fixed non-DST zones
|
||||
"UTC": {"UTC", 0, "UTC", 0, false},
|
||||
"EST": {"EST", -5 * 3600, "EST", -5 * 3600, false},
|
||||
"EDT": {"EDT", -4 * 3600, "EDT", -4 * 3600, false},
|
||||
"CST": {"CST", -6 * 3600, "CST", -6 * 3600, false},
|
||||
"CDT": {"CDT", -5 * 3600, "CDT", -5 * 3600, false},
|
||||
"MST": {"MST", -7 * 3600, "MST", -7 * 3600, false},
|
||||
"MDT": {"MDT", -6 * 3600, "MDT", -6 * 3600, false},
|
||||
"PST": {"PST", -8 * 3600, "PST", -8 * 3600, false},
|
||||
"PDT": {"PDT", -7 * 3600, "PDT", -7 * 3600, false},
|
||||
}
|
||||
|
||||
// IsUSDST checks if the given UTC time falls in US Daylight Saving Time
|
||||
// (Second Sunday in March at 02:00 local to first Sunday in November at 02:00 local).
|
||||
func IsUSDST(t time.Time, stdOffsetSec int) bool {
|
||||
month := t.Month()
|
||||
if month < time.March || month > time.November {
|
||||
return false // Dec, Jan, Feb
|
||||
}
|
||||
if month > time.March && month < time.November {
|
||||
return true // Apr through Oct
|
||||
}
|
||||
|
||||
day := t.Day()
|
||||
weekday := int(t.Weekday())
|
||||
|
||||
if month == time.March {
|
||||
// Second Sunday in March is between 8th and 14th
|
||||
prevSunday := day - weekday
|
||||
if prevSunday < 8 {
|
||||
return false
|
||||
}
|
||||
if prevSunday >= 15 {
|
||||
return true
|
||||
}
|
||||
// Transition occurs at 2:00 AM standard local time -> (2 - stdOffsetHours) in UTC
|
||||
stdOffsetHours := stdOffsetSec / 3600
|
||||
switchHourUTC := 2 - stdOffsetHours
|
||||
return weekday != int(time.Sunday) || t.Hour() >= switchHourUTC
|
||||
}
|
||||
|
||||
// November: first Sunday is between 1st and 7th
|
||||
prevSunday := day - weekday
|
||||
if prevSunday < 1 {
|
||||
return true // Before first Sunday
|
||||
}
|
||||
// Fall-back occurs at 2:00 AM daylight local time -> (2 - (stdOffsetHours + 1)) in UTC
|
||||
stdOffsetHours := stdOffsetSec / 3600
|
||||
switchHourUTC := 2 - (stdOffsetHours + 1)
|
||||
return weekday == int(time.Sunday) && t.Hour() < switchHourUTC
|
||||
}
|
||||
|
||||
// ResolveTimezone dynamically determines the *time.Location and label
|
||||
// based on whether the zone observes DST at the given UTC time.
|
||||
func ResolveTimezone(name string, utcNow time.Time) (*time.Location, string) {
|
||||
cfg, ok := timezones[name]
|
||||
if !ok {
|
||||
return time.FixedZone("UTC", 0), "UTC"
|
||||
}
|
||||
|
||||
if cfg.ObservesUSDST && IsUSDST(utcNow, cfg.StandardOffset) {
|
||||
return time.FixedZone(cfg.DaylightName, cfg.DaylightOffset), cfg.DaylightName
|
||||
}
|
||||
return time.FixedZone(cfg.StandardName, cfg.StandardOffset), cfg.StandardName
|
||||
}
|
||||
Reference in New Issue
Block a user