Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions badge/apps/system/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# System

A small status + settings screen for the badge.

- **Clock** β€” current local time and date.
- **Battery** β€” charge level and charging / on-battery state.
- **Time sync** β€” fetches UTC over NTP when WiFi is configured and writes it to
the battery-backed PCF85063A RTC, so the clock survives a power cycle even
without WiFi on the next boot.

## Controls

| Button | Action |
|----------|----------------------------|
| **UP** | Timezone +1 hour |
| **DOWN** | Timezone βˆ’1 hour |
| **A** | Re-sync the time over NTP |
Comment on lines +13 to +17

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked the raw bytes (od -c) β€” every row starts with a single |, there's no doubled pipe. The only non-ASCII character is the typographic minus (U+2212) in "Timezone βˆ’1 hour", which GFM renders fine, and the table displays correctly in the Files view. Looks like a false positive, so leaving the README unchanged.


The timezone offset (UTCβˆ’12 … UTC+14) is changed live with **UP** / **DOWN** and
saved automatically via the badge `State` store (`/state/system.json`), then
restored on the next boot. The RTCs always hold UTC; the offset is applied only
for display.
362 changes: 362 additions & 0 deletions badge/apps/system/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,362 @@
import sys
import os

sys.path.insert(0, "/system/apps/system")
os.chdir("/system/apps/system")
Comment on lines +1 to +5

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This matches the established convention in this repo β€” the badge and copilot-loop apps open with the same two lines. MonaOS apps are launched directly by main.py rather than imported as libraries, and these paths need to be set before the module-level badgeware/font loading that follows, so moving them into init() would make this app boot differently from every other app on the device. Keeping it as-is for consistency; happy to follow if the convention changes repo-wide.


from badgeware import io, brushes, shapes, screen, PixelFont, run, State
from badgeware import get_battery_level, is_charging
import time

# machine exists on the badge but not in the desktop simulator.
try:
import machine
except ImportError:
machine = None

# network/ntptime exist on the badge but not in the desktop simulator.
try:
import network
import ntptime
NET_AVAILABLE = True
except ImportError:
NET_AVAILABLE = False

# Battery-backed hardware RTC (PCF85063A on I2C GPIO4/5). Absent in the sim.
try:
import board
from pimoroni_i2c import PimoroniI2C
from pcf85063a import PCF85063A
HW_RTC_AVAILABLE = True
except ImportError:
HW_RTC_AVAILABLE = False

# Fonts
small_font = PixelFont.load("/system/assets/fonts/ark.ppf")
large_font = PixelFont.load("/system/assets/fonts/absolute.ppf")

# Colors
white = brushes.color(235, 245, 255)
phosphor = brushes.color(211, 250, 55)
background = brushes.color(13, 17, 23)
gray = brushes.color(100, 110, 120)
green = brushes.color(46, 160, 67)
red = brushes.color(248, 81, 73)

# Timezone offset from UTC in hours. Change it live with the UP / DOWN buttons;
# the choice is saved automatically and restored on the next boot. The RTCs
# always hold UTC - this offset is applied only for display.
#
# Persistence uses badgeware.State (the same store every other app uses), which
# writes to a writable location off-device (/state/system.json on hardware,
# .badge_state/system.json in the simulator). The app's own folder lives under
# the read-only /system filesystem and can't be written to at runtime.
DEFAULT_TZ_OFFSET = 2
TZ_MIN = -12
TZ_MAX = 14
config = {"tz_offset": DEFAULT_TZ_OFFSET}


def load_config():
"""Restore persisted settings. A missing or corrupt file leaves defaults."""
try:
State.load("system", config)
except (OSError, ValueError):
pass
# The state file lives on a user-visible filesystem - never trust the
# loaded value, or a bad edit crashes the app at every boot.
try:
config["tz_offset"] = max(TZ_MIN, min(TZ_MAX, int(config["tz_offset"])))
except (TypeError, ValueError):
config["tz_offset"] = DEFAULT_TZ_OFFSET


def save_config():
"""Persist settings so the timezone survives a reboot."""
global hw_status
try:
ok = State.save("system", config)
except (OSError, ValueError):
ok = False
if ok is False: # State.save reports failure as False, not an exception
hw_status = "save err"


def tz_label():
"""Human-readable current offset, e.g. 'UTC+02:00' or 'UTC-05:00'."""
return "UTC%+03d:00" % config["tz_offset"]

WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]

# State
WIFI_SSID = None
WIFI_PASSWORD = None
wlan = None
_hw_rtc = None
time_valid = False # do we have a real time to display?
ntp_synced = False # has an NTP sync completed at least once?
ntp_fail_at = None # io.ticks of the last failed NTP attempt
NTP_RETRY_MS = 30000 # wait between failed NTP attempts (the call blocks)
connect_at = 0 # io.ticks of the last wlan.connect() attempt
WIFI_RETRY_MS = 30000 # re-issue connect() if no IP after this long
source = "--" # where the shown time came from: "RTC" or "NTP"
Comment on lines +98 to +104
status = "Starting..." # short network/sync status line
hw_status = "" # hardware RTC write result: "RTC set" / "RTC err"


def load_credentials():
"""Load WiFi credentials from secrets.py once."""
global WIFI_SSID, WIFI_PASSWORD
if WIFI_SSID is not None:
return True
sys.path.insert(0, "/")
try:
from secrets import WIFI_SSID as S, WIFI_PASSWORD as P
WIFI_SSID, WIFI_PASSWORD = S, P
except Exception:
# ImportError when secrets.py is missing, but also e.g. SyntaxError
# from a botched edit - a bad credentials file must not crash the app.
WIFI_SSID, WIFI_PASSWORD = None, None
finally:
sys.path.pop(0)
return WIFI_SSID is not None
Comment on lines +115 to +124


def get_hw_rtc():
"""Lazily build the PCF85063A driver on I2C (GPIO4/5). None if unavailable."""
global _hw_rtc
if not HW_RTC_AVAILABLE:
return None
if _hw_rtc is None:
try:
i2c = PimoroniI2C(sda=board.I2C_SDA, scl=board.I2C_SCL)
_hw_rtc = PCF85063A(i2c)
except Exception:
_hw_rtc = None
return _hw_rtc


def restore_from_hw_rtc():
"""Seed the internal RTC from the battery-backed RTC so the clock is
correct immediately at boot, even with no WiFi/NTP."""
global time_valid, source, status
rtc = get_hw_rtc()
if rtc is None:
return
try:
# PCF85063A: (year, month, day, hour, minute, second, weekday)
y, mo, d, hh, mm, ss, wday = rtc.datetime()
if y >= 2024: # a sane year => it was set before, trust it
# machine.RTC wants (year, month, day, weekday, h, m, s, subsec)
machine.RTC().datetime((y, mo, d, wday, hh, mm, ss, 0))
time_valid = True
source = "RTC"
status = "From RTC"
except Exception:
pass


def set_hw_rtc_from_utc():
"""Write the current internal (UTC) time into the battery-backed RTC."""
global hw_status
rtc = get_hw_rtc()
if rtc is None:
hw_status = "no RTC"
return
try:
t = time.localtime() # internal RTC holds UTC after ntptime.settime()
# PCF85063A: (year, month, day, hour, minute, second, weekday)
rtc.datetime((t[0], t[1], t[2], t[3], t[4], t[5], t[6]))
hw_status = "RTC set"
except Exception:
hw_status = "RTC err"


def have_ip():
"""True only when we actually hold an IP. isconnected() lies on CYW43."""
return wlan is not None and wlan.ifconfig()[0] != "0.0.0.0"


def tick_network():
"""Bring WiFi up and sync time over NTP (once). On success also writes the
battery-backed hardware RTC. Non-blocking-ish."""
global wlan, ntp_synced, time_valid, source, status, ntp_fail_at, connect_at

if ntp_synced:
return

if not NET_AVAILABLE:
status = "No network (sim)"
return

if not load_credentials():
status = "No WiFi config"
return

if wlan is None:
wlan = network.WLAN(network.STA_IF)
wlan.active(True)
if not have_ip():
wlan.connect(WIFI_SSID, WIFI_PASSWORD)
connect_at = io.ticks
status = "Connecting..."
return

if not have_ip():
# Re-issue connect() if the first attempt stalled (e.g. AP was down
# at boot) - but only every WIFI_RETRY_MS; the CYW43 driver resets
# its handshake when connect() is spammed.
elapsed = io.ticks - connect_at
if not (0 <= elapsed < WIFI_RETRY_MS): # negative => ticks wrapped
wlan.connect(WIFI_SSID, WIFI_PASSWORD)
connect_at = io.ticks
status = "Connecting..."
return

# We have an IP - try an NTP sync. settime() blocks for up to a second,
# so after a failure back off instead of freezing the UI every frame.
if ntp_fail_at is not None:
elapsed = io.ticks - ntp_fail_at
if 0 <= elapsed < NTP_RETRY_MS: # negative => ticks wrapped: retry now
return
status = "Syncing time..."
try:
ntptime.settime() # internal RTC <- UTC
set_hw_rtc_from_utc() # battery-backed RTC <- UTC
ntp_synced = True
time_valid = True
source = "NTP"
status = "Synced"
except Exception:
status = "Sync failed"
ntp_fail_at = io.ticks


def init():
# Restore the saved timezone, then show the battery-backed time straight
# away, before networking.
global time_valid, source, status
load_config()
restore_from_hw_rtc()
Comment on lines +237 to +242
if machine is None:
# Desktop simulator: no RTC or NTP, but the host clock is correct.
time_valid = True
source = "SIM"
status = "Host clock"


def center_text(text, y):
w, _ = screen.measure_text(text)
screen.text(text, 80 - (w / 2), y)


def draw_battery(x, y, level, charging):
"""Small battery glyph at (x, y), 26x12, filled by level %."""
# body outline
screen.brush = gray
screen.draw(shapes.rectangle(x, y, 24, 12).stroke(1))
# positive terminal
screen.draw(shapes.rectangle(x + 24, y + 3, 2, 6))
# fill
fill_w = int(20 * max(0, min(100, level)) / 100)
if charging:
screen.brush = green
elif level <= 20:
screen.brush = red
else:
screen.brush = phosphor
if fill_w > 0:
screen.draw(shapes.rectangle(x + 2, y + 2, fill_w, 8))


def update():
tick_network()

# Manual re-sync with button A: re-runs NTP (skipping any backoff) and
# re-writes the hardware RTC.
if io.BUTTON_A in io.pressed:
global ntp_synced, hw_status, ntp_fail_at
ntp_synced = False
hw_status = ""
ntp_fail_at = None

# Adjust the timezone with UP / DOWN and save the change immediately.
if io.BUTTON_UP in io.pressed and config["tz_offset"] < TZ_MAX:
config["tz_offset"] += 1
save_config()
if io.BUTTON_DOWN in io.pressed and config["tz_offset"] > TZ_MIN:
config["tz_offset"] -= 1
save_config()

# Background
screen.brush = background
screen.draw(shapes.rectangle(0, 0, 160, 120))

# Header
screen.font = small_font
screen.brush = phosphor
center_text("System", 3)
screen.brush = gray
screen.draw(shapes.rectangle(5, 13, 150, 1))

# Local time = RTC (UTC) + offset
t = time.localtime(time.time() + config["tz_offset"] * 3600)
weekday = WEEKDAYS[t[6]]
date_str = "%s %02d %s %d" % (weekday, t[2], MONTHS[t[1] - 1], t[0])
time_str = "%02d:%02d:%02d" % (t[3], t[4], t[5])

if not time_valid:
# No reliable time yet - show status instead of a bogus clock.
screen.font = small_font
screen.brush = gray
center_text(status, 38)
center_text("waiting for time...", 50)
else:
# Big clock
screen.font = large_font
screen.brush = white
center_text(time_str, 24)
# Date
screen.font = small_font
screen.brush = gray
center_text(date_str, 48)
# Time source + hardware-RTC status (verifiable on screen)
if ntp_synced:
line = "via %s - %s" % (source, hw_status or "RTC set")
else:
# hw_status carries a config-save error here, if any
line = "via %s - %s" % (source, hw_status or status)
center_text(line, 70)

# Current timezone (the value the UP / DOWN buttons change).
screen.font = small_font
screen.brush = phosphor
center_text(tz_label(), 60)

# Charging / battery row
level = get_battery_level()
charging = is_charging()

screen.font = small_font
draw_battery(22, 82, level, charging)
screen.brush = white
screen.text("%d%%" % level, 54, 84)

if charging:
screen.brush = green
screen.text("Charging", 85, 84)
else:
screen.brush = gray
screen.text("On battery", 85, 84)

# Footer
screen.brush = gray
screen.draw(shapes.rectangle(5, 104, 150, 1))
screen.font = small_font
center_text("A: sync UP/DOWN: zone", 107)


if __name__ == "__main__":
run(update, init=init)
Binary file added badge/apps/system/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.