diff --git a/docs/screenshots/google_photos_connect.png b/docs/screenshots/google_photos_connect.png index e56f58ab..7df0b99d 100644 Binary files a/docs/screenshots/google_photos_connect.png and b/docs/screenshots/google_photos_connect.png differ diff --git a/lib/examples/google_photos/README.md b/lib/examples/google_photos/README.md index bcaa25a6..8f6bd765 100644 --- a/lib/examples/google_photos/README.md +++ b/lib/examples/google_photos/README.md @@ -85,7 +85,8 @@ token for hour-long access tokens. overrides the path. Then run the example, tap **PICK**, scan the code with your phone, pick -photos, tap **Done** — the list appears on the board. +photos, tap **Done** in Google Photos — the board notices by itself and the +list appears. There is nothing to press on the board. ## Using it @@ -93,7 +94,7 @@ photos, tap **Done** — the list appears on the board. |---|---| | **list** | **PICK** new session · **SLIDES** slideshow from the first photo · **MORE** next page (long picks are paged to keep RAM bounded) · tap a row to view it | | **view** | **BACK** · **PREV** · **PLAY / PAUSE** slideshow · **NEXT** · tapping the photo also advances | -| **connect** | QR code of the session link · **OPEN** (desktop only: opens the link in the local browser) · **RETRY** new session · **BACK** to the list | +| **connect** | QR code of the session link, the three steps (scan, pick, tap Done), and a live "Waiting for your picks" line; the page moves on by itself when you tap Done · **Open on this PC** (desktop only: opens the link in the local browser) · **New link** fresh session · **Back** to the list you had | The slideshow interval is `slideshow_s` in the prefs file (default 5). diff --git a/lib/examples/google_photos/google_photos.py b/lib/examples/google_photos/google_photos.py index f4355f03..1cf6da75 100644 --- a/lib/examples/google_photos/google_photos.py +++ b/lib/examples/google_photos/google_photos.py @@ -64,10 +64,15 @@ "zephyr", ) if getattr(sys, "platform", "") not in _MCU_PLATFORMS: - env_set("PYDEVICES_WIDTH", _WIDTH) - env_set("PYDEVICES_HEIGHT", _HEIGHT) - if _SCALE is not None: - env_set("PYDEVICES_SCALE", _SCALE) + # None leaves the environment alone, so PYDEVICES_WIDTH / _HEIGHT set by + # the caller (or tools/screenshot.py --resolution) still apply. + for _name, _value in ( + ("PYDEVICES_WIDTH", _WIDTH), + ("PYDEVICES_HEIGHT", _HEIGHT), + ("PYDEVICES_SCALE", _SCALE), + ): + if _value is not None: + env_set(_name, _value) import gphotos_engine # noqa: E402 from gphotos_sim import make_engine # noqa: E402 @@ -113,7 +118,7 @@ def _block_if_batch(): from appdev import _hostloop from display_driver import app - if not _hostloop.batch() or _hostloop.ambient(): + if not _launched_with_m(_hostloop) or _hostloop.ambient(): return if getattr(getattr(sys, "flags", None), "interactive", 0): return @@ -134,4 +139,23 @@ def _block_if_batch(): timer.sleep_ms(50) +def _launched_with_m(_hostloop): + """``-m`` / ``-c`` launch, even where the OS command line is unreadable. + + ``_hostloop.batch()`` reads the real command line. On micropython.exe that + read fails (pydevices <= 0.5.0 decodes it with a UTF-16 codec MicroPython + doesn't have), so a ``-m`` launch looked like a script run and the process + returned after ~2 s (#142). MicroPython's own tell is ``sys.argv``: ``-m + pkg`` leaves ``["pkg"]``, a script leaves its ``.py`` path, a REPL ``[]`` + or ``[""]``. + """ + if _hostloop.batch(): + return True + if getattr(sys.implementation, "name", "") != "micropython": + return False + argv = getattr(sys, "argv", None) or [""] + first = argv[0] or "" + return bool(first) and not first.endswith(".py") and not first.endswith(".mpy") + + main() diff --git a/lib/examples/google_photos/gphotos_engine.py b/lib/examples/google_photos/gphotos_engine.py index 8d84cda4..66701992 100644 --- a/lib/examples/google_photos/gphotos_engine.py +++ b/lib/examples/google_photos/gphotos_engine.py @@ -861,12 +861,35 @@ def poll_session(self): self.last_error = self._api_error("poll session", status, data) return False - def delete_session(self): - """Drop the picker session (Google forgets the picks); keeps the list.""" - s = self.session - self.session = None - self._urls_at = None + def snapshot(self): + """The current session + picked list, for :meth:`put_back`. + + A new session clears the list (its picks replace it). Take a snapshot + first when the user may back out of picking and want the old list. + """ + return (self.session, list(self.items), self._urls_at) + + def put_back(self, snap): + """Restore a :meth:`snapshot`; returns the session it replaced.""" + replaced = self.session + self.session, items, self._urls_at = snap + self.items = list(items) self._save_prefs() + return replaced + + def delete_session(self, session=None): + """Drop the picker session (Google forgets the picks); keeps the list. + + With ``session``, delete that one at Google and leave ``self.session`` + alone (an abandoned session after :meth:`put_back`). + """ + if session is not None: + s = session + else: + s = self.session + self.session = None + self._urls_at = None + self._save_prefs() if not s or not s.get("id"): return True status, data = self._api("DELETE", PICKER_BASE + "/sessions/" + quote(s["id"])) diff --git a/lib/examples/google_photos/gphotos_lvgl.py b/lib/examples/google_photos/gphotos_lvgl.py index 84b6f890..c66ec88d 100644 --- a/lib/examples/google_photos/gphotos_lvgl.py +++ b/lib/examples/google_photos/gphotos_lvgl.py @@ -10,10 +10,12 @@ Pages: -* **connect** -- QR code of the Picker session link. Scan it with your - phone, choose photos in Google Photos, tap Done; the device polls the - session and jumps to the list. Desktop hosts also get OPEN (opens the - link in the local browser). +* **connect** -- QR code of the Picker session link, with the three steps + spelled out: scan it with your phone, choose photos in Google Photos, + tap Done there. A live "Waiting for your picks" line counts up while the + device polls the session; it jumps to the list by itself. **New link** + starts a fresh session; desktop hosts also get **Open on this PC** + (opens the link in the local browser); **Back** returns to the list. * **list** -- paged rows of thumbnail + file name + date. PICK starts a new session, SLIDES starts the slideshow, MORE pages through long picks. * **view** -- one photo fitted to the panel with BACK / PREV / PLAY / NEXT; @@ -329,6 +331,13 @@ def __init__(self, engine=None, start_page="list"): self._pending_view = None self._pending_list = False self._pending_connect = False + self._creating = False + self._picked = False + self._before_pick = None # engine.snapshot() of the list PICK replaces + self._pending_back = False + self._wait_lbl = None + self._wait_short = False + self._last_wait_text = None self._poll_busy = False self._poll_at = None self._session_started = None @@ -530,6 +539,7 @@ def _clear_content(self): root = self._page_root self._page_root = None self._rows = {} + self._wait_lbl = None self.view_img = None self.view_msg = None self.play_lbl = None @@ -569,48 +579,88 @@ def _show_page(self, page): # connect ------------------------------------------------------------ def _build_connect(self, parent): + """QR + three plain steps + a live waiting line + self-explaining buttons. + + Portrait panels stack steps, QR, waiting line and buttons; landscape + panels put the QR on the left and everything else beside it. + """ W, H = self._content_metrics() pad = self.pad gap = pad btn_h = max(36, H // 11) - session = self.engine.session + session = None if self._creating else self.engine.session uri = (session or {}).get("pickerUri") or "" self._set_title("Google Photos") self._set_count("") + self._wait_lbl = None + self._last_wait_text = None - labels = [("RETRY", "ui", self._start_pick)] + labels = [] if uri and _webbrowser is not None and not self.engine.sim: - labels.insert(0, ("OPEN", "accent", self._open_browser)) + labels.append(("Open on this PC", "accent", self._open_browser)) + labels.append(("New link", "accent" if not labels else "ui", self._start_pick)) if self.engine.items: - labels.append(("BACK", "ui", self._goto_list)) + labels.append(("Back", "ui", self._goto_list)) + elif self._before_pick is not None: + labels.append(("Back", "ui", self._cancel_pick)) + + landscape = W > H + H // 8 + if landscape: + qr_side = min(H - 2 * gap, (W * 45) // 100, 320) + col_x = qr_side + 3 * pad + col_w = W - col_x - pad + else: + col_x = pad + col_w = W - 2 * pad + + # Buttons: one row when every label gets a fair width, else the first + # button on its own row and the rest sharing the row below it. n = len(labels) - w = W - 2 * pad - bw = (w - (n - 1) * gap) // n - y_btn = H - btn_h - for i, (text, role, cb) in enumerate(labels): - self._button(parent, text, pad + i * (bw + gap), y_btn, bw, btn_h, role, cb) + need = max(self._text_w(text, self.font_sm) for text, _r, _c in labels) + 4 * pad + rows = [labels] if n == 1 or (col_w - (n - 1) * gap) // n >= need else [labels[:1], labels[1:]] + y = H - btn_h + for row in reversed(rows): + bw = (col_w - (len(row) - 1) * gap) // len(row) + for i, (text, role, cb) in enumerate(row): + self._button(parent, text, col_x + i * (bw + gap), y, bw, btn_h, role, cb, self.font_sm) + y -= btn_h + gap + buttons_top = y + btn_h + gap + + tight = (H if landscape else W) < 260 + self._wait_short = col_w < 200 + steps = self._text(parent, col_w, _COL["text"], self.font_sm) + if tight: + steps.set_text("Scan with your phone, pick photos, tap Done. This screen moves on by itself.") + else: + steps.set_text( + "1. Scan this code with your phone.\n" + "2. Pick photos in Google Photos.\n" + "3. Tap Done. This screen moves on by itself." + ) + steps.set_pos(col_x, 0 if landscape else gap) - caption = lv.label(parent) - caption.set_width(w) - self._long_mode(caption, "WRAP") - caption.set_style_text_color(_hex(_COL["text"]), 0) - _apply_font(caption, self.font_sm) - ta = getattr(lv, "TEXT_ALIGN", None) - center = getattr(ta, "CENTER", None) if ta is not None else None - if center is not None: + wait = self._text(parent, col_w, _COL["accent"], self.font_sm) + self._long_mode(wait, "CLIP") + self._wait_lbl = wait + self._update_wait() + wait.set_pos(col_x, buttons_top - gap - self._line_h(self.font_sm)) + + if not landscape: try: - caption.set_style_text_align(center, 0) + steps.update_layout() + steps_h = int(steps.get_height()) except Exception: - pass - caption_h = max(2 * 16, 2 * (self.unit // 16)) - caption.align(lv.ALIGN.BOTTOM_MID, 0, -(btn_h + gap)) + steps_h = 4 * self._line_h(self.font_sm) + top = gap + steps_h + gap + bottom = buttons_top - 2 * gap - self._line_h(self.font_sm) + qr_side = min(W - 4 * pad, bottom - top, 320) + qr_x = (W - qr_side) // 2 + qr_y = top + max(0, (bottom - top - qr_side) // 2) + else: + qr_x = pad + qr_y = (H - qr_side) // 2 - qr_side = min(W - 4 * pad, y_btn - caption_h - 3 * gap, 320) if uri: - if self.engine.sim: - caption.set_text("Simulator: a real session shows a Google Photos link here.\nPicks arrive by themselves in a moment.") - else: - caption.set_text("Scan with your phone, pick photos in Google Photos, tap Done.") qr_cls = getattr(lv, "qrcode", None) if qr_cls is not None and qr_side >= 48: try: @@ -622,25 +672,82 @@ def _build_connect(self, parent): qr.set_quiet_zone(True) raw = uri.encode("utf-8") # bytes: buffer protocol on every binding qr.update(raw, len(raw)) - qr.align(lv.ALIGN.TOP_MID, 0, gap) + qr.set_pos(int(qr_x), int(qr_y)) except Exception as err: - self._link_label(parent, uri, w, "QR unavailable (%s)" % err) - else: - self._link_label(parent, uri, w, "") - if not self.engine.sim: - print("google_photos: open this link in Google Photos:\n " + uri) - if self._poll_timed_out: - self._set_status("picker timed out - tap RETRY") + self._link_label(parent, uri, col_w, "QR unavailable (%s)" % err) else: - self._set_status("waiting for your picks...") - else: - caption.set_text("Tap RETRY to start a picker session.") - if self.engine.last_error: - self._set_status(self.engine.last_error) - elif not self.engine.has_credentials(): - self._set_status("no tokens file - see README") - else: - self._set_status("starting picker session...") + self._link_label(parent, uri, col_w, "") + self._set_status( + "simulator: picks arrive by themselves" if self.engine.sim else "pick photos on your phone" + ) + elif self._creating: + self._set_status("pick photos on your phone") + elif self.engine.last_error: + self._set_status(self.engine.last_error) + elif not self.engine.has_credentials(): + self._set_status("no tokens file - see README") + + def _text(self, parent, w, color, font): + lbl = lv.label(parent) + lbl.set_width(int(w)) + self._long_mode(lbl, "WRAP") + lbl.set_style_text_color(_hex(color), 0) + _apply_font(lbl, font) + return lbl + + @staticmethod + def _text_w(text, font): + """Pixel width of one line of ``text`` (a rough estimate if LVGL can't say).""" + try: + lbl = lv.label(lv.screen_active()) + _apply_font(lbl, font) + lbl.set_text(text) + lbl.update_layout() + w = int(lbl.get_width()) + lbl.delete() + return w + except Exception: + return len(text) * 8 + + @staticmethod + def _line_h(font): + try: + return int(font.get_line_height()) + except Exception: + try: + return int(font.line_height) + except Exception: + return 16 + + def _wait_text(self): + """The live line under the QR: what the screen is doing right now.""" + if self._creating: + return "Getting a link from Google..." + if self._picked: + return "Got your picks - loading them..." + session = self.engine.session + if not session: + return "Tap New link to get a code." + if self._poll_timed_out: + return "This code timed out. Tap New link." + started = self._session_started + secs = ticks_diff(ticks_ms(), started) // 1000 if started is not None else 0 + dots = "." * (1 + secs % 3) + if self._wait_short: + return "Waiting%s %d:%02d" % (dots + " " * (3 - len(dots)), secs // 60, secs % 60) + return "Waiting for your picks%s %d:%02d" % (dots + " " * (3 - len(dots)), secs // 60, secs % 60) + + def _update_wait(self): + lbl = self._wait_lbl + if lbl is None: + return + text = self._wait_text() + if text != self._last_wait_text: + self._last_wait_text = text + try: + lbl.set_text(text) + except Exception: + pass def _link_label(self, parent, uri, w, note): lbl = lv.label(parent) @@ -657,24 +764,37 @@ def _open_browser(self): return try: _webbrowser.open(uri) - self._set_status("opened in your browser - pick, then tap Done") + self._set_status("opened in your browser - pick, then tap Done there") except Exception as err: self._set_status("browser: %s" % err) def _start_pick(self, rebuild=True): - """Create a fresh picker session in the background, then show its QR.""" + """Create a fresh picker session in the background, then show its QR. + + Until the new session arrives the page says so instead of showing a + leftover session's code (a restored one from the last run, or the + one New link is replacing). The link is printed once, here, when + Google hands out a new session -- not on every redraw of the page. + """ self._poll_timed_out = False self._session_started = None self._poll_at = None self._poll_busy = False + self._picked = False + self._creating = True self.slideshow = False self.list_offset = 0 engine = self.engine + if engine.items: + self._before_pick = engine.snapshot() def _work(): s = engine.create_session() + self._creating = False if s: self._session_started = ticks_ms() + if not engine.sim: + print("google_photos: new picker session; open this link in Google Photos:\n " + s.get("pickerUri", "")) else: self._pending_status = engine.last_error or "could not start a session" self._pending_connect = True @@ -683,16 +803,40 @@ def _work(): if rebuild: self._show_page("connect") + def _cancel_pick(self): + """Back from a PICK: put the old list back and drop the new session.""" + snap = self._before_pick + self._before_pick = None + if snap is None: + self._goto_list() + return + self._creating = True # no polling of the session we are abandoning + engine = self.engine + + def _work(): + abandoned = engine.put_back(snap) + self._creating = False + if abandoned is not None and abandoned is not snap[0]: + engine.delete_session(abandoned) + self._pending_back = True + + self._run_bg(_work) + def _poll_pick(self): engine = self.engine self._poll_busy = True def _work(): + if self._pending_back or self._creating: # Back was tapped meanwhile + self._poll_busy = False + return try: done = engine.poll_session() if done: + self._picked = True items = engine.list_items() if items is None: + self._picked = False self._pending_status = engine.last_error self._pending_connect = True else: @@ -1115,8 +1259,15 @@ def _pump(self, _timer=None): if text: self._set_status(text) + if self._pending_back: + self._pending_back = False + self._pending_connect = False + self._goto_list() + return + if self._pending_list: self._pending_list = False + self._before_pick = None self.list_offset = 0 self.index = 0 self._show_page("list") @@ -1149,16 +1300,16 @@ def _pump(self, _timer=None): if self.page == "connect": session = self.engine.session - if session and not self._poll_busy and not self._poll_timed_out: + if session and not self._creating and not self._poll_busy and not self._poll_timed_out: started = self._session_started timeout_ms = int(float(session.get("timeoutIn") or 1800.0) * 1000) if started is not None and ticks_diff(ticks_ms(), started) > timeout_ms: self._poll_timed_out = True - self._set_status("picker timed out - tap RETRY") else: interval = int(self.engine.poll_interval_s() * 1000) if self._poll_at is None or ticks_diff(ticks_ms(), self._poll_at) >= interval: self._poll_pick() + self._update_wait() if ( self.page == "view" diff --git a/lib/examples/google_photos/gphotos_sim.py b/lib/examples/google_photos/gphotos_sim.py index 7a25182f..993ee5a9 100644 --- a/lib/examples/google_photos/gphotos_sim.py +++ b/lib/examples/google_photos/gphotos_sim.py @@ -203,8 +203,9 @@ def poll_session(self): self.session["mediaItemsSet"] = done return done - def delete_session(self): - self.session = None + def delete_session(self, session=None): + if session is None: + self.session = None return True def list_items(self): diff --git a/tests/test_gphotos_engine.py b/tests/test_gphotos_engine.py index d90c87ee..08515bc5 100644 --- a/tests/test_gphotos_engine.py +++ b/tests/test_gphotos_engine.py @@ -362,6 +362,27 @@ def test_delete_session(self): self.assertIsNone(eng.session) self.assertEqual(self.transport.calls[-1][0], "DELETE") + def test_snapshot_put_back_after_abandoned_pick(self): + # PICK then Back: the old list comes back and the new session is dropped. + self.transport.route("DELETE", ge.PICKER_BASE + "/sessions/", lambda u, h, b: (200, {})) + eng = self.make() + eng.create_session() + eng.list_items() + old_session = eng.session + snap = eng.snapshot() + new_session = eng.create_session() + self.assertEqual(eng.items, []) + replaced = eng.put_back(snap) + self.assertIs(replaced, new_session) + self.assertIs(eng.session, old_session) + self.assertEqual(len(eng.items), 3) + self.assertTrue(eng.delete_session(replaced)) + self.assertIs(eng.session, old_session) # only the abandoned one went + self.assertEqual(self.transport.calls[-1][0], "DELETE") + other = self.make() + self.assertTrue(other.restore()) + self.assertEqual(len(other.items), 3) + class FakeSocketModule: """Enough of ``socket`` for ``_socket_request`` (plain HTTP only)."""