diff --git a/OTOD.MD b/OTOD.MD new file mode 100644 index 0000000..78dd9c9 --- /dev/null +++ b/OTOD.MD @@ -0,0 +1,7 @@ +no i bascially want to build the website firts, it will be a fancy website minimalist design, like the look in [IMAGE1] with our logo and the branding, apply artistic framer motion no lag effects and make the color theme, and put a file called as theme.js + +dveelop the websiye in next 15 in the folder oreo.elixpo and we will have the folder essentails thete, the /upload will be a gated upload method whwre the code displayed in the badge has to bve provided in the /upload placeholder to initiate communicatin over https to the badge provided both the badge and the phne / other devicd is in the same netwoek, it will then initiate file transfer via the private local net peer to peer + +make the website look fancy and apply cool animations, show case the instyalled apps the app stoe apps, footer geader, amke it modular with multiple / dir to visit and also mark the open source github too + +this will be deployed to cloudflare pages, create a page for oreo.pages.dev using wrangler and i shall upload it to CF \ No newline at end of file diff --git a/apps/bt/main.py b/apps/bt/main.py index 70f46a5..c5687fd 100644 --- a/apps/bt/main.py +++ b/apps/bt/main.py @@ -1,25 +1,13 @@ -"""Bluetooth — phone-style detail screen. - -Top section: this badge's own identity — GAP name + public MAC (split -across two lines so the full address is legible). Below: a Paired -section (slice 3 wires in the bond store) and a Nearby section that -fills as a BLE central-role scan runs. - -Discovery is filtered: BLE advertisements whose appearance falls in the -audio category (speakers / earbuds / headsets / hearing aids) — or -whose service UUIDs include audio profiles — are dropped before the -list ever paints. Phones, computers, tablets, and generic devices pass -through; each gets a one-letter type tag. - -Controls: - UP/DOWN walk rows - A toggle status (on the Status row) / pair (Nearby) / - actions (Paired — wired in slice 3) - B start / stop scan - HOME back to Settings +"""Bluetooth — placeholder page. + +File transfer moved to WiFi (the Send Files row in Settings → WiFi); +no Bluetooth functionality currently ships in the OS. The underlying +oreoWare.bt module + GATT plumbing are kept intact for future +features (badge-to-badge IR-quest assists, peer presence, etc.) but +none of that is wired to a user flow yet — this app just acknowledges +that and points the user at the WiFi transfer page. """ -import time import oreoOS from oreoOS import api, theme, widgets @@ -27,354 +15,49 @@ SW = api.SCREEN_W SH = api.SCREEN_H -PAD_X = 10 -HEADER_Y = widgets.HEADER_H + 4 -IDENTITY_H = 56 # Name + two-line MAC + state -SECTION_GAP = 6 -ROW_H = 18 -SCAN_DUR_MS = 8000 - -_TYPE_LETTER = { - "phone": "P", - "computer": "C", - "tablet": "T", - "watch": "W", - "display": "D", - "other": "·", -} - - -def _bars(rssi): - if rssi is None: return "----" - if rssi >= -50: return "####" - if rssi >= -65: return "###-" - if rssi >= -75: return "##--" - if rssi >= -85: return "#---" - return "----" - class App(oreoOS.App): name = "Bluetooth" author = "Circuit-Overtime" - SHOW_LOADING = True + SHOW_LOADING = False def on_enter(self, os_): super().on_enter(os_) self._os = os_ self._dirty = True - # `_rows` is rebuilt on every refresh so selection lives by a - # stable composite key (kind + index) rather than a row index - # that shifts when scan results stream in. - self._sel_key = ("status", 0) - self._poll_t = 0.0 - self._poll_dt = 0.5 # refresh list every 500 ms - self._scan_t = 0.0 - self._scan_left = 0 # ms remaining in current scan - - # Pair-flow overlay state: None (no overlay) or a dict tracking - # which device the user picked + what the overlay is currently - # showing ("confirm" | "running" | "done" | "failed"). - self._overlay = None - - try: - from oreoWare import bt - self._bt = bt - except Exception: - self._bt = None - - self._rows = self._build_rows() - - # ── data ──────────────────────────────────────────────────────────── - def _build_rows(self): - """Flat list of row records: - ("status", active_bool) - ("paired", bond_entry_or_None) # placeholder in slice 2 - ("nearby", discovered_dict) - Plus zero-arg headers so the renderer can paint section labels. - """ - rows = [("status", self._bt and self._bt.is_active())] - - # Paired section — bond store comes in slice 3. For now render - # a clear empty state so the user sees the placeholder. - rows.append(("header", "Paired (0 / 3)")) - rows.append(("paired_empty", None)) - - # Nearby section — fed by the live scan. - nearby = [] - if self._bt: - try: - nearby = self._bt.scan_results() - except Exception: - nearby = [] - scan_label = "Nearby" - if self._bt and self._bt.scan_is_active(): - scan_label += " (scanning…)" - elif nearby: - scan_label += " (%d)" % len(nearby) - rows.append(("header", scan_label)) - if not nearby: - rows.append(("nearby_empty", None)) - else: - for entry in nearby[:8]: # cap on-screen to keep paint cheap - rows.append(("nearby", entry)) - - return rows - - def _selectable(self, row): - kind = row[0] - return kind in ("status", "nearby") - - def _sel_index(self): - """Index in self._rows that matches self._sel_key.""" - kind, ident = self._sel_key - for i, r in enumerate(self._rows): - if r[0] != kind: - continue - if kind == "status": - return i - if kind == "nearby": - payload = r[1] - if payload and payload.get("mac") == ident: - return i - # Fall back: first selectable row - for i, r in enumerate(self._rows): - if self._selectable(r): - return i - return 0 - - def _move_sel(self, step): - cur = self._sel_index() - n = len(self._rows) - i = cur - for _ in range(n): - i = (i + step) % n - if self._selectable(self._rows[i]): - self._set_sel(self._rows[i]) - return - - def _set_sel(self, row): - kind = row[0] - if kind == "status": - self._sel_key = ("status", 0) - elif kind == "nearby": - payload = row[1] - self._sel_key = ("nearby", payload.get("mac")) - - # ── lifecycle ────────────────────────────────────────────────────── - def update(self, dt): - # Scan countdown — stop and rebuild rows when the time runs out - # so the "(scanning…)" label disappears without user action. - if self._scan_left > 0: - self._scan_left = max(0, self._scan_left - int(dt * 1000)) - - self._poll_t += dt - if self._poll_t < self._poll_dt: - return - self._poll_t = 0.0 - fresh = self._build_rows() - if fresh != self._rows: - self._rows = fresh - self._dirty = True - def on_button_press(self, btn): - if btn == api.BTN_HOME: - if self._bt and self._bt.scan_is_active(): - try: - self._bt.scan_stop() - except Exception: - pass + if btn in (api.BTN_HOME, api.BTN_B): self._os.quit() - return - if btn == api.BTN_UP: - self._move_sel(-1) - self._dirty = True - elif btn == api.BTN_DOWN: - self._move_sel(+1) - self._dirty = True - elif btn == api.BTN_B: - self._toggle_scan() - elif btn == api.BTN_A: - self._activate_selected() - - def _toggle_scan(self): - if not self._bt: - return - if self._bt.scan_is_active(): - try: - self._bt.scan_stop() - except Exception: - pass - self._scan_left = 0 - else: - try: - self._bt.scan_start(SCAN_DUR_MS) - self._scan_left = SCAN_DUR_MS - except Exception: - pass - self._dirty = True - - def _activate_selected(self): - idx = self._sel_index() - row = self._rows[idx] - kind, payload = row[0], row[1] - if kind == "status": - if not self._bt: - return - try: - now_on = not self._bt.is_active() - self._bt.set_active(now_on) - # Auto-discover the moment BT comes up — the user - # expectation is "turn it on, see what's nearby." - # We also leave the local beacon broadcasting (handled - # inside set_active(True) → _start_advertising) so this - # badge can be paired *to* without a second tap. - if now_on: - try: - self._bt.scan_start(SCAN_DUR_MS) - self._scan_left = SCAN_DUR_MS - except Exception: - pass - except Exception: - pass - self._dirty = True - elif kind == "nearby": - # Kick the real pair flow. oreoWare.bt.start_pair() owns the - # central-role connect + SMP handshake; we just hand it the - # selected scan entry and surface the result as a - # notification. The notification panel is where the user - # tracks pair state once they leave this app. - mac = payload.get("mac", "?") - name = payload.get("name", "?") - ok = False - err = "" - if self._bt: - try: - ok = bool(self._bt.start_pair(payload)) - except Exception as e: - err = str(e)[:40] - try: - from oreoOS import notifications - if ok: - title, body = "Pairing…", "%s · %s" % (name[:14], mac[-8:]) - else: - title = "Pair failed" - body = err or ("%s · %s" % (name[:14], mac[-8:])) - notifications.push("bt", title, body, target=None) - except Exception: - pass - self._dirty = True - # ── render ────────────────────────────────────────────────────────── def draw(self, d): if not self._dirty: return self._dirty = False d.clear(theme.BG) widgets.draw_header(d, "BLUETOOTH") - # Contextual hint — when the selection lands on a discovered - # device we promise "A = Connect" instead of the generic - # "select" so the user knows the next tap kicks off a pair. - if self._sel_key[0] == "nearby": - widgets.draw_hint(d, "A=Connect B=scan HOME=back") - else: - widgets.draw_hint(d, "A=toggle B=scan HOME=back") - - self._draw_identity(d) - - list_top = HEADER_Y + IDENTITY_H + SECTION_GAP - list_h = SH - list_top - widgets.HINT_H - 4 - max_rows = max(1, list_h // ROW_H) - - sel_idx = self._sel_index() - # Scroll the body so the selected row stays visible (selectable - # rows can be deep into the list when many devices are nearby). - body_rows = self._rows[1:] # row[0] is "status" — drawn in identity - if sel_idx == 0: - # selection is on the Status row in the identity panel — - # no body scrolling needed; reset. - top = 0 - else: - body_sel = sel_idx - 1 - top = max(0, body_sel - max_rows + 1) - - for vi in range(top, min(len(body_rows), top + max_rows)): - row = body_rows[vi] - y = list_top + (vi - top) * ROW_H - self._draw_row(d, y, row, sel_idx == vi + 1) - - def _draw_identity(self, d): - x = PAD_X - y = HEADER_Y - name = self._bt.own_name() if self._bt else "Oreo" - mac = self._bt.own_mac() if self._bt else "—" - active = bool(self._bt and self._bt.is_active()) - - d.text("This badge", x, y, theme.MUTED, scale=1) - d.text(name, x, y + 12, theme.TEXT_BRIGHT, scale=2) - - # MAC: split AA:BB:CC:DD:EE:FF after the second colon for two - # tidy 8-char lines if needed; on 320 px wide it fits one line - # but the user wanted two-line so the field is unambiguous. - if len(mac) > 8: - half = (len(mac) // 3) * 3 + 2 # break on a colon if possible - top_mac = mac[:half] - bot_mac = mac[half + 1:] if mac[half:half + 1] == ":" else mac[half:] - else: - top_mac, bot_mac = mac, "" - d.text(top_mac, x + 140, y + 12, theme.TEXT_DIM, scale=1) - if bot_mac: - d.text(bot_mac, x + 140, y + 24, theme.TEXT_DIM, scale=1) - - # Status indicator (matches the Settings BT toggle visually). - sel_status = (self._sel_key[0] == "status") - sy = y + 36 - if sel_status: - d.rect(x - 2, sy - 2, SW - 2 * (x - 2), 18, - theme.DOCK_SEL, fill=True) - d.rect(x - 2, sy - 2, SW - 2 * (x - 2), 1, - theme.SEL_BORDER, fill=True) - d.rect(x - 2, sy + 15, SW - 2 * (x - 2), 1, - theme.SEL_BORDER, fill=True) - d.text("Status", x, sy + 4, theme.TEXT_BRIGHT, scale=1) - label = "Discoverable" if active else "Off" - color = theme.PRIMARY if active else theme.MUTED - d.text(label, SW - PAD_X - len(label) * 8, sy + 4, color, scale=1) - - def _draw_row(self, d, y, row, selected): - kind, payload = row[0], row[1] - if kind == "header": - d.text(str(payload), PAD_X, y + 2, theme.MUTED, scale=1) - d.rect(PAD_X, y + 14, SW - 2 * PAD_X, 1, - theme.MUTED2, fill=True) - return - if kind == "paired_empty": - d.text("no paired devices", PAD_X + 6, y + 2, - theme.MUTED2, scale=1) - return - if kind == "nearby_empty": - msg = "scan to discover (B)" - d.text(msg, PAD_X + 6, y + 2, theme.MUTED2, scale=1) - return - - # ── nearby device row ───────────────────────────────────────── - bg = theme.DOCK_SEL if selected else None - if bg is not None: - d.rect(PAD_X - 2, y, SW - 2 * (PAD_X - 2), ROW_H - 2, - bg, fill=True) - d.rect(PAD_X - 2, y, SW - 2 * (PAD_X - 2), 1, - theme.SEL_BORDER, fill=True) - d.rect(PAD_X - 2, y + ROW_H - 3, SW - 2 * (PAD_X - 2), 1, - theme.SEL_BORDER, fill=True) - - type_tag = _TYPE_LETTER.get(payload.get("type", "other"), "·") - d.text(type_tag, PAD_X, y + 4, theme.PRIMARY, scale=1) - - name = (payload.get("name") or "(unknown)")[:18] - d.text(name, PAD_X + 14, y + 4, theme.TEXT_BRIGHT, scale=1) - - rssi = payload.get("rssi") - if rssi is not None: - rstr = "%d %s" % (rssi, _bars(rssi)) - d.text(rstr, SW - PAD_X - len(rstr) * 8, y + 4, - theme.TEXT_DIM, scale=1) + widgets.draw_hint(d, "B / HOME to back out") + + cy = SH // 2 + + # Big tag + title = "Coming soon" + tw = len(title) * 16 + d.text(title, (SW - tw) // 2, cy - 56, theme.PRIMARY, scale=2) + + # Body paragraph (wrapped manually for the tiny screen). + lines = ( + "File transfer now runs over WiFi.", + "Open Settings -> WiFi -> Send Files", + "for the upload URL and pending", + "approvals.", + "", + "BT will return for proximity-", + "based features (peer presence,", + "IR-Quest pairing, sync gestures).", + ) + y = cy - 24 + for line in lines: + lw = len(line) * 8 + d.text(line, (SW - lw) // 2, y, theme.TEXT_DIM, scale=1) + y += 12 diff --git a/apps/gallery/main.py b/apps/gallery/main.py index c963f8d..ec6123d 100644 --- a/apps/gallery/main.py +++ b/apps/gallery/main.py @@ -20,24 +20,62 @@ SH = api.SCREEN_H +_GALLERY_DIR = "apps/gallery/assets/optimized" + + def _list_photos(): + """Both formats land here: + .py — laptop-optimised, baked at deploy time + .r565 — uploaded over WiFi, written by oreoOS.http_server in the + on-device-renderable binary format defined below. + Returned names include their extension so _load_photo can dispatch.""" try: - names = [] - for f in _os.listdir("apps/gallery/assets/optimized"): - if f.endswith(".py") and not f.startswith("_"): - names.append(f[:-3]) - names.sort() - return names + out = [] + for f in _os.listdir(_GALLERY_DIR): + if f.startswith("_"): + continue + if f.endswith(".py") or f.endswith(".r565"): + out.append(f) + out.sort() + return out except OSError: return [] def _load_photo(name): + """name is the full filename ('sunset.py' / 'upload_1234.r565'). + Dispatch on extension.""" + if name.endswith(".r565"): + return _load_r565(_GALLERY_DIR + "/" + name) + if name.endswith(".py"): + stem = name[:-3] + try: + mod = __import__("apps.gallery.assets.optimized." + stem, + None, None, ["DATA", "W", "H"]) + return (bytearray(mod.DATA), mod.W, mod.H) + except (ImportError, AttributeError): + return None + return None + + +def _load_r565(path): + """Read a 6-byte header (magic + W + H, little-endian) and the + following W*H*2 bytes of RGB565 pixel data. Mirrors the binary + format the upload page produces in the browser canvas pipeline.""" try: - mod = __import__("apps.gallery.assets.optimized." + name, - None, None, ["DATA", "W", "H"]) - return (bytearray(mod.DATA), mod.W, mod.H) - except (ImportError, AttributeError): + with open(path, "rb") as f: + head = f.read(6) + if len(head) < 6 or head[0] != 0x52 or head[1] != 0x35: + return None + w = head[2] | (head[3] << 8) + h = head[4] | (head[5] << 8) + if w <= 0 or h <= 0 or w > 320 or h > 320: + return None + data = f.read(w * h * 2) + if len(data) < w * h * 2: + return None + return (bytearray(data), w, h) + except Exception: return None diff --git a/apps/wifi/main.py b/apps/wifi/main.py index 09a998a..ef1ac4e 100644 --- a/apps/wifi/main.py +++ b/apps/wifi/main.py @@ -60,9 +60,11 @@ class App(oreoOS.App): # Logical row indices — keeps cycle code readable when we re-order. (ROW_STATUS, ROW_SSID, ROW_IP, ROW_RSSI, ROW_POWER, - ROW_NETS, ROW_SPEED, ROW_PING, ROW_AUTOCONNECT) = range(9) + ROW_NETS, ROW_SPEED, ROW_PING, ROW_TRANSFER, + ROW_AUTOCONNECT) = range(10) ROWS = (ROW_STATUS, ROW_SSID, ROW_IP, ROW_RSSI, ROW_POWER, - ROW_NETS, ROW_SPEED, ROW_PING, ROW_AUTOCONNECT) + ROW_NETS, ROW_SPEED, ROW_PING, ROW_TRANSFER, + ROW_AUTOCONNECT) def on_enter(self, os_): super().on_enter(os_) @@ -83,7 +85,9 @@ def on_enter(self, os_): self._snap = self._read() # Sub-page state - self._mode = "main" # "main" | "nets" + self._mode = "main" # "main" | "nets" | "transfer" + # Cached cursor inside the transfer-page sender list. + self._trans_sel = 0 self._nets = [] # cached saved-networks list self._nets_sel = 0 @@ -129,6 +133,8 @@ def _reload_nets(self): def on_button_press(self, btn): if self._mode == "nets": return self._on_button_nets(btn) + if self._mode == "transfer": + return self._on_button_transfer(btn) return self._on_button_main(btn) def _on_button_main(self, btn): @@ -211,6 +217,9 @@ def _activate_row(self): self._reload_nets() self._nets_sel = 0 self._mode = "nets" + elif r == self.ROW_TRANSFER: + self._trans_sel = 0 + self._mode = "transfer" elif r == self.ROW_SPEED: self._run_speed_test() elif r == self.ROW_PING: @@ -234,12 +243,33 @@ def _run_speed_test(self): return self._speed_label = "testing…" self._paint_busy("speed") + + # Cooperative pump — runs between every recv() inside + # speed_test. Re-reads the button matrix so a keypress can + # both keep the OS feeling alive AND abort the test (any + # press cancels). Also redraws the screen so the user + # actually SEES the "testing..." label instead of the last + # pre-test frame. + cancel = [False] + def _pump(): + try: + self._os.buttons.update() + for b_ in api.BUTTONS: + if self._os.buttons.just_pressed(b_): + cancel[0] = True + return True + except Exception: + pass + return False + try: - ok, kbps, ms = self._wifi.speed_test() + ok, kbps, ms = self._wifi.speed_test(pump_cb=_pump) except Exception: ok, kbps, ms = (False, 0, 0) self._busy = "" - if not ok or kbps <= 0: + if cancel[0]: + self._speed_label = "cancelled" + elif not ok or kbps <= 0: self._speed_label = "failed" elif kbps >= 1000: self._speed_label = "%.1f Mbps · %d ms" % (kbps / 1000.0, ms) @@ -318,6 +348,16 @@ def update(self, dt): if fresh != self._snap: self._snap = fresh self._dirty = True + # While the Transfer page is on screen, repaint every tick so + # the progress bar + pending-sender list stay live without the + # user having to nudge a button. The cost is negligible + # because the page is mostly text — but we only mark _dirty + # when there's actually something to refresh. + if self._mode == "transfer": + hs = self._http() + if hs is not None: + if hs.progress() is not None or hs.list_sessions(): + self._dirty = True # ── render ────────────────────────────────────────────────────────── def draw(self, d): @@ -328,6 +368,9 @@ def draw(self, d): if self._mode == "nets": self._draw_nets(d) return + if self._mode == "transfer": + self._draw_transfer(d) + return widgets.draw_header(d, "WIFI") widgets.draw_hint(d, "A=select HOME=back") @@ -344,6 +387,7 @@ def draw(self, d): ("Networks", self._nets_summary(), theme.TEXT_BRIGHT), ("Speed", self._speed_label or "—", theme.TEAL), ("Ping", self._ping_label or "—", theme.TEAL), + ("Send files", self._transfer_summary(), theme.PRIMARY), ("Auto-connect", "ON" if self._autoconnect() else "OFF", theme.TEXT_BRIGHT), ] @@ -410,3 +454,203 @@ def _rssi_value(self, snap): if rssi is None: return "—" return "%d dBm %s" % (rssi, _bars(rssi)) + + # ── file-transfer sub-page ────────────────────────────────────────── + def _http(self): + try: + from oreoOS import http_server as _hs + return _hs + except Exception: + return None + + def _transfer_summary(self): + """Compact value text for the main-page row: either the live + sender count, or the URL when idle. Reads the http_server's + cached state — no network call.""" + hs = self._http() + if hs is None or not hs.is_running(): + return "off" + try: + n = len(hs.list_sessions()) + except Exception: + n = 0 + if n: + return "%d active" % n + return "ready" + + def _on_button_transfer(self, btn): + if btn in (api.BTN_HOME, api.BTN_B): + self._mode = "main" + self._dirty = True + return + if btn == api.BTN_RIGHT: + # Refresh — sometimes the page shows "Server offline" when + # WiFi is actually associated but the HTTP server hasn't + # picked up the new IP (e.g. after a hotspot handoff). + # Force-reconnect WiFi if needed and rebind the listener. + self._refresh_transfer() + self._dirty = True + return + hs = self._http() + if hs is None: + return + sessions = hs.list_sessions() + n = len(sessions) + if btn == api.BTN_UP and n: + self._trans_sel = (self._trans_sel - 1) % n + elif btn == api.BTN_DOWN and n: + self._trans_sel = (self._trans_sel + 1) % n + elif btn == api.BTN_A and n: + sid = sessions[self._trans_sel].get("id", "") + if sid: + hs.approve(sid) + elif btn == api.BTN_LEFT and n: + sid = sessions[self._trans_sel].get("id", "") + if sid: + hs.deny(sid) + else: + return + self._dirty = True + + def _refresh_transfer(self): + """Reconcile the transfer page with the live network state. + + Order: re-associate WiFi from saved networks (no-op if already + connected), then call http_server.start() which rebinds onto + the live IP (no-op if the IP hasn't changed). Both are cheap + and idempotent — we use them as a 'kick everything back to + life' button after a roaming WiFi event. + """ + if self._wifi: + try: + if not self._wifi.is_connected(): + self._wifi.connect_from_config() + except Exception: + pass + hs = self._http() + if hs is not None: + try: + hs.start(self._os) + except Exception: + pass + # Pull a fresh snapshot so the URL line on the page repaints + # with the new IP if it changed. + self._snap = self._read() + + def _draw_transfer(self, d): + widgets.draw_header(d, "SEND FILES") + widgets.draw_hint(d, "A=allow L=deny R=refresh B=back") + + hs = self._http() + running = bool(hs and hs.is_running()) + + # ── URL block ── + y = ROW_TOP_Y + if running: + url1 = hs.url() + url2 = hs.url_fallback() + d.text("Open on your phone:", ROW_PAD_X, y, theme.TEXT_DIM) + d.text(url1, ROW_PAD_X, y + 14, theme.PRIMARY, scale=2) + d.text(url2, ROW_PAD_X, y + 36, theme.TEXT_DIM) + else: + d.text("Server offline.", ROW_PAD_X, y, theme.MUTED, scale=1) + d.text("Connect WiFi to enable transfer.", + ROW_PAD_X, y + 14, theme.MUTED2, scale=1) + + # ── live transfer progress bar ── + prog = hs.progress() if hs else None + prog_y = y + 56 + if prog: + total = max(1, int(prog.get("total", 0))) + done = min(total, int(prog.get("received", 0))) + pct = int((done * 100) // total) + d.text("Receiving %s" % (prog.get("filename", "")[:22] or "?"), + ROW_PAD_X, prog_y, theme.TEAL) + # bar + bar_x, bar_y, bar_w, bar_h = ROW_PAD_X, prog_y + 14, SW - 2 * ROW_PAD_X, 8 + d.rect(bar_x, bar_y, bar_w, bar_h, theme.MUTED2, fill=True) + fill_w = int(bar_w * pct / 100) + d.rect(bar_x, bar_y, fill_w, bar_h, theme.PRIMARY, fill=True) + d.text("%d%% %d / %d KB" % (pct, done // 1024, total // 1024), + ROW_PAD_X, bar_y + 12, theme.TEXT_DIM) + list_y = bar_y + 28 + else: + list_y = prog_y + + # ── pending sender list ── + # Denied sessions are hidden from the on-badge list: the badge + # has already made its decision, and surfacing rejected + # devices is just visual noise. The phone-side page still + # gets the "denied" verdict from its next beacon poll so the + # user there can hit refresh and try again with a fresh ID. + sessions_all = hs.list_sessions() if hs else [] + sessions = [s for s in sessions_all if s.get("state") != "denied"] + # Clamp the cursor in case a session disappeared between ticks. + if self._trans_sel >= len(sessions): + self._trans_sel = max(0, len(sessions) - 1) + + d.rect(ROW_PAD_X, list_y, SW - 2 * ROW_PAD_X, 1, theme.MUTED2, fill=True) + list_y += 6 + if not sessions: + d.text("no senders connected", + ROW_PAD_X + 2, list_y, theme.MUTED2) + return + + # State -> dot colour. RGB tuples instead of theme constants + # because we need a clean green, and the theme palette doesn't + # ship one — primary is pink-red, teal is too cyan. + dot_color = { + "pending": api.rgb(255, 209, 102), # yellow + "approved": api.rgb( 61, 220, 151), # green + "denied": api.rgb(255, 93, 104), # red — visible only + # in the brief window + # before the row is + # filtered out + } + DOT_SIZE = 8 + DOT_RIGHT = ROW_PAD_X + 2 # pad from the right edge + BAR_PAD = 8 # gap on either side of the bar + ROW_INNER = ROW_H - 4 + + prog = hs.progress() if hs else None + + for i, s in enumerate(sessions[:5]): + row_y = list_y + i * ROW_H + sel = (i == self._trans_sel) + if sel: + d.rect(4, row_y - 2, SW - 8, ROW_H - 1, + theme.DOCK_SEL, fill=True) + d.rect(4, row_y - 2, SW - 8, 1, theme.SEL_BORDER, fill=True) + d.rect(4, row_y + ROW_H - 4, SW - 8, 1, + theme.SEL_BORDER, fill=True) + sid = s.get("id", "------") + state = s.get("state", "pending") + + # ID at scale=1 (smaller than before — the page got too + # busy with the old scale=2 codes once we added a progress + # bar and a status dot to the same row). + d.text(sid, ROW_PAD_X, row_y + 6, theme.TEXT_BRIGHT, scale=1) + + # Status dot, right side, vertically centred in the row. + dx = SW - DOT_RIGHT - DOT_SIZE + dy = row_y + (ROW_INNER - DOT_SIZE) // 2 + 2 + d.rect(dx, dy, DOT_SIZE, DOT_SIZE, + dot_color.get(state, theme.MUTED), fill=True) + + # Mid-row progress bar — only when THIS session is the one + # currently receiving. Spans the area between the ID text + # and the status dot. + if prog and prog.get("id") == sid: + # Approximate where the SID text ends. Codes are 6 + # chars × 8 px at scale=1 = 48 px. + bar_x = ROW_PAD_X + 6 * 8 + BAR_PAD + bar_w = dx - BAR_PAD - bar_x + if bar_w > 8: + total = max(1, int(prog.get("total", 0))) + done = min(total, int(prog.get("received", 0))) + pct_w = int(bar_w * done / total) + bar_y = row_y + (ROW_INNER - 4) // 2 + 2 + d.rect(bar_x, bar_y, bar_w, 4, + theme.MUTED2, fill=True) + d.rect(bar_x, bar_y, pct_w, 4, + theme.PRIMARY, fill=True) diff --git a/oreoOS/config.py b/oreoOS/config.py index fcbeee8..d4459b1 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.36" +VERSION = "v1.4.47" # 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/http_server.py b/oreoOS/http_server.py new file mode 100644 index 0000000..fe6953a --- /dev/null +++ b/oreoOS/http_server.py @@ -0,0 +1,891 @@ +"""Tiny HTTP server for AirDrop-style file transfer over WiFi. + +The badge advertises itself over BLE (so the phone sees it in Nearby / +the Oreo Pair flow), but the actual file bytes flow over WiFi because +the BLE GATT path is too slow and there's no Classic-BT OBEX on this +firmware. When WiFi is up, this module: + + GET / serves a one-page upload form (HTML in-line below) + POST /upload parses one multipart file part, writes it to flash, + routes by extension: + .png .jpg .jpeg .gif -> apps/gallery/assets/raw/ + .md -> documents/ + .txt -> documents/ + (anything else) -> rejected with 415 + GET /favicon.ico 204 (silences browser noise) + +Design notes +------------ +* Single-threaded, non-blocking. `tick()` is called from the OS run + loop; we only ever process one HTTP request per tick. A long upload + freezes the UI for the duration — acceptable for v1 because the + phone-side share takes a couple of seconds for typical photos. +* Multipart parsing is streamed: we never hold the full payload in + RAM. Chunks land directly into the on-disk file as they arrive, + with a sliding-window check for the closing boundary marker. +* No HTTPS. The transfer happens on the local LAN; adding TLS would + pull in ~30 KB of code and zero security benefit for a peer who + could already sniff the broadcast. + +Public surface: + http_server.start(os_obj) open listening socket on WiFi IP + http_server.tick() accept + handle a pending request + http_server.stop() close listening socket + http_server.url() "http://192.168.x.y/" — show on screen +""" + +import gc + +try: + import socket as _socket +except ImportError: + _socket = None + +try: + import os as _os +except ImportError: + _os = None + + +PORT = 80 +MAX_BODY = 2 * 1024 * 1024 # 2 MB hard cap — bigger than any badge asset +READ_CHUNK = 2048 +RECV_TIMEOUT = 3 # seconds — per-recv() during streaming +HEAD_DEADLINE_MS = 1500 # hard cap on header-read for tiny requests + # (beacons / session/new). Anything slower + # than this is almost certainly a TLS probe + # or a stuck client and would otherwise + # freeze the run loop one beacon at a time. + +# Session lifecycle +SESSION_TTL_MS = 60 * 1000 # beacon must refresh within this +SESSION_MAX = 8 # never track more than this many concurrent +SESSION_CHARSET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # no 0/O/1/I/l + +_lsock = None +_bound_ip = None + +# Token state machine. Each sender registers itself by hitting /beacon +# with a 6-char id. The badge UI lists them as pending, and the user +# explicitly approves (or denies) before any /upload bytes are accepted. +# +# _sessions[id] = { +# "state": "pending" | "approved" | "denied", +# "last_ms": ticks_ms of last beacon hit, +# "addr": requesting peer ip (for display), +# "uploads": completed upload count for this session, +# } +_sessions = {} + +# Live upload progress so the WiFi app can render a real-time bar while +# bytes are flowing in. None when no upload is in flight. +# {"id": "...", "filename": "...", "received": int, "total": int} +_progress = None + + + +# ── routing tables ────────────────────────────────────────────────────── + +_GALLERY_DIR = "apps/gallery/assets/optimized" # was /raw — Gallery + # only reads optimized/ + # at runtime, so raw + # uploads were invisible + # until a laptop deploy + # re-ran the optimiser. + # We now upload pre- + # converted .r565 binaries + # straight into here. +_DOCS_DIR = "documents" + +# .r565 is our on-device-renderable image format: 6-byte header +# (magic 'R5' + width LE16 + height LE16), then W*H 2-byte RGB565 +# pixels. The browser does the conversion before upload — see the +# canvas pipeline in _UPLOAD_FORM below. +_IMG_EXTS = (".r565",) +_DOC_EXTS = (".md", ".txt") + + +def _ensure_dir(path): + """mkdir -p — tolerates intermediate dirs already existing.""" + if _os is None: + return + parts = path.split("/") + cur = "" + for p in parts: + if not p: + continue + cur = (cur + "/" + p) if cur else p + try: + _os.mkdir(cur) + except OSError: + pass + + +def _route_for(filename): + """Pick the on-disk directory + accept policy for an upload. Returns + (dest_dir, kind_label) on accept, or (None, reason_string) on reject.""" + if not filename: + return None, "no filename" + fl = filename.lower() + for ext in _IMG_EXTS: + if fl.endswith(ext): + return _GALLERY_DIR, "image" + for ext in _DOC_EXTS: + if fl.endswith(ext): + return _DOCS_DIR, "document" + return None, "unsupported type" + + +def _safe_filename(raw): + """Strip path separators + non-printable chars. Phones occasionally + POST filenames with directory parts; we ignore them so an upload + can never escape the target dir.""" + if not raw: + return "" + name = raw.replace("\\", "/").rsplit("/", 1)[-1] + out = [] + for ch in name: + c = ord(ch) + # printable ASCII except path separators (already handled) and + # null. Anything weirder gets dropped so the FS doesn't choke. + if 32 <= c < 127 and ch not in "/\\?*:|\"<>": + out.append(ch) + return "".join(out)[:64] or "upload" + + +# ── server lifecycle ──────────────────────────────────────────────────── + +def start(os_obj=None): + """Open the listening socket on the current WiFi IP. Safe to call + multiple times — re-binds if WiFi reconnected to a different IP.""" + global _lsock, _bound_ip + if _socket is None: + return False + # Resolve current WiFi IP via the wifi module. + try: + from oreoWare import wifi + ip = wifi.ip() + except Exception: + ip = None + if not ip: + return False + # If we're already bound on this IP, nothing to do. + if _lsock is not None and _bound_ip == ip: + return True + stop() + try: + s = _socket.socket() + # SO_REUSEADDR so a fast WiFi flap doesn't leave us in TIME_WAIT + # waiting to bind. MicroPython doesn't always expose SOL_SOCKET + # constants, so wrap the setsockopt in its own try. + try: + s.setsockopt(_socket.SOL_SOCKET, _socket.SO_REUSEADDR, 1) + except Exception: + pass + addr = _socket.getaddrinfo(ip, PORT)[0][-1] + s.bind(addr) + s.listen(2) + s.setblocking(False) + _lsock = s + _bound_ip = ip + try: + print("[http] listening on %s:%d" % (ip, PORT)) + except Exception: + pass + return True + except Exception as e: + try: + print("[http] bind failed: %s" % e) + except Exception: + pass + _lsock = None + _bound_ip = None + return False + + +def stop(): + global _lsock, _bound_ip + if _lsock is not None: + try: + _lsock.close() + except Exception: + pass + _lsock = None + _bound_ip = None + + +def url(): + """The address users should type on their phone. Prefers the + mDNS hostname (oreo.local) over the raw IP because it survives + DHCP-lease rotation and reads better off a tiny screen.""" + if _bound_ip is None: + return "" + return "http://oreo.local/" + + +def url_fallback(): + """Raw-IP version, shown beneath the mDNS URL for users on networks + where multicast DNS doesn't work (some corporate WiFi).""" + if _bound_ip is None: + return "" + return "http://%s/" % _bound_ip + + +def is_running(): + return _lsock is not None + + +# ── session state queries (UI hooks) ──────────────────────────────────── + +def _prune_sessions(): + """Drop sessions that haven't beaconed in SESSION_TTL_MS. Called + cheaply on every query so the UI never shows ghosts.""" + try: + import time as _t + now = _t.ticks_ms() + except Exception: + return + stale = [] + for sid, s in _sessions.items(): + last = s.get("last_ms", 0) + try: + if _t.ticks_diff(now, last) > SESSION_TTL_MS: + stale.append(sid) + except Exception: + pass + for sid in stale: + _sessions.pop(sid, None) + + +def list_sessions(): + """Snapshot for the WiFi/Transfer UI. Returns sessions sorted by + last-seen so the newest beacons are on top.""" + _prune_sessions() + items = [] + for sid, s in _sessions.items(): + items.append({ + "id": sid, + "state": s.get("state", "pending"), + "addr": s.get("addr", ""), + "uploads": s.get("uploads", 0), + "last_ms": s.get("last_ms", 0), + }) + items.sort(key=lambda v: v.get("last_ms", 0), reverse=True) + return items + + +def approve(sid): + """User tapped Allow on the WiFi/Transfer page.""" + if sid in _sessions: + _sessions[sid]["state"] = "approved" + return True + return False + + +def deny(sid): + if sid in _sessions: + _sessions[sid]["state"] = "denied" + return True + return False + + +def progress(): + """Live upload progress dict, or None if no transfer is in flight.""" + return _progress + + + + +def _new_session_id(): + """Generate a 6-char alphanumeric id excluding ambiguous chars + (0/O, 1/I, l). os.urandom would be ideal but isn't always present + on MicroPython; time-mixed prand works for our scale.""" + import time as _t + try: + import os as _o + seed = int.from_bytes(_o.urandom(4), "big") + except Exception: + seed = _t.ticks_ms() ^ id(_sessions) + out = [] + n = len(SESSION_CHARSET) + for _ in range(6): + seed = (seed * 1103515245 + 12345) & 0x7FFFFFFF + out.append(SESSION_CHARSET[seed % n]) + return "".join(out) + + +def tick(): + """Accept and handle one pending request, if any. Cheap when idle + (one non-blocking accept() syscall that ENOENTs out).""" + if _lsock is None: + return + try: + cli, _addr = _lsock.accept() + except OSError: + # EAGAIN — no pending connection. The common path. + return + except Exception: + return + try: + cli.settimeout(RECV_TIMEOUT) + except Exception: + pass + try: + _handle(cli) + except Exception as e: + try: + print("[http] handler crashed: %s" % e) + except Exception: + pass + finally: + try: + cli.close() + except Exception: + pass + gc.collect() + + +# ── request handling ──────────────────────────────────────────────────── + +def _read_until(sock, sep, max_len, deadline_ms=None): + """Buffered recv that returns once `sep` appears in the stream. + Returns (head_bytes, tail_bytes_after_sep) or (None, None) on + timeout / overflow. + + `deadline_ms` is a hard wallclock cap measured from now. We need + this so a TLS probe (browser ever-helpfully tries https first) + can't pin the run loop for the full RECV_TIMEOUT × N recvs while + the badge stops accepting button input. + """ + try: + import time as _t + if deadline_ms is not None: + deadline = _t.ticks_add(_t.ticks_ms(), int(deadline_ms)) + else: + deadline = None + except Exception: + deadline = None + buf = bytearray() + while True: + if len(buf) > max_len: + return None, None + if deadline is not None: + try: + if _t.ticks_diff(deadline, _t.ticks_ms()) <= 0: + return None, None + except Exception: + pass + try: + chunk = sock.recv(READ_CHUNK) + except OSError: + return None, None + if not chunk: + return None, None + # Cheap TLS-handshake reject: a ClientHello starts with the + # record-type byte 0x16. If the very first byte we ever see + # is that, the browser was talking HTTPS — bail immediately + # so we don't burn RECV_TIMEOUT waiting for header bytes + # that will never come. + if len(buf) == 0 and chunk and chunk[0] == 0x16: + return None, None + buf.extend(chunk) + idx = buf.find(sep) + if idx >= 0: + head = bytes(buf[:idx]) + tail = bytes(buf[idx + len(sep):]) + return head, tail + + +def _parse_headers(head): + """Returns (method, path, {header: value}). Headers are lowercased.""" + try: + text = head.decode("utf-8", "ignore") + except Exception: + text = "" + lines = text.split("\r\n") + if not lines: + return "", "", {} + parts = lines[0].split(" ") + method = parts[0] if len(parts) > 0 else "" + path = parts[1] if len(parts) > 1 else "/" + headers = {} + for line in lines[1:]: + if ":" not in line: + continue + k, _, v = line.partition(":") + headers[k.strip().lower()] = v.strip() + return method, path, headers + + +def _send_status(sock, code, reason, body=b"", content_type="text/html; charset=utf-8"): + head = ("HTTP/1.1 %d %s\r\n" + "Content-Type: %s\r\n" + "Content-Length: %d\r\n" + "Connection: close\r\n\r\n") % (code, reason, content_type, len(body)) + try: + sock.send(head.encode()) + if body: + sock.send(body) + except Exception: + pass + + +_UPLOAD_FORM = ( + b"" + b"" + b"Send to Oreo" + b"" + b"

Send to Oreo

" + b"
" + b"
" + b"

Waiting for badge approval

" + b"
------
" + b"

Show this code on the badge to allow this device.

" + b"
" + b"
" + b"

✓ Approved — pick a file:

" + b"" + b"" + b"
" + b"
" + b"

✓ Sent. Pick another?

" + b"
" + b"
" + b"" + b"" +) + + +def _parse_query(path): + """Return (path_without_query, {key: value}) — minimal urlparse + replacement since MicroPython doesn't ship one. Decodes %xx.""" + if "?" not in path: + return path, {} + pure, _, qs = path.partition("?") + out = {} + for part in qs.split("&"): + if not part: + continue + k, _, v = part.partition("=") + out[_pct(k)] = _pct(v) + return pure, out + + +def _pct(s): + """Tiny percent-decode. Only handles the ASCII subset we expect + in our query params — IDs are alphanumeric, no spaces.""" + out = [] + i = 0 + while i < len(s): + c = s[i] + if c == "%" and i + 2 < len(s): + try: + out.append(chr(int(s[i + 1:i + 3], 16))) + i += 3 + continue + except ValueError: + pass + out.append(c) + i += 1 + return "".join(out) + + +def _peer_addr(sock): + """Best-effort peer address for the session log.""" + try: + addr = sock.getpeername() + return "%s:%d" % (addr[0], addr[1]) + except Exception: + return "" + + +def _handle(sock): + """One request, one response. Closes on return.""" + head, after_head = _read_until(sock, b"\r\n\r\n", 8 * 1024, + deadline_ms=HEAD_DEADLINE_MS) + if head is None: + _send_status(sock, 408, "Request Timeout", b"timeout") + return + method, full_path, headers = _parse_headers(head) + path, qs = _parse_query(full_path) + + if method == "GET" and path in ("/", "/index.html"): + _send_status(sock, 200, "OK", _UPLOAD_FORM) + return + if method == "GET" and path == "/favicon.ico": + _send_status(sock, 204, "No Content", b"") + return + if method == "GET" and path == "/beacon": + _handle_beacon(sock, qs, _peer_addr(sock)) + return + if method == "GET" and path == "/session/new": + _handle_session_new(sock, _peer_addr(sock)) + return + if method == "POST" and path == "/upload": + _handle_upload(sock, headers, after_head, qs) + return + + _send_status(sock, 404, "Not Found", b"not found") + + +def _handle_session_new(sock, peer_addr): + """Hand the sender a freshly-minted session id so it doesn't have + to roll its own (saves us from collisions between two phones that + both picked the same client-side random).""" + _prune_sessions() + if len(_sessions) >= SESSION_MAX: + _send_status(sock, 503, "Service Unavailable", + b'{"error":"too many active sessions"}', + content_type="application/json") + return + sid = _new_session_id() + try: + import time as _t + now = _t.ticks_ms() + except Exception: + now = 0 + _sessions[sid] = {"state": "pending", "last_ms": now, + "addr": peer_addr, "uploads": 0} + body = ('{"id":"%s","state":"pending"}' % sid).encode() + _send_status(sock, 200, "OK", body, content_type="application/json") + + +def _handle_beacon(sock, qs, peer_addr): + """Heartbeat from a sender's browser. Bumps last_ms so the session + stays alive, and reports the current state so the UI on the phone + can switch from 'Waiting…' to 'Ready' once the badge approves.""" + sid = qs.get("id", "") + if not sid or len(sid) != 6: + _send_status(sock, 400, "Bad Request", + b'{"error":"missing id"}', + content_type="application/json") + return + _prune_sessions() + try: + import time as _t + now = _t.ticks_ms() + except Exception: + now = 0 + s = _sessions.get(sid) + if s is None: + if len(_sessions) >= SESSION_MAX: + _send_status(sock, 503, "Service Unavailable", + b'{"error":"too many sessions"}', + content_type="application/json") + return + _sessions[sid] = {"state": "pending", "last_ms": now, + "addr": peer_addr, "uploads": 0} + state = "pending" + else: + s["last_ms"] = now + if peer_addr: + s["addr"] = peer_addr + state = s["state"] + body = ('{"id":"%s","state":"%s"}' % (sid, state)).encode() + _send_status(sock, 200, "OK", body, content_type="application/json") + + +def _handle_upload(sock, headers, body_prefix, qs): + """Stream a single multipart part to disk. Phones POST exactly one + file per share, so we don't try to handle multi-part bodies — we + extract the first file part and ignore everything after. + + Gated on `?token=XXXXXX`: the session must exist AND be in state + "approved", which only happens after the user explicitly taps Allow + on the badge's WiFi/Transfer page. Without that the badge silently + accepting uploads from anyone on the LAN would be a footgun. + """ + token = qs.get("token", "") + s = _sessions.get(token) if token else None + if s is None or s.get("state") != "approved": + _send_status(sock, 403, "Forbidden", + b'{"error":"token not approved"}', + content_type="application/json") + return + ctype = headers.get("content-type", "") + if "multipart/form-data" not in ctype: + _send_status(sock, 400, "Bad Request", b"expected multipart/form-data") + return + # Extract boundary= from the Content-Type header. RFC 7578 wraps it + # in quotes sometimes; strip them. + bidx = ctype.find("boundary=") + if bidx < 0: + _send_status(sock, 400, "Bad Request", b"missing boundary") + return + boundary = ctype[bidx + len("boundary="):].split(";", 1)[0].strip().strip('"') + if not boundary: + _send_status(sock, 400, "Bad Request", b"empty boundary") + return + boundary_marker = ("--" + boundary).encode() + + try: + clen = int(headers.get("content-length", "0")) + except ValueError: + clen = 0 + if clen <= 0 or clen > MAX_BODY: + _send_status(sock, 413, "Payload Too Large", b"file too large") + return + + # Find the first part's header block. + head_buf = bytearray(body_prefix) + bytes_read = len(body_prefix) + while head_buf.find(b"\r\n\r\n") < 0: + if bytes_read > 16 * 1024 or bytes_read >= clen: + _send_status(sock, 400, "Bad Request", b"part header missing") + return + try: + chunk = sock.recv(READ_CHUNK) + except OSError: + _send_status(sock, 408, "Request Timeout", b"timeout reading part header") + return + if not chunk: + _send_status(sock, 400, "Bad Request", b"truncated") + return + head_buf.extend(chunk) + bytes_read += len(chunk) + hdr_end = head_buf.find(b"\r\n\r\n") + part_head = bytes(head_buf[:hdr_end]) + rest = bytes(head_buf[hdr_end + 4:]) + + # Extract filename from Content-Disposition. + cd = "" + for line in part_head.decode("utf-8", "ignore").split("\r\n"): + if line.lower().startswith("content-disposition:"): + cd = line + break + fname = "" + fkey = "filename=" + fi = cd.find(fkey) + if fi >= 0: + rest_cd = cd[fi + len(fkey):] + if rest_cd.startswith('"'): + end = rest_cd.find('"', 1) + if end > 0: + fname = rest_cd[1:end] + else: + fname = rest_cd.split(";", 1)[0].strip() + fname = _safe_filename(fname) + + dest_dir, kind = _route_for(fname) + if dest_dir is None: + _send_status(sock, 415, "Unsupported Media Type", + ("rejected: " + str(kind)).encode()) + return + + _ensure_dir(dest_dir) + dst_path = dest_dir + "/" + fname + # If something with this name already exists, suffix a counter so we + # don't clobber. Cheap and predictable. + if _os is not None: + try: + _os.stat(dst_path) + base = fname + ext = "" + dot = base.rfind(".") + if dot > 0: + base, ext = base[:dot], base[dot:] + i = 1 + while True: + cand = "%s/%s-%d%s" % (dest_dir, base, i, ext) + try: + _os.stat(cand) + i += 1 + except OSError: + dst_path = cand + break + except OSError: + pass + + # Stream the rest of the body into the file, scanning for the + # closing boundary. The "tail buffer" trick: keep the last + # (len(boundary)+8) bytes unwritten until we're sure they're not + # the start of the closing marker. + closing = b"\r\n" + boundary_marker + tail_keep = len(closing) + 4 + written = 0 + try: + f = open(dst_path, "wb") + except Exception: + _send_status(sock, 500, "Internal Error", + b"write failed (out of space?)") + return + + # Publish a live progress slot the WiFi UI polls every frame to + # render the progress bar. Total here is the *body length*, not + # the file length (we don't know the file length until we hit the + # closing boundary). Off by ~boundary-length bytes — good enough. + global _progress + _progress = {"id": token, "filename": fname, + "received": 0, "total": clen} + + try: + buf = bytearray(rest) + body_left = clen - bytes_read # bytes still on the wire after we filled head_buf + while True: + # If `buf` contains the closing boundary, flush up to it and stop. + idx = buf.find(closing) + if idx >= 0: + f.write(bytes(buf[:idx])) + written += idx + break + # Otherwise flush all but the last tail_keep bytes (they + # might be the start of the boundary). + if len(buf) > tail_keep: + cut = len(buf) - tail_keep + f.write(bytes(buf[:cut])) + written += cut + buf = buf[cut:] + if body_left <= 0 and not buf: + f.write(bytes(buf)) + written += len(buf) + break + try: + chunk = sock.recv(READ_CHUNK) + except OSError: + break + if not chunk: + f.write(bytes(buf)) + written += len(buf) + break + body_left -= len(chunk) + buf.extend(chunk) + # Live progress update — the WiFi UI samples this at 4 Hz + # so an O(1) dict assignment per chunk is fine. + _progress["received"] = written + max(0, len(buf) - tail_keep) + if written > MAX_BODY: + break + finally: + try: + f.close() + except Exception: + pass + _progress = None + + if written <= 0: + try: + _os.remove(dst_path) + except Exception: + pass + _send_status(sock, 400, "Bad Request", b"empty upload") + return + + # Mark the upload on the session so the WiFi UI can show "got 2 + # files from session ABCD12" instead of just "approved". + if token in _sessions: + _sessions[token]["uploads"] = _sessions[token].get("uploads", 0) + 1 + + # Surface the new file in the notification panel so the user knows + # where it landed without having to open the Gallery or Reader. + target_app = "gallery" if kind == "image" else "reader" + try: + from oreoOS import notifications + notifications.push("wifi", + "Received %s" % kind, + "%s · %d KB" % (fname[:18], written // 1024), + target=target_app) + except Exception: + pass + + # We deliberately DON'T auto-launch the receiving app — the file + # is already on flash and Gallery / Reader will pick it up the + # next time the user opens those apps. The notification above is + # the "your file arrived" cue. + + body = (b"" + b"" + b"Sent" + b"" + b"

Sent ✓

" + b"

Returning to upload form...

") + _send_status(sock, 200, "OK", body) diff --git a/oreoOS/launcher.py b/oreoOS/launcher.py index 7d26cb9..71d8b76 100644 --- a/oreoOS/launcher.py +++ b/oreoOS/launcher.py @@ -167,6 +167,12 @@ def run_app(os_obj, app): from oreoOS import notif_panel as _np_mod panel = _np_mod.get(os_obj) + # Pair-confirm overlay — sits above the notif panel and the app. + # While active it eats all input. Drawn after the panel so a fresh + # SMP prompt is never visually clobbered by a passing notification. + from oreoOS import pair_prompt as _pp_mod + pair_pp = _pp_mod.get(os_obj) + # Consecutive-frame error counter. A single bad frame (transient # I2C glitch, momentary divide-by-zero) shouldn't kill the whole # OS — we count, log, and only escalate to a crash screen if the @@ -206,6 +212,15 @@ def run_app(os_obj, app): if os_obj.buttons.just_pressed(b): if pm: pm.note_event() + # Pair-confirm prompt has top priority — when an + # SMP numeric comparison is in flight, NOTHING + # else gets the button. Eats every key including + # C and HOME so the user can't accidentally + # accept a stranger's pair attempt by hitting C. + if pair_pp.is_active(): + pair_pp.handle_button(b) + continue + # Global C hotkey → toggle the notification panel # before anything else gets a look. Works from any # app, including ones that would otherwise consume C. @@ -290,10 +305,32 @@ def run_app(os_obj, app): except Exception: pass + # Refresh the pair-confirm overlay's view of the SMP state + # every frame. Cheap — just one module-level dict peek. + pp_was_active = pair_pp.is_active() + try: + pair_pp.tick() + except Exception: + pass + if pp_was_active and not pair_pp.is_active(): + # Prompt dismissed — force the underlying app to redraw + # because the overlay painted over its frame. + try: + app._dirty = True + except Exception: + pass + try: app.update(dt) app.draw(os_obj.display) panel.draw(os_obj.display) + # Pair prompt is the topmost layer — drawn last so the + # 6-digit code can never be obscured by a panel slide-in. + if pair_pp.is_active(): + try: + pair_pp.draw(os_obj.display) + except Exception: + pass os_obj.display.present() frame_errs = 0 except Exception as e: @@ -336,6 +373,30 @@ def run_app(os_obj, app): # i2c.scan ran every iteration — now apps without an IMU # need pay nothing. + # BLE adv watchdog. Internal rate-limit means this is a + # ticks_diff comparison on the fast path; only every 30 s + # does it actually re-issue gap_advertise. Fixes the + # "phone says paired-but-not-connected and won't reconnect" + # case where adv silently stopped between sessions. + try: + from oreoWare import bt as _bt + _bt.watchdog_tick() + except Exception: + pass + + # LAN HTTP upload server tick. Non-blocking accept on the + # listening socket — costs one syscall per frame on the + # idle path. Handles one request synchronously when a + # phone POSTs, which briefly stalls the UI for the + # duration of the upload (fine for sub-second photo + # transfers; we'll move to a background worker later if + # uploads ever grow above a few hundred KB). + try: + from oreoOS import http_server as _hs + _hs.tick() + except Exception: + pass + elapsed = time.ticks_diff(time.ticks_ms(), now) if elapsed < FRAME_MIN_MS: time.sleep_ms(FRAME_MIN_MS - elapsed) @@ -615,6 +676,14 @@ def boot(): _bc("wifi.connect_from_config begin") wifi.connect_from_config() _bc("wifi.connect_from_config done") + # Bring up the LAN file-upload server now that we have an + # IP. Listener is non-blocking + idempotent; the run-loop + # tick handles accept(). No-op if WiFi failed. + try: + from oreoOS import http_server as _hs + _hs.start(os_obj) + except Exception: + pass else: _bc("wifi skipped") _bc("bt.init_from_config begin") diff --git a/oreoOS/notif_panel.py b/oreoOS/notif_panel.py index 8a83112..6a105c1 100644 --- a/oreoOS/notif_panel.py +++ b/oreoOS/notif_panel.py @@ -448,7 +448,24 @@ def _quick_states(self): from oreoWare import bt active = bt.is_active() out[1]["on"] = bool(active) - out[1]["sub"] = "on" if active else "off" + # Blink "transferring" while a peer is connected. The panel + # samples this dict every frame the panel is open, so just + # alternating the sublabel is enough to make the BT chip + # visibly pulse without a dedicated animator. ticks_ms / 500 + # gives a ~2 Hz cadence. + try: + busy = bt.is_busy() + except Exception: + busy = False + if busy: + try: + import time as _t + phase = (_t.ticks_ms() // 500) & 1 + except Exception: + phase = 0 + out[1]["sub"] = "transfer…" if phase else "transferring" + else: + out[1]["sub"] = "on" if active else "off" except Exception: pass return out diff --git a/oreoOS/pair_prompt.py b/oreoOS/pair_prompt.py new file mode 100644 index 0000000..9768b6d --- /dev/null +++ b/oreoOS/pair_prompt.py @@ -0,0 +1,133 @@ +"""On-screen BLE pair-confirm overlay. + +Renders a full-screen "Confirm pairing?" prompt the moment the BLE +stack hands us an SMP Numeric Comparison request. The 6-digit code +shown here is the same one the peer (phone / laptop / tablet) shows +on its own screen — the user reads both, confirms they match, and +taps A to accept or B to reject. + +The overlay is OS-global, like the notification panel: it's rendered +over whatever app is on screen, and it captures all input while +active. The launcher run loop ticks + draws it every frame and routes +button presses to `handle_button()` when `is_active()` is True. + +State flow: + idle → (peripheral_pair fires) → active + active → user A → accept → idle + active → user B → reject → idle + active → peer drops link → idle (cancel) +""" + +from oreoOS import api, theme + + +def get(os_obj): + """Singleton accessor — same pattern as notif_panel.""" + pp = getattr(os_obj, "_pair_prompt_ui", None) + if pp is not None: + return pp + pp = PairPrompt(os_obj) + try: + os_obj._pair_prompt_ui = pp + except Exception: + pass + return pp + + +class PairPrompt: + """Frame-driven overlay. Holds no state of its own — the SMP + prompt lives inside oreoWare.bt and we just mirror it visually.""" + + def __init__(self, os_obj): + self._os = os_obj + # Cached last-seen prompt dict so a transient peek-failure + # doesn't flicker the overlay off mid-prompt. + self._last = None + self._dirty = True + + # ── module-edge polling ───────────────────────────────────────────── + def tick(self): + """Re-read the SMP state from bt.py. Called from the OS run loop.""" + try: + from oreoWare import bt + live = bt.peek_pair_prompt() + except Exception: + live = None + if live is None and self._last is not None: + self._last = None + self._dirty = True + elif live is not None and self._last is not live: + self._last = live + self._dirty = True + + def is_active(self): + return self._last is not None + + # ── input routing ─────────────────────────────────────────────────── + def handle_button(self, btn): + """Routed from the run loop when is_active() is True. Returns + True iff the button was consumed.""" + if self._last is None: + return False + if btn == api.BTN_A: + try: + from oreoWare import bt + bt.accept_pair_prompt() + except Exception: + pass + self._last = None + self._dirty = True + return True + if btn in (api.BTN_B, api.BTN_HOME): + try: + from oreoWare import bt + bt.reject_pair_prompt() + except Exception: + pass + self._last = None + self._dirty = True + return True + return True # swallow other buttons so the app behind doesn't see them + + # ── render ────────────────────────────────────────────────────────── + def draw(self, d): + if self._last is None: + return + SW = api.SCREEN_W + SH = api.SCREEN_H + + # Dim the screen behind the prompt so the user can't possibly + # miss it. Fill, then draw a centered card. + d.rect(0, 0, SW, SH, theme.BG, fill=True) + + card_w = SW - 24 + card_h = 200 + card_x = 12 + card_y = (SH - card_h) // 2 + d.rect(card_x, card_y, card_w, card_h, theme.CARD, fill=True) + d.rect(card_x, card_y, card_w, 3, theme.PRIMARY, fill=True) + d.rect(card_x, card_y + card_h - 3, card_w, 3, theme.PRIMARY, fill=True) + + # Heading + title = "Confirm pairing?" + tw = len(title) * 16 + d.text(title, card_x + (card_w - tw) // 2, + card_y + 16, theme.TEXT_BRIGHT, scale=2) + + # Sub-heading + sub = "Does this code match your phone?" + sw = len(sub) * 8 + d.text(sub, card_x + (card_w - sw) // 2, + card_y + 46, theme.TEXT_DIM, scale=1) + + # The 6-digit code, big and centered. + code = "%06d" % int(self._last.get("passkey", 0)) + cw = len(code) * 32 + d.text(code, card_x + (card_w - cw) // 2, + card_y + 78, theme.PRIMARY, scale=4) + + # Footer actions. + foot_y = card_y + card_h - 36 + d.text("A=YES match", card_x + 14, foot_y, theme.PRIMARY, scale=1) + d.text("B=NO reject", card_x + 14, foot_y + 14, theme.MUTED, scale=1) + self._dirty = False diff --git a/oreoWare/bt.py b/oreoWare/bt.py index 2d9dda3..b8926a5 100644 --- a/oreoWare/bt.py +++ b/oreoWare/bt.py @@ -59,6 +59,12 @@ _tx_handle = None _conn = None # current central connection handle _rx_state = None # active reassembler, if any +_conn_started_ms = None # ticks_ms at the last CENTRAL_CONNECT, used + # to log how long the link survived on a + # subsequent CENTRAL_DISCONNECT +_conn_addr_type = None # addr_type from the live CENTRAL_CONNECT — + # needed so we can bond inbound pairs +_conn_addr_bytes = None # raw addr bytes from the live CENTRAL_CONNECT _HEADER_LEN = 5 # type (1) + length (4) _CRC_LEN = 4 @@ -125,18 +131,38 @@ def _classify_appearance(app): _AD_COMPLETE_UUID16 = 0x03 _AD_SHORT_NAME = 0x08 _AD_COMPLETE_NAME = 0x09 +_AD_TX_POWER = 0x0A _AD_APPEARANCE = 0x19 +_AD_MANUFACTURER = 0xFF + +# Company IDs we'll surface as "label hints" when a peer omits its name +# (common on iOS, which suppresses the GAP name until pairing). The full +# Bluetooth SIG assigned-numbers list is huge; we cover the brands the +# user is most likely to see in a conference hall. +_MFR_TAGS = { + 0x004C: "Apple", # iPhone, iPad, Mac, AirPods + 0x0006: "Microsoft", # Surface, Xbox + 0x00E0: "Google", # Pixel, Chromebook + 0x0075: "Samsung", + 0x038F: "Xiaomi", + 0x0157: "Anker", + 0x012D: "Sony", + 0x0001: "Ericsson", + 0x0059: "Nordic", # nRF dev boards +} def _parse_adv(adv_data): - """Walk AD structures. Returns (name_or_None, appearance_or_None, - [uuid16, ...]). Tolerates malformed payloads — we never trust a peer - advertiser to be conformant.""" + """Walk AD structures. Returns (name, appearance, [uuid16…], mfr_id). + Tolerates malformed payloads — we never trust a peer advertiser to + be conformant. mfr_id is the first 16-bit Company Identifier from + a manufacturer-specific data record (AD type 0xFF), or None.""" name = None appearance = None services = [] + mfr_id = None if not adv_data: - return name, appearance, services + return name, appearance, services, mfr_id i = 0 n = len(adv_data) while i < n: @@ -152,7 +178,11 @@ def _parse_adv(adv_data): payload = adv_data[i + 2 : i + 1 + ln] if ad_type in (_AD_COMPLETE_NAME, _AD_SHORT_NAME): try: - name = bytes(payload).decode("utf-8") + # Strip embedded NULs and trailing whitespace — iOS + # sometimes pads short names with NUL. + name = bytes(payload).decode("utf-8").rstrip("\x00").strip() + if not name: + name = None except Exception: name = None elif ad_type == _AD_APPEARANCE and len(payload) >= 2: @@ -161,8 +191,13 @@ def _parse_adv(adv_data): for j in range(0, len(payload), 2): if j + 1 < len(payload): services.append(payload[j] | (payload[j + 1] << 8)) + elif ad_type == _AD_MANUFACTURER and len(payload) >= 2: + # First two bytes are the little-endian Company ID; the rest + # is vendor-specific. We only keep the Company ID — that's + # enough to label "Apple" / "Samsung" / etc in the UI. + mfr_id = payload[0] | (payload[1] << 8) i += 1 + ln - return name, appearance, services + return name, appearance, services, mfr_id def _mac_str(addr_bytes): @@ -232,14 +267,32 @@ def scan_is_active(): def scan_results(): - """Filtered, RSSI-sorted snapshot of discovered devices.""" - out = [] + """Name-deduped, RSSI-sorted snapshot of discovered BLE devices. + + No filtering. The earlier appearance/service blocklists hid phones + and useful peripherals along with audio gear, and the trade wasn't + worth it — the user wants to see *everything* the radio sees. + + iOS and recent Android phones rotate their Resolvable Private + Address every ~15 min, so the same physical phone keeps showing + up under fresh MACs. We collapse all entries that share a real + (non-MAC-tail-fallback) name into one row, picking the strongest + RSSI as the representative. Anonymous "device EE:FF" entries stay + individual because we can't tell them apart. + """ + by_name = {} + anon = [] for v in _scan_results.values(): - if _is_audio_appearance(v.get("appearance")): - continue - if _has_audio_service(v.get("services", [])): - continue - out.append(v) + nm = v.get("name") or "" + if nm and not nm.startswith("device "): + cur = by_name.get(nm) + # Pick the entry with the better RSSI as the live one — its + # mac/addr_type are the ones we'll try to connect to. + if cur is None or (v.get("rssi", -999) > cur.get("rssi", -999)): + by_name[nm] = v + else: + anon.append(v) + out = list(by_name.values()) + anon out.sort(key=lambda d: d.get("rssi", -999), reverse=True) return out @@ -278,21 +331,40 @@ def _addr_from_mac(mac_str): def _apply_security_config(ble): - """Enable bonding + LE Secure Connections with JustWorks IO caps. - - Best-effort: older MicroPython builds may not support every kwarg, - so each is wrapped individually so the radio still works in a - degraded mode (no bonding) if a build is missing one.""" + """Enable bonding + LE Secure Connections with DISPLAY_YESNO IO caps. + + DISPLAY_YESNO + mitm=True asks the stack to negotiate the strongest + method available with the peer; with a modern phone that's + Numeric Comparison, which fires _IRQ_PASSKEY_ACTION on the badge so + we can render a confirm prompt. Older peers that can't do SC fall + back to legacy pairing. + + Each kwarg is wrapped individually so the radio still works in a + degraded mode if an older MicroPython build is missing one of the + keys. We PRINT which kwargs actually stuck so the user can see + from the serial log whether the build supports Secure Connections + at all — if `le_secure` or `mitm` got rejected, the stack drops + to JustWorks pairing and the numeric-comparison prompt will never + fire (which was the entire point of this config). + """ + accepted = [] + rejected = [] for kw in ( {"bond": True}, - {"mitm": False}, + {"mitm": True}, {"le_secure": True}, - {"io": _IO_NO_INPUT_NO_OUTPUT}, + {"io": _IO_DISPLAY_YESNO}, ): try: ble.config(**kw) - except Exception: - pass + accepted.append(list(kw.keys())[0]) + except Exception as e: + rejected.append("%s(%s)" % (list(kw.keys())[0], str(e)[:24])) + try: + print("[bt] security: accepted=%s rejected=%s" % + (",".join(accepted) or "-", ",".join(rejected) or "-")) + except Exception: + pass def start_pair(target): @@ -307,8 +379,24 @@ def start_pair(target): if _pair_state in (PAIR_CONNECTING, PAIR_ENCRYPTING): return False - addr_type = target.get("addr_type", 0) - addr = target.get("addr") + # Pull addr_type from the live scan dict if the caller didn't + # pass one through — defaulting to 0 (PUBLIC) silently bricked + # iPhone connections because iOS uses Random Resolvable addresses + # (type 1). The scan IRQ stashes the correct type per-entry. + mac_key = (target.get("mac") or "").upper() + scan_hit = None + for k, v in _scan_results.items(): + if k.upper() == mac_key: + scan_hit = v + break + addr_type = target.get("addr_type") + if addr_type is None and scan_hit is not None: + addr_type = scan_hit.get("addr_type", 0) + if addr_type is None: + addr_type = 0 + addr = target.get("addr") + if addr is None and scan_hit is not None: + addr = scan_hit.get("addr") if addr is None: addr = _addr_from_mac(target.get("mac", "")) if addr is None: @@ -399,11 +487,21 @@ def is_active(): def set_active(on): """Bring the radio up or down. On up, also registers the transfer - service and starts advertising as 'Oreo'.""" + service, applies JustWorks security so inbound pair attempts from + phones don't fail on IO-capability mismatch, and starts advertising. + + The security config has to land BEFORE the first inbound connection + or the stack will default to KEYBOARD_DISPLAY IO caps. When a phone + then initiates bonding, our peripheral side claims it can show a + passkey, the phone asks for one, the badge has nothing to give, and + the link is dropped. With JustWorks (NO_INPUT_NO_OUTPUT) the pair + completes silently. + """ try: ble = _get_ble() ble.active(on) if on: + _apply_security_config(ble) _register_service(ble) _start_advertising(ble) else: @@ -421,13 +519,18 @@ def toggle(): def init_from_config(): - """Enable BT on boot when the deploy-baked secrets request it.""" - try: - from secrets import BT_AUTO_ENABLE - if BT_AUTO_ENABLE: - set_active(True) - except Exception: - pass + """No-op on boot. + + Bluetooth is currently a Coming Soon feature — the BT settings + page renders a placeholder and file transfer runs over WiFi + (see oreoOS.http_server). Leaving the radio off at boot saves + flash, RAM, and ~3-4 mA standby; the user can still flip BT on + manually via a future settings toggle. When we wire the radio + back into a real user flow (peer presence, IR-Quest assist, etc.) + this function will read a `bt_enable` setting from oreoOS + settings instead of the build-time .env flag. + """ + return # ─── service registration ──────────────────────────────────────────────── @@ -460,28 +563,86 @@ def _register_service(ble): # ─── advertising ───────────────────────────────────────────────────────── +# Generic-tag appearance value — tells scanning phones to render us with +# a "generic" icon rather than "unknown peripheral". 0x0000 is the safest +# value across iOS / Android — it reads as "Unknown" but still passes +# their "advertiser must declare an Appearance" filters. If we ever ship +# a watch form factor, 0x00C0 (Generic Watch) would be a better fit. +_APPEARANCE_GENERIC = 0x0000 + + def _adv_payload(name): - """Build a minimal connectable adv payload: Flags (LE general disc) + - Complete Local Name. Stays well under the 31-byte limit.""" + """Connectable adv payload: Flags + Appearance + Complete Local Name. + + iOS and recent Android scanners often hide or de-prioritise devices + that don't declare an Appearance, so we always include one. Total + payload stays under the 31-byte cap for typical badge names — we + truncate the name itself if needed and rely on the scan response + to carry the full string.""" + name_bytes = name.encode("utf-8") + # 2-byte appearance (LE) + appearance = bytes((3, _AD_APPEARANCE, + _APPEARANCE_GENERIC & 0xFF, + (_APPEARANCE_GENERIC >> 8) & 0xFF)) + # Truncate name so Flags(3) + Appearance(4) + NameHdr(2) + name <= 31. + max_name = 31 - 3 - 4 - 2 + if len(name_bytes) > max_name: + name_bytes = name_bytes[:max_name] + return (b"\x02\x01\x06" # Flags + + appearance + + bytes((len(name_bytes) + 1, 0x09)) # Complete Local Name + + name_bytes) + + +def _scan_resp_payload(name): + """Scan-response payload returned to active scanners. Carries the + full untruncated name (so phones running active scans see "Oreo + Badge" cleanly) plus the 128-bit service UUID so apps that filter + by service can find us.""" + import bluetooth name_bytes = name.encode("utf-8") - return (b"\x02\x01\x06" - + bytes((len(name_bytes) + 1, 0x09)) + name_bytes) + name_ad = bytes((len(name_bytes) + 1, 0x09)) + name_bytes + # 128-bit Complete Service UUID list. Endianness is little-endian + # on the wire (Core Spec Vol 3 Part C 18.2). The constant matches + # the UUID registered in _register_service. + svc_bytes = bytes(reversed(bytes( + bluetooth.UUID("6f72656f-0000-1000-8000-00805f9b34fb")))) + svc_ad = bytes((len(svc_bytes) + 1, 0x07)) + svc_bytes # 0x07 = Complete 128-bit UUIDs + return name_ad + svc_ad def _start_advertising(ble): - """Set the GAP name and start advertising at the configured interval.""" + """Set the GAP name and start advertising. Default 200 ms interval + so phones (which scan in short bursts) reliably see us within one + scan window. Override via secrets.BT_ADV_INTERVAL_MS.""" try: ble.config(gap_name=DEVICE_NAME) except Exception: pass - interval_us = 500_000 + interval_us = 200_000 # was 500_000 — too slow for iOS opportunistic scans try: from secrets import BT_ADV_INTERVAL_MS interval_us = int(BT_ADV_INTERVAL_MS) * 1000 except Exception: pass + adv = _adv_payload(DEVICE_NAME) try: - ble.gap_advertise(interval_us, adv_data=_adv_payload(DEVICE_NAME)) + resp = _scan_resp_payload(DEVICE_NAME) + except Exception: + resp = None + # Try the resp_data kwarg first; older MicroPython builds without it + # fall back silently to adv-only. + try: + if resp is not None: + ble.gap_advertise(interval_us, adv_data=adv, resp_data=resp) + else: + ble.gap_advertise(interval_us, adv_data=adv) + except TypeError: + # Build doesn't accept resp_data — keep going with adv only. + try: + ble.gap_advertise(interval_us, adv_data=adv) + except Exception: + pass except Exception: pass @@ -514,10 +675,32 @@ def _start_advertising(ble): _pair_target = None # {"mac", "name", "kind", "addr_type", "addr", "conn"} _pair_message = "" -# Security config — JustWorks pairing (no passkey UI). The on-badge "do -# you want to pair with X?" confirmation popup is the consent step; once -# the user accepts on the badge, the BLE handshake completes silently. -_IO_NO_INPUT_NO_OUTPUT = 3 +# Security config — LE Secure Connections with Numeric Comparison. +# Both the badge (DISPLAY_YESNO IO caps) and a modern phone (also +# DISPLAY_YESNO) end up running the SMP "numeric comparison" method: +# the stack hands us a 6-digit confirmation value, the phone shows the +# same one, the user taps YES on both sides. This is the strongest +# pairing method available on consumer BLE and the only one that gives +# us a real on-badge consent prompt. +# +# Older phones / odd peers that can't negotiate Secure Connections fall +# back to legacy pairing; we keep mitm=True so the stack picks the most +# secure method available rather than silently dropping to JustWorks. +_IO_DISPLAY_YESNO = 1 +_IO_NO_INPUT_NO_OUTPUT = 3 + +# Passkey-action codes the stack hands back in _IRQ_PASSKEY_ACTION. +_PASSKEY_ACTION_NONE = 0 +_PASSKEY_ACTION_INPUT = 1 # peer-side: we'd need a keyboard +_PASSKEY_ACTION_DISP = 2 # legacy: display passkey, peer enters +_PASSKEY_ACTION_NUMCMP = 4 # Secure-Connections numeric comparison + +_IRQ_PASSKEY_ACTION = 31 + +# Inbound pair-prompt state. Populated by _IRQ_PASSKEY_ACTION and +# polled by oreoOS.pair_prompt to drive the on-screen confirmation +# overlay. Cleared by accept_pair_prompt() / reject_pair_prompt(). +_pair_prompt = None # {"conn", "action", "passkey", "name", "mac"} class _RxState: @@ -531,14 +714,83 @@ def __init__(self): def _irq(event, data): - global _conn, _rx_state + global _conn, _rx_state, _conn_started_ms + global _conn_addr_type, _conn_addr_bytes if event == _IRQ_CENTRAL_CONNECT: conn_handle, _addr_type, _addr = data + # Single-connection policy: if we already have a peer, reject + # the second one by disconnecting it immediately. MicroPython + # is compiled with MAX_CONNECTIONS=3, but the UX we want is + # "one badge ↔ one peer at a time" so the user doesn't get a + # rats' nest of half-connected phones. (A true firmware change + # would set CONFIG_BT_NIMBLE_MAX_CONNECTIONS=1, but that + # requires recompiling the MicroPython port.) + if _conn is not None and conn_handle != _conn: + try: + _get_ble().gap_disconnect(conn_handle) + print("[bt] rejected 2nd connect handle=%d (busy with %d)" % + (conn_handle, _conn)) + except Exception: + pass + return _conn = conn_handle _rx_state = _RxState() + # Stash addr so a successful inbound bond can be saved into the + # bond store. Without this we'd only persist outbound (we- + # initiated) pairs, and the user would see "phone paired" on + # their phone but no entry in the badge's Paired list. + _conn_addr_type = _addr_type + try: + _conn_addr_bytes = bytes(_addr) + except Exception: + _conn_addr_bytes = None + try: + import time as _t + _conn_started_ms = _t.ticks_ms() + print("[bt] central connect handle=%d type=%d" % + (conn_handle, _addr_type)) + except Exception: + _conn_started_ms = None + # Surface the new peer in the notification panel so the user + # knows something connected even if they're not on the BT + # page. Best-effort: notifications module may not be importable + # in the IRQ context on some builds, so swallow errors. + try: + mac = _mac_str(bytes(_addr)) + from oreoOS import notifications as _n + _n.push("bt", "BT connected", mac, target=None) + except Exception: + pass + # Stop advertising while a peer is connected. With single- + # connection policy in force, continuing to advertise just + # invites failed connects from other devices. + try: + _get_ble().gap_advertise(None) + except Exception: + pass + # Try to push a larger MTU. Phones almost always request one; + # missing this exchange has caused some stacks to drop the link + # after the first GATT read returns an undersized ATT response. + try: + _get_ble().gattc_exchange_mtu(conn_handle) + except Exception: + # Older MicroPython builds don't expose gattc_exchange_mtu + # from the peripheral side — silently fine, the central + # drives MTU negotiation in that case. + pass elif event == _IRQ_CENTRAL_DISCONNECT: + try: + import time as _t + held = _t.ticks_diff(_t.ticks_ms(), _conn_started_ms) \ + if _conn_started_ms else -1 + print("[bt] central disconnect held=%d ms" % held) + except Exception: + pass _conn = None _rx_state = None + _conn_started_ms = None + _conn_addr_type = None + _conn_addr_bytes = None # Restart advertising so the next peer can find us. try: _start_advertising(_get_ble()) @@ -557,26 +809,67 @@ def _irq(event, data): # merge into the dict so the most recent RSSI + any newly # discovered name wins. bytes(addr) copies out of the IRQ-scoped # buffer so we can keep the dict entry around. - mac = _mac_str(bytes(addr)) - name, appearance, services = _parse_adv(bytes(adv_data)) + mac = _mac_str(bytes(addr)) + name, appearance, services, mfr_id = _parse_adv(bytes(adv_data)) + # Best-effort label when the peer suppresses its name (common + # on iOS — and on Android in privacy mode). Cascade of + # fallbacks: real name → manufacturer tag → MAC tail. The MAC + # tail "device EE:FF" beats "(unknown)" because the user can + # at least see how many distinct anonymous peers are nearby. + if not name and mfr_id is not None: + tag = _MFR_TAGS.get(mfr_id) + if tag: + name = tag + " device" + if not name: + tail = mac[-5:] if len(mac) >= 5 else mac + # addr_type 1 = Random — tag it so the user knows the name + # won't stick even after a successful association. + if addr_type == 1: + name = "device " + tail + " (R)" + else: + name = "device " + tail cur = _scan_results.get(mac) if cur is None: cur = {"mac": mac, - "name": name or "(unknown)", + "name": name, "rssi": rssi, "appearance": appearance, "services": services, - "type": _classify_appearance(appearance)} + "type": _classify_appearance(appearance), + # Capture addr_type so start_pair() can pass the + # correct type to gap_connect — defaulting to 0 + # (PUBLIC) was breaking every iPhone connection + # because iOS uses Random Resolvable addresses. + "addr_type": addr_type, + "addr": bytes(addr), + "mfr_id": mfr_id} _scan_results[mac] = cur else: - cur["rssi"] = rssi - if name and (cur["name"] == "(unknown)" or len(name) > len(cur["name"])): + cur["rssi"] = rssi + cur["addr_type"] = addr_type + cur["addr"] = bytes(addr) + # Names from active scan responses are usually more complete + # than the ones in the initial adv. Prefer real names over + # our manufacturer-tag / MAC-tail fallbacks; among real + # names prefer the longer one. + cur_name = cur["name"] or "" + cur_is_fallback = (cur_name.startswith("device ") or + cur_name.endswith("device")) + new_is_fallback = (name and (name.startswith("device ") or + name.endswith("device"))) + if name and not new_is_fallback and ( + cur_is_fallback or len(name) > len(cur_name)): + cur["name"] = name + elif name and new_is_fallback and cur_is_fallback and \ + len(name) > len(cur_name): cur["name"] = name if appearance and not cur["appearance"]: cur["appearance"] = appearance cur["type"] = _classify_appearance(appearance) if services: cur["services"] = services + if mfr_id and not cur.get("mfr_id"): + cur["mfr_id"] = mfr_id elif event == _IRQ_SCAN_DONE: global _scan_active _scan_active = False @@ -607,28 +900,86 @@ def _irq(event, data): conn_handle, encrypted, _auth, bonded, _ks = data except (ValueError, TypeError): return - if _pair_target is None: - return if encrypted: - # Persist the bond record (separate from BLE secrets, which - # arrive via _IRQ_SET_SECRET as their own events). + if _pair_target is not None: + # OUTBOUND pair (we initiated via start_pair). The + # target dict already holds the friendly name / kind + # from the scan result, so the bond record is rich. + try: + from oreoOS import bonds + bonds.add(_pair_target["mac"], + _pair_target.get("name", ""), + _pair_target.get("kind", "other")) + except Exception: + pass + _set_pair_state(PAIR_DONE, + "paired" + (" + bonded" if bonded else "")) + elif _conn_addr_bytes is not None: + # INBOUND pair (phone initiated). We don't know the + # peer's friendly name — we never scanned it — so the + # bond entry gets a MAC-derived placeholder name that + # the user can rename later. This is what fixes the + # "phone says paired but Paired list is empty" bug. + try: + from oreoOS import bonds + mac = _mac_str(_conn_addr_bytes) + bonds.add(mac, "BT peer " + mac[-5:], "other") + print("[bt] inbound bond saved: %s" % mac) + except Exception as e: + try: + print("[bt] inbound bond save failed: %s" % e) + except Exception: + pass + # Keep the link open. We used to gap_disconnect here on the + # theory that the bond record was the thing the user wanted + # — but to the user it read as "I tried to connect and it + # immediately hung up." Leave the link up so the peer's + # GATT exchange (file transfer, etc.) can proceed, and let + # whichever side actually finishes its work close it. + else: + if _pair_target is not None: + _set_pair_state(PAIR_FAILED, "encryption rejected") + elif event == _IRQ_PASSKEY_ACTION: + # data = (conn_handle, action, passkey) + # Fired during inbound pairing when the SMP method needs user + # interaction. We only handle NUMCMP (the path Secure Connections + # takes when both sides are DISPLAY_YESNO) — for DISP we'd need + # to render a 6-digit code and have the user type it on the + # phone, which we can support later; for now we treat anything + # other than NUMCMP as a silent accept so legacy pairing still + # completes for older peers. + try: + conn_handle, action, passkey = data + except (ValueError, TypeError): + return + if action == _PASSKEY_ACTION_NUMCMP: + global _pair_prompt + # Resolve a friendly name for the prompt — _scan_results is + # keyed by MAC, but we don't actually know the MAC of an + # inbound connection at this point. Best we can do is show + # the conn handle alongside the number; the BT app's + # connect notification already surfaced the MAC. + _pair_prompt = { + "conn": conn_handle, + "action": action, + "passkey": int(passkey), + "name": "BT peer", + "mac": "", + } try: - from oreoOS import bonds - bonds.add(_pair_target["mac"], - _pair_target.get("name", ""), - _pair_target.get("kind", "other")) + print("[bt] pair prompt passkey=%06d conn=%d" % + (int(passkey), conn_handle)) except Exception: pass - _set_pair_state(PAIR_DONE, - "paired" + (" + bonded" if bonded else "")) - # Politely disconnect now that bonding's stashed — the user - # doesn't need an active link sitting open. + else: + # Legacy / unsupported action — silently approve so we + # don't strand the peer. They'll either succeed via legacy + # JustWorks-ish fallback or the encryption update will + # later flag failure. try: - _get_ble().gap_disconnect(conn_handle) + _get_ble().gap_passkey(conn_handle, action, passkey) except Exception: pass - else: - _set_pair_state(PAIR_FAILED, "encryption rejected") elif event == _IRQ_SET_SECRET: sec_type, key, value = data try: @@ -657,6 +1008,170 @@ def _notify(status_byte): pass +# ── transfer-progress notification (throttled) ────────────────────────── +# Emits "BT receiving 30%" notifications during a long file transfer so +# the notification panel reflects live progress. Updates fire at most +# every PROGRESS_STEP_BYTES of fresh payload to keep the LCD redraw cost +# bounded (the panel ticks the BT icon as a side effect of the push). +PROGRESS_STEP_BYTES = 64 * 1024 +_last_progress_bytes = 0 + + +def _emit_progress(received, total, type_byte): + global _last_progress_bytes + if total <= 0: + return + if received == total: + _last_progress_bytes = 0 + return + if (received - _last_progress_bytes) < PROGRESS_STEP_BYTES: + return + _last_progress_bytes = received + pct = int((received * 100) // total) + kind = "image" if type_byte == b"I" else \ + "text" if type_byte == b"T" else \ + "markdown" if type_byte == b"M" else "file" + try: + from oreoOS import notifications as _n + _n.push("bt", + "Receiving %s" % kind, + "%d%% · %d/%d KB" % (pct, received // 1024, total // 1024), + target=None) + except Exception: + pass + + +def is_busy(): + """True iff a peer is currently connected. Used by the notif panel + to drive the blinking BT icon while a transfer is live.""" + return _conn is not None + + +def disconnect_peer(): + """Force-disconnect the currently connected peer, if any. Called + by the BT app's "Disconnect" action on a paired-but-active row.""" + if _conn is None: + return False + try: + _get_ble().gap_disconnect(_conn) + return True + except Exception: + return False + + +def is_advertising(): + """Best-effort introspection — MicroPython doesn't expose a direct + 'is advertising' getter, so we infer: BLE is active AND no peer is + currently connected (we stop adv on connect).""" + try: + return bool(_get_ble().active()) and _conn is None + except Exception: + return False + + +def force_readvertise(): + """Stop + restart advertising. Used as a watchdog kick when a + bonded peer claims it can't see us — also re-applies security + config + service registration in case either got cleared after a + failed connection attempt.""" + try: + ble = _get_ble() + if not ble.active(): + return False + try: + ble.gap_advertise(None) + except Exception: + pass + _apply_security_config(ble) + _register_service(ble) + _start_advertising(ble) + return True + except Exception: + return False + + +# ── advertising watchdog ──────────────────────────────────────────────── +# A bonded phone expects the badge to be findable whenever it's nearby. +# In practice we've seen the badge stop advertising on a transient stack +# glitch (failed connect attempt, RX queue overflow, etc.) and the phone +# then just shows "paired, not connected" with no recovery. The watchdog +# is called by the OS run loop on a slow cadence and forces a re-adv any +# time we should be discoverable but apparently aren't. + +_watchdog_last_ms = 0 +_WATCHDOG_INTERVAL_MS = 30 * 1000 + + +def watchdog_tick(): + """Called from the OS run loop. Cheap when nothing's wrong.""" + global _watchdog_last_ms + try: + import time as _t + now = _t.ticks_ms() + except Exception: + return + if _t.ticks_diff(now, _watchdog_last_ms) < _WATCHDOG_INTERVAL_MS: + return + _watchdog_last_ms = now + # If BT is on AND nobody's connected, we MUST be advertising. There + # is no way for MicroPython to tell us "advertising stopped" — the + # only safe action is to nudge gap_advertise periodically. Cheap: + # calling gap_advertise with a payload identical to the running one + # is effectively a no-op inside NimBLE. + if not is_active(): + return + if _conn is not None: + return + try: + _start_advertising(_get_ble()) + except Exception: + pass + + +# ── inbound pair-confirm prompt API ───────────────────────────────────── +# Surfaces the SMP Numeric Comparison value to oreoOS.pair_prompt so it +# can render the on-screen confirmation overlay. Three calls cover the +# state machine: peek to read the live prompt, accept to confirm, reject +# to refuse. + +def peek_pair_prompt(): + """Return the live pair-prompt dict, or None if no prompt is + pending. Caller MUST NOT mutate the returned dict.""" + return _pair_prompt + + +def accept_pair_prompt(): + """User tapped YES on the confirm overlay. Pass our agreement back + to the SMP layer; encryption completes asynchronously and + _IRQ_ENCRYPTION_UPDATE fires when it's done.""" + global _pair_prompt + p = _pair_prompt + _pair_prompt = None + if p is None: + return False + try: + _get_ble().gap_passkey(p["conn"], p["action"], p["passkey"]) + return True + except Exception: + return False + + +def reject_pair_prompt(): + """User tapped NO. Drop the link — SMP will tear itself down once + the connection's gone, and the peer sees an explicit refusal + instead of a silent timeout.""" + global _pair_prompt + p = _pair_prompt + _pair_prompt = None + if p is None: + return False + try: + _get_ble().gap_disconnect(p["conn"]) + return True + except Exception: + return False + + def _feed(chunk): """Push bytes into the active reassembler. May complete one transfer per call (or part of one — we resume across chunks).""" @@ -717,6 +1232,14 @@ def _feed(chunk): take = chunk[pos:pos + remaining] st.buf.extend(take) pos += len(take) + # Progress hook — best-effort throttled notification update so + # the user can watch a large image come in. We post at most + # every ~64 KB of fresh data; finer granularity would spam the + # notification panel and the SPI bus the LCD uses. + try: + _emit_progress(len(st.buf), st.length, st.type_byte) + except Exception: + pass if len(st.buf) < st.length: return diff --git a/oreoWare/wifi.py b/oreoWare/wifi.py index 5d784f0..afe8592 100644 --- a/oreoWare/wifi.py +++ b/oreoWare/wifi.py @@ -77,9 +77,40 @@ def _apply_power_cap(wlan): pass +MDNS_HOSTNAME = "oreo" + + +def _apply_hostname(wlan): + """Set the WiFi hostname so the ESP-IDF mDNS responder advertises + `oreo.local` to the LAN. MicroPython builds vary in which kwarg key + actually takes effect (`hostname` vs `dhcp_hostname`) so we try both. + + On most ESP32 IDF builds this is enough — IDF auto-spins up an mDNS + responder for the configured hostname. Older builds that don't have + the responder enabled will silently ignore this and `oreo.local` + won't resolve; the badge will still be reachable by IP. + """ + for key in ("hostname", "dhcp_hostname"): + try: + wlan.config(**{key: MDNS_HOSTNAME}) + except Exception: + pass + # Some forks expose a top-level mdns start. Best-effort. + try: + import network as _net + if hasattr(_net, "hostname"): + _net.hostname(MDNS_HOSTNAME) + except Exception: + pass + + def connect(ssid, password, timeout_ms=12000): wlan = _get_wlan() wlan.active(True) + # Hostname must be set BEFORE associate — the DHCP REQUEST carries + # the hostname as Option 12 and the IDF mDNS service uses the same + # name. Setting it after gets ignored by some routers. + _apply_hostname(wlan) _apply_power_cap(wlan) if wlan.isconnected() and wlan.config("essid") == ssid: return True @@ -89,9 +120,11 @@ def connect(ssid, password, timeout_ms=12000): if time.ticks_diff(time.ticks_ms(), start) > timeout_ms: return False time.sleep_ms(200) - # Re-apply power-save AFTER association — some IDF versions reset PM - # state on connect and need it set again to actually take effect. + # Re-apply power-save + hostname AFTER association — some IDF + # versions reset PM/hostname state on connect and need it set + # again to actually take effect for the live link. _apply_power_cap(wlan) + _apply_hostname(wlan) return True @@ -321,15 +354,23 @@ def ping(host="8.8.8.8", port=53, timeout_s=2): pass -def speed_test(bytes_=500_000, timeout_s=15): +def speed_test(bytes_=200_000, timeout_s=10, pump_cb=None): """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. + and report observed kbps. + + `pump_cb` is called between every recv() so the caller can keep + the OS run loop alive — typically passes a closure that re-reads + the button matrix and bumps the screen. The socket is set to a + 50-ms recv timeout so each blocked read can't freeze the UI for + more than that. If pump_cb returns True the test aborts cleanly + (used by the WiFi app's "cancel by any keypress" UX). + + Default size dropped from 500 KB → 200 KB. The handshake is + still amortised but the test finishes in ~1–2 s on home WiFi + and ~5 s on weak links, both well within user attention span. - 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. + Returns (ok, kbps, elapsed_ms). On failure or cancel, + (False, 0, elapsed). """ try: import socket as _s @@ -344,19 +385,38 @@ def speed_test(bytes_=500_000, timeout_s=15): 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 + cancelled = False + + # 50 ms recv timeout caps each blocking read so the run loop + # can resume between reads. This is the difference between + # "buttons frozen for 5 s" and "buttons responsive throughout". + PER_READ_S = 0.05 + + def _pump(): + """Run the caller's pump callback if any; flag cancel.""" + nonlocal cancelled + if pump_cb is None: + return + try: + if pump_cb(): + cancelled = True + except Exception: + pass + try: raw = _s.socket() - try: raw.settimeout(timeout_s) + try: raw.settimeout(timeout_s) # connect can take a while except Exception: pass raw.connect(addr) s = _ssl.wrap_socket(raw, server_hostname=host) - try: s.settimeout(timeout_s) + try: s.settimeout(PER_READ_S) except Exception: pass req = ("GET %s HTTP/1.1\r\nHost: %s\r\n" "User-Agent: OreoBadge-Speed\r\n" @@ -364,13 +424,19 @@ def speed_test(bytes_=500_000, timeout_s=15): "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. + # Read headers. With the short PER_READ_S timeout each recv + # bails fast on no-data; we loop until the separator arrives + # or the overall deadline blows. while b"\r\n\r\n" not in head: + if cancelled: + break if time.ticks_diff(deadline, time.ticks_ms()) <= 0: break try: chunk = s.read(2048) + except OSError: + _pump() + continue except Exception: break if not chunk: @@ -378,22 +444,28 @@ def speed_test(bytes_=500_000, timeout_s=15): head += chunk if len(head) > 16 * 1024: break - # Count any payload bytes that arrived alongside the headers. + _pump() 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. + received = len(head) - (sep + 4) + # Drain the body. while body_started: + if cancelled: + break if time.ticks_diff(deadline, time.ticks_ms()) <= 0: break try: chunk = s.read(4096) + except OSError: + _pump() + continue except Exception: break if not chunk: break received += len(chunk) + _pump() if received >= bytes_: break except Exception: @@ -406,6 +478,8 @@ def speed_test(bytes_=500_000, timeout_s=15): except Exception: pass elapsed = max(1, time.ticks_diff(time.ticks_ms(), t0)) + if cancelled: + return (False, 0, elapsed) if received <= 0: return (False, 0, elapsed) kbps = int((received * 8) // elapsed) # bytes*8 / ms = kbps diff --git a/tools/build_release.py b/tools/build_release.py index 830e92d..9de77c1 100644 --- a/tools/build_release.py +++ b/tools/build_release.py @@ -56,6 +56,8 @@ "oreoOS/store.py", "oreoOS/notifications.py", "oreoOS/notif_panel.py", + "oreoOS/pair_prompt.py", + "oreoOS/http_server.py", "oreoOS/pixelfont.py", "oreoOS/power.py", "oreoOS/splash.py", diff --git a/tools/deploy.py b/tools/deploy.py index 13f6420..35c89ca 100644 --- a/tools/deploy.py +++ b/tools/deploy.py @@ -151,6 +151,8 @@ def bump_patch_version(): ("oreoOS/store.py", "oreoOS/store.py"), ("oreoOS/notifications.py", "oreoOS/notifications.py"), ("oreoOS/notif_panel.py", "oreoOS/notif_panel.py"), + ("oreoOS/pair_prompt.py", "oreoOS/pair_prompt.py"), + ("oreoOS/http_server.py", "oreoOS/http_server.py"), ("oreoOS/gestures.py", "oreoOS/gestures.py"), # Hardware drivers