From 7767bab47b242831f28c53a92b27f695c241e9be Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Oct 2025 18:04:29 -0700 Subject: [PATCH 1/6] Add Christmas countdown app with falling snowflakes (#3) * Initial plan * Add Christmas countdown app with falling snowflakes Co-authored-by: atarukun <6511495+atarukun@users.noreply.github.com> * Fix date calculation for leap years and year boundaries Co-authored-by: atarukun <6511495+atarukun@users.noreply.github.com> * Reorder text layout and optimize performance Co-authored-by: atarukun <6511495+atarukun@users.noreply.github.com> * Improve variable naming for clarity in next year calculation Co-authored-by: atarukun <6511495+atarukun@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: atarukun <6511495+atarukun@users.noreply.github.com> --- badge/apps/christmas/__init__.py | 131 +++++++++++++++++++++++++++++++ badge/apps/christmas/icon.png | Bin 0 -> 286 bytes 2 files changed, 131 insertions(+) create mode 100644 badge/apps/christmas/__init__.py create mode 100644 badge/apps/christmas/icon.png diff --git a/badge/apps/christmas/__init__.py b/badge/apps/christmas/__init__.py new file mode 100644 index 0000000..2edfeb4 --- /dev/null +++ b/badge/apps/christmas/__init__.py @@ -0,0 +1,131 @@ +import time +import random +from badgeware import screen, PixelFont, shapes, brushes, io, run, Matrix + +# Christmas colors +BG_COLOR = (10, 20, 40) # Dark blue night sky +TEXT_COLOR = (255, 255, 255) # White text +SNOWFLAKE_COLOR = (240, 248, 255) # Light snow color + +# Pre-create brushes for performance +BG_BRUSH = brushes.color(*BG_COLOR) +TEXT_BRUSH = brushes.color(*TEXT_COLOR) +SNOWFLAKE_BRUSH = brushes.color(*SNOWFLAKE_COLOR) + +# Load font +large_font = PixelFont.load("/system/assets/fonts/ziplock.ppf") +small_font = PixelFont.load("/system/assets/fonts/nope.ppf") + +class Snowflake: + """A single falling snowflake""" + def __init__(self): + self.reset() + + def reset(self): + """Reset snowflake to top of screen with random properties""" + self.x = random.randint(0, 160) + self.y = random.randint(-20, 0) # Start slightly above screen + self.size = random.randint(1, 2) # Small snowflakes + self.speed = random.uniform(0.3, 0.8) # Gentle fall speed + self.drift = random.uniform(-0.2, 0.2) # Slight horizontal drift + + def update(self): + """Update snowflake position""" + self.y += self.speed + self.x += self.drift + + # Reset if fallen off screen + if self.y > 120: + self.reset() + + # Wrap horizontally + if self.x < 0: + self.x = 160 + elif self.x > 160: + self.x = 0 + + def draw(self): + """Draw the snowflake""" + screen.brush = SNOWFLAKE_BRUSH + screen.draw(shapes.circle(int(self.x), int(self.y), self.size)) + +# Create snowflakes +snowflakes = [Snowflake() for _ in range(15)] + +# Base days in each month (non-leap year) +BASE_DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] + +def get_days_until_christmas(): + """Calculate days until next Christmas (Dec 25)""" + now = time.localtime() + current_year = now[0] + current_month = now[1] + current_day = now[2] + + # Determine which Christmas to count down to + christmas_year = current_year + if current_month == 12 and current_day > 25: + # After Christmas, count to next year + christmas_year += 1 + + # Helper function to get days in month for a specific year + def get_days_in_month(year): + days = BASE_DAYS_IN_MONTH.copy() + is_leap = (year % 4 == 0 and year % 100 != 0) or (year % 400 == 0) + if is_leap: + days[1] = 29 + return days + + # Calculate day of year for current date + current_days_in_month = get_days_in_month(current_year) + current_day_of_year = sum(current_days_in_month[:current_month-1]) + current_day + + # Calculate day of year for Christmas + if christmas_year == current_year: + # Christmas this year + christmas_day_of_year = sum(current_days_in_month[:11]) + 25 # December 25 + days_left = christmas_day_of_year - current_day_of_year + else: + # Christmas next year + days_left_this_year = sum(current_days_in_month) - current_day_of_year + # Calculate days from Jan 1 to Christmas in next year + next_year_days = get_days_in_month(christmas_year) + days_jan1_to_christmas = sum(next_year_days[:11]) + 25 # Jan 1 to Dec 25 + days_left = days_left_this_year + days_jan1_to_christmas + + return max(0, days_left) + +def update(): + # Clear screen with dark blue background + screen.brush = BG_BRUSH + screen.clear() + + # Update and draw snowflakes (backdrop) + for snowflake in snowflakes: + snowflake.update() + snowflake.draw() + + # Calculate days until Christmas + days = get_days_until_christmas() + + # Draw countdown + screen.font = large_font + screen.brush = TEXT_BRUSH + + # Main number (at top) + days_text = str(days) + w, h = screen.measure_text(days_text) + screen.text(days_text, 80 - (w // 2), 30) + + # Labels + screen.font = small_font + label = "days until" + w, _ = screen.measure_text(label) + screen.text(label, 80 - (w // 2), 60) + + label2 = "Christmas" + w, _ = screen.measure_text(label2) + screen.text(label2, 80 - (w // 2), 75) + +if __name__ == "__main__": + run(update) diff --git a/badge/apps/christmas/icon.png b/badge/apps/christmas/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..e8f6a5d09787847dd3fc83523b91bd4afd574985 GIT binary patch literal 286 zcmV+(0pb3MP)sAr;;LtucB7)AJ8&8Ugr45BQ+W)UHSVSwl$QtDRvkB^TF5bcBi*BSmR zZ&8Bt@A}*$$`?dgfX}t}O7Gz{gJ2LTbu0bf)hUHlZda!i!Jxux0kYxfmLQAcvxKN1 zx+7xq->v)^hWM^dDF&4{M<`3C#H1E{@j!GC;R1w$2&V=3!XMdiLY5E=BJ}Xz)hWe* z&c_!Icr8G86|zszBOO;f;0+>lF_@v)QWQx+giW#Y4ov-fJutPS`-JGSDAidJ!-m;= k?-SHd^9pC^TR?LF0QN* literal 0 HcmV?d00001 From a7df27672696587899c2513574ad1cdd2c4be3db Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Oct 2025 05:12:05 -0700 Subject: [PATCH 2/6] Fix Christmas countdown using internet time instead of unreliable device clock (#4) * Initial plan * Fix Christmas app to fetch date from internet instead of using local device time Co-authored-by: atarukun <6511495+atarukun@users.noreply.github.com> * Address code review feedback: use HTTPS, proper resource cleanup, and clearer constant Co-authored-by: atarukun <6511495+atarukun@users.noreply.github.com> * Fix cache timing, reduce timeout, add date parsing validation Co-authored-by: atarukun <6511495+atarukun@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: atarukun <6511495+atarukun@users.noreply.github.com> --- badge/apps/christmas/__init__.py | 78 ++++++++++++++++++++++++++++++-- 1 file changed, 74 insertions(+), 4 deletions(-) diff --git a/badge/apps/christmas/__init__.py b/badge/apps/christmas/__init__.py index 2edfeb4..8138d5e 100644 --- a/badge/apps/christmas/__init__.py +++ b/badge/apps/christmas/__init__.py @@ -2,6 +2,13 @@ import random from badgeware import screen, PixelFont, shapes, brushes, io, run, Matrix +try: + from urllib.urequest import urlopen + import json + NETWORK_AVAILABLE = True +except ImportError: + NETWORK_AVAILABLE = False + # Christmas colors BG_COLOR = (10, 20, 40) # Dark blue night sky TEXT_COLOR = (255, 255, 255) # White text @@ -55,12 +62,75 @@ def draw(self): # Base days in each month (non-leap year) BASE_DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] +# Cache for fetched time data +_cached_time = None +_last_fetch_attempt = 0 +FETCH_INTERVAL = 60 * 60 * 1000 # Try to fetch once per hour (in milliseconds) + +def fetch_current_date(): + """ + Fetch current date from worldtimeapi.org + Returns (year, month, day) tuple or None if fetch fails + """ + global _cached_time, _last_fetch_attempt + + if not NETWORK_AVAILABLE: + return None + + # Return cached time if still valid + current_ticks = io.ticks + if _cached_time and (current_ticks - _last_fetch_attempt < FETCH_INTERVAL): + return _cached_time + + # Only update last fetch attempt on an actual attempt (not when returning cached value) + try: + # Use worldtimeapi.org - a free API that doesn't require authentication + # Reduced timeout to 3 seconds to keep badge responsive + response = urlopen("https://worldtimeapi.org/api/timezone/Etc/UTC", timeout=3) + try: + data = response.read() + time_data = json.loads(data) + # datetime format: "2025-10-30T01:23:45.123456+00:00" + datetime_str = time_data.get("datetime", "") + + if datetime_str: + # Parse the date part (YYYY-MM-DD) with validation + try: + if "T" not in datetime_str: + raise ValueError("datetime string missing 'T' separator") + date_part = datetime_str.split("T")[0] + parts = date_part.split("-") + if len(parts) != 3: + raise ValueError("date part does not have three components") + year, month, day = parts + _cached_time = (int(year), int(month), int(day)) + # Only update last fetch attempt after successful parse + _last_fetch_attempt = current_ticks + return _cached_time + except (ValueError, TypeError) as parse_err: + print(f"Failed to parse date from API response: {parse_err}") + finally: + response.close() + except Exception as e: + # Network request failed, will fall back to local time + print(f"Failed to fetch time from internet: {e}") + + return None + def get_days_until_christmas(): """Calculate days until next Christmas (Dec 25)""" - now = time.localtime() - current_year = now[0] - current_month = now[1] - current_day = now[2] + # Try to fetch current date from internet + fetched_date = fetch_current_date() + + if fetched_date: + # Use internet time + current_year, current_month, current_day = fetched_date + else: + # Fall back to local time + now = time.localtime() + current_year = now[0] + current_month = now[1] + current_day = now[2] # Determine which Christmas to count down to christmas_year = current_year From c2b7be53218d5d450bdcf28393f4935217f33490 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Oct 2025 09:37:28 -0700 Subject: [PATCH 3/6] Add current date display to Christmas app with network caching (#8) * Initial plan * Add date display to Christmas app with network caching Co-authored-by: atarukun <6511495+atarukun@users.noreply.github.com> * Fix bare except clause in date formatting Co-authored-by: atarukun <6511495+atarukun@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: atarukun <6511495+atarukun@users.noreply.github.com> --- badge/apps/christmas/__init__.py | 37 ++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/badge/apps/christmas/__init__.py b/badge/apps/christmas/__init__.py index 8138d5e..5519156 100644 --- a/badge/apps/christmas/__init__.py +++ b/badge/apps/christmas/__init__.py @@ -62,6 +62,10 @@ def draw(self): # Base days in each month (non-leap year) BASE_DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] +# Month names (short format) +MONTH_NAMES = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] + # Cache for fetched time data _cached_time = None _last_fetch_attempt = 0 @@ -117,6 +121,33 @@ def fetch_current_date(): return None +def format_date(year, month, day): + """ + Format date as DD MMM YYYY (e.g., 30 Oct 2025) + """ + month_name = MONTH_NAMES[month - 1] if 1 <= month <= 12 else "???" + return f"{day:02d} {month_name} {year}" + +def get_current_date_string(): + """ + Get current date formatted as DD MMM YYYY + Returns formatted string or None if date cannot be determined + """ + # Try to fetch current date from internet + fetched_date = fetch_current_date() + + if fetched_date: + # Use internet time + year, month, day = fetched_date + return format_date(year, month, day) + + # Fall back to local time + try: + now = time.localtime() + return format_date(now[0], now[1], now[2]) + except Exception: + return None + def get_days_until_christmas(): """Calculate days until next Christmas (Dec 25)""" # Try to fetch current date from internet @@ -196,6 +227,12 @@ def update(): label2 = "Christmas" w, _ = screen.measure_text(label2) screen.text(label2, 80 - (w // 2), 75) + + # Display today's date at the bottom + date_string = get_current_date_string() + if date_string: + w, _ = screen.measure_text(date_string) + screen.text(date_string, 80 - (w // 2), 105) if __name__ == "__main__": run(update) From 668b530aed578b44f9c864b38fcb27efaf89f8fe Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Oct 2025 10:23:30 -0700 Subject: [PATCH 4/6] Display "thinking..." state while fetching network time in Christmas app (#9) * Initial plan * Implement thinking state and network retry logic for Christmas app Co-authored-by: atarukun <6511495+atarukun@users.noreply.github.com> * Fix code review comments: clarify return value and update comment Co-authored-by: atarukun <6511495+atarukun@users.noreply.github.com> * Fix: prevent displaying 'thinking...' twice on screen Co-authored-by: atarukun <6511495+atarukun@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: atarukun <6511495+atarukun@users.noreply.github.com> --- badge/apps/christmas/__init__.py | 96 ++++++++++++++++++-------------- 1 file changed, 55 insertions(+), 41 deletions(-) diff --git a/badge/apps/christmas/__init__.py b/badge/apps/christmas/__init__.py index 5519156..87f883b 100644 --- a/badge/apps/christmas/__init__.py +++ b/badge/apps/christmas/__init__.py @@ -69,24 +69,34 @@ def draw(self): # Cache for fetched time data _cached_time = None _last_fetch_attempt = 0 -FETCH_INTERVAL = 60 * 60 * 1000 # Try to fetch once per hour (in milliseconds) +_fetch_success = False +FETCH_INTERVAL = 60 * 60 * 1000 # Try to fetch once per hour (in milliseconds) when successful +RETRY_INTERVAL = 5 * 1000 # Retry every 5 seconds (in milliseconds) when failed def fetch_current_date(): """ Fetch current date from worldtimeapi.org Returns (year, month, day) tuple or None if fetch fails """ - global _cached_time, _last_fetch_attempt + global _cached_time, _last_fetch_attempt, _fetch_success if not NETWORK_AVAILABLE: return None # Return cached time if still valid current_ticks = io.ticks - if _cached_time and (current_ticks - _last_fetch_attempt < FETCH_INTERVAL): + if _cached_time and _fetch_success and (current_ticks - _last_fetch_attempt < FETCH_INTERVAL): return _cached_time - # Only update last fetch attempt on an actual attempt (not when returning cached value) + # Check if we should retry (use shorter interval when failed) + if not _fetch_success and _last_fetch_attempt > 0: + if current_ticks - _last_fetch_attempt < RETRY_INTERVAL: + return None # Return None to indicate we're still waiting to retry + + # Update last fetch attempt timestamp before trying + _last_fetch_attempt = current_ticks + + # Attempt to fetch current date from the API try: # Use worldtimeapi.org - a free API that doesn't require authentication # Reduced timeout to 3 seconds to keep badge responsive @@ -108,16 +118,17 @@ def fetch_current_date(): raise ValueError("date part does not have three components") year, month, day = parts _cached_time = (int(year), int(month), int(day)) - # Only update last fetch attempt after successful parse - _last_fetch_attempt = current_ticks + _fetch_success = True return _cached_time except (ValueError, TypeError) as parse_err: print(f"Failed to parse date from API response: {parse_err}") + _fetch_success = False finally: response.close() except Exception as e: - # Network request failed, will fall back to local time + # Network request failed, will retry on next attempt print(f"Failed to fetch time from internet: {e}") + _fetch_success = False return None @@ -131,7 +142,7 @@ def format_date(year, month, day): def get_current_date_string(): """ Get current date formatted as DD MMM YYYY - Returns formatted string or None if date cannot be determined + Returns formatted string or "thinking..." if date cannot be determined """ # Try to fetch current date from internet fetched_date = fetch_current_date() @@ -141,27 +152,22 @@ def get_current_date_string(): year, month, day = fetched_date return format_date(year, month, day) - # Fall back to local time - try: - now = time.localtime() - return format_date(now[0], now[1], now[2]) - except Exception: - return None + # Return "thinking..." if we don't have a date yet (no fallback to local time) + return "thinking..." def get_days_until_christmas(): - """Calculate days until next Christmas (Dec 25)""" + """ + Calculate days until next Christmas (Dec 25) + Returns None if date cannot be determined from network + """ # Try to fetch current date from internet fetched_date = fetch_current_date() - if fetched_date: - # Use internet time - current_year, current_month, current_day = fetched_date - else: - # Fall back to local time - now = time.localtime() - current_year = now[0] - current_month = now[1] - current_day = now[2] + if not fetched_date: + # Cannot calculate without valid date from network + return None + + current_year, current_month, current_day = fetched_date # Determine which Christmas to count down to christmas_year = current_year @@ -209,28 +215,36 @@ def update(): # Calculate days until Christmas days = get_days_until_christmas() - # Draw countdown screen.font = large_font screen.brush = TEXT_BRUSH - # Main number (at top) - days_text = str(days) - w, h = screen.measure_text(days_text) - screen.text(days_text, 80 - (w // 2), 30) - - # Labels - screen.font = small_font - label = "days until" - w, _ = screen.measure_text(label) - screen.text(label, 80 - (w // 2), 60) - - label2 = "Christmas" - w, _ = screen.measure_text(label2) - screen.text(label2, 80 - (w // 2), 75) + if days is not None: + # We have a valid date - show countdown + # Main number (at top) + days_text = str(days) + w, h = screen.measure_text(days_text) + screen.text(days_text, 80 - (w // 2), 30) + + # Labels + screen.font = small_font + label = "days until" + w, _ = screen.measure_text(label) + screen.text(label, 80 - (w // 2), 60) + + label2 = "Christmas" + w, _ = screen.measure_text(label2) + screen.text(label2, 80 - (w // 2), 75) + else: + # Still fetching date - show "thinking..." + screen.font = small_font + message = "thinking..." + w, _ = screen.measure_text(message) + screen.text(message, 80 - (w // 2), 55) - # Display today's date at the bottom + # Display today's date at the bottom (only if we have a real date, not "thinking...") date_string = get_current_date_string() - if date_string: + if date_string and date_string != "thinking...": + screen.font = small_font w, _ = screen.measure_text(date_string) screen.text(date_string, 80 - (w // 2), 105) From a678758dfc1f2d9db814cd4e21a9d9ddd8ce5dd3 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Oct 2025 10:48:50 -0700 Subject: [PATCH 5/6] Add network configuration to christmas app (#10) * Initial plan * Add network configuration to christmas app following badge app pattern Co-authored-by: atarukun <6511495+atarukun@users.noreply.github.com> * Add explanatory comment for exception handling Co-authored-by: atarukun <6511495+atarukun@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: atarukun <6511495+atarukun@users.noreply.github.com> --- badge/apps/christmas/__init__.py | 128 ++++++++++++++++++++++++++++++- 1 file changed, 126 insertions(+), 2 deletions(-) diff --git a/badge/apps/christmas/__init__.py b/badge/apps/christmas/__init__.py index 87f883b..f9433f7 100644 --- a/badge/apps/christmas/__init__.py +++ b/badge/apps/christmas/__init__.py @@ -1,10 +1,12 @@ import time import random +import sys from badgeware import screen, PixelFont, shapes, brushes, io, run, Matrix try: from urllib.urequest import urlopen import json + import network NETWORK_AVAILABLE = True except ImportError: NETWORK_AVAILABLE = False @@ -73,6 +75,111 @@ def draw(self): FETCH_INTERVAL = 60 * 60 * 1000 # Try to fetch once per hour (in milliseconds) when successful RETRY_INTERVAL = 5 * 1000 # Retry every 5 seconds (in milliseconds) when failed +# Network connection state +WIFI_TIMEOUT = 60 +WIFI_PASSWORD = None +WIFI_SSID = None +wlan = None +connected = False +ticks_start = None + +def get_connection_details(): + """Get WiFi credentials from secrets.py""" + global WIFI_PASSWORD, WIFI_SSID + + if WIFI_SSID is not None: + return True + + try: + sys.path.insert(0, "/") + from secrets import WIFI_PASSWORD, WIFI_SSID + sys.path.pop(0) + except ImportError: + WIFI_PASSWORD = None + WIFI_SSID = None + + if not WIFI_SSID: + return False + + return True + +def wlan_start(): + """Initialize and connect to WiFi network""" + global wlan, ticks_start, connected, WIFI_PASSWORD, WIFI_SSID + + if not NETWORK_AVAILABLE: + return False + + if ticks_start is None: + ticks_start = io.ticks + + if connected: + return True + + if wlan is None: + wlan = network.WLAN(network.STA_IF) + wlan.active(True) + + if wlan.isconnected(): + connected = True + return True + + # attempt to find the SSID by scanning; some APs may be hidden intermittently + try: + ssid_found = False + try: + scans = wlan.scan() + except Exception: + scans = [] + + for s in scans: + # s[0] is SSID (bytes or str) + ss = s[0] + if isinstance(ss, (bytes, bytearray)): + try: + ss = ss.decode("utf-8", "ignore") + except Exception: + ss = str(ss) + if ss == WIFI_SSID: + ssid_found = True + break + + if not ssid_found: + # not found yet; if still within timeout, keep trying on subsequent calls + if io.ticks - ticks_start < WIFI_TIMEOUT * 1000: + # return True to indicate we're still attempting to connect (in-progress) + return True + else: + # timed out + return False + + # SSID is visible; attempt to connect (or re-attempt) + try: + wlan.connect(WIFI_SSID, WIFI_PASSWORD) + except Exception: + # connection initiation failed; we'll retry while still within timeout + if io.ticks - ticks_start < WIFI_TIMEOUT * 1000: + return True + return False + + # update connected state + connected = wlan.isconnected() + + # if connected, return True; otherwise indicate in-progress until timeout + if connected: + return True + if io.ticks - ticks_start < WIFI_TIMEOUT * 1000: + return True + return False + except Exception as e: + # on unexpected errors, don't crash the UI; report and return False + try: + print("wlan_start error:", e) + except Exception: + # Ignore errors in error reporting to avoid crashing the UI + pass + return False + def fetch_current_date(): """ Fetch current date from worldtimeapi.org @@ -83,6 +190,9 @@ def fetch_current_date(): if not NETWORK_AVAILABLE: return None + if not connected: + return None + # Return cached time if still valid current_ticks = io.ticks if _cached_time and _fetch_success and (current_ticks - _last_fetch_attempt < FETCH_INTERVAL): @@ -203,6 +313,8 @@ def get_days_in_month(year): return max(0, days_left) def update(): + global connected + # Clear screen with dark blue background screen.brush = BG_BRUSH screen.clear() @@ -212,6 +324,11 @@ def update(): snowflake.update() snowflake.draw() + # Try to get connection details and start WLAN if network is available + if NETWORK_AVAILABLE: + if get_connection_details(): + wlan_start() + # Calculate days until Christmas days = get_days_until_christmas() @@ -235,9 +352,16 @@ def update(): w, _ = screen.measure_text(label2) screen.text(label2, 80 - (w // 2), 75) else: - # Still fetching date - show "thinking..." + # Still fetching date - show appropriate message screen.font = small_font - message = "thinking..." + if not NETWORK_AVAILABLE: + message = "network unavailable" + elif not get_connection_details(): + message = "no wifi config" + elif not connected: + message = "connecting..." + else: + message = "thinking..." w, _ = screen.measure_text(message) screen.text(message, 80 - (w // 2), 55) From 1258040d559db6fd509b872c55f88fdd49c5cd65 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Oct 2025 12:28:31 -0700 Subject: [PATCH 6/6] Replace worldtimeapi.org HTTP calls with NTP time sync (#13) * Initial plan * Replace worldtimeapi.org with NTP time sync Co-authored-by: atarukun <6511495+atarukun@users.noreply.github.com> * Improve year validation for better future compatibility Co-authored-by: atarukun <6511495+atarukun@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: atarukun <6511495+atarukun@users.noreply.github.com> --- badge/apps/christmas/__init__.py | 138 ++++++++++++++++--------------- 1 file changed, 70 insertions(+), 68 deletions(-) diff --git a/badge/apps/christmas/__init__.py b/badge/apps/christmas/__init__.py index f9433f7..f089152 100644 --- a/badge/apps/christmas/__init__.py +++ b/badge/apps/christmas/__init__.py @@ -4,9 +4,8 @@ from badgeware import screen, PixelFont, shapes, brushes, io, run, Matrix try: - from urllib.urequest import urlopen - import json import network + import ntptime NETWORK_AVAILABLE = True except ImportError: NETWORK_AVAILABLE = False @@ -68,12 +67,11 @@ def draw(self): MONTH_NAMES = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] -# Cache for fetched time data -_cached_time = None -_last_fetch_attempt = 0 -_fetch_success = False -FETCH_INTERVAL = 60 * 60 * 1000 # Try to fetch once per hour (in milliseconds) when successful -RETRY_INTERVAL = 5 * 1000 # Retry every 5 seconds (in milliseconds) when failed +# NTP sync state +_ntp_synced = False +_ntp_sync_attempt = 0 +_last_sync_attempt = 0 +SYNC_RETRY_INTERVAL = 5 * 1000 # Retry NTP sync every 5 seconds (in milliseconds) when failed # Network connection state WIFI_TIMEOUT = 60 @@ -180,67 +178,68 @@ def wlan_start(): pass return False -def fetch_current_date(): +def sync_time_via_ntp(): """ - Fetch current date from worldtimeapi.org - Returns (year, month, day) tuple or None if fetch fails + Sync system time using NTP + Returns True if sync was successful, False otherwise """ - global _cached_time, _last_fetch_attempt, _fetch_success + global _ntp_synced, _ntp_sync_attempt, _last_sync_attempt if not NETWORK_AVAILABLE: - return None + return False if not connected: - return None + return False - # Return cached time if still valid - current_ticks = io.ticks - if _cached_time and _fetch_success and (current_ticks - _last_fetch_attempt < FETCH_INTERVAL): - return _cached_time + # If already synced, don't sync again + if _ntp_synced: + return True - # Check if we should retry (use shorter interval when failed) - if not _fetch_success and _last_fetch_attempt > 0: - if current_ticks - _last_fetch_attempt < RETRY_INTERVAL: - return None # Return None to indicate we're still waiting to retry + # Check if we should retry + current_ticks = io.ticks + if _ntp_sync_attempt > 0: + if current_ticks - _last_sync_attempt < SYNC_RETRY_INTERVAL: + return False # Still waiting to retry - # Update last fetch attempt timestamp before trying - _last_fetch_attempt = current_ticks + # Update last sync attempt timestamp + _last_sync_attempt = current_ticks + _ntp_sync_attempt += 1 - # Attempt to fetch current date from the API try: - # Use worldtimeapi.org - a free API that doesn't require authentication - # Reduced timeout to 3 seconds to keep badge responsive - response = urlopen("https://worldtimeapi.org/api/timezone/Etc/UTC", timeout=3) - try: - data = response.read() - time_data = json.loads(data) - # datetime format: "2025-10-30T01:23:45.123456+00:00" - datetime_str = time_data.get("datetime", "") - - if datetime_str: - # Parse the date part (YYYY-MM-DD) with validation - try: - if "T" not in datetime_str: - raise ValueError("datetime string missing 'T' separator") - date_part = datetime_str.split("T")[0] - parts = date_part.split("-") - if len(parts) != 3: - raise ValueError("date part does not have three components") - year, month, day = parts - _cached_time = (int(year), int(month), int(day)) - _fetch_success = True - return _cached_time - except (ValueError, TypeError) as parse_err: - print(f"Failed to parse date from API response: {parse_err}") - _fetch_success = False - finally: - response.close() + # Sync time via NTP + ntptime.settime() + _ntp_synced = True + print("NTP time sync successful") + return True except Exception as e: - # Network request failed, will retry on next attempt - print(f"Failed to fetch time from internet: {e}") - _fetch_success = False + print(f"NTP sync failed (attempt {_ntp_sync_attempt}): {e}") + return False + +def get_current_date(): + """ + Get current date from system time (after NTP sync) + Returns (year, month, day) tuple or None if time is not synced + """ + if not _ntp_synced: + return None - return None + try: + # Get current time from system + # time.localtime() returns: (year, month, day, hour, minute, second, weekday, yearday) + current_time = time.localtime() + year = current_time[0] + month = current_time[1] + day = current_time[2] + + # Sanity check: if year is unreasonable, NTP didn't actually work + # Valid range: 2025-2100 (the badge was created in 2025) + if year < 2025 or year > 2100: + return None + + return (year, month, day) + except Exception as e: + print(f"Failed to get current date from system time: {e}") + return None def format_date(year, month, day): """ @@ -254,30 +253,30 @@ def get_current_date_string(): Get current date formatted as DD MMM YYYY Returns formatted string or "thinking..." if date cannot be determined """ - # Try to fetch current date from internet - fetched_date = fetch_current_date() + # Get current date from NTP-synced system time + current_date = get_current_date() - if fetched_date: - # Use internet time - year, month, day = fetched_date + if current_date: + # Use synced time + year, month, day = current_date return format_date(year, month, day) - # Return "thinking..." if we don't have a date yet (no fallback to local time) + # Return "thinking..." if we don't have a synced time yet return "thinking..." def get_days_until_christmas(): """ Calculate days until next Christmas (Dec 25) - Returns None if date cannot be determined from network + Returns None if date cannot be determined from NTP-synced time """ - # Try to fetch current date from internet - fetched_date = fetch_current_date() + # Get current date from NTP-synced system time + current_date = get_current_date() - if not fetched_date: - # Cannot calculate without valid date from network + if not current_date: + # Cannot calculate without valid synced date return None - current_year, current_month, current_day = fetched_date + current_year, current_month, current_day = current_date # Determine which Christmas to count down to christmas_year = current_year @@ -328,6 +327,9 @@ def update(): if NETWORK_AVAILABLE: if get_connection_details(): wlan_start() + # Once connected, attempt NTP sync + if connected: + sync_time_via_ntp() # Calculate days until Christmas days = get_days_until_christmas()