From 936876c14ed587460fe948d72f31dfb4eef68ecd Mon Sep 17 00:00:00 2001 From: Circuit-Overtime Date: Mon, 18 May 2026 14:27:29 +0530 Subject: [PATCH 1/6] made the apps app module level cached + multi wifi --- apps/launcher/main.py | 79 +++++++++++++++++++++++++++++++++++++------ apps/store/main.py | 9 +++++ oreoOS/config.py | 2 +- 3 files changed, 78 insertions(+), 12 deletions(-) diff --git a/apps/launcher/main.py b/apps/launcher/main.py index b7657fb..4539cb7 100644 --- a/apps/launcher/main.py +++ b/apps/launcher/main.py @@ -257,6 +257,23 @@ def _draw_kind_glyph(d, x, y, kind, ink): _LABEL_CACHE = {} # dir → list[str] (pre-wrapped lines) _ICON_CACHE_KEY = None # (apps_tuple, ICON_SZ) signature to invalidate +# Apps-list cache. list_apps() opens 20+ manifest.json files from flash +# every time the drawer opens (~1-2 s on a busy filesystem) — pointless +# when the on-disk roster hasn't changed since the last open. We bust +# this cache when the Store installs/uninstalls; the drawer's on_enter +# just reads the snapshot. Set to None to force a re-scan on next open. +_APPS_CACHE = None +_CATEGORIES_CACHE = None # (apps_tuple, mode) → [(name, icon, idxs)…] + + +def invalidate_apps_cache(): + """Hook for the Store + Updates apps to drop the cached app list + after an install / uninstall lands. Without this the launcher would + keep showing the pre-install roster until reboot.""" + global _APPS_CACHE, _CATEGORIES_CACHE + _APPS_CACHE = None + _CATEGORIES_CACHE = None + def _rounded_outline(d, x, y, w, h, color, r=CORNER_R): """Outline-only rounded rect with a small chamfered corner.""" @@ -287,26 +304,48 @@ class App(oreoOS.App): SHOW_LOADING = True # ~80 ms upscaling 12 icons from 32→64 at on_enter def on_enter(self, os): + # Segment-timed on_enter — each `_lap()` call prints how many + # milliseconds the previous segment took. Lets us pinpoint which + # piece of the drawer-open path is actually slow on a real boot + # (manifest scan? icon upscale? settings read?) instead of + # guessing from a single "total" number. try: import time as _t - _t0 = _t.ticks_ms() + _t0 = _t.ticks_ms() + _seg = _t0 + def _lap(label, last=[_seg]): + now = _t.ticks_ms() + print("[launcher] %s %d ms" % + (label.ljust(18), _t.ticks_diff(now, last[0]))) + last[0] = now except Exception: _t0 = None + def _lap(label): + pass self._os = os - from oreoOS.launcher import list_apps - # bt + wifi are surfaced through Settings rather than as their - # own drawer tiles — Settings has dedicated rows that launch - # those screens, so a separate tile would just be duplication. DRAWER_HIDDEN = ("launcher", "bt", "wifi", "gestures", "updates") - self._apps = [a for a in list_apps() if a["dir"] not in DRAWER_HIDDEN] + + # Apps-list cache. list_apps() opens every manifest.json from + # flash — ~50-100 ms per file on a busy FS, so 20+ apps was + # the biggest single cost in on_enter. The Store + Updates apps + # call invalidate_apps_cache() when they mutate the roster. + global _APPS_CACHE + if _APPS_CACHE is None: + from oreoOS.launcher import list_apps + _APPS_CACHE = list_apps() + _lap("list_apps (cold)") + else: + _lap("list_apps (warm)") + self._apps = [a for a in _APPS_CACHE if a["dir"] not in DRAWER_HIDDEN] # Icon cache lives at module scope (see _ICON_CACHE above). We # invalidate on (apps_tuple, ICON_SZ) change — adding or removing # an app rebuilds, but re-entering the launcher with the same # roster is instant. global _ICON_CACHE_KEY - cache_key = (tuple(a["dir"] for a in self._apps), ICON_SZ) - if _ICON_CACHE_KEY != cache_key: + cache_key = (tuple(a["dir"] for a in self._apps), ICON_SZ) + icon_cache_hit = (_ICON_CACHE_KEY == cache_key) + if not icon_cache_hit: _ICON_CACHE.clear() _SMALL_ICON_CACHE.clear() _LABEL_CACHE.clear() @@ -329,6 +368,9 @@ def on_enter(self, os): for a in self._apps: _LABEL_CACHE[a["dir"]] = _wrap_label(a["name"]) + _lap("icons (cold)") + else: + _lap("icons (warm)") # Per-instance views into the module cache — keeps the rest of # the draw code untouched. @@ -342,11 +384,25 @@ def on_enter(self, os): # Persisted on the OS settings dict via the Settings app. self._mode = "categories" if ( os.settings_get("app_view", "grid") == "categories") else "grid" + _lap("settings_get") # Category-mode state machine: # _cat_level 0 = picker (5 vertical tiles) # _cat_level 1 = grid of apps inside the selected category - self._categories = self._build_categories() if self._mode == "categories" else [] + # Categories are pure-Python and derive from self._apps — cache + # them keyed by the apps roster so they only rebuild when the + # roster actually changes. + global _CATEGORIES_CACHE + if self._mode == "categories": + roster_key = tuple(a["dir"] for a in self._apps) + if _CATEGORIES_CACHE is not None and _CATEGORIES_CACHE[0] == roster_key: + self._categories = _CATEGORIES_CACHE[1] + else: + self._categories = self._build_categories() + _CATEGORIES_CACHE = (roster_key, self._categories) + else: + self._categories = [] + _lap("categories") self._cat_level = 0 self._cat_sel = 0 # picker selection (in level 0) @@ -365,12 +421,13 @@ def on_enter(self, os): # app brought us back here. Done last so it overrides the # fresh-state defaults set above. self._try_restore_resume_ctx() + _lap("resume_ctx") try: if _t0 is not None: - print("[launcher] on_enter done in %d ms (cached=%s)" + print("[launcher] on_enter TOTAL %d ms (icons_cache_hit=%s)" % (_t.ticks_diff(_t.ticks_ms(), _t0), - _ICON_CACHE_KEY is not None and len(_ICON_CACHE) > 0)) + icon_cache_hit)) except Exception: pass diff --git a/apps/store/main.py b/apps/store/main.py index 24af323..41c4ec1 100644 --- a/apps/store/main.py +++ b/apps/store/main.py @@ -140,6 +140,15 @@ def _toggle_install(self, name_dir): else: ok = store.install(name_dir) self._msg = "Installed" if ok else "Install failed" + # The drawer caches its app list at module scope — invalidate + # it so the newly-installed (or just-removed) app shows up on + # the next drawer open instead of waiting for a reboot. + if ok: + try: + from apps.launcher.main import invalidate_apps_cache + invalidate_apps_cache() + except Exception: + pass self._busy = None self._dirty = True diff --git a/oreoOS/config.py b/oreoOS/config.py index 96b87fa..e180150 100644 --- a/oreoOS/config.py +++ b/oreoOS/config.py @@ -18,7 +18,7 @@ def _load_env(): # OS version. tools/deploy.py auto-bumps the PATCH number on every push. # The literal MUST stay on its own line as `VERSION = "vN.N.N"` — the # deploy regex relies on that exact format to rewrite in place. -VERSION = "v1.4.30" +VERSION = "v1.4.33" # ISO-date stamp of the current VERSION. Updated by tools/release.py # (or by hand for hot-fix builds). Shown on the Updates page as the # "Latest stable as of …" line when no newer release is available. From ac0e40f6fd22ac7dc0e9233aaf3c1d2b8694efca Mon Sep 17 00:00:00 2001 From: Circuit-Overtime Date: Mon, 18 May 2026 14:29:52 +0530 Subject: [PATCH 2/6] updated the gestures to loose the scanning at the imu init --- oreoOS/gestures.py | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/oreoOS/gestures.py b/oreoOS/gestures.py index 03fefe9..dd92c75 100644 --- a/oreoOS/gestures.py +++ b/oreoOS/gestures.py @@ -61,22 +61,35 @@ def get(os_obj): """Singleton accessor. Lazily detects the IMU. Returns None if no - chip on the bus — caller treats absence as "gestures disabled".""" + chip on the bus — caller treats absence as "gestures disabled". + + CRITICAL: a negative detect (no IMU on bus) MUST be cached. The OS + run loop calls this every frame; without the negative cache, + `_imu_mod.detect()` re-runs a full `i2c.scan()` (~50-100 ms blocking + on 100 kHz) every frame, pinning the CPU and starving the UI/ + network. On a breadboard build without the MPU6050 wired, that's + the whole boot suddenly feeling like molasses. + """ g = getattr(os_obj, "_gestures", None) if g is not None: return g - imu = getattr(os_obj, "_imu", None) - if imu is None: + # _imu can be: + # missing attr → never tried + # None → tried, no IMU (negative cache; don't retry) + # → tried, present + if hasattr(os_obj, "_imu"): + imu = os_obj._imu + else: try: from oreoWare import imu as _imu_mod imu = _imu_mod.detect() except Exception: imu = None - if imu is not None: - try: - os_obj._imu = imu - except Exception: - pass + # Cache the result either way so we don't re-probe the bus. + try: + os_obj._imu = imu + except Exception: + pass if imu is None: return None g = Gestures(os_obj, imu) From 52b0b61af0fcf1cf8ce5165a0cdc5d91db52f8de Mon Sep 17 00:00:00 2001 From: Circuit-Overtime Date: Mon, 18 May 2026 14:31:34 +0530 Subject: [PATCH 3/6] Strip gesture polling + dispatch + boot seeding from OS --- oreoOS/launcher.py | 91 +++++----------------------------------------- 1 file changed, 10 insertions(+), 81 deletions(-) diff --git a/oreoOS/launcher.py b/oreoOS/launcher.py index f1eeb7c..7d26cb9 100644 --- a/oreoOS/launcher.py +++ b/oreoOS/launcher.py @@ -328,24 +328,13 @@ def run_app(os_obj, app): except Exception: pass - # Gesture engine — early-exits when all toggles are off, so - # the cost on the disabled path is one dict lookup per frame. - # When events fire we dispatch them globally: HARD_SHAKE - # paints a mascot splash, FLIP_UP runs the user's chosen - # quick action, TAP / DOUBLE_TAP route to the current app's - # `on_gesture` hook if it has one. - try: - from oreoOS import gestures as _g - gs = _g.get(os_obj) - if gs: - gs.tick() - while True: - ev = gs.pop_event() - if not ev: - break - _dispatch_gesture(os_obj, app, ev) - except Exception: - pass + # Gestures are NOT polled in the OS run loop. The IMU is + # opt-in per app: an app that wants tilt / tap / shake + # imports oreoOS.gestures itself and ticks the engine on + # its own update(). Polling here previously cost ~50-100 ms + # per frame on a breadboard without the IMU wired because + # i2c.scan ran every iteration — now apps without an IMU + # need pay nothing. elapsed = time.ticks_diff(time.ticks_ms(), now) if elapsed < FRAME_MIN_MS: @@ -355,61 +344,6 @@ def run_app(os_obj, app): gc.collect() -# ── gesture dispatch ────────────────────────────────────────────────────────── - -def _dispatch_gesture(os_obj, app, ev): - """Route a gesture event from oreoOS.gestures into the right surface. - - Global-effect gestures (HARD_SHAKE, FLIP_UP) are handled here at the - OS layer so any app inherits them. App-local gestures (TAP, - DOUBLE_TAP) are forwarded to the current app's on_gesture(kind) - hook if one exists; absent hook = silent ignore. - """ - kind = ev.get("kind", "") - if kind == "hard_shake": - try: - from oreoOS.splash import show_shake_mascot - show_shake_mascot(os_obj) - except Exception: - pass - # Force a full repaint of the current app so the splash - # doesn't leave its frame behind. - try: - app._dirty = True - except Exception: - pass - return - if kind == "flip_up": - action = ev.get("action", "drawer") - try: - if action == "drawer": - os_obj.launch("__appmenu__") - elif action == "notifs": - from oreoOS import notif_panel as _np - _np.get(os_obj).toggle() - elif action == "wifi": - from oreoWare import wifi as _w - if _w.is_connected(): - _w.disconnect() - else: - _w.connect_from_config() - elif action == "bt": - from oreoWare import bt as _b - _b.set_active(not _b.is_active()) - # "camera" left as a reserved placeholder until we wire IR - # quest capture in. Silent no-op for now. - except Exception: - pass - return - # TAP / DOUBLE_TAP — hand to the current app's gesture hook. - hook = getattr(app, "on_gesture", None) - if hook is not None: - try: - hook(kind) - except Exception: - pass - - # ── crash screen ────────────────────────────────────────────────────────────── def show_crash(os_obj, name, err): @@ -617,14 +551,9 @@ def boot(): except Exception: os_obj._boot_ts_s = 0 - # Seed gesture-related Settings keys so the Settings rows display - # sensible values on first boot. Pure dictionary writes — no IMU - # touch yet, so this is cheap. - try: - from oreoOS import gestures as _g - _g.push_default_settings(os_obj) - except Exception: - pass + # Gestures intentionally NOT seeded at the OS layer — the IMU stack + # is opt-in per app now. The Gestures settings page (`apps/gestures`) + # imports oreoOS.gestures itself when the user opens it. _bc("checking pending OTA") applied = _maybe_apply_ota(os_obj) From 648478ebb59904f2a6b38e19acf8126e581bca69 Mon Sep 17 00:00:00 2001 From: Circuit-Overtime Date: Mon, 18 May 2026 14:33:18 +0530 Subject: [PATCH 4/6] smoke test deploy --- oreoOS/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oreoOS/config.py b/oreoOS/config.py index e180150..ed0268a 100644 --- a/oreoOS/config.py +++ b/oreoOS/config.py @@ -18,7 +18,7 @@ def _load_env(): # OS version. tools/deploy.py auto-bumps the PATCH number on every push. # The literal MUST stay on its own line as `VERSION = "vN.N.N"` — the # deploy regex relies on that exact format to rewrite in place. -VERSION = "v1.4.33" +VERSION = "v1.4.34" # ISO-date stamp of the current VERSION. Updated by tools/release.py # (or by hand for hot-fix builds). Shown on the Updates page as the # "Latest stable as of …" line when no newer release is available. From fd914263556bf5337b6a2d20abf546e82288585d Mon Sep 17 00:00:00 2001 From: Circuit-Overtime Date: Mon, 18 May 2026 14:35:44 +0530 Subject: [PATCH 5/6] fixed left over launcher error --- apps/launcher/main.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/launcher/main.py b/apps/launcher/main.py index 4539cb7..a551135 100644 --- a/apps/launcher/main.py +++ b/apps/launcher/main.py @@ -314,9 +314,11 @@ def on_enter(self, os): _t0 = _t.ticks_ms() _seg = _t0 def _lap(label, last=[_seg]): + # MicroPython's str has no .ljust; manual pad instead. + pad = label if len(label) >= 18 else label + " " * (18 - len(label)) now = _t.ticks_ms() print("[launcher] %s %d ms" % - (label.ljust(18), _t.ticks_diff(now, last[0]))) + (pad, _t.ticks_diff(now, last[0]))) last[0] = now except Exception: _t0 = None From cbea41b32f6284eb22a033b444c9a1f3a9a9485f Mon Sep 17 00:00:00 2001 From: Circuit-Overtime Date: Mon, 18 May 2026 14:43:02 +0530 Subject: [PATCH 6/6] speed test and multi wifi setup --- apps/wifi/main.py | 252 ++++++++++++++++++++++++++++++++----- oreoOS/config.py | 2 +- oreoOS/ota.py | 17 ++- oreoOS/widgets.py | 90 ++++++++++++++ oreoWare/wifi.py | 308 ++++++++++++++++++++++++++++++++++++++++++++-- 5 files changed, 624 insertions(+), 45 deletions(-) diff --git a/apps/wifi/main.py b/apps/wifi/main.py index c3d3098..09a998a 100644 --- a/apps/wifi/main.py +++ b/apps/wifi/main.py @@ -1,14 +1,18 @@ """WiFi — phone-style detail screen. -Replaces the two-row WiFi/IP entries in Settings. Shows live status, -DHCP-assigned IP / subnet / gateway / DNS, RSSI bars, and a single -"power mode" cycle (off / eco / balanced / max) that bundles TX power -and power-save state together. - -Controls: - UP/DOWN pick a row - A toggle (status) / cycle (power mode) / disconnect (IP row) - HOME back to Settings +Two modes: + + main : the per-row info screen (status / IP / power / auto-connect / + saved-networks / speed / ping). UP/DOWN walks rows, A toggles + or drills in. + nets : the saved-networks sub-page. UP/DOWN walks entries, A connects + to the focused entry (and promotes it to priority 1), B removes + it, LEFT toggles its `metered` flag. HOME/B exits the sub-page + back to main. + +Saved networks live in `/wifi.json` on flash, managed by oreoWare.wifi. +The first network from .env is auto-seeded into /wifi.json on first +boot via the wifi module's bootstrap path. """ import oreoOS @@ -21,12 +25,8 @@ ROW_H = 22 ROW_PAD_X = 12 ROW_TOP_Y = widgets.HEADER_H + 6 -VALUE_X = 138 # left edge of the right-aligned value column -AUTOCON_GAP = 8 # extra breathing room above the Auto-connect row - # so it reads as a separate group from the live - # link-info rows above it - -# Power-mode helpers — bound at on_enter when oreoWare.wifi is in hand. +VALUE_X = 138 +AUTOCON_GAP = 8 POWER_LABELS = { "off": "Off", @@ -44,7 +44,6 @@ def _bars(rssi_dbm): - """Map RSSI to a 4-cell bar string. -50 dBm or stronger = full.""" if rssi_dbm is None: return "----" if rssi_dbm >= -50: return "####" @@ -60,10 +59,10 @@ class App(oreoOS.App): SHOW_LOADING = True # Logical row indices — keeps cycle code readable when we re-order. - ROW_STATUS, ROW_SSID, ROW_IP, ROW_SUBNET, ROW_GATEWAY, \ - ROW_RSSI, ROW_POWER, ROW_AUTOCONNECT = range(8) - ROWS = (ROW_STATUS, ROW_SSID, ROW_IP, ROW_SUBNET, ROW_GATEWAY, - ROW_RSSI, ROW_POWER, ROW_AUTOCONNECT) + (ROW_STATUS, ROW_SSID, ROW_IP, ROW_RSSI, ROW_POWER, + ROW_NETS, ROW_SPEED, ROW_PING, ROW_AUTOCONNECT) = range(9) + ROWS = (ROW_STATUS, ROW_SSID, ROW_IP, ROW_RSSI, ROW_POWER, + ROW_NETS, ROW_SPEED, ROW_PING, ROW_AUTOCONNECT) def on_enter(self, os_): super().on_enter(os_) @@ -83,6 +82,18 @@ def on_enter(self, os_): self._poll_dt = 0.2 self._snap = self._read() + # Sub-page state + self._mode = "main" # "main" | "nets" + self._nets = [] # cached saved-networks list + self._nets_sel = 0 + + # One-shot result strings shown under their respective rows. + # `_speed_label` and `_ping_label` are set after a test runs; + # they survive until the row's value re-renders normally. + self._speed_label = "" + self._ping_label = "" + self._busy = "" # "speed" | "ping" while a test runs + # ── data ──────────────────────────────────────────────────────────── def _read(self): if not self._wifi: @@ -106,8 +117,21 @@ def _autoconnect(self): except Exception: return True + def _reload_nets(self): + try: + self._nets = self._wifi.list_saved() if self._wifi else [] + except Exception: + self._nets = [] + if self._nets_sel >= len(self._nets): + self._nets_sel = max(0, len(self._nets) - 1) + # ── input ─────────────────────────────────────────────────────────── def on_button_press(self, btn): + if self._mode == "nets": + return self._on_button_nets(btn) + return self._on_button_main(btn) + + def _on_button_main(self, btn): if btn == api.BTN_HOME: self._os.quit() return @@ -121,6 +145,33 @@ def on_button_press(self, btn): return self._dirty = True + def _on_button_nets(self, btn): + if btn in (api.BTN_HOME, api.BTN_B) and not self._nets: + # Empty list — only escape is back out. + self._mode = "main" + self._dirty = True + return + if btn == api.BTN_HOME: + self._mode = "main" + self._dirty = True + return + n = len(self._nets) + if n == 0: + return + if btn == api.BTN_UP: + self._nets_sel = (self._nets_sel - 1) % n + elif btn == api.BTN_DOWN: + self._nets_sel = (self._nets_sel + 1) % n + elif btn == api.BTN_A: + self._connect_saved(self._nets[self._nets_sel]) + elif btn == api.BTN_B: + self._forget_saved(self._nets[self._nets_sel]) + elif btn == api.BTN_LEFT: + self._toggle_metered(self._nets[self._nets_sel]) + else: + return + self._dirty = True + def _activate_row(self): if not self._wifi: return @@ -156,6 +207,106 @@ def _activate_row(self): not self._autoconnect()) except Exception: pass + elif r == self.ROW_NETS: + self._reload_nets() + self._nets_sel = 0 + self._mode = "nets" + elif r == self.ROW_SPEED: + self._run_speed_test() + elif r == self.ROW_PING: + self._run_ping() + + # ── async-ish helpers (blocking but with a "Testing..." paint) ────── + def _paint_busy(self, label): + """Force a frame showing the busy label so the user sees that + the device hasn't frozen during the synchronous test call.""" + self._busy = label + self._dirty = True + try: + self.draw(self._os.display) + self._os.display.present() + except Exception: + pass + + def _run_speed_test(self): + if not self._wifi or not self._snap.get("connected"): + self._speed_label = "no link" + return + self._speed_label = "testing…" + self._paint_busy("speed") + try: + ok, kbps, ms = self._wifi.speed_test() + except Exception: + ok, kbps, ms = (False, 0, 0) + self._busy = "" + if not ok or kbps <= 0: + self._speed_label = "failed" + elif kbps >= 1000: + self._speed_label = "%.1f Mbps · %d ms" % (kbps / 1000.0, ms) + else: + self._speed_label = "%d kbps · %d ms" % (kbps, ms) + self._dirty = True + + def _run_ping(self): + if not self._wifi or not self._snap.get("connected"): + self._ping_label = "no link" + return + self._ping_label = "pinging…" + self._paint_busy("ping") + try: + ok, rtt = self._wifi.ping() + except Exception: + ok, rtt = (False, None) + self._busy = "" + if not ok or rtt is None: + self._ping_label = "timeout" + else: + self._ping_label = "%d ms" % rtt + self._dirty = True + + # ── saved-network actions ─────────────────────────────────────────── + def _connect_saved(self, net): + ssid = net.get("ssid") or "" + pw = net.get("password") or "" + if not ssid: + return + # Promote to priority 1 so it auto-reconnects next boot. + try: + self._wifi.set_priority(ssid, 1) + except Exception: + pass + # Synchronous connect — blocks the run loop briefly. We don't + # paint a busy label because the existing Status row already + # flips "ON"/"OFF" once it's settled. + try: + self._wifi.connect(ssid, pw) + except Exception: + pass + self._snap = self._read() + self._reload_nets() + self._mode = "main" + + def _forget_saved(self, net): + ssid = net.get("ssid") or "" + if not ssid: + return + try: + self._wifi.remove_saved(ssid) + except Exception: + pass + self._reload_nets() + + def _toggle_metered(self, net): + ssid = net.get("ssid") or "" + if not ssid: + return + try: + self._wifi.add_saved(ssid, net.get("password") or "", + priority=int(net.get("priority", 10)), + metered=not bool(net.get("metered"))) + except Exception: + pass + self._reload_nets() # ── update ────────────────────────────────────────────────────────── def update(self, dt): @@ -174,8 +325,11 @@ def draw(self, d): return self._dirty = False d.clear(theme.BG) + if self._mode == "nets": + self._draw_nets(d) + return widgets.draw_header(d, "WIFI") - widgets.draw_hint(d, "A=toggle/cycle HOME=back") + widgets.draw_hint(d, "A=select HOME=back") snap = self._snap rows = [ @@ -184,18 +338,16 @@ def draw(self, d): else theme.MUTED), ("SSID", snap.get("ssid") or "—", theme.TEXT_BRIGHT), ("IP", snap.get("ip") or "—", theme.TEXT_DIM), - ("Subnet", snap.get("subnet") or "—", theme.TEXT_DIM), - ("Gateway", snap.get("gateway") or "—", theme.TEXT_DIM), ("RSSI", self._rssi_value(snap), theme.TEXT_DIM), ("Power", POWER_LABELS.get(self._power_mode(), "?"), theme.TEAL), + ("Networks", self._nets_summary(), theme.TEXT_BRIGHT), + ("Speed", self._speed_label or "—", theme.TEAL), + ("Ping", self._ping_label or "—", theme.TEAL), ("Auto-connect", "ON" if self._autoconnect() else "OFF", theme.TEXT_BRIGHT), ] for i, (label, value, color) in enumerate(rows): - # Auto-connect sits in its own group below the live link-info - # rows; nudge it down so the visual break matches its logical - # role as a persistent preference rather than a live readout. y = ROW_TOP_Y + i * ROW_H if i == self.ROW_AUTOCONNECT: y += AUTOCON_GAP @@ -209,17 +361,49 @@ def draw(self, d): d.text(label, ROW_PAD_X, y + 4, theme.TEXT_BRIGHT, scale=1) d.text(str(value)[:20], VALUE_X, y + 4, color, scale=1) - # Thin divider in the gap above Auto-connect to make the group - # break read as deliberate, not as a bug. + # Thin divider above Auto-connect. sep_y = ROW_TOP_Y + self.ROW_AUTOCONNECT * ROW_H + AUTOCON_GAP // 2 - 1 d.rect(ROW_PAD_X, sep_y, SW - 2 * ROW_PAD_X, 1, theme.MUTED2, fill=True) - # Mode sub-line — small caption under the Power row. - mode = self._power_mode() - hint = POWER_HINTS.get(mode, "") - if hint: - y = ROW_TOP_Y + self.ROW_POWER * ROW_H + ROW_H - 4 - d.text(hint, VALUE_X, y, theme.MUTED, scale=1) + def _nets_summary(self): + try: + n = len(self._wifi.list_saved()) if self._wifi else 0 + except Exception: + n = 0 + return "%d saved" % n + + def _draw_nets(self, d): + widgets.draw_header(d, "NETWORKS") + widgets.draw_hint(d, "A=connect B=forget L=metered") + if not self._nets: + self._reload_nets() + if not self._nets: + d.text("no saved networks", ROW_PAD_X, ROW_TOP_Y + 16, + theme.MUTED, scale=1) + d.text("flash /wifi.json or add via .env", + ROW_PAD_X, ROW_TOP_Y + 34, theme.MUTED2, scale=1) + return + cur_ssid = self._snap.get("ssid") or "" + for i, n in enumerate(self._nets): + y = ROW_TOP_Y + i * ROW_H + sel = (i == self._nets_sel) + if sel: + d.rect(4, y - 2, SW - 8, ROW_H - 1, + theme.DOCK_SEL, fill=True) + d.rect(4, y - 2, SW - 8, 1, theme.SEL_BORDER, fill=True) + d.rect(4, y + ROW_H - 4, SW - 8, 1, + theme.SEL_BORDER, fill=True) + ssid = (n.get("ssid") or "?")[:18] + d.text(ssid, ROW_PAD_X, y + 4, theme.TEXT_BRIGHT, scale=1) + # Right side: priority number + active dot + metered $. + pri = "p%d" % int(n.get("priority", 10)) + d.text(pri, VALUE_X, y + 4, theme.MUTED, scale=1) + x = VALUE_X + 28 + if n.get("ssid") == cur_ssid: + d.text("●", x, y + 4, theme.PRIMARY, scale=1) + x += 12 + if n.get("metered"): + d.text("$", x, y + 4, theme.GOLD, scale=1) def _rssi_value(self, snap): rssi = snap.get("rssi") diff --git a/oreoOS/config.py b/oreoOS/config.py index ed0268a..04ae504 100644 --- a/oreoOS/config.py +++ b/oreoOS/config.py @@ -18,7 +18,7 @@ def _load_env(): # OS version. tools/deploy.py auto-bumps the PATCH number on every push. # The literal MUST stay on its own line as `VERSION = "vN.N.N"` — the # deploy regex relies on that exact format to rewrite in place. -VERSION = "v1.4.34" +VERSION = "v1.4.35" # ISO-date stamp of the current VERSION. Updated by tools/release.py # (or by hand for hot-fix builds). Shown on the Updates page as the # "Latest stable as of …" line when no newer release is available. diff --git a/oreoOS/ota.py b/oreoOS/ota.py index 2fe378e..dc2c3e4 100644 --- a/oreoOS/ota.py +++ b/oreoOS/ota.py @@ -108,7 +108,12 @@ # settle, lets the user open the home screen / press a button without # eating a synchronous HTTP round-trip. Manual "Check Update" from # Settings is unaffected: it calls check() directly. -_OTA_BOOT_GRACE_S = 60 +_OTA_BOOT_GRACE_S = 15 * 60 # 15 min — boot finishes in seconds, but + # the user wants a clean post-boot + # experience without a probe-driven spike + # for at least one drink-of-water window. + # Manual "Check Update" from Settings is + # unaffected. # ── helpers ───────────────────────────────────────────────────────────────── @@ -569,11 +574,19 @@ def background_check(os_obj, min_interval_s=6 * 3600): return False # Only probe when there's a working network connection — silently - # skips when offline so the boot is never gated on DNS. + # skips when offline so the boot is never gated on DNS. Also skip + # entirely when the current SSID is flagged metered in /wifi.json, + # so a phone hotspot or tethered connection isn't billed for + # background traffic. try: from oreoWare import wifi if not wifi.is_connected(): return False + try: + if wifi.is_metered(): + return False + except Exception: + pass except Exception: return False diff --git a/oreoOS/widgets.py b/oreoOS/widgets.py index 5400c4d..8412fb7 100644 --- a/oreoOS/widgets.py +++ b/oreoOS/widgets.py @@ -13,12 +13,93 @@ from oreoOS import api, pixelfont from oreoOS import theme +try: + import time as _time +except ImportError: + _time = None + HEADER_H = 28 HINT_H = 16 # Forest-green header for the home screen (matches the bg image's tones). HEADER_HOME_BG = api.rgb(46, 102, 74) +# Cached link state for the header WiFi pip. The actual `wifi.rssi()` +# call is cheap (~1 ms) but we still amortise it to keep header draws +# free of network-module syscalls on tight game loops. +_WIFI_POLL_MS = 2000 +_wifi_cache = {"connected": False, "rssi": None, "metered": False, + "last_ms": None} + + +def _poll_wifi(): + """Refresh the cached WiFi snapshot on a 2 s cadence. Returns the + dict so draw_header() can render the right-aligned icon without + poking the network module on every paint.""" + now = _time.ticks_ms() if _time else 0 + last = _wifi_cache["last_ms"] + if last is not None and _time and \ + _time.ticks_diff(now, last) < _WIFI_POLL_MS: + return _wifi_cache + try: + from oreoWare import wifi as _w + _wifi_cache["connected"] = bool(_w.is_connected()) + try: + _wifi_cache["rssi"] = _w.rssi() if _wifi_cache["connected"] else None + except Exception: + _wifi_cache["rssi"] = None + try: + _wifi_cache["metered"] = bool(_w.is_metered()) \ + if _wifi_cache["connected"] else False + except Exception: + _wifi_cache["metered"] = False + except Exception: + # WiFi module not importable — leave cache as-is. + pass + _wifi_cache["last_ms"] = now + return _wifi_cache + + +def _draw_wifi_pip(d, x, y, w, h, state): + """4-bar WiFi indicator. Hollow bars when disconnected, full bars + by RSSI when associated, with a tiny "$" tucked next to a metered + link so the user knows OTA + store won't auto-fetch on it.""" + bars = 4 + bar_w = 2 + gap = 1 + block_w = bars * bar_w + (bars - 1) * gap + bx = x + (w - block_w) // 2 + by_base = y + h - 1 + rssi = state.get("rssi") + connected = state.get("connected") + # Pick a fill count by RSSI thresholds; disconnected → 0. + if not connected: + fill = 0 + elif rssi is None: + fill = 2 + elif rssi >= -55: + fill = 4 + elif rssi >= -65: + fill = 3 + elif rssi >= -75: + fill = 2 + elif rssi >= -85: + fill = 1 + else: + fill = 1 + for i in range(bars): + bh = 2 + i * 2 # ascending 2,4,6,8 px + bx_i = bx + i * (bar_w + gap) + if i < fill: + d.rect(bx_i, by_base - bh, bar_w, bh, api.WHITE, fill=True) + else: + # Hollow outline for the empty cells so the gauge stays + # readable even at full bars. + d.rect(bx_i, by_base - bh, bar_w, 1, theme.MUTED, fill=True) + # Metered marker: a tiny "$" 8 px to the left of the pip cluster. + if connected and state.get("metered"): + d.text("$", bx - 10, y + (h - 8) // 2, theme.GOLD, scale=1) + # Lazy-loaded title font (Pixelify Sans 16 — fits the 28-px header bar nicely). _TITLE_FONT = None @@ -53,6 +134,15 @@ def draw_header(d, title, color=None, accent=None): tx = (SW - len(title) * 16) // 2 d.text(title, tx, (HEADER_H - 16) // 2, api.WHITE, scale=2) + # Top-right WiFi pip. Polled on a 2 s cadence so this paint stays + # cheap. Renders nothing on top of the header bg when WiFi is + # off — the empty space reads as "no link" without an extra glyph. + pip_w = 16 + pip_h = HEADER_H - 4 + pip_x = SW - pip_w - 6 + pip_y = 2 + _draw_wifi_pip(d, pip_x, pip_y, pip_w, pip_h, _poll_wifi()) + def draw_hint(d, text, color=None): """Small grey hint text at the very bottom of the screen. diff --git a/oreoWare/wifi.py b/oreoWare/wifi.py index 2dfa445..5d784f0 100644 --- a/oreoWare/wifi.py +++ b/oreoWare/wifi.py @@ -1,19 +1,48 @@ """WiFi manager for the Oreo Badge. -Credentials come from a device-local `secrets.py` (generated by the deploy -script from your local .env file — never committed). +Saved networks live in `/wifi.json` on flash — a user-editable list +maintained by the WiFi settings app: + + [ + {"ssid": "Home", "password": "...", "priority": 1, "metered": false}, + {"ssid": "Hotspot","password": "...", "priority": 5, "metered": true} + ] + +`connect_from_config()` walks the list in ascending-priority order +(lower number = tried first) and stops on the first SSID that +associates. `secrets.py` (generated by the deploy script) is only used +as a first-boot bootstrap: if `/wifi.json` doesn't exist yet AND +`secrets.WIFI_SSID` is set, we seed the json with that one network so +the badge associates immediately after the first flash. Usage: from oreoWare import wifi - wifi.connect_from_config() + wifi.connect_from_config() # auto-pick best saved network wifi.is_connected() + wifi.list_saved() # for the settings UI + wifi.add_saved(ssid, pw, priority=10, metered=False) + wifi.remove_saved(ssid) + wifi.is_metered() # current link metered? """ import network import time +try: + import json as _json +except ImportError: + _json = None + +try: + import os as _os +except ImportError: + _os = None + _wlan = None +_SAVED_PATH = "/wifi.json" +_PER_NET_TIMEOUT = 8000 # ms — per-SSID cap so a wrong network is skipped fast + def _get_wlan(): global _wlan @@ -66,17 +95,153 @@ def connect(ssid, password, timeout_ms=12000): return True -def connect_from_config(): - """Read secrets.py and connect if WIFI_SSID is set.""" +def _exists(path): + if _os is None: + return False + try: + _os.stat(path) + return True + except OSError: + return False + + +def _load_saved_raw(): + """Read `/wifi.json` as a Python list. Returns [] on missing/bad + file — the caller is expected to bootstrap from secrets if so.""" + if _json is None or not _exists(_SAVED_PATH): + return [] + try: + with open(_SAVED_PATH) as f: + data = _json.loads(f.read()) + if not isinstance(data, list): + return [] + return data + except Exception: + return [] + + +def _save_raw(networks): + if _json is None: + return False + try: + with open(_SAVED_PATH, "w") as f: + f.write(_json.dumps(networks)) + return True + except Exception: + return False + + +def _bootstrap_from_secrets(): + """First-boot migration: if /wifi.json is missing AND secrets.py + carries a single SSID, materialise it as a one-entry list. Idempotent + — once /wifi.json exists this is a no-op.""" + if _exists(_SAVED_PATH): + return _load_saved_raw() try: import secrets ssid = getattr(secrets, "WIFI_SSID", "") pw = getattr(secrets, "WIFI_PASSWORD", "") - if not ssid: - return False - return connect(ssid, pw) except Exception: + ssid, pw = "", "" + if not ssid: + return [] + seed = [{"ssid": ssid, "password": pw, "priority": 1, "metered": False}] + _save_raw(seed) + return seed + + +def list_saved(): + """Return the saved-networks list, sorted by ascending priority. + Caller gets fresh dicts — mutating them doesn't affect on-disk state + until save_saved() is called.""" + nets = _load_saved_raw() or _bootstrap_from_secrets() + # Defensive copy + priority sort (None or missing → very high so + # nameless entries sink to the bottom). + def _key(n): + try: + return int(n.get("priority", 999)) + except Exception: + return 999 + return sorted([dict(n) for n in nets if n.get("ssid")], key=_key) + + +def save_saved(networks): + """Replace the entire saved list. Settings UI uses this after + add / remove / reorder.""" + return _save_raw(list(networks)) + + +def add_saved(ssid, password, priority=10, metered=False): + """Add or update a single saved network. Matched by SSID; existing + entries are overwritten so the user doesn't accumulate duplicates + if they re-enter the same network.""" + nets = _load_saved_raw() or _bootstrap_from_secrets() + out = [n for n in nets if n.get("ssid") != ssid] + out.append({ + "ssid": ssid, + "password": password or "", + "priority": int(priority), + "metered": bool(metered), + }) + return _save_raw(out) + + +def remove_saved(ssid): + nets = _load_saved_raw() + out = [n for n in nets if n.get("ssid") != ssid] + if len(out) == len(nets): + return False + return _save_raw(out) + + +def set_priority(ssid, priority): + nets = _load_saved_raw() + changed = False + for n in nets: + if n.get("ssid") == ssid: + n["priority"] = int(priority) + changed = True + break + if not changed: + return False + return _save_raw(nets) + + +def is_metered(): + """True iff the currently-associated SSID is flagged metered in + /wifi.json. Used by OTA + Store to back off on tethered/hotspot + connections so we don't burn the user's data plan.""" + cur = ssid() + if not cur: + return False + for n in _load_saved_raw(): + if n.get("ssid") == cur: + return bool(n.get("metered")) + return False + + +def connect_from_config(): + """Try each saved network in priority order until one associates. + + Per-network timeout is short (_PER_NET_TIMEOUT) so a stale entry + doesn't stall the boot. Returns True iff we associated to *any* + saved SSID; False if the list is empty or every entry failed. + """ + nets = list_saved() + if not nets: return False + for n in nets: + ssid_ = n.get("ssid") or "" + if not ssid_: + continue + pw = n.get("password") or "" + try: + ok = connect(ssid_, pw, timeout_ms=_PER_NET_TIMEOUT) + except Exception: + ok = False + if ok: + return True + return False def disconnect(): @@ -120,6 +285,133 @@ def ssid(): return None +def ping(host="8.8.8.8", port=53, timeout_s=2): + """TCP-connect "ping" to a known endpoint. MicroPython doesn't ship + raw ICMP sockets on this port, so we time the TCP handshake to a + cheap target (Google's DNS on port 53 by default). + + Returns (ok, rtt_ms). `ok=False, rtt_ms=None` on failure. + """ + try: + import socket as _s + except ImportError: + return (False, None) + if not is_connected(): + return (False, None) + try: + addr = _s.getaddrinfo(host, port)[0][-1] + except Exception: + return (False, None) + sock = _s.socket() + try: + sock.settimeout(timeout_s) + except Exception: + pass + t0 = time.ticks_ms() + try: + sock.connect(addr) + rtt = time.ticks_diff(time.ticks_ms(), t0) + return (True, rtt) + except Exception: + return (False, None) + finally: + try: + sock.close() + except Exception: + pass + + +def speed_test(bytes_=500_000, timeout_s=15): + """Throughput probe: download `bytes_` from speed.cloudflare.com + and report observed kbps. Cloudflare's __down endpoint serves an + arbitrary number of bytes without auth and is geo-routed. + + Returns (ok, kbps, elapsed_ms). On failure, (False, 0, elapsed). + Default size is 500 KB — enough to amortise SSL handshake on + typical home WiFi, small enough to finish in seconds on EDGE/3G + tethering. + """ + try: + import socket as _s + import ssl as _ssl + except ImportError: + return (False, 0, 0) + if not is_connected(): + return (False, 0, 0) + host = "speed.cloudflare.com" + path = "/__down?bytes=%d" % int(bytes_) + try: + addr = _s.getaddrinfo(host, 443)[0][-1] + except Exception: + return (False, 0, 0) + raw = None + s = None + deadline = time.ticks_add(time.ticks_ms(), int(timeout_s * 1000)) + t0 = time.ticks_ms() + received = 0 + body_started = False + try: + raw = _s.socket() + try: raw.settimeout(timeout_s) + except Exception: pass + raw.connect(addr) + s = _ssl.wrap_socket(raw, server_hostname=host) + try: s.settimeout(timeout_s) + except Exception: pass + req = ("GET %s HTTP/1.1\r\nHost: %s\r\n" + "User-Agent: OreoBadge-Speed\r\n" + "Accept-Encoding: identity\r\n" + "Connection: close\r\n\r\n") % (path, host) + s.write(req.encode()) + head = b"" + # Read headers — we don't care about chunked decoding for the + # bytes-counted speed metric; raw byte volume is what matters. + while b"\r\n\r\n" not in head: + if time.ticks_diff(deadline, time.ticks_ms()) <= 0: + break + try: + chunk = s.read(2048) + except Exception: + break + if not chunk: + break + head += chunk + if len(head) > 16 * 1024: + break + # Count any payload bytes that arrived alongside the headers. + sep = head.find(b"\r\n\r\n") + if sep >= 0: + body_started = True + received = len(head) - (sep + 4) + # Drain the rest of the body counting bytes. + while body_started: + if time.ticks_diff(deadline, time.ticks_ms()) <= 0: + break + try: + chunk = s.read(4096) + except Exception: + break + if not chunk: + break + received += len(chunk) + if received >= bytes_: + break + except Exception: + pass + finally: + for h in (s, raw): + try: + if h is not None: + h.close() + except Exception: + pass + elapsed = max(1, time.ticks_diff(time.ticks_ms(), t0)) + if received <= 0: + return (False, 0, elapsed) + kbps = int((received * 8) // elapsed) # bytes*8 / ms = kbps + return (True, kbps, elapsed) + + def info(): """One-shot snapshot for the WiFi detail screen.