-
Notifications
You must be signed in to change notification settings - Fork 144
Add System app (clock, battery, adjustable timezone) #97
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | | ||
|
|
||
| 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. | ||
| 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
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This matches the established convention in this repo β the |
||
|
|
||
| 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) | ||
There was a problem hiding this comment.
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.