7 Commits

Author SHA1 Message Date
gballan d09369c394 [Issue #1] - Added configurable NTPServer option in config.go; also added DNS name resolution so you don't have to use IPs
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2026-09-11 00:39:54 -04:00
gballan 5e00c5c8ed [Issue #2] - Updating README with timezone info
ci/woodpecker/push/woodpecker Pipeline was successful
2026-09-11 00:31:11 -04:00
gballan 8798092cc8 [Issue #2] - Added timezone support with a list of support timezones
ci/woodpecker/push/woodpecker Pipeline was successful
2026-09-11 00:23:24 -04:00
gballan cc96fbccc0 [Issue #10] - Added config option to allow the user to set the hostname
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2026-09-10 23:57:46 -04:00
gballan e478758c49 [Issue #9] - Set network stack to enable ICMP, with a queue limit of 4
ci/woodpecker/push/woodpecker Pipeline was successful
2026-09-10 23:51:41 -04:00
gballan a06f269d48 Removed unused TCP function from network.go
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2026-09-09 17:20:20 -04:00
gballan 9a097b05c6 Updated README with testing info 2026-09-09 17:20:08 -04:00
5 changed files with 197 additions and 50 deletions
+29 -3
View File
@@ -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.
+9
View File
@@ -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"
)
+32 -20
View File
@@ -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
View File
@@ -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)
}
+91
View File
@@ -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
}