From 9f8b79816f4a480ac564281e6c5d28518e5a3566 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 10:05:21 +0000 Subject: [PATCH 1/2] Add google_photos example: Picker API list, viewer, slideshow (LVGL) Google Photos on a PyDevices display. Since Google's March 2025 Library API cutoff, third-party apps can only show photos the user explicitly picks, so the example drives the Photos Picker API: the board shows a picker session as a QR code, the user picks on their phone, and the board lists the picks with thumbnails, views them full screen, and runs a slideshow. - lib/examples/google_photos/gphotos_engine.py: UI-agnostic Picker API client (refresh-token auth, sessions, paged listing, sized thumbnail downloads with a bounded flash cache, prefs) over urllib / MicroPython requests / adafruit_requests / raw socket + TLS. - gphotos_sim.py: simulator with sample photos for the gallery, PyScript, offline, or no-credentials runs (same shape as roku_sim). - gphotos_lvgl.py: LVGL front end with connect (QR), paged list, viewer, slideshow; network work drained from an lv.timer pump, no _thread. JPEG via TJPGD on CPython or displayif's jpegio LVGL decoder on MCU builds. - google_photos.py launcher mirrors roku_remote, and keeps -m launches alive (the host loop misreads package -m launches as a REPL). - tools/gphotos_auth.py: one-time PC-side Google sign-in (stdlib only, PKCE, loopback redirect) that writes the tokens file to copy to a board. - tests/test_gphotos_engine.py: 27 tests with faked HTTP for the engine, transports, simulator, and login tool. - README with Google Cloud setup, limits, and per-platform decoder notes; screenshots; MIP/TOML manifests, gallery card, and test-kit entries. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GTpFxNSKF3xWnX6xhgPS7T --- .site/gallery/index.html | 10 + .site/gallery/python-files.json | 7 + .site/pyscript/index.html | 10 + .site/pyscript/thumbnails/google_photos.png | Bin 0 -> 11335 bytes docs/screenshots/README.md | 3 +- docs/screenshots/google_photos.png | Bin 0 -> 31390 bytes docs/screenshots/google_photos_connect.png | Bin 0 -> 10300 bytes docs/screenshots/google_photos_view.png | Bin 0 -> 18874 bytes lib/examples/google_photos/README.md | 170 +++ lib/examples/google_photos/__init__.py | 7 + lib/examples/google_photos/__main__.py | 13 + .../google_photos/assets/gen_sim_assets.py | 110 ++ .../google_photos/assets/sim_01_160x120.jpg | Bin 0 -> 4190 bytes .../google_photos/assets/sim_01_320x240.jpg | Bin 0 -> 12085 bytes .../google_photos/assets/sim_01_64.jpg | Bin 0 -> 1691 bytes .../google_photos/assets/sim_01_96.jpg | Bin 0 -> 2756 bytes .../google_photos/assets/sim_02_120x160.jpg | Bin 0 -> 3458 bytes .../google_photos/assets/sim_02_240x320.jpg | Bin 0 -> 9410 bytes .../google_photos/assets/sim_02_64.jpg | Bin 0 -> 1613 bytes .../google_photos/assets/sim_02_96.jpg | Bin 0 -> 2333 bytes .../google_photos/assets/sim_03_160x120.jpg | Bin 0 -> 3920 bytes .../google_photos/assets/sim_03_320x240.jpg | Bin 0 -> 11525 bytes .../google_photos/assets/sim_03_64.jpg | Bin 0 -> 1634 bytes .../google_photos/assets/sim_03_96.jpg | Bin 0 -> 2644 bytes .../google_photos/assets/sim_04_120x160.jpg | Bin 0 -> 3714 bytes .../google_photos/assets/sim_04_240x320.jpg | Bin 0 -> 9940 bytes .../google_photos/assets/sim_04_64.jpg | Bin 0 -> 1736 bytes .../google_photos/assets/sim_04_96.jpg | Bin 0 -> 2635 bytes .../google_photos/assets/sim_05_160x120.jpg | Bin 0 -> 3497 bytes .../google_photos/assets/sim_05_320x240.jpg | Bin 0 -> 9402 bytes .../google_photos/assets/sim_05_64.jpg | Bin 0 -> 1572 bytes .../google_photos/assets/sim_05_96.jpg | Bin 0 -> 2381 bytes .../google_photos/assets/sim_06_120x160.jpg | Bin 0 -> 3630 bytes .../google_photos/assets/sim_06_240x320.jpg | Bin 0 -> 10152 bytes .../google_photos/assets/sim_06_64.jpg | Bin 0 -> 1611 bytes .../google_photos/assets/sim_06_96.jpg | Bin 0 -> 2426 bytes lib/examples/google_photos/google_photos.py | 134 ++ lib/examples/google_photos/gphotos_engine.py | 1127 ++++++++++++++++ lib/examples/google_photos/gphotos_lvgl.py | 1197 +++++++++++++++++ lib/examples/google_photos/gphotos_sim.py | 269 ++++ packages/examples.json | 28 + packages/google_photos.json | 33 + pydevices-examples.toml | 7 + tests/test_gphotos_engine.py | 602 +++++++++ tools/example_test_manifest.toml | 16 + tools/gphotos_auth.py | 397 ++++++ 46 files changed, 4139 insertions(+), 1 deletion(-) create mode 100644 .site/pyscript/thumbnails/google_photos.png create mode 100644 docs/screenshots/google_photos.png create mode 100644 docs/screenshots/google_photos_connect.png create mode 100644 docs/screenshots/google_photos_view.png create mode 100644 lib/examples/google_photos/README.md create mode 100644 lib/examples/google_photos/__init__.py create mode 100644 lib/examples/google_photos/__main__.py create mode 100644 lib/examples/google_photos/assets/gen_sim_assets.py create mode 100644 lib/examples/google_photos/assets/sim_01_160x120.jpg create mode 100644 lib/examples/google_photos/assets/sim_01_320x240.jpg create mode 100644 lib/examples/google_photos/assets/sim_01_64.jpg create mode 100644 lib/examples/google_photos/assets/sim_01_96.jpg create mode 100644 lib/examples/google_photos/assets/sim_02_120x160.jpg create mode 100644 lib/examples/google_photos/assets/sim_02_240x320.jpg create mode 100644 lib/examples/google_photos/assets/sim_02_64.jpg create mode 100644 lib/examples/google_photos/assets/sim_02_96.jpg create mode 100644 lib/examples/google_photos/assets/sim_03_160x120.jpg create mode 100644 lib/examples/google_photos/assets/sim_03_320x240.jpg create mode 100644 lib/examples/google_photos/assets/sim_03_64.jpg create mode 100644 lib/examples/google_photos/assets/sim_03_96.jpg create mode 100644 lib/examples/google_photos/assets/sim_04_120x160.jpg create mode 100644 lib/examples/google_photos/assets/sim_04_240x320.jpg create mode 100644 lib/examples/google_photos/assets/sim_04_64.jpg create mode 100644 lib/examples/google_photos/assets/sim_04_96.jpg create mode 100644 lib/examples/google_photos/assets/sim_05_160x120.jpg create mode 100644 lib/examples/google_photos/assets/sim_05_320x240.jpg create mode 100644 lib/examples/google_photos/assets/sim_05_64.jpg create mode 100644 lib/examples/google_photos/assets/sim_05_96.jpg create mode 100644 lib/examples/google_photos/assets/sim_06_120x160.jpg create mode 100644 lib/examples/google_photos/assets/sim_06_240x320.jpg create mode 100644 lib/examples/google_photos/assets/sim_06_64.jpg create mode 100644 lib/examples/google_photos/assets/sim_06_96.jpg create mode 100644 lib/examples/google_photos/google_photos.py create mode 100644 lib/examples/google_photos/gphotos_engine.py create mode 100644 lib/examples/google_photos/gphotos_lvgl.py create mode 100644 lib/examples/google_photos/gphotos_sim.py create mode 100644 packages/google_photos.json create mode 100644 tests/test_gphotos_engine.py create mode 100755 tools/gphotos_auth.py diff --git a/.site/gallery/index.html b/.site/gallery/index.html index e281ab3a..9d202867 100644 --- a/.site/gallery/index.html +++ b/.site/gallery/index.html @@ -354,6 +354,16 @@

Fonts

Framebuf Simpletest

Simple test example to demonstrate pygraphics.FrameBuffer.

+ +
+ + + lvgl + +
+

Google Photos

+

Pick Google Photos on your phone; the board lists, views, and slideshows them.

+
diff --git a/.site/gallery/python-files.json b/.site/gallery/python-files.json index c55a8d01..9dd99624 100644 --- a/.site/gallery/python-files.json +++ b/.site/gallery/python-files.json @@ -51,6 +51,13 @@ "feathers.py", "fonts.py", "framebuf_simpletest.py", + "google_photos/__init__.py", + "google_photos/__main__.py", + "google_photos/assets/gen_sim_assets.py", + "google_photos/google_photos.py", + "google_photos/gphotos_engine.py", + "google_photos/gphotos_lvgl.py", + "google_photos/gphotos_sim.py", "graphics_simpletest.py", "hello.py", "joystick_list_select.py", diff --git a/.site/pyscript/index.html b/.site/pyscript/index.html index 07b644b5..0961b7f6 100644 --- a/.site/pyscript/index.html +++ b/.site/pyscript/index.html @@ -354,6 +354,16 @@

Fonts

Framebuf Simpletest

Simple test example to demonstrate pygraphics.FrameBuffer.

+ +
+ + + lvgl + +
+

Google Photos

+

Pick Google Photos on your phone; the board lists, views, and slideshows them.

+
diff --git a/.site/pyscript/thumbnails/google_photos.png b/.site/pyscript/thumbnails/google_photos.png new file mode 100644 index 0000000000000000000000000000000000000000..fcf9f6ce90750ed3fbd126dadbb39226c9e27eff GIT binary patch literal 11335 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

g;Wth&iKWfnV`n^h(Zna5-=;NX<{%Q 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 literal 0 HcmV?d00001 diff --git a/docs/screenshots/README.md b/docs/screenshots/README.md index 4f37478b..058cd534 100644 --- a/docs/screenshots/README.md +++ b/docs/screenshots/README.md @@ -4,4 +4,5 @@ | ![color_test.png](color_test.png) | ![console_advanced_demo.gif](console_advanced_demo.gif) | ![displaybuf_simpletest.png](displaybuf_simpletest.png) | | ![fonts.png](fonts.png) | ![framebuf_simpletest.png](framebuf_simpletest.png) | ![noto_fonts.png](noto_fonts.png) | | ![paint.png](paint.png) | ![proverbs.png](proverbs.png) | ![testris.png](testris.png) | -| ![tiny_toasters.gif](tiny_toasters.gif) | ![piano.png](piano.png) | | \ No newline at end of file +| ![tiny_toasters.gif](tiny_toasters.gif) | ![piano.png](piano.png) | ![google_photos.png](google_photos.png) | +| ![google_photos_view.png](google_photos_view.png) | ![google_photos_connect.png](google_photos_connect.png) | | \ No newline at end of file diff --git a/docs/screenshots/google_photos.png b/docs/screenshots/google_photos.png new file mode 100644 index 0000000000000000000000000000000000000000..ac8e1d149d9c4d5d94ac64f8deb1752a96176404 GIT binary patch literal 31390 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

>&$L~yo&0u{0n#EYsL)1P#b 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: create a + project (any name), then *APIs & Services → Library → enable + **Google Photos Picker API***. +2. **OAuth consent screen** — user type *External*. Fill in the app name and + your e-mail. Then either **publish to Production** (an unverified app is + fine for personal use; Google shows a warning you click through once) or + stay in *Testing* and add your account as a test user. Testing refresh + tokens expire after **7 days**, so Production is the one that keeps + working. A Google Workspace account can use *Internal* instead. +3. **Credentials** — *Create credentials → OAuth client ID → Desktop app*. + Download the JSON (`client_secret_….json`). +4. **Sign in on the PC** (CPython 3.8+, stdlib only): + + ```bash + python tools/gphotos_auth.py --client-secrets ~/Downloads/client_secret_XXX.json --verify + ``` + + A browser opens (or copy the printed URL). Sign in with the account whose + photos you want to show. The script writes `~/.gphotos_tokens.json` + (owner-only permissions) and, with `--verify`, refreshes the token and + creates + deletes a picker session to prove the API is enabled. +5. **Copy to the board** (join Wi-Fi first on MicroPython/CircuitPython): + + ```bash + mpremote cp ~/.gphotos_tokens.json :/gphotos_tokens.json + ``` + + Desktop runs read `~/.gphotos_tokens.json` directly. `GPHOTOS_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. + +## Using it + +| Page | Buttons | +|---|---| +| **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 | + +The slideshow interval is `slideshow_s` in the prefs file (default 5). + +## Files + +| Desktop | Microcontroller | Contents | +|---|---|---| +| `~/.gphotos_tokens.json` | `/gphotos_tokens.json` | `client_id`, `client_secret`, `refresh_token` (written by `tools/gphotos_auth.py`; read-only on the device) | +| `~/.gphotos_prefs` | `/gphotos_prefs` | JSON: current session, picked items (without their short-lived URLs), `slideshow_s` | +| `~/.gphotos_cache/` | `/gphotos_cache/` | `_x[c].jpg\|png` thumbnails, at most 48 files | + +Override with `GPHOTOS_TOKENS`, `GPHOTOS_PREFS`, `GPHOTOS_CACHE`. + +## Images on each platform + +Google serves JPEG (PNG for PNG originals). LVGL decodes: + +| Runtime | JPEG | PNG | +|---|---|---| +| CPython (`pydevices-lvgl`) | LVGL's built-in TJPGD | LODEPNG | +| MicroPython LVGL firmware (lvgl-micropython + displayif) | displayif's `jpegio`, registered as an LVGL image decoder ([displayif#23](https://github.com/PyDevices/displayif/issues/23)) | LODEPNG | +| CircuitPython LVGL firmware | CircuitPython's `jpegio` via lvgl-circuitpython | LODEPNG | + +Without a JPEG decoder the list still works: tiles keep a placeholder and +the viewer explains why. Both decoders hold the decoded image +(`width × height × 2` bytes) while it is drawn, so the viewer requests an +image no larger than the panel; a 320×480 panel needs ~300 KB of heap for +the full-screen photo — comfortable with PSRAM, tight without. Thumbnails +are tiny. Only one page of tiles is resident at a time. + +## Limits and gotchas + +- **Thumbnail URLs expire after 60 minutes.** The engine re-lists the + session (one request) to refresh them; nothing is re-picked. +- **Picker sessions expire** (about a week). After that the cached list still + shows, but new downloads fail with *session expired* until you **PICK** + again. The prefs keep the picked list either way. +- **Refresh tokens die after 7 days while the consent screen is in + *Testing*.** Publish to Production (see Setup) and they last until revoked. +- **`invalid_grant` on start** means the refresh token was revoked or expired: + run `tools/gphotos_auth.py` again and copy the new file. +- **`HTTP 403 … SERVICE_DISABLED`** means the Picker API is not enabled in + the project (Setup step 1). **`access_denied` in the browser** means your + account is not a test user of a Testing app. +- **TLS on MicroPython** does not verify Google's certificate (the platform + default: boards ship no CA bundle). The refresh token is the secret to + protect — treat the tokens file like a password. +- **`-m` launches** (`python -m examples.google_photos`) block inside the + launcher's `app.run()` because the shared app host-loop declines `-m` + entry points; a script-path launch (`python examples/google_photos/google_photos.py`) + keeps itself alive like every other example. +- Videos appear in the list with a *video* tag; the viewer shows their poster + frame. + +## Module layout + +``` +google_photos/ + google_photos.py launcher: sim detection, restore prefs, start LVGL front end + gphotos_engine.py Picker API client, token refresh, prefs, thumbnail cache (no UI) + gphotos_sim.py simulator (sample photos) + make_engine() + gphotos_lvgl.py LVGL front end: connect (QR), list, view, slideshow + assets/ sim_NN_*.jpg sample photos + gen_sim_assets.py (Pillow) +tools/gphotos_auth.py PC-side one-time Google sign-in (stdlib, PKCE, loopback) +tests/test_gphotos_engine.py +``` + +`gphotos_engine` has no display, event, or UI imports, so a pdwidgets or +pygraphics front end can be added the way `roku_remote` has three. All +network calls are blocking and are queued on the LVGL pump from the main +thread (no `_thread`, like the Roku example). + +## Testing + +```bash +.venv/bin/python -m unittest discover -s tests -p "test_gphotos_engine.py" +SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy .venv/bin/python tools/example_test_kit.py \ + --no-unit-tests --only-interpreter cpython-venv --only-example google_photos gphotos_lvgl +``` + +The unit tests fake every HTTP call (token refresh, sessions, paging, +downloads, the raw-socket fallback, the MicroPython `requests` path) and the +login tool's loopback redirect. The kit run exercises the real LVGL UI in +simulator mode. diff --git a/lib/examples/google_photos/__init__.py b/lib/examples/google_photos/__init__.py new file mode 100644 index 00000000..1163223b --- /dev/null +++ b/lib/examples/google_photos/__init__.py @@ -0,0 +1,7 @@ +import sys + +_wd = __file__.replace("\\", "/") +_wd = _wd.rsplit("/", 1)[0] if "/" in _wd else "." +if _wd not in sys.path: + sys.path.insert(0, _wd) +from . import google_photos # noqa: F401 — gallery/kit entry: import google_photos diff --git a/lib/examples/google_photos/__main__.py b/lib/examples/google_photos/__main__.py new file mode 100644 index 00000000..8d65a8b4 --- /dev/null +++ b/lib/examples/google_photos/__main__.py @@ -0,0 +1,13 @@ +"""Entry for ``-m examples.google_photos`` (MicroPython / CPython). + +MicroPython requires this file when using ``-m`` on a package. The launcher +side effect lives in :mod:`google_photos` (also imported by ``__init__`` for +plain ``import google_photos`` / gallery). Re-import here is a no-op if +``__init__`` already ran the app to completion. + +From ``pydevices-examples/lib`` (swap in ``micropython``, ``python``, …):: + + micropython -m examples.google_photos +""" + +from . import google_photos # noqa: F401 diff --git a/lib/examples/google_photos/assets/gen_sim_assets.py b/lib/examples/google_photos/assets/gen_sim_assets.py new file mode 100644 index 00000000..3dc40d11 --- /dev/null +++ b/lib/examples/google_photos/assets/gen_sim_assets.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""Generate the simulator's sample photos (CPython + Pillow only). + +Six procedural "landscape" scenes, each written as baseline JPEGs at the +sizes ``gphotos_sim.GPhotosSimEngine.asset_path`` looks for: + +* ``sim_NN_64.jpg`` / ``sim_NN_96.jpg`` — square list tiles (Google ``-c`` crop) +* ``sim_NN_320x240.jpg`` / ``sim_NN_160x120.jpg`` — landscape scenes (odd NN) +* ``sim_NN_240x320.jpg`` / ``sim_NN_120x160.jpg`` — portrait scenes (even NN) + +Run from the repo root:: + + .venv/bin/python lib/examples/google_photos/assets/gen_sim_assets.py + +Baseline (non-progressive) JPEG is required: TJpgDec cannot decode +progressive files. +""" + +import math +import os +import sys + +try: + from PIL import Image, ImageDraw +except ImportError: # pragma: no cover + raise SystemExit("Pillow is required: .venv/bin/pip install pillow") + +HERE = os.path.dirname(os.path.abspath(__file__)) + +# (sky top, sky bottom, hills, sun, water) per scene +_PALETTES = [ + ((0x1E, 0x3A, 0x8A), (0xF9, 0x73, 0x16), (0x14, 0x53, 0x2D), (0xFD, 0xE0, 0x47), (0x0E, 0x74, 0x90)), + ((0x0F, 0x17, 0x2A), (0x7C, 0x3A, 0xED), (0x1F, 0x29, 0x37), (0xF4, 0xF4, 0xF5), (0x31, 0x2E, 0x81)), + ((0x38, 0xBD, 0xF8), (0xE0, 0xF2, 0xFE), (0x16, 0xA3, 0x4A), (0xFA, 0xCC, 0x15), (0x06, 0x92, 0xB4)), + ((0x7F, 0x1D, 0x1D), (0xFB, 0xBF, 0x24), (0x45, 0x1A, 0x03), (0xFE, 0xF3, 0xC7), (0x9A, 0x34, 0x12)), + ((0x0C, 0x4A, 0x6E), (0x67, 0xE8, 0xF9), (0x0F, 0x76, 0x6E), (0xFF, 0xFF, 0xFF), (0x15, 0x5E, 0x75)), + ((0x3B, 0x07, 0x64), (0xEC, 0x48, 0x99), (0x2E, 0x10, 0x65), (0xFB, 0x71, 0x85), (0x6B, 0x21, 0xA8)), +] + + +def _lerp(a, b, t): + return tuple(int(a[i] + (b[i] - a[i]) * t) for i in range(3)) + + +def scene(width, height, palette, seed): + """Sky gradient, sun, rolling hills, and a reflective water band.""" + sky_top, sky_bot, hills, sun, water = palette + img = Image.new("RGB", (width, height)) + draw = ImageDraw.Draw(img) + horizon = int(height * 0.62) + for y in range(horizon): + draw.line([(0, y), (width, y)], fill=_lerp(sky_top, sky_bot, y / max(1, horizon - 1))) + # water below the horizon (darker toward the bottom) + for y in range(horizon, height): + t = (y - horizon) / max(1, height - horizon - 1) + draw.line([(0, y), (width, y)], fill=_lerp(water, _lerp(water, (0, 0, 0), 0.55), t)) + # sun + r = max(6, min(width, height) // 7) + cx = int(width * (0.25 + 0.5 * ((seed * 0.37) % 1.0))) + cy = int(horizon * 0.55) + draw.ellipse([cx - r, cy - r, cx + r, cy + r], fill=sun) + # hills: two layered sine ridges + for layer, (amp, freq, shade) in enumerate(((0.10, 1.7, 0.85), (0.16, 0.9, 1.0))): + pts = [] + for x in range(width + 1): + y = horizon - int(height * amp * (0.5 + 0.5 * math.sin(freq * x / width * math.pi * 2 + seed + layer))) + pts.append((x, y)) + pts += [(width, horizon), (0, horizon)] + draw.polygon(pts, fill=_lerp((0, 0, 0), hills, shade)) + # sun reflection stripes + for i in range(0, height - horizon, max(3, height // 40)): + y = horizon + i + w = max(2, int(r * (1.0 - i / max(1, height - horizon)) * 1.5)) + draw.line([(cx - w, y), (cx + w, y)], fill=_lerp(sun, water, 0.35)) + return img + + +def main(): + out_dir = sys.argv[1] if len(sys.argv) > 1 else HERE + os.makedirs(out_dir, exist_ok=True) + written = [] + for n, palette in enumerate(_PALETTES, start=1): + portrait = n % 2 == 0 + big = (240, 320) if portrait else (320, 240) + small = (120, 160) if portrait else (160, 120) + base = scene(big[0], big[1], palette, seed=n * 1.3) + variants = { + "%dx%d" % big: base, + "%dx%d" % small: base.resize(small, Image.LANCZOS), + } + # Square tiles: center crop like Google's ``-c`` parameter. + side = min(big) + left = (big[0] - side) // 2 + top = (big[1] - side) // 2 + square = base.crop((left, top, left + side, top + side)) + variants["96"] = square.resize((96, 96), Image.LANCZOS) + variants["64"] = square.resize((64, 64), Image.LANCZOS) + for tag, im in variants.items(): + path = os.path.join(out_dir, "sim_%02d_%s.jpg" % (n, tag)) + im.save(path, "JPEG", quality=82, optimize=False, progressive=False, subsampling=0) + written.append(path) + total = sum(os.path.getsize(p) for p in written) + print("wrote %d files (%.1f KiB) to %s" % (len(written), total / 1024.0, out_dir)) + + +if __name__ == "__main__": + main() diff --git a/lib/examples/google_photos/assets/sim_01_160x120.jpg b/lib/examples/google_photos/assets/sim_01_160x120.jpg new file mode 100644 index 0000000000000000000000000000000000000000..4c3787389011e4740494d750a189bb0ddd3db1ed GIT binary patch literal 4190 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 b@F2#*=Ii|dnym%iygL1USA9+-8|?ijxeQQ# literal 0 HcmV?d00001 diff --git a/lib/examples/google_photos/assets/sim_01_320x240.jpg b/lib/examples/google_photos/assets/sim_01_320x240.jpg new file mode 100644 index 0000000000000000000000000000000000000000..4a25efb80d038e03981fea75aeae560688b2d091 GIT binary patch literal 12085 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 EZ(5>5RsaA1 literal 0 HcmV?d00001 diff --git a/lib/examples/google_photos/assets/sim_02_120x160.jpg b/lib/examples/google_photos/assets/sim_02_120x160.jpg new file mode 100644 index 0000000000000000000000000000000000000000..994e363beea15d547b94f4fb5920d9e4b6ee3f35 GIT binary patch literal 3458 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 IKyh#W572-yqyPW_ literal 0 HcmV?d00001 diff --git a/lib/examples/google_photos/assets/sim_02_64.jpg b/lib/examples/google_photos/assets/sim_02_64.jpg new file mode 100644 index 0000000000000000000000000000000000000000..ccd06591b21e348fc570250d3605fc4ba582264b GIT binary patch literal 1613 zcmex=C#Rr_ zgouO+$>9G120;!62L=abMnMK9K}Kdl#{WkcRY=j$kxe)-kzJ`!#HexNLJno8jR!@8E`CrkPAY2R z|V^&07y2J$~}^+4C1KUw!=a`ODXD-+%o41@ado12e>1Koa6HG=BjDfsu)Yg_(sN zWKS#c@69{ z;yl(wme1fGL-^|!0}nGJF!GoM8SEK8yjQ-pZ~o1x37>3Nd`l#TO#06-@ontAGnc2> z^k>=5C~BYh)_pTy09gO2%!=&8=JPbKuPKRUjo%XIr5tp>ls)M94F>;*k=F{BvRmdf zu9gq4NPf`YWMiHzqrW&hYr0~g>fJv{VH3V9`kz{Sga63&v!TaBXYYNpr)Rb$f7gEo z#uD#dvDc9wwoc0Y{%zux^O<}#vs;*TnC9>LaZ=*JV~O{%7B)3IuW0RFX0h-2*+T+u z4kt{%$N}BA{|Ud>yKiaR_!X)G-yAv!#jd0F7+=vG12!~ zG<$MGuaao+{B`V)-eZM(3%*1AX*86X1dlo+V9r)((6d(^2 zRFgqL6*!5dk^97}F6)g)(*qC9sa*DE&ylyHW#v`kZ}r!`5X($0h06RU$}Z6 z?`?iQT5^B)LbpfHvX|et=~b@&wq9?a($dP}(5W}iYo7A^lv3KM=)m7pnY%>DSy`Fy z(bmOLOC^ghm!-P->Al#b`-n#(k42_Qnk9V0hLYf2d;hv@xpC1q?Wcgk&Lnop1kOJn zb+E^(2-0Jq$$l@!x`^}OyEv>hizxnV> z{@mtYy>j~4zoyFF@8%X=_x7!0XrHO<$>4uHuBJ_kZ-3+#wYhbFM&h%J zXBeTFwOf1Aq_;c=>}7;3|8T6#;pMmTfA9I9q1OICgJ|#>L#gsmohS!(^=I|}w7MIo zsvewp^Twoz=aXmcOBDHY>oAI045|mu${ff#^ep3=YtMvQf%#9xv?|*i>-DEJa=q1k Vwd3(e*T0%88Qsq=KcoErCICNh!(ada literal 0 HcmV?d00001 diff --git a/lib/examples/google_photos/assets/sim_02_96.jpg b/lib/examples/google_photos/assets/sim_02_96.jpg new file mode 100644 index 0000000000000000000000000000000000000000..8c7a7202f75b0be280ecfda62b7420c3d77292b5 GIT binary patch literal 2333 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 literal 0 HcmV?d00001 diff --git a/lib/examples/google_photos/assets/sim_03_320x240.jpg b/lib/examples/google_photos/assets/sim_03_320x240.jpg new file mode 100644 index 0000000000000000000000000000000000000000..17402ef3e338721d33c5654c42573a3a8a7d04da GIT binary patch literal 11525 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 literal 0 HcmV?d00001 diff --git a/lib/examples/google_photos/assets/sim_03_64.jpg b/lib/examples/google_photos/assets/sim_03_64.jpg new file mode 100644 index 0000000000000000000000000000000000000000..d98e13fcedc25390e306ea1fc428c09525fbc6c3 GIT binary patch literal 1634 zcmex=C#Rr_ zgouO+$>9G120;!62L=abMnMK9K}Kdl#{WkcRY=j$kxe)-kzJ`!#HexNLJno8jR!@8E`CrkPAY2R z|V^&07y2J$~}^+4C1KUw!=a`ODXD-+%o41@ado12e>1Koa6HG=BjDfsu)Yg_(sN zWKS#c@69{ z;yl(wme1fGL-^|!0}nGJF!GoM8SEKe|2zFQenIj4Db)(U^|#7AeAE19-<9I_P5hgF z2cDBVVR!niy9IWs>p=B|pXc?%m7emKSo1n5(T#7ZXOwo>y_2FmHmf|HxZb|$4EIek z^6Jm}^IP~)UcBFORjr~~?-#|Z-2Km>{3_!^oL<4RIg7<_hkw8Qk5l@lk&NJv>(^55 zI5(eNxobvb?yf4+SNlU3K71zWrp#>T^}2S}&CYM}S>Gzp@u$eAep@`pc0%p+TYUxJ z+_vA`ySjhQ7a)H&i0>p(vw2SVb32vfEN6DFmiXP4*`LC_-(P=H8_xM~!AIfMx{tQ} z{Lc{IvGvEv?~g?9AFZ`#PkNR3xytVRTf6XQ^MB@lvdQ_)&vip;&Rx^E)xS3EPy4q1 zMSMnGJTQ>%fdgrCH7Jm-|29VniG{0TUjC{{f4lwKwdE^5r*PHlZTGe%XJjHr~wGA6c6k=I=Y}lVx|mY=(g$)7xjqD;6d3KXO=qd^*qLpR=CEZ4a}X zwp{$_#3-K{^oyY6#Z>Fyy~{LT(_p-{67_3f3+lAIbSTPFgS8y-lh17TPEmq z$Z~Cx>shYw&-BVF^;K3=tzWf6LeO1$bxq>sg*>z3=9^wFn^gRC`4c1mmvOQ_N7py$ z&I#Q-%iAk(|GIYZ)iur^`PT20`u=94`Jc^OC?{hQYYTo-JWI}xykcScHgC{8&3)+UtMTs|6Tgi;;G;GJj?iX|1<2kKUFXMhNbEa zH}j~m$}*4P+p=Z1H`RB{ zRoSX(`|g{%ZT5whouPe}!Mk_d-}?J%wNvec+V%JPGvrPj{T;uz=<~ekoA|f>z6eYj zHRk_f8aE$z4h+5*rX}*`EMxJ+i6?%A^IKkhbL#r^gM|{xw*I0Qoqx{kjVRIjyZ+v; z$}?cIfXUbK+x{xc!#5@7XMbN8S9}S^=Y|N2>y|1gCfu-#IPbS;=PK=!(JR*^E(~1n fy)miM=DO3=sXcS8dx37TgPQ~L?{of?`u{fp<<#vY literal 0 HcmV?d00001 diff --git a/lib/examples/google_photos/assets/sim_03_96.jpg b/lib/examples/google_photos/assets/sim_03_96.jpg new file mode 100644 index 0000000000000000000000000000000000000000..42adfc923ca3b31c46518bb8bb462e9b3e233672 GIT binary patch literal 2644 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

{title}

{body}

" +) + + +class AuthError(Exception): + """Login could not be completed.""" + + +def base64url(data: bytes) -> str: + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") + + +def pkce_pair() -> tuple[str, str]: + """PKCE ``(code_verifier, code_challenge)`` using S256.""" + verifier = base64url(os.urandom(48)) + challenge = base64url(hashlib.sha256(verifier.encode("ascii")).digest()) + return verifier, challenge + + +def load_client_secrets(path: str) -> tuple[str, str]: + """``client_id, client_secret`` from a Google Cloud client JSON download.""" + with open(os.path.expanduser(path), "r", encoding="utf-8") as f: + data = json.load(f) + for key in ("installed", "web"): + if isinstance(data.get(key), dict): + data = data[key] + break + client_id = data.get("client_id") or "" + client_secret = data.get("client_secret") or "" + if not client_id: + raise AuthError(f"{path}: no client_id found") + return client_id, client_secret + + +def build_auth_url(client_id: str, redirect_uri: str, challenge: str, state: str) -> str: + query = { + "client_id": client_id, + "redirect_uri": redirect_uri, + "response_type": "code", + "scope": SCOPE, + "code_challenge": challenge, + "code_challenge_method": "S256", + "access_type": "offline", + "prompt": "consent", + "state": state, + } + return AUTH_URL + "?" + urllib.parse.urlencode(query) + + +def _post_form(url: str, fields: dict, timeout: float = 30.0) -> tuple[int, dict]: + body = urllib.parse.urlencode(fields).encode("ascii") + req = urllib.request.Request( + url, + data=body, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + return resp.status, _json(resp.read()) + except urllib.error.HTTPError as err: + return err.code, _json(err.read()) + except urllib.error.URLError as err: + raise AuthError(f"network error: {err.reason}") from err + + +def _json(raw: bytes) -> dict: + try: + data = json.loads(raw.decode("utf-8")) + except (ValueError, UnicodeDecodeError): + return {} + return data if isinstance(data, dict) else {} + + +def _describe(status: int, data: dict) -> str: + err = data.get("error") + if isinstance(err, dict): + return f"HTTP {status} {err.get('status', '')} {err.get('message', '')}".strip() + desc = data.get("error_description") or "" + return f"HTTP {status} {err or ''} {desc}".strip() + + +def exchange_code( + client_id: str, client_secret: str, code: str, redirect_uri: str, verifier: str +) -> dict: + status, data = _post_form( + TOKEN_URL, + { + "code": code, + "client_id": client_id, + "client_secret": client_secret, + "redirect_uri": redirect_uri, + "grant_type": "authorization_code", + "code_verifier": verifier, + }, + ) + if status != 200 or not data.get("access_token"): + raise AuthError("token exchange failed: " + _describe(status, data)) + return data + + +def refresh_access_token(client_id: str, client_secret: str, refresh_token: str) -> dict: + status, data = _post_form( + TOKEN_URL, + { + "client_id": client_id, + "client_secret": client_secret, + "refresh_token": refresh_token, + "grant_type": "refresh_token", + }, + ) + if status != 200 or not data.get("access_token"): + raise AuthError("token refresh failed: " + _describe(status, data)) + return data + + +def picker_probe(access_token: str, timeout: float = 30.0) -> str: + """Create and delete one picker session; returns a human-readable verdict.""" + headers = { + "Authorization": "Bearer " + access_token, + "Content-Type": "application/json", + } + req = urllib.request.Request(PICKER_SESSIONS_URL, data=b"{}", headers=headers, method="POST") + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + session = _json(resp.read()) + except urllib.error.HTTPError as err: + raise AuthError( + "Picker API check failed: " + _describe(err.code, _json(err.read())) + ) from err + except urllib.error.URLError as err: + raise AuthError(f"network error: {err.reason}") from err + sid = session.get("id") or "" + if sid: + req = urllib.request.Request( + PICKER_SESSIONS_URL + "/" + urllib.parse.quote(sid, safe=""), + headers={"Authorization": "Bearer " + access_token}, + method="DELETE", + ) + try: + urllib.request.urlopen(req, timeout=timeout).close() + except (urllib.error.HTTPError, urllib.error.URLError): + pass + return "Picker API OK (session created and deleted)" + + +def write_tokens(path: str, data: dict) -> str: + """Write the tokens JSON with owner-only permissions; returns the path.""" + path = os.path.expanduser(path) + directory = os.path.dirname(path) + if directory: + os.makedirs(directory, exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + f.write("\n") + try: + os.chmod(path, 0o600) + except OSError: + pass + return path + + +def read_tokens(path: str) -> dict: + with open(os.path.expanduser(path), "r", encoding="utf-8") as f: + data = json.load(f) + if not isinstance(data, dict) or not data.get("refresh_token"): + raise AuthError(f"{path}: not a tokens file (no refresh_token)") + return data + + +class _CallbackHandler(http.server.BaseHTTPRequestHandler): + """Captures ``?code=…&state=…`` from Google's loopback redirect.""" + + result: dict = {} + + def do_GET(self): # noqa: N802 - http.server API + query = urllib.parse.urlparse(self.path).query + params = {k: v[0] for k, v in urllib.parse.parse_qs(query).items()} + if "code" in params or "error" in params: + type(self).result = params + if "error" in params: + title, body = "Sign-in failed", params.get("error", "") + elif "code" in params: + title, body = "Signed in", "You can close this tab and return to the terminal." + else: + title, body = "Waiting", "This page only answers Google's redirect." + payload = _DONE_HTML.format(title=title, body=body).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def log_message(self, *args): # silence the default stderr access log + pass + + +def start_callback_server(port: int = 0) -> http.server.HTTPServer: + _CallbackHandler.result = {} + return http.server.HTTPServer(("127.0.0.1", port), _CallbackHandler) + + +def wait_for_callback(server: http.server.HTTPServer, timeout: float) -> dict: + """Serve until a code (or error) arrives, or ``timeout`` seconds pass.""" + server.timeout = 1.0 + deadline = _now() + timeout + while not _CallbackHandler.result and _now() < deadline: + server.handle_request() + return dict(_CallbackHandler.result) + + +def _now() -> float: + import time + + return time.monotonic() + + +def login( + client_id: str, + client_secret: str, + port: int = 0, + open_browser: bool = True, + timeout: float = LOGIN_TIMEOUT_S, + out=sys.stdout, +) -> dict: + """Run the browser login; returns Google's token response.""" + verifier, challenge = pkce_pair() + state = base64url(os.urandom(16)) + server = start_callback_server(port) + try: + redirect_uri = "http://127.0.0.1:%d/" % server.server_address[1] + url = build_auth_url(client_id, redirect_uri, challenge, state) + print("Sign in with the Google account whose photos you want to show.", file=out) + if open_browser: + print("Opening your browser (or copy this URL into one):", file=out) + else: + print("Open this URL in a browser on this machine:", file=out) + print("\n" + url + "\n", file=out) + if open_browser: + thread = threading.Thread(target=webbrowser.open, args=(url,), daemon=True) + thread.start() + params = wait_for_callback(server, timeout) + finally: + server.server_close() + if not params: + raise AuthError("timed out waiting for the browser redirect") + if params.get("error"): + raise AuthError("Google refused the sign-in: " + params["error"]) + if params.get("state") != state: + raise AuthError("state mismatch on the redirect (stale tab?) - try again") + return exchange_code(client_id, client_secret, params["code"], redirect_uri, verifier) + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + parser.add_argument( + "--client-secrets", help="client_secret_*.json downloaded from Google Cloud" + ) + parser.add_argument("--client-id") + parser.add_argument("--client-secret", default="") + parser.add_argument( + "--output", default=DEFAULT_OUTPUT, help=f"tokens file (default {DEFAULT_OUTPUT})" + ) + parser.add_argument( + "--port", type=int, default=0, help="loopback port (default: any free port)" + ) + parser.add_argument( + "--no-browser", action="store_true", help="print the URL instead of opening a browser" + ) + parser.add_argument( + "--timeout", type=float, default=LOGIN_TIMEOUT_S, help="seconds to wait for the redirect" + ) + parser.add_argument( + "--verify", + action="store_true", + help="after login (or alone) refresh the token and check the Picker API", + ) + args = parser.parse_args(argv) + + try: + if args.client_secrets or args.client_id: + if args.client_secrets: + client_id, client_secret = load_client_secrets(args.client_secrets) + if args.client_secret: + client_secret = args.client_secret + else: + client_id, client_secret = args.client_id, args.client_secret + response = login( + client_id, + client_secret, + port=args.port, + open_browser=not args.no_browser, + timeout=args.timeout, + ) + refresh_token = response.get("refresh_token") + if not refresh_token: + raise AuthError( + "Google returned no refresh token. Remove the app at " + "https://myaccount.google.com/permissions and run this again." + ) + tokens = { + "client_id": client_id, + "client_secret": client_secret, + "refresh_token": refresh_token, + "scope": response.get("scope", SCOPE), + "token_type": response.get("token_type", "Bearer"), + } + path = write_tokens(args.output, tokens) + print(f"Wrote {path}") + print("Copy it to the board, e.g.:") + print(f" mpremote cp {path} :/gphotos_tokens.json") + print("Desktop runs read it from that same path (override: GPHOTOS_TOKENS=...).") + elif args.verify: + tokens = read_tokens(args.output) + else: + parser.error("give --client-secrets FILE or --client-id ID (or --verify)") + return 2 + + if args.verify: + data = refresh_access_token( + tokens["client_id"], tokens.get("client_secret", ""), tokens["refresh_token"] + ) + print("Token refresh OK (expires in %s s)" % data.get("expires_in", "?")) + print(picker_probe(data["access_token"])) + except AuthError as err: + print(f"error: {err}", file=sys.stderr) + return 1 + except KeyboardInterrupt: + print("\ninterrupted", file=sys.stderr) + return 130 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 7a13dbbc1d9eb55cdd8c5f7c43bd1125f30bbf62 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 19:05:28 +0000 Subject: [PATCH 2/2] google_photos card: drop the stray # modules header, fixing CI CI's new test_every_module_a_card_imports_is_copied (from the main merge) caught a real gallery bug: google_photos.py's `# modules:` header put gphotos_engine/gphotos_sim/gphotos_lvgl into the card's bare `?modules=` query, but those files are staged under the google_photos/ package prefix via `manifests=google_photos`, not at the tree root -- a browser loading that card would 404 on them. `# modules:` is for a module-kind card's extra flat sibling files (e.g. calc_lvgl.py pulling in the top-level calc_engine.py); a manifest-kind package entry never needs it for its own package-internal siblings, matching the existing roku_remote.py precedent. Removing the line lets discover_package_py_files stage the package as-is and the card link becomes plain `?manifests=google_photos`. Regenerated the gallery pages and MIP manifests with the repo's generators; full unittest suite, ruff, and `gallery_generator.py --check` all pass. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01GTpFxNSKF3xWnX6xhgPS7T --- .site/gallery/index.html | 2 +- .site/pyscript/index.html | 2 +- lib/examples/google_photos/google_photos.py | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.site/gallery/index.html b/.site/gallery/index.html index 258c2e8a..97156d12 100644 --- a/.site/gallery/index.html +++ b/.site/gallery/index.html @@ -366,7 +366,7 @@

Fonts

Framebuf Simpletest

Simple test example to demonstrate pygraphics.FrameBuffer.

- +

`kQSMPQgq?F^;+ML%i}`4D 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 Q3vAMV(?!4C8buuXFV4KiRR910 literal 0 HcmV?d00001 diff --git a/lib/examples/google_photos/assets/sim_04_64.jpg b/lib/examples/google_photos/assets/sim_04_64.jpg new file mode 100644 index 0000000000000000000000000000000000000000..ee3895ecf949928e3fe9aa1f4842b02c53e2e9b2 GIT binary patch literal 1736 zcmbV}dpOit7{||V?lUgaFs^IZF(lGPD5NN5#Fhr-5|4G6NF-9Bm`X(|vqVWXGNVvh zJ3==-ByualNJa=DGPw^KW_Hv*yU*_av%BB(ocE9S{y9hTR`MP!bFkZH2OtOlNP2*z z6W9V20)a#zP)H;ag+igR@>nbegH@1|!^tZtC@U)|5Q!@4WDONnEj1#Mq({=yrs(SG zDr@K)uFzRY*3s2jY63}1u^21?izVo&5><8nvm_OOfB`nZ1`ZJbi~zw2kfaJ|N%urT zUj=>_1cM`xC^QC(laVHrECVnI4u>J&NGXFfJ60M82m(@pY`h7jNb^N&hZEPFOv%Pj zHglgTIX92%nD|AU!s3)wRMpgVm+P(2H!w9@yUyIg(ss*MJF5LQ2N%~}yWQM9JpBXc zdjo^^1s{q$eB@|UbWHr|GiMVL&n2Z^N=v_dCF5!)o0D_vc5Yt&oud22C8ZA@K6+gF zysG*|O>JF$ODnJKP5avpes5p@hk?PNkHaGqlT*_(!r8em^GjS1fPcf1+HYWg9WOJWkDJIV2N`4W|IAtBvUfqc$ zw6A3U4(!zbBKrgEPcA-?g+tQMgA;%?_#mzm_-G06<@lmwGy&LHC!iJskzR@BYxpRO zcg*G=Zn;9P$PHbr^n5f<8h#kd^NUm)ovmE>^c{m!`7S=|g^@cbYD^$^d7mST2rzrpBfh3 z5hky`WA(B{uGQIFZ6sa5A~ekjM^=X6f@Sp8Zdj$cbY)1u4yRtP41N-k9`~3OrE1}J z@o{j}73rv<*zD=s%_TUjIgm|_fxI@prs@|Qw;gX}Qnno6PtxDtVvhk)>1J`s@Xm&C zvt0uEQ0{^vw$E4dfpOp5bGGTVfY!s9R_o&-VXbDoNzXSejuR&CwL;5#GF5z)#M5CN)AXiN9=M$hKcgtZdJ|XFuGNU1m_pkXc7Q>lp1ESYR4+Cr(?gP}S<9X>PEU1oTIx})%^ZCC7LMula*-}T7LxrYiDJjY+8->Nz6f6#3G zIbOy+c73`8{BoLkX_zORDb$in?F;4|%p7n$D)w$n%E;U59UL7xTRo=Y{mzu@Q?oZA zXXoZ8uCeY43`L)9PCAu#Ys1U0g-x5&&)?}5S~j5;_Iv0$S`+OOReQs3{s@WZ<-0$9 zal4XStL`=DHp$>ffJuydW4iomk@|B{Dx+ub)2vXF@)6f#apc8|zKeC%WA~}08LCNUW^hiJwyK0J@+Eqro+@nX@82Cv7Vrlhe6qCkTcJ&m|YAmKRZD0t$zB_s6 zUo45#ShM?6ykKz?C(Yv3PLC60)%aR6UINrPX{?C!nHTMZendosu>=(5bJBw8KGF`f IsVqsyZxTEY)Bpeg literal 0 HcmV?d00001 diff --git a/lib/examples/google_photos/assets/sim_04_96.jpg b/lib/examples/google_photos/assets/sim_04_96.jpg new file mode 100644 index 0000000000000000000000000000000000000000..cb8f3a75c2e9a57d2828e4f5768f09411575f065 GIT binary patch literal 2635 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 literal 0 HcmV?d00001 diff --git a/lib/examples/google_photos/assets/sim_05_160x120.jpg b/lib/examples/google_photos/assets/sim_05_160x120.jpg new file mode 100644 index 0000000000000000000000000000000000000000..4de141534063e3072181b643235e1739cb6eb6bd GIT binary patch literal 3497 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 aK--tqD{AZwM45F)NdEZ?`f{`vkN!`;f^6&n literal 0 HcmV?d00001 diff --git a/lib/examples/google_photos/assets/sim_05_320x240.jpg b/lib/examples/google_photos/assets/sim_05_320x240.jpg new file mode 100644 index 0000000000000000000000000000000000000000..8bbd486c08f88d38e65b8fd3a0f905bf3a30d8a1 GIT binary patch literal 9402 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 literal 0 HcmV?d00001 diff --git a/lib/examples/google_photos/assets/sim_05_64.jpg b/lib/examples/google_photos/assets/sim_05_64.jpg new file mode 100644 index 0000000000000000000000000000000000000000..311b7c54ea023dc17494cbfca94b2cc98733f3cc GIT binary patch literal 1572 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 literal 0 HcmV?d00001 diff --git a/lib/examples/google_photos/assets/sim_06_120x160.jpg b/lib/examples/google_photos/assets/sim_06_120x160.jpg new file mode 100644 index 0000000000000000000000000000000000000000..25b876edd3173d19affbebee38557d6f5b936d14 GIT binary patch literal 3630 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 brdGs-%HX31;2NjKvr2a+5;dE z0DzhPm4e#tR4cAwjvQo6cULds9;nG|5=h>0TcqT2CTs#6d;2Ffl(ky z6M&QEgn~W`_){PmFa!#NBapIdqy-i002vS%ECT^Ur3}*QaOpk(K|#@4TXw<}sUC1` ze~fX&tqcU-rnFvZe-EEva)K6#l*KBmtXCy&)Y+uF+0<<7Hgk*Zw!7@?$qtUYoenr3 zJaqVo%gIxoUfw>ZeFH8A23-mc361*kYIMxC>o;!Sxtnw^IVJV}qs*-A$2qxqzmze` zE1pzVRX=TLY-(<4ZEJt=x|jK;@9n$(fuZ4#Bka+!@lTxT8NsYjG&jGnxXJ|rz+bSW z{srtGT+&AX$v_}r2z-?bBy&kR!6*n+YYPm$lM46nSJXC+KwxZcWt7$<@h1EEN+)PN zvRH!Y5OI1H?K9cG1B?7$WM9Gl;u-+tz#!@8fl&Ynn4vJ14lxC|<83%^QD%w-m@8%m zz&C`a4P^$8v1)N{q|rlA9739S~= zShMG8GpckiQ9)6VG+AwK)!$qrF@mrwk~0{waaxCY5|<+^RyK^rob zsmOF=T&DWZe0v}I=)#jb`mZd>+Or;1^A>;h?Wa&R-}sE-*dfhc2vh>pMlI(@u*-Qh zrlZ~lMf3Mx>qUp#@Eu7M1K4n8FE~3iIBc7K$mv1_OQpmNyMe3+VXdNBU3*#YrFufBXH+IV)W)iuPF8GkMph@i;wEa_eguh)9m(} zhyU#JaAU7oN$zcgA>}Y}1Df9dz1s4$Yq*PIPSN-x7wa9%MGboZb(46xV)@f5Sd#Rt4}05VWUqy+1IWr z10SaC4Qy3VWc!W<=e?K?%P(^qRdzM%Gn|Any7Kz7t_Lv^ZOD{Hr2->-D9ZF4!5<) zRpZAgcz%IrWMlhpO;b6s#rv#o=1y|FGz{C>#tvl;9kk{VLg}0^f3d>w+VDkpmdTrY zq_Xn&^gOpEo&+e%6HzA@B*3jJ4at5JJOyr{r8u~FWljS0Q@NRg0tv7|gsGvs1=BrG zvjoj_-gEJbL{Cjt`U1QpdvI~74Y#LM-y!~oS-CTI_OSC;chrwvW(txn2x`a2*2JXo z$mYkac%c>z>}!U|<264e28hibWEXbg4Ukr&Lcb0P5Kl4Q%surM>crb-6-fH%yk75) zuFpY1?ZmzS%e&7w>@kz75N5#D#;(i`qF5AFinG-f#E$ROQuXW-J literal 0 HcmV?d00001 diff --git a/lib/examples/google_photos/assets/sim_06_96.jpg b/lib/examples/google_photos/assets/sim_06_96.jpg new file mode 100644 index 0000000000000000000000000000000000000000..05b45887fad975b9e7456abb8f24306c091868b4 GIT binary patch literal 2426 zcmbW0c{H1O7ssEFh^VC@i<)Rg8@pN>s+Ll1QH&~z(!G?@MeRZ(Ep06wjJ;CJSXw2P z!_?kTwUrviz6{C`Qd9d9q4G@9_RM?Eyyx`1@7(7+&mZ^qy}x@u-+LZ#2%G?Ro12)K z01yZOK)4S8{tg@m1fWnD6e<9N!2|>Z;0RF!LQoJPE+Qf%iWbLU(Bdc*R!RnkmBjBs zp`_)d@%#46%F1GJiYf{OWf_7jfp-anTZ#}wAQ1>8K@ufN_`d^u4Il-9%fMxR2om5! zLimvounE9(=Y&B%2kr4 zK*IV8e4iKU;QhEP!TrZd8_+i0O9bLo?@)vg1}nKoN>)x@K~YIl>yY+g9o^$6j7>~W znwi_4v9otL>*(a{a?RE4y1R#uub+QFASo#9!NW)45s^{x35iL`DNjWw40K)tb?(+DNfFZCVCO=YFFgxHKo-l9R zn5duWp^D3z-nPEO;2t~#Q&KUVjfHs+a5*qvN=Kkn>v^ccrkYsNLjHzmLO zk$v3QU}yjgkojIXBP`+QUNaSso@%3&WCEGkOnz{#teSCnPP=U*&&xaIZl=fM^l$FY zCU{S7@4{5||=hJMgA~(7qwH< zy?M-7w4Z{TtZO*ZYf8Zw_iF1@gngdGapK z7mIY&XJ34bk+MBsUZVj<0}#uf6%r34L)<7thwpN%p|YrS6!r z{u${Tcr!x06(M82C-CV*00k@_0D&qHV6_!6oEcXb^(;Jxt?3x9 zta||4Qd69WQA{eiH5eTqR&DwVCFTb8xj3<3nK<0wcJ{|3Ifg+7!JjhO>Ce-rZb^ND z5aJ!*1{E@oftW&)NBZM+rdg=V~c>41k*{F&vuOSn$VcA&u{5m)MZ!dEemW&r zr*yYpi;|KpBW1Z5Y!SP?L3vYT?5Dn=Wtg znR;C1`^i(xBhgk2${mW^MG)vpZ{6AUT+05MlQvj3lIxj(w8;^hv0~Q@85A$EWDppo zY)|)lm+3h6k@ETm^E!em#Wc65I-!#@nYO0t=kI52wWM8WB1ww{JDuJ zznvL*se?AN?=y-rJ1JHJD|RsEauq4j2iDk;^W-1Y)t>rg^6j_JdE#rMPofj4R#}BE zBR$i#d24kbP_Jx}N}9K>{cZ{`_x[c].jpg|png`` thumbnails. + +Usage:: + + from gphotos_engine import GPhotosEngine + + eng = GPhotosEngine() + eng.restore() # prefs: session + items + if not eng.items: + s = eng.create_session() # show s["pickerUri"] as a QR code + while not eng.poll_session(): + time.sleep(eng.poll_interval_s()) + eng.list_items() + path = eng.thumbnail_path(eng.items[0], 96, 96, crop=True) + +Blocking network calls only; the LVGL front end queues them on its main-tick +pump (no ``_thread``). Errors never raise out of the public methods: they +return ``None`` / ``False`` / ``""`` and set :attr:`GPhotosEngine.last_error`. +""" + +import sys + +try: + import json +except ImportError: # pragma: no cover + json = None + +try: + import os +except ImportError: # pragma: no cover + os = None + +try: + import time +except ImportError: # pragma: no cover + time = None + +try: + import socket +except ImportError: # pragma: no cover — e.g. CircuitPython unix, PyScript + socket = None + +TOKEN_URL = "https://oauth2.googleapis.com/token" +PICKER_BASE = "https://photospicker.googleapis.com/v1" +SCOPE = "https://www.googleapis.com/auth/photospicker.mediaitems.readonly" + +TOKENS_HOME_NAME = ".gphotos_tokens.json" +TOKENS_MCU_NAME = "/gphotos_tokens.json" +PREFS_HOME_NAME = ".gphotos_prefs" +PREFS_MCU_NAME = "/gphotos_prefs" +CACHE_HOME_NAME = ".gphotos_cache" +CACHE_MCU_NAME = "/gphotos_cache" + +# Picker base URLs stay valid for 60 minutes; re-list a little early. +URL_TTL_S = 50 * 60 +# Refresh the access token this long before Google says it expires. +TOKEN_MARGIN_S = 60 +PAGE_SIZE = 25 +DEFAULT_MAX_ITEMS = 100 +DEFAULT_CACHE_MAX = 48 +DEFAULT_SLIDESHOW_S = 5 +DOWNLOAD_CHUNK = 2048 + +# Set by the launcher so ``gphotos_lvgl`` does not auto-run on import. +_LAUNCHER_OWNS_RUN = False + + +class GPhotosError(Exception): + """Transport-level failure (no HTTP status available).""" + + +# ---------------------------------------------------------------------- +# Small portable helpers (no os.path / pathlib — MicroPython-safe) +# ---------------------------------------------------------------------- + + +def _env_get(name): + """Portable getenv (CPython / MicroPython / CircuitPython).""" + if os is None: + return None + environ = getattr(os, "environ", None) + if environ is not None: + try: + val = environ.get(name) + if val: + return val + except Exception: + pass + getenv = getattr(os, "getenv", None) + if getenv is not None: + try: + return getenv(name) + except Exception: + return None + return None + + +def _path_join(base, name): + """Join directory + filename without requiring ``os.path``.""" + if not base: + return name + if base.endswith("/") or base.endswith("\\"): + return base + name + if sys.platform == "win32" and len(base) >= 2 and base[1] == ":": + return base + "\\" + name + return base + "/" + name + + +def _user_home_dir(): + """Best-effort user home on desktop hosts; empty on typical MCU images.""" + for key in ("HOME", "USERPROFILE"): + val = _env_get(key) + if val: + return val + try: + expanduser = getattr(getattr(os, "path", None), "expanduser", None) + if expanduser is not None: + home = expanduser("~") + if home and home != "~": + return home + except Exception: + pass + return "" + + +def _default_path(env_name, home_name, mcu_name): + override = _env_get(env_name) + if override: + return override + home = _user_home_dir() + if home and home not in (".", "/"): + return _path_join(home, home_name) + return mcu_name + + +def tokens_path(): + """Desktop ``~/.gphotos_tokens.json``; MCU ``/gphotos_tokens.json``.""" + return _default_path("GPHOTOS_TOKENS", TOKENS_HOME_NAME, TOKENS_MCU_NAME) + + +def prefs_path(): + """Desktop ``~/.gphotos_prefs``; MCU ``/gphotos_prefs``.""" + return _default_path("GPHOTOS_PREFS", PREFS_HOME_NAME, PREFS_MCU_NAME) + + +def cache_dir(): + """Desktop ``~/.gphotos_cache``; MCU ``/gphotos_cache``.""" + return _default_path("GPHOTOS_CACHE", CACHE_HOME_NAME, CACHE_MCU_NAME) + + +def _exists(path): + if os is None or not path: + return False + try: + os.stat(path) + return True + except OSError: + return False + + +def _ensure_dir(path): + if os is None or not path: + return False + if _exists(path): + return True + try: + os.mkdir(path) + return True + except OSError: + return False + + +def _remove(path): + if os is None or not path: + return + try: + os.remove(path) + except OSError: + pass + + +def _read_text(path): + try: + with open(path, "r") as f: + return f.read() + except OSError: + return "" + + +def _write_text(path, text): + try: + with open(path, "w") as f: + f.write(text) + return True + except OSError: + return False + + +def _mono_ms(): + """Monotonic milliseconds; compare only via :func:`_elapsed_ms`.""" + if time is None: + return 0 + f = getattr(time, "ticks_ms", None) + if f is not None: + return f() + f = getattr(time, "monotonic", None) + if f is not None: + return int(f() * 1000) + return int(time.time() * 1000) + + +def _elapsed_ms(since): + now = _mono_ms() + f = getattr(time, "ticks_diff", None) if time is not None else None + if f is not None: + return f(now, since) + return now - since + + +_SAFE = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.~" + + +def quote(value): + """RFC 3986 percent-encoding (everything but unreserved characters).""" + out = [] + for ch in str(value): + if ch in _SAFE: + out.append(ch) + else: + for b in ch.encode("utf-8"): + out.append("%%%02X" % b) + return "".join(out) + + +def form_encode(fields): + """``application/x-www-form-urlencoded`` body from ``[(key, value), …]``.""" + return "&".join(quote(k) + "=" + quote(v) for k, v in fields) + + +def json_loads(raw): + """Parse bytes/str JSON → dict (``{}`` on any failure).""" + if json is None or not raw: + return {} + try: + if isinstance(raw, (bytes, bytearray, memoryview)): + raw = bytes(raw).decode("utf-8") + data = json.loads(raw) + except Exception: + return {} + return data if isinstance(data, dict) else {} + + +def parse_duration_s(text, default=5.0): + """Protobuf JSON duration (``"5s"``, ``"1.5s"``) → seconds.""" + if text is None: + return default + try: + t = str(text).strip() + if t.endswith("s"): + t = t[:-1] + return float(t) + except (ValueError, TypeError): + return default + + +def date_label(create_time): + """``2024-05-03T14:22:11Z`` → ``2024-05-03``.""" + return (create_time or "")[:10] + + +def time_label(create_time): + """``2024-05-03T14:22:11Z`` → ``14:22`` (UTC as reported by Google).""" + t = create_time or "" + return t[11:16] if len(t) >= 16 and t[10] == "T" else "" + + +def item_hash(item_id): + """Short stable name for cache files (FNV-1a 32-bit of the media item id).""" + h = 2166136261 + for b in str(item_id).encode("utf-8"): + h ^= b + h = (h * 16777619) & 0xFFFFFFFF + return "%08x" % h + + +def thumb_url(base_url, w, h, crop=False): + """Sized image URL from a Picker ``baseUrl`` (``-c`` crops to exactly w×h).""" + if not base_url: + return "" + return "%s=w%d-h%d%s" % (base_url, int(w), int(h), "-c" if crop else "") + + +def _to_int(value): + try: + return int(value) + except (TypeError, ValueError): + return 0 + + +def normalize_item(raw): + """Flatten one Picker ``PickedMediaItem`` into the dict the UI consumes.""" + if not isinstance(raw, dict): + return {} + mf = raw.get("mediaFile") or {} + meta = mf.get("mediaFileMetadata") or {} + return { + "id": raw.get("id") or "", + "filename": mf.get("filename") or "", + "createTime": raw.get("createTime") or "", + "type": raw.get("type") or "", + "mime": mf.get("mimeType") or "", + "width": _to_int(meta.get("width")), + "height": _to_int(meta.get("height")), + "baseUrl": mf.get("baseUrl") or "", + } + + +def sniff_ext(head): + """``.jpg`` / ``.png`` / ``""`` from the first bytes of an image file.""" + if head[:3] == b"\xff\xd8\xff": + return ".jpg" + if head[:8] == b"\x89PNG\r\n\x1a\n": + return ".png" + return "" + + +# ---------------------------------------------------------------------- +# HTTP transport (urllib → requests / adafruit_requests → raw socket + TLS) +# ---------------------------------------------------------------------- + +_requests_backend = None + + +def _find_requests(): + """MicroPython ``requests`` module or a CircuitPython ``adafruit_requests`` session.""" + global _requests_backend + if _requests_backend is not None: + return _requests_backend or None + try: + import requests as mod + + if hasattr(mod, "request"): + _requests_backend = mod + return mod + except ImportError: + pass + try: + import ssl + import wifi + import socketpool + import adafruit_requests + + pool = socketpool.SocketPool(wifi.radio) + _requests_backend = adafruit_requests.Session(pool, ssl.create_default_context()) + return _requests_backend + except ImportError: + pass + _requests_backend = False + return None + + +def _wrap_tls(sock, host): + """Client TLS for the raw-socket fallback (no CA verification — the + MicroPython ``requests`` default, and most boards have no CA bundle).""" + try: + import tls as _tls + + ctx = _tls.SSLContext(_tls.PROTOCOL_TLS_CLIENT) + ctx.verify_mode = _tls.CERT_NONE + return ctx.wrap_socket(sock, server_hostname=host) + except ImportError: + pass + import ssl + + try: + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + try: + ctx.check_hostname = False + except AttributeError: + pass + ctx.verify_mode = ssl.CERT_NONE + return ctx.wrap_socket(sock, server_hostname=host) + except (AttributeError, TypeError): + return ssl.wrap_socket(sock, server_hostname=host) + + +class _SockIO: + """Tiny buffered stream over CPython / MicroPython sockets (plain or TLS).""" + + def __init__(self, sock): + self.s = sock + self._buf = b"" + + def send(self, data): + s = self.s + if hasattr(s, "sendall"): + s.sendall(data) + return + mv = memoryview(data) + off = 0 + while off < len(mv): + n = s.write(mv[off:]) if hasattr(s, "write") else s.send(mv[off:]) + if n is None: + n = len(mv) - off + off += n + + def _recv(self, n): + s = self.s + if hasattr(s, "recv"): + return s.recv(n) + return s.read(n) + + def readline(self): + while b"\n" not in self._buf: + chunk = self._recv(512) + if not chunk: + line, self._buf = self._buf, b"" + return line + self._buf += chunk + i = self._buf.index(b"\n") + 1 + line, self._buf = self._buf[:i], self._buf[i:] + return line + + def read(self, n): + if self._buf: + out, self._buf = self._buf[:n], self._buf[n:] + return out + return self._recv(n) or b"" + + +def _socket_request(method, url, headers, body, timeout, sink=None): + """HTTP/1.0 over ``socket`` (+TLS). Returns ``(status, body_bytes | n)``.""" + if socket is None: + raise GPhotosError("no socket module") + parts = url.split("/", 3) + proto = parts[0] + host = parts[2] if len(parts) > 2 else "" + path = "/" + (parts[3] if len(parts) > 3 else "") + https = proto == "https:" + port = 443 if https else 80 + if ":" in host: + host, p = host.rsplit(":", 1) + port = int(p) + ai = socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM)[0] + s = socket.socket(ai[0], socket.SOCK_STREAM, ai[2]) + try: + s.settimeout(timeout) + except (AttributeError, OSError): + pass + s.connect(ai[-1]) + if https: + s = _wrap_tls(s, host) + io = _SockIO(s) + try: + lines = ["%s %s HTTP/1.0" % (method, path), "Host: " + host, "Connection: close"] + hdrs = dict(headers or {}) + if body is not None and "Content-Length" not in hdrs: + hdrs["Content-Length"] = str(len(body)) + for k in hdrs: + lines.append("%s: %s" % (k, hdrs[k])) + io.send(("\r\n".join(lines) + "\r\n\r\n").encode("utf-8")) + if body: + io.send(body) + status_line = io.readline().split(None, 2) + if len(status_line) < 2: + raise GPhotosError("bad status line") + status = int(status_line[1]) + length = -1 + while True: + line = io.readline() + if not line or line in (b"\r\n", b"\n"): + break + if line.lower().startswith(b"content-length:"): + length = int(line.split(b":", 1)[1].strip()) + total = 0 + chunks = [] + while length < 0 or total < length: + want = DOWNLOAD_CHUNK if length < 0 else min(DOWNLOAD_CHUNK, length - total) + chunk = io.read(want) + if not chunk: + break + total += len(chunk) + if sink is not None: + sink(chunk) + else: + chunks.append(chunk) + finally: + try: + s.close() + except Exception: + pass + return status, (total if sink is not None else b"".join(chunks)) + + +def _urllib(): + """``(Request, urlopen, HTTPError, URLError)`` on CPython, else ``None``.""" + try: + from urllib.request import Request, urlopen + from urllib.error import HTTPError, URLError + except ImportError: + return None + return Request, urlopen, HTTPError, URLError + + +def _status_of(resp): + for name in ("status_code", "status", "code"): + val = getattr(resp, name, None) + if val is not None: + try: + return int(val) + except (TypeError, ValueError): + pass + return 0 + + +def _transport(method, url, headers, body, timeout, sink=None): + """Run one request. ``sink(chunk)`` streams the body; else it is returned.""" + headers = dict(headers or {}) + if isinstance(body, str): + body = body.encode("utf-8") + + # 1) CPython + mods = _urllib() + if mods is not None: + Request, urlopen, HTTPError, URLError = mods + req = Request(url, data=body, headers=headers, method=method) + try: + resp = urlopen(req, timeout=timeout) + except HTTPError as err: + raw = b"" + try: + raw = err.read() + except Exception: + pass + return int(getattr(err, "code", 0) or 0), raw + except URLError as err: + raise GPhotosError(str(getattr(err, "reason", err))) + try: + status = int(getattr(resp, "status", 200) or 200) + if sink is None: + return status, resp.read() + total = 0 + while True: + chunk = resp.read(DOWNLOAD_CHUNK) + if not chunk: + break + total += len(chunk) + sink(chunk) + return status, total + finally: + resp.close() + + # 2) MicroPython requests / CircuitPython adafruit_requests + req = _find_requests() + if req is not None: + kwargs = {"data": body, "headers": headers, "stream": sink is not None} + try: + resp = req.request(method, url, timeout=timeout, **kwargs) + except TypeError: + resp = req.request(method, url, **kwargs) + except AttributeError: + # settimeout unsupported on this port + resp = req.request(method, url, **kwargs) + try: + status = _status_of(resp) + if sink is None: + raw = getattr(resp, "content", None) + if raw is None: + raw = resp.raw.read() + return status, bytes(raw) + total = 0 + iter_content = getattr(resp, "iter_content", None) + if iter_content is not None: + for chunk in iter_content(DOWNLOAD_CHUNK): + if chunk: + total += len(chunk) + sink(chunk) + else: + raw = resp.raw + while True: + chunk = raw.read(DOWNLOAD_CHUNK) + if not chunk: + break + total += len(chunk) + sink(chunk) + return status, total + finally: + try: + resp.close() + except Exception: + pass + + # 3) raw socket + return _socket_request(method, url, headers, body, timeout, sink) + + +def http_request(method, url, headers=None, data=None, timeout=20.0): + """Portable HTTPS request → ``(status, body_bytes)``. + + Raises :class:`GPhotosError` only when no response arrived at all. + """ + if isinstance(data, str): + data = data.encode("utf-8") + return _transport(method.upper(), url, headers, data, timeout) + + +def http_download(url, path, headers=None, timeout=30.0): + """Stream a GET to ``path`` → ``(status, bytes_written)``. + + The file holds whatever the server sent (an error body on non-2xx), so + callers delete it unless the status is 200. + """ + f = open(path, "wb") + try: + return _transport("GET", url, headers, None, timeout, sink=f.write) + finally: + f.close() + + +# ---------------------------------------------------------------------- +# Credentials +# ---------------------------------------------------------------------- + + +def load_tokens(path=None): + """Read the tokens JSON (``{}`` when missing/invalid).""" + data = json_loads(_read_text(path or tokens_path())) + return data if isinstance(data, dict) else {} + + +def credentials_ok(tokens): + """True when ``client_id`` and ``refresh_token`` are present.""" + return bool(tokens and tokens.get("client_id") and tokens.get("refresh_token")) + + +# ---------------------------------------------------------------------- +# Engine +# ---------------------------------------------------------------------- + + +class GPhotosEngine: + """Picker API client with prefs + thumbnail cache (blocking calls).""" + + sim = False + + def __init__( + self, + tokens=None, + tokens_file=None, + prefs_file=None, + cache_path=None, + timeout=20.0, + max_items=DEFAULT_MAX_ITEMS, + cache_max=DEFAULT_CACHE_MAX, + ): + self.tokens_file = tokens_file or tokens_path() + self.prefs_file = prefs_file or prefs_path() + self.cache_path = cache_path or cache_dir() + self.tokens = tokens if tokens is not None else load_tokens(self.tokens_file) + self.timeout = timeout + self.max_items = max_items + self.cache_max = cache_max + self.access_token = "" + self._token_at = None + self._token_ttl_ms = 0 + self.session = None + self.items = [] + self._urls_at = None + self.last_error = "" + self.prefs = None + + # ----- credentials / tokens --------------------------------------- + + def has_credentials(self): + return credentials_ok(self.tokens) + + def _token_expired(self): + if self._token_at is None or not self.access_token: + return True + return _elapsed_ms(self._token_at) >= self._token_ttl_ms + + def ensure_token(self, force=False): + """Mint an access token from the refresh token when needed.""" + if not force and not self._token_expired(): + return True + if not self.has_credentials(): + self.last_error = "no credentials - run tools/gphotos_auth.py" + return False + t = self.tokens + body = form_encode( + [ + ("client_id", t.get("client_id", "")), + ("client_secret", t.get("client_secret", "")), + ("refresh_token", t.get("refresh_token", "")), + ("grant_type", "refresh_token"), + ] + ) + status, raw = self._request( + "POST", + TOKEN_URL, + {"Content-Type": "application/x-www-form-urlencoded"}, + body, + ) + data = json_loads(raw) + token = data.get("access_token") if status == 200 else None + if not token: + err = data.get("error") or "" + if isinstance(err, dict): + err = err.get("status") or err.get("message") or "" + desc = data.get("error_description") or "" + text = ("%s: %s" % (err, desc)) if (err and desc) else (err or desc) + if status: + self.last_error = ("token refresh: HTTP %d %s" % (status, text)).rstrip() + elif not self.last_error: + self.last_error = "token refresh failed" + self.access_token = "" + self._token_at = None + return False + ttl = _to_int(data.get("expires_in")) or 3600 + self.access_token = token + self._token_at = _mono_ms() + self._token_ttl_ms = max(30, ttl - TOKEN_MARGIN_S) * 1000 + self.last_error = "" + return True + + def auth_headers(self): + return {"Authorization": "Bearer " + self.access_token} + + # ----- raw HTTP ---------------------------------------------------- + + def _request(self, method, url, headers=None, data=None): + try: + return http_request(method, url, headers=headers, data=data, timeout=self.timeout) + except Exception as err: + self.last_error = "network: %s" % err + return 0, b"" + + def _api(self, method, url, body=None, content_type=None, retry=True): + """Authorized JSON call → ``(status, dict)``; refreshes once on 401.""" + if not self.ensure_token(): + return 0, {} + headers = self.auth_headers() + if body is not None: + headers["Content-Type"] = content_type or "application/json" + status, raw = self._request(method, url, headers, body) + if status == 401 and retry and self.ensure_token(force=True): + return self._api(method, url, body, content_type, retry=False) + return status, json_loads(raw) + + def _api_error(self, what, status, data): + err = data.get("error") if isinstance(data, dict) else None + msg = "" + if isinstance(err, dict): + msg = err.get("message") or err.get("status") or "" + elif isinstance(err, str): + msg = err + if status == 0: + return self.last_error or ("%s: no response" % what) + return "%s: HTTP %d %s" % (what, status, msg) if msg else "%s: HTTP %d" % (what, status) + + @staticmethod + def _session_gone(status, data): + if status in (404, 410): + return True + err = data.get("error") if isinstance(data, dict) else None + text = "" + if isinstance(err, dict): + text = ("%s %s" % (err.get("status", ""), err.get("message", ""))).lower() + return status == 400 and ("expired" in text or "not found" in text) + + # ----- sessions ---------------------------------------------------- + + @staticmethod + def _session_from(data, previous=None): + pc = data.get("pollingConfig") or {} + prev = previous or {} + return { + "id": data.get("id") or prev.get("id") or "", + "pickerUri": data.get("pickerUri") or prev.get("pickerUri") or "", + "expireTime": data.get("expireTime") or prev.get("expireTime") or "", + "mediaItemsSet": bool(data.get("mediaItemsSet")), + "pollInterval": parse_duration_s( + pc.get("pollInterval"), prev.get("pollInterval", 5.0) + ), + "timeoutIn": parse_duration_s(pc.get("timeoutIn"), prev.get("timeoutIn", 1800.0)), + } + + def create_session(self): + """Start a picker session; returns the session dict (``pickerUri``…).""" + status, data = self._api("POST", PICKER_BASE + "/sessions", body=b"{}") + if status != 200 or not data.get("id") or not data.get("pickerUri"): + self.last_error = self._api_error("create session", status, data) + return None + self.session = self._session_from(data) + self.items = [] + self._urls_at = None + self.last_error = "" + self._save_prefs() + return self.session + + def poll_interval_s(self): + s = self.session or {} + return max(2.0, float(s.get("pollInterval") or 5.0)) + + def poll_session(self): + """Refresh session state; True once the user finished picking.""" + s = self.session + if not s or not s.get("id"): + self.last_error = "no session" + return False + status, data = self._api("GET", PICKER_BASE + "/sessions/" + quote(s["id"])) + if status == 200 and data.get("id"): + self.session = self._session_from(data, s) + self.last_error = "" + self._save_prefs() + return self.session["mediaItemsSet"] + if self._session_gone(status, data): + self.last_error = "session expired - pick again" + self.session = None + self._save_prefs() + return False + 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 + self._save_prefs() + if not s or not s.get("id"): + return True + status, data = self._api("DELETE", PICKER_BASE + "/sessions/" + quote(s["id"])) + if status not in (200, 204) and not self._session_gone(status, data): + self.last_error = self._api_error("delete session", status, data) + return False + return True + + # ----- media items ------------------------------------------------- + + def list_items(self): + """Fetch every picked item (fresh ``baseUrl``s) → list or ``None``.""" + s = self.session + if not s or not s.get("id"): + self.last_error = "no session" + return None + items = [] + token = "" + while True: + url = "%s/mediaItems?sessionId=%s&pageSize=%d" % (PICKER_BASE, quote(s["id"]), PAGE_SIZE) + if token: + url += "&pageToken=" + quote(token) + status, data = self._api("GET", url) + if status != 200: + if self._session_gone(status, data): + self.last_error = "session expired - pick again" + self.session = None + self._save_prefs() + else: + self.last_error = self._api_error("list media items", status, data) + return None + for raw in data.get("mediaItems") or []: + item = normalize_item(raw) + if item.get("id"): + items.append(item) + if len(items) >= self.max_items: + break + token = data.get("nextPageToken") or "" + if not token or len(items) >= self.max_items: + break + self.items = items + self._urls_at = _mono_ms() + self.last_error = "" + self._save_prefs() + return items + + def urls_valid(self): + """True while the cached ``baseUrl``s are still usable.""" + if self._urls_at is None or not self.items: + return False + if _elapsed_ms(self._urls_at) >= URL_TTL_S * 1000: + return False + for item in self.items: + if item.get("baseUrl"): + return True + return False + + def refresh_urls(self): + """Re-list to get fresh ``baseUrl``s (keeps the same items).""" + if self.urls_valid(): + return True + return self.list_items() is not None + + def item_by_id(self, item_id): + for item in self.items: + if item.get("id") == item_id: + return item + return None + + # ----- thumbnail cache --------------------------------------------- + + def cache_name(self, item, w, h, crop=False): + return "%s_%dx%d%s" % (item_hash(item.get("id", "")), int(w), int(h), "c" if crop else "") + + def cached_path(self, item, w, h, crop=False): + """Existing cache file for this item/size, or ``""``.""" + base = _path_join(self.cache_path, self.cache_name(item, w, h, crop)) + for ext in (".jpg", ".png"): + if _exists(base + ext): + return base + ext + return "" + + def _download(self, url, path, retry=True): + if not self.ensure_token(): + return 0 + try: + status, n = http_download(url, path, headers=self.auth_headers(), timeout=self.timeout) + except Exception as err: + self.last_error = "download: %s" % err + _remove(path) + return 0 + if status == 401 and retry and self.ensure_token(force=True): + return self._download(url, path, retry=False) + if status != 200 or not n: + self.last_error = "download: HTTP %d" % status + _remove(path) + return 0 + return n + + def thumbnail_path(self, item, w, h, crop=False): + """Path of a cached w×h image for ``item`` (downloading it if needed). + + ``crop=True`` asks Google for an exact w×h center crop (list tiles); + ``crop=False`` fits the photo inside w×h (full-screen view). Returns + ``""`` on failure (see :attr:`last_error`). + """ + if not item or not item.get("id"): + self.last_error = "no item" + return "" + path = self.cached_path(item, w, h, crop) + if path: + return path + if not _ensure_dir(self.cache_path): + self.last_error = "cache dir unavailable: " + self.cache_path + return "" + fresh = item + if not item.get("baseUrl") or not self.urls_valid(): + if not self.refresh_urls(): + return "" + fresh = self.item_by_id(item["id"]) or item + if not fresh.get("baseUrl"): + self.last_error = "item no longer in the picked set" + return "" + url = thumb_url(fresh["baseUrl"], w, h, crop) + base = _path_join(self.cache_path, self.cache_name(item, w, h, crop)) + tmp = base + ".part" + if not self._download(url, tmp): + return "" + head = b"" + try: + with open(tmp, "rb") as f: + head = f.read(8) + except OSError: + pass + ext = sniff_ext(head) + if not ext: + _remove(tmp) + self.last_error = "download: not an image" + return "" + final = base + ext + _remove(final) + try: + os.rename(tmp, final) + except OSError: + self.last_error = "cache write failed" + _remove(tmp) + return "" + self._evict_cache() + return final + + def _evict_cache(self): + """Keep at most ``cache_max`` files: drop orphans first, then oldest.""" + if os is None or self.cache_max <= 0: + return + try: + names = [n for n in os.listdir(self.cache_path) if not n.endswith(".part")] + except OSError: + return + if len(names) <= self.cache_max: + return + keep = set(item_hash(it.get("id", "")) for it in self.items) + orphans = [n for n in names if n[:8] not in keep] + for n in orphans: + _remove(_path_join(self.cache_path, n)) + names.remove(n) + if len(names) <= self.cache_max: + return + + def _mtime(name): + try: + return os.stat(_path_join(self.cache_path, name))[8] + except (OSError, IndexError): + return 0 + + names.sort(key=_mtime) + while len(names) > self.cache_max: + _remove(_path_join(self.cache_path, names.pop(0))) + + def clear_cache(self): + if os is None: + return + try: + names = os.listdir(self.cache_path) + except OSError: + return + for n in names: + _remove(_path_join(self.cache_path, n)) + + # ----- prefs ------------------------------------------------------- + + def _load_prefs(self): + if self.prefs is None: + data = json_loads(_read_text(self.prefs_file)) + self.prefs = data if isinstance(data, dict) else {} + return self.prefs + + def _save_prefs(self): + prefs = self._load_prefs() + prefs["session"] = dict(self.session) if self.session else None + prefs["items"] = [ + { + "id": it.get("id", ""), + "filename": it.get("filename", ""), + "createTime": it.get("createTime", ""), + "type": it.get("type", ""), + "mime": it.get("mime", ""), + "width": it.get("width", 0), + "height": it.get("height", 0), + } + for it in self.items + ] + if json is None: + return False + try: + body = json.dumps(prefs) + except Exception: + return False + return _write_text(self.prefs_file, body) + + def get_pref(self, key, default=None): + val = self._load_prefs().get(key) + return default if val is None else val + + def set_pref(self, key, value): + self._load_prefs()[key] = value + return self._save_prefs() + + def restore(self): + """Load the saved session + picked list; True when items exist.""" + prefs = self._load_prefs() + session = prefs.get("session") + self.session = dict(session) if isinstance(session, dict) and session.get("id") else None + items = [] + for raw in prefs.get("items") or []: + if isinstance(raw, dict) and raw.get("id"): + it = dict(raw) + it["baseUrl"] = "" + items.append(it) + self.items = items + self._urls_at = None + return bool(items) + + def forget(self, clear_cache=False): + """Drop session + list (and optionally the cached thumbnails).""" + self.session = None + self.items = [] + self._urls_at = None + self._save_prefs() + if clear_cache: + self.clear_cache() + + # ----- labels ------------------------------------------------------ + + def count_label(self): + n = len(self.items) + if n == 1: + return "1 photo" + return "%d photos" % n diff --git a/lib/examples/google_photos/gphotos_lvgl.py b/lib/examples/google_photos/gphotos_lvgl.py new file mode 100644 index 00000000..84b6f890 --- /dev/null +++ b/lib/examples/google_photos/gphotos_lvgl.py @@ -0,0 +1,1197 @@ +# deps: lvgl +# modules: gphotos_engine, gphotos_sim +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +""" +gphotos_lvgl +==================================================== +Google Photos picker, list, viewer, and slideshow built with LVGL. + +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). +* **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; + tapping the photo advances. PLAY auto-advances every ``slideshow_s`` + seconds (prefs; default 5). + +Import order matters: ``display_driver`` must be imported after +``board_config`` so LVGL's display / input devices are wired before widgets +are created. All network work (token refresh, session polling, image +downloads) is queued and drained from an ``lv.timer`` pump on the main +thread -- no ``_thread`` (ESP32 thread stacks are too small for TLS). Jobs +only touch the engine and post results into mailboxes; the pump applies +them to LVGL. + +JPEG decoding: CPython's LVGL keeps TJPGD; MicroPython / CircuitPython LVGL +firmware decodes through displayif's ``jpegio``, registered as an LVGL image +decoder. PNG goes through LODEPNG on every build. Without a JPEG decoder the +list still works -- tiles keep a placeholder and the viewer says why. + +Launch via ``google_photos`` (prefs + sim detection). Direct +``gphotos_lvgl.run()`` also works. Join Wi-Fi before running on a +microcontroller. +""" + +import gc +import sys + +_EXAMPLES = __file__.replace("\\", "/").rsplit("/", 1)[0] +if _EXAMPLES not in sys.path: + sys.path.insert(0, _EXAMPLES) + +import board_config # noqa: E402,F401 — before display_driver so LVGL wiring sees it +import display_driver # noqa: E402 — wires LVGL display/input into the app +import lvgl as lv # noqa: E402 +from board_config import display_drv # noqa: E402 +from gphotos_engine import date_label, time_label # noqa: E402 +from gphotos_sim import make_engine # noqa: E402 +from multimer import ticks_diff, ticks_ms # noqa: E402 + +try: + import webbrowser as _webbrowser +except ImportError: # MicroPython / CircuitPython + _webbrowser = None + +FRONTEND = "lvgl" +PUMP_MS = 250 +DEFAULT_SLIDESHOW_S = 5 + +# Charcoal chassis, one teal accent (distinct from the Roku violet). +_COL = { + "bg": 0x10141A, + "plaque": 0x1B2129, + "plaque_edge": 0x2C3540, + "row": 0x222B35, + "row_alt": 0x1D252E, + "tile": 0x33404D, + "accent": 0x1E9E8A, + "accent2": 0x157A6A, + "ui": 0x2F3A47, + "ui2": 0x243039, + "text": 0xF3F5F7, + "muted": 0x9AA5B1, + "on_accent": 0xFFFFFF, + "black": 0x000000, + "white": 0xFFFFFF, +} + + +def _hex(rgb): + return lv.color_hex(rgb) + + +def _shade(rgb, factor): + r = max(0, min(255, int(((rgb >> 16) & 0xFF) * factor))) + g = max(0, min(255, int(((rgb >> 8) & 0xFF) * factor))) + b = max(0, min(255, int((rgb & 0xFF) * factor))) + return (r << 16) | (g << 8) | b + + +def _sym(name, fallback): + sym = getattr(lv, "SYMBOL", None) + if sym is not None: + val = getattr(sym, name, None) + if val: + return val + return fallback + + +def _pick_font_from(candidates, ref_obj=None): + for size in candidates: + font = getattr(lv, "font_montserrat_" + str(size), None) + if font is not None: + return font + if ref_obj is not None: + for getter in ("theme_get_font_normal", "theme_get_font_small"): + fn = getattr(lv, getter, None) + if fn is None: + continue + try: + font = fn(ref_obj) + if font is not None: + return font + except Exception: + pass + fn = getattr(lv, "font_get_default", None) + if fn is not None: + try: + return fn() + except Exception: + pass + return None + + +def _pick_font_at_most(max_size, ref_obj=None): + return _pick_font_from([s for s in (24, 22, 20, 18, 16, 14, 12) if s <= max_size], ref_obj) + + +def _apply_font(obj, font): + if font is None: + return + try: + obj.set_style_text_font(font, 0) + except Exception: + pass + + +def _flag(name): + obj_flag = getattr(getattr(lv, "obj", None), "FLAG", None) + if obj_flag is not None: + return getattr(obj_flag, name, None) + return getattr(getattr(lv, "OBJ_FLAG", None), name, None) + + +def _add_flag(obj, name): + flag = _flag(name) + if flag is None or obj is None: + return + try: + obj.add_flag(flag) + except Exception: + pass + + +def _remove_flag(obj, name): + flag = _flag(name) + if flag is None or obj is None: + return + try: + if hasattr(obj, "remove_flag"): + obj.remove_flag(flag) + else: + obj.clear_flag(flag) + except Exception: + pass + + +def _no_scroll(obj): + _remove_flag(obj, "SCROLLABLE") + + +def _set_hidden(obj, hidden): + if hidden: + _add_flag(obj, "HIDDEN") + else: + _remove_flag(obj, "HIDDEN") + + +# ----- image decoding --------------------------------------------------- + +_JPEG_OK = None +_PNG_OK = None + + +def jpeg_supported(): + """True when LVGL can decode JPEG on this build (TJPGD or jpegio).""" + global _JPEG_OK + if _JPEG_OK is not None: + return _JPEG_OK + ok = hasattr(lv, "tjpgd_init") # CPython: registered by lv.init() + if not ok: + try: + import jpegio # MicroPython (displayif) / CircuitPython + + ok = True + reg = getattr(jpegio, "register_lvgl_decoder", None) + if reg is not None: + try: + reg() + except RuntimeError: + ok = False + names = getattr(jpegio, "lvgl_decoders", None) + if ok and names is not None: + try: + ok = "jpegio" in tuple(names()) + except Exception: + pass + except ImportError: + ok = False + _JPEG_OK = ok + return ok + + +def png_supported(): + global _PNG_OK + if _PNG_OK is None: + _PNG_OK = hasattr(lv, "lodepng_init") + return _PNG_OK + + +def _jpeg_size(data): + """(width, height) from the SOF marker, or (0, 0).""" + i = 2 + n = len(data) + while i + 9 < n: + if data[i] != 0xFF: + i += 1 + continue + marker = data[i + 1] + i += 2 + if marker in (0xD8, 0xD9, 0x01) or 0xD0 <= marker <= 0xD7: + continue + seg = (data[i] << 8) | data[i + 1] + if marker in (0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB): + return (data[i + 5] << 8) | data[i + 6], (data[i + 3] << 8) | data[i + 4] + i += seg + return 0, 0 + + +def _png_size(data): + if len(data) < 24: + return 0, 0 + w = (data[16] << 24) | (data[17] << 16) | (data[18] << 8) | data[19] + h = (data[20] << 24) | (data[21] << 16) | (data[22] << 8) | data[23] + return w, h + + +def image_descriptor(path): + """``(dsc, data, w, h)`` for a cached JPEG/PNG, else ``(None, None, 0, 0)``. + + ``data`` must stay referenced as long as ``dsc`` is an image source. + """ + try: + with open(path, "rb") as f: + data = f.read() + except OSError: + return None, None, 0, 0 + if data[:3] == b"\xff\xd8\xff": + if not jpeg_supported(): + return None, None, 0, 0 + w, h = _jpeg_size(data) + elif data[:8] == b"\x89PNG\r\n\x1a\n": + if not png_supported(): + return None, None, 0, 0 + w, h = _png_size(data) + else: + return None, None, 0, 0 + if not w or not h: + return None, None, 0, 0 + try: + dsc = lv.image_dsc_t() + dsc.header.magic = lv.IMAGE_HEADER_MAGIC + dsc.header.cf = lv.COLOR_FORMAT.RAW + dsc.header.w = w + dsc.header.h = h + dsc.header.stride = 0 + dsc.data_size = len(data) + dsc.data = data + except Exception: + try: + dsc = lv.image_dsc_t( + { + "header": { + "magic": lv.IMAGE_HEADER_MAGIC, + "w": w, + "h": h, + "cf": lv.COLOR_FORMAT.RAW, + }, + "data_size": len(data), + "data": data, + } + ) + except Exception: + return None, None, 0, 0 + return dsc, data, w, h + + +# ----- front end ---------------------------------------------------------- + + +class _GPhotosLvgl: + PAGES = ("connect", "list", "view") + + def __init__(self, engine=None, start_page="list"): + self.engine = engine if engine is not None else make_engine() + self.page = start_page if start_page in self.PAGES else "list" + self.index = 0 + self.list_offset = 0 + self.page_size = 1 + self.slideshow = False + try: + self.slideshow_s = int(self.engine.get_pref("slideshow_s", DEFAULT_SLIDESHOW_S)) + except (TypeError, ValueError): + self.slideshow_s = DEFAULT_SLIDESHOW_S + if self.slideshow_s < 1: + self.slideshow_s = 1 + + # Cooperative bg queue (drained by ``_pump`` -- no ``_thread``). + self._bg_q = [] + self._bg_busy = False + # Mailboxes: jobs write, the pump applies on the LVGL thread. + self._pending_status = None + self._pending_thumbs = [] + self._pending_view = None + self._pending_list = False + self._pending_connect = False + self._poll_busy = False + self._poll_at = None + self._session_started = None + self._poll_timed_out = False + self._thumb_busy = {} + self._images = {} # item id -> (dsc, data): resident tiles (current page) + self._rows = {} # item id -> (tile image, placeholder label) + self._view_ref = None # (dsc, data) for the viewer + self._view_fetching = None + self._view_loaded_at = None + self._prefetched = None + self._page_root = None + self._last_status_text = None + self._last_title_text = None + self._last_count_text = None + + self.W = display_drv.width + self.H = display_drv.height + self.unit = min(self.W, self.H) + self.pad = max(4, self.unit // 64) + self.radius = max(6, self.unit // 26) + self.plaque_h = max(40 if self.H <= 360 else 60, self.H // 10) + self.font = None + self.font_sm = None + self.title_lbl = None + self.count_lbl = None + self.status_lbl = None + self.content = None + self.view_img = None + self.view_msg = None + self.play_lbl = None + self._timer = None + + self.build_ui() + + # ----- chrome --------------------------------------------------------- + + def _panel(self, obj, bg, radius=0, edge=None): + try: + obj.set_style_bg_color(_hex(bg), 0) + obj.set_style_bg_opa(lv.OPA.COVER, 0) + obj.set_style_radius(radius, 0) + obj.set_style_pad_all(0, 0) + if edge is None: + obj.set_style_border_width(0, 0) + else: + obj.set_style_border_color(_hex(edge), 0) + obj.set_style_border_width(1, 0) + obj.set_style_shadow_width(0, 0) + except Exception: + pass + + def build_ui(self): + loop = getattr(display_driver, "event_loop", None) + inst = loop.current_instance() if loop is not None else None + if inst is not None: + inst.disable() + try: + scr = lv.screen_active() + if self.H <= 360 or self.unit < 280: + self.font = _pick_font_at_most(14, scr) + self.font_sm = _pick_font_at_most(12, scr) or self.font + else: + self.font = _pick_font_at_most(16, scr) + self.font_sm = _pick_font_at_most(14, scr) or self.font + self._panel(scr, _COL["bg"]) + _no_scroll(scr) + + plaque = lv.obj(scr) + plaque_w = self.W - 2 * self.pad + plaque.set_size(plaque_w, self.plaque_h) + plaque.align(lv.ALIGN.TOP_MID, 0, self.pad) + self._panel(plaque, _COL["plaque"], self.radius, edge=_COL["plaque_edge"]) + _no_scroll(plaque) + inset = max(3, self.pad) + half = max(1, self.plaque_h // 2) + + self.count_lbl = lv.label(plaque) + self.count_lbl.set_text("") + self.count_lbl.set_style_text_color(_hex(_COL["muted"]), 0) + _apply_font(self.count_lbl, self.font) + self.count_lbl.align(lv.ALIGN.RIGHT_MID, -inset, -half // 2) + + self.title_lbl = lv.label(plaque) + self.title_lbl.set_text("Google Photos") + self.title_lbl.set_style_text_color(_hex(_COL["text"]), 0) + _apply_font(self.title_lbl, self.font) + self.title_lbl.set_width(plaque_w - 2 * inset - max(48, self.unit // 5)) + self._long_mode(self.title_lbl, "DOTS") + self.title_lbl.align(lv.ALIGN.LEFT_MID, inset, -half // 2) + + self.status_lbl = lv.label(plaque) + self.status_lbl.set_text("") + self.status_lbl.set_style_text_color(_hex(_COL["muted"]), 0) + _apply_font(self.status_lbl, self.font_sm) + self.status_lbl.set_width(plaque_w - 2 * inset) + self._long_mode(self.status_lbl, "DOTS") + self.status_lbl.align(lv.ALIGN.LEFT_MID, inset, half // 2) + + self.content = lv.obj(scr) + self.content.set_size(self.W, self.H - self.plaque_h - 2 * self.pad) + self.content.set_pos(0, self.plaque_h + 2 * self.pad) + self._panel(self.content, _COL["bg"]) + self.content.set_style_bg_opa(lv.OPA.TRANSP, 0) + _no_scroll(self.content) + + if self.page == "connect": + self._start_pick(rebuild=False) + if self.page == "view" and not self.engine.items: + self.page = "list" + self._show_page(self.page) + finally: + if inst is not None: + inst.enable() + self._install_pump() + + def _long_mode(self, lbl, name): + lm = getattr(lv.label, "LONG_MODE", None) + mode = getattr(lm, name, None) if lm is not None else None + if mode is None and name == "DOTS": + mode = getattr(lm, "DOT", None) if lm is not None else None + if mode is not None: + try: + lbl.set_long_mode(mode) + except Exception: + pass + + def _install_pump(self): + creator = getattr(lv, "timer_create", None) + if creator is None: + return + try: + self._timer = creator(self._pump, PUMP_MS, None) + except Exception: + self._timer = None + + def _set_label(self, lbl, text, attr): + if lbl is None: + return + text = text or "" + if getattr(self, attr) == text: + return + setattr(self, attr, text) + try: + lbl.set_text(text) + except Exception: + pass + + def _set_status(self, text): + self._set_label(self.status_lbl, text, "_last_status_text") + + def _set_title(self, text): + self._set_label(self.title_lbl, text, "_last_title_text") + + def _set_count(self, text): + self._set_label(self.count_lbl, text, "_last_count_text") + + def _content_metrics(self): + W = self.W + H = max(80, self.H - self.plaque_h - 2 * self.pad) + if self.content is not None: + try: + reported = int(self.content.get_height()) + if reported >= 80: + H = reported + except Exception: + pass + return W, H + + def _button(self, parent, text, x, y, w, h, role, on_click, font=None): + if role == "accent": + top, fg = _COL["accent"], _COL["on_accent"] + else: + top, fg = _COL["ui"], _COL["text"] + btn = lv.button(parent) + btn.set_size(int(w), int(h)) + btn.set_pos(int(x), int(y)) + _remove_flag(btn, "CLICK_FOCUSABLE") + self._panel(btn, top, self.radius, edge=_shade(top, 0.6)) + try: + btn.set_style_bg_color(_hex(_shade(top, 1.25)), lv.STATE.PRESSED) + except Exception: + pass + lbl = lv.label(btn) + lbl.set_text(text) + lbl.set_style_text_color(_hex(fg), 0) + _apply_font(lbl, font or self.font) + lbl.center() + + def _cb(_e, _fn=on_click): + _fn() + + btn.add_event_cb(_cb, lv.EVENT.CLICKED, None) + return btn, lbl + + # ----- pages ---------------------------------------------------------- + + def _clear_content(self): + root = self._page_root + self._page_root = None + self._rows = {} + self.view_img = None + self.view_msg = None + self.play_lbl = None + if root is not None: + try: + root.delete() + except Exception: + pass + + def _show_page(self, page): + self.page = page + if self.content is None: + return + self._clear_content() + if page != "view": + self._view_ref = None + self._view_fetching = None + self._view_loaded_at = None + if self.slideshow and page == "list": + pass # SLIDES re-arms it; PLAY on the viewer toggles it + root = lv.obj(self.content) + W, H = self._content_metrics() + root.set_size(W, H) + root.set_pos(0, 0) + self._panel(root, _COL["bg"]) + root.set_style_bg_opa(lv.OPA.TRANSP, 0) + _no_scroll(root) + self._page_root = root + if page == "connect": + self._build_connect(root) + elif page == "view": + self._build_view(root) + else: + self._build_list(root) + gc.collect() + + # connect ------------------------------------------------------------ + + def _build_connect(self, parent): + W, H = self._content_metrics() + pad = self.pad + gap = pad + btn_h = max(36, H // 11) + session = self.engine.session + uri = (session or {}).get("pickerUri") or "" + self._set_title("Google Photos") + self._set_count("") + + labels = [("RETRY", "ui", self._start_pick)] + if uri and _webbrowser is not None and not self.engine.sim: + labels.insert(0, ("OPEN", "accent", self._open_browser)) + if self.engine.items: + labels.append(("BACK", "ui", self._goto_list)) + 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) + + 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: + try: + caption.set_style_text_align(center, 0) + except Exception: + pass + caption_h = max(2 * 16, 2 * (self.unit // 16)) + caption.align(lv.ALIGN.BOTTOM_MID, 0, -(btn_h + gap)) + + 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: + qr = qr_cls(parent) + qr.set_size(int(qr_side)) + qr.set_dark_color(_hex(_COL["black"])) + qr.set_light_color(_hex(_COL["white"])) + if hasattr(qr, "set_quiet_zone"): + 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) + 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") + 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...") + + def _link_label(self, parent, uri, w, note): + lbl = lv.label(parent) + lbl.set_width(w) + self._long_mode(lbl, "WRAP") + lbl.set_style_text_color(_hex(_COL["muted"]), 0) + _apply_font(lbl, self.font_sm) + lbl.set_text((note + "\n" if note else "") + uri) + lbl.align(lv.ALIGN.TOP_MID, 0, self.pad) + + def _open_browser(self): + uri = (self.engine.session or {}).get("pickerUri") or "" + if not uri or _webbrowser is None: + return + try: + _webbrowser.open(uri) + self._set_status("opened in your browser - pick, then tap Done") + 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.""" + self._poll_timed_out = False + self._session_started = None + self._poll_at = None + self._poll_busy = False + self.slideshow = False + self.list_offset = 0 + engine = self.engine + + def _work(): + s = engine.create_session() + if s: + self._session_started = ticks_ms() + else: + self._pending_status = engine.last_error or "could not start a session" + self._pending_connect = True + + self._run_bg(_work) + if rebuild: + self._show_page("connect") + + def _poll_pick(self): + engine = self.engine + self._poll_busy = True + + def _work(): + try: + done = engine.poll_session() + if done: + items = engine.list_items() + if items is None: + self._pending_status = engine.last_error + self._pending_connect = True + else: + self._pending_list = True + elif engine.session is None: + self._pending_status = engine.last_error or "session ended" + self._pending_connect = True + elif engine.last_error: + self._pending_status = engine.last_error + except Exception as err: + self._pending_status = "poll: %s" % err + self._poll_at = ticks_ms() + self._poll_busy = False + + self._run_bg(_work) + + # list ----------------------------------------------------------------- + + def _goto_list(self): + self.slideshow = False + self._show_page("list") + + def _build_list(self, parent): + W, H = self._content_metrics() + pad = self.pad + gap = pad + engine = self.engine + items = engine.items + n = len(items) + btn_h = max(36, H // 11) + x0 = pad + w = W - 2 * pad + row_h = max(48, min(104, H // 6)) + inner = max(3, pad // 2) + tile = row_h - 2 * inner + avail = H - btn_h - gap + page_size = max(1, (avail + gap) // (row_h + gap)) + self.page_size = page_size + if self.list_offset >= n or self.list_offset < 0: + self.list_offset = 0 + offset = self.list_offset + + labels = [("PICK", "accent", self._start_pick)] + if n: + labels.append(("SLIDES", "ui", self._start_slideshow)) + if n > page_size: + labels.append(("MORE", "ui", self._next_page)) + count = len(labels) + bw = (w - (count - 1) * gap) // count + for i, (text, role, cb) in enumerate(labels): + self._button(parent, text, x0 + i * (bw + gap), 0, bw, btn_h, role, cb) + y = btn_h + gap + + self._set_title("Google Photos") + self._set_count(engine.count_label() if n else "") + notice = getattr(engine, "sim_notice", "") or "" + if not n: + msg = lv.label(parent) + msg.set_width(w) + self._long_mode(msg, "WRAP") + msg.set_style_text_color(_hex(_COL["muted"]), 0) + _apply_font(msg, self.font) + msg.set_text("No photos picked yet.\nTap PICK, then scan the code with your phone.") + msg.align(lv.ALIGN.CENTER, 0, y // 2) + self._set_status(notice or engine.last_error or "") + return + + # Drop tiles that are not on this page (bounded RAM on MCUs). + page_items = items[offset : offset + page_size] + keep = set(it.get("id") for it in page_items) + for key in list(self._images.keys()): + if key not in keep: + self._images.pop(key, None) + name_x = tile + 2 * inner + name_w = w - name_x - inner + for i, item in enumerate(page_items): + key = item.get("id", "") + row = lv.obj(parent) + row.set_size(w, row_h) + row.set_pos(x0, y + i * (row_h + gap)) + self._panel(row, _COL["row"] if i % 2 == 0 else _COL["row_alt"], self.radius) + _no_scroll(row) + _add_flag(row, "CLICKABLE") + try: + row.set_style_bg_color(_hex(_shade(_COL["row"], 1.3)), lv.STATE.PRESSED) + except Exception: + pass + + def _open(_e, _idx=offset + i): + self._open_view(_idx) + + row.add_event_cb(_open, lv.EVENT.CLICKED, None) + + img = lv.image(row) + img.set_size(tile, tile) + img.set_pos(inner, inner) + self._panel(img, _COL["tile"], max(2, self.radius // 2)) + _remove_flag(img, "CLICKABLE") + try: + img.set_inner_align(lv.image.ALIGN.CENTER) + except Exception: + pass + ph = lv.label(row) + ph.set_text(_sym("IMAGE", "#")) + ph.set_style_text_color(_hex(_COL["muted"]), 0) + _apply_font(ph, self.font) + ph.align_to(img, lv.ALIGN.CENTER, 0, 0) + + name = lv.label(row) + name.set_width(name_w) + self._long_mode(name, "DOTS") + name.set_style_text_color(_hex(_COL["text"]), 0) + _apply_font(name, self.font) + name.set_text(item.get("filename") or key or "photo") + name.align(lv.ALIGN.TOP_LEFT, name_x, inner) + + meta = lv.label(row) + meta.set_width(name_w) + self._long_mode(meta, "DOTS") + meta.set_style_text_color(_hex(_COL["muted"]), 0) + _apply_font(meta, self.font_sm) + meta.set_text(self._meta_text(item)) + meta.align(lv.ALIGN.BOTTOM_LEFT, name_x, -inner) + + self._rows[key] = (img, ph) + cached = self._images.get(key) + if cached is not None: + self._apply_tile(key, cached[0]) + else: + self._queue_thumb(item, tile) + + last = min(n, offset + page_size) + if n > page_size: + self._set_status("photos %d-%d of %d%s" % (offset + 1, last, n, (" " + notice) if notice else "")) + else: + self._set_status(notice or "tap a photo to view it") + + @staticmethod + def _meta_text(item): + parts = [] + d = date_label(item.get("createTime")) + t = time_label(item.get("createTime")) + if d: + parts.append(d + (" " + t if t else "")) + if item.get("width") and item.get("height"): + parts.append("%dx%d" % (item["width"], item["height"])) + if (item.get("type") or "").upper() == "VIDEO": + parts.append("video") + return " ".join(parts) + + def _next_page(self): + n = len(self.engine.items) + if not n: + return + self.list_offset += self.page_size + if self.list_offset >= n: + self.list_offset = 0 + self._show_page("list") + + def _queue_thumb(self, item, size): + key = item.get("id", "") + if not key or key in self._thumb_busy: + return + self._thumb_busy[key] = True + engine = self.engine + + def _work(): + path = "" + try: + path = engine.thumbnail_path(item, size, size, crop=True) or "" + except Exception as err: + engine.last_error = "thumbnail: %s" % err + if not path: + self._pending_status = engine.last_error + self._pending_thumbs.append((key, path)) + + self._run_bg(_work) + + def _apply_tile(self, key, dsc): + row = self._rows.get(key) + if row is None: + return + img, ph = row + try: + img.set_src(dsc) + _set_hidden(ph, True) + except Exception: + pass + + # view ----------------------------------------------------------------- + + def _open_view(self, idx): + n = len(self.engine.items) + if not n: + return + self.index = idx % n + self._show_page("view") + + def _start_slideshow(self): + if not self.engine.items: + return + self.slideshow = True + self.index = 0 + self._show_page("view") + + def _build_view(self, parent): + W, H = self._content_metrics() + pad = self.pad + gap = pad + bar_h = max(36, H // 11) + img_h = H - bar_h - gap + self.view_w = W + self.view_h = img_h + + frame = lv.obj(parent) + frame.set_size(W, img_h) + frame.set_pos(0, 0) + self._panel(frame, _COL["black"]) + _no_scroll(frame) + _add_flag(frame, "CLICKABLE") + + def _tap(_e): + self._go(1) + + frame.add_event_cb(_tap, lv.EVENT.CLICKED, None) + + img = lv.image(frame) + img.set_size(W, img_h) + img.set_pos(0, 0) + _remove_flag(img, "CLICKABLE") + try: + img.set_inner_align(lv.image.ALIGN.CENTER) + except Exception: + pass + self.view_img = img + + msg = lv.label(frame) + msg.set_width(W - 2 * pad) + self._long_mode(msg, "WRAP") + msg.set_style_text_color(_hex(_COL["muted"]), 0) + _apply_font(msg, self.font) + ta = getattr(lv, "TEXT_ALIGN", None) + center = getattr(ta, "CENTER", None) if ta is not None else None + if center is not None: + try: + msg.set_style_text_align(center, 0) + except Exception: + pass + msg.set_text("loading...") + msg.center() + self.view_msg = msg + + w = W - 2 * pad + bw = (w - 3 * gap) // 4 + y = img_h + gap + specs = ( + ("BACK", "ui", self._goto_list), + (_sym("PREV", "<"), "ui", lambda: self._go(-1)), + ("", "accent", self._toggle_slideshow), + (_sym("NEXT", ">"), "ui", lambda: self._go(1)), + ) + for i, (text, role, cb) in enumerate(specs): + _btn, lbl = self._button(parent, text, pad + i * (bw + gap), y, bw, bar_h, role, cb) + if i == 2: + self.play_lbl = lbl + self._refresh_play_face() + self._show_view_meta() + self._load_view(self.index) + + def _refresh_play_face(self): + if self.play_lbl is None: + return + try: + self.play_lbl.set_text(_sym("PAUSE", "||") if self.slideshow else _sym("PLAY", ">")) + self.play_lbl.center() + except Exception: + pass + + def _toggle_slideshow(self): + self.slideshow = not self.slideshow + self._view_loaded_at = ticks_ms() if self.slideshow else None + self._refresh_play_face() + + def _show_view_meta(self): + items = self.engine.items + n = len(items) + if not n: + return + item = items[self.index] + self._set_title(item.get("filename") or "photo") + self._set_count("%d/%d" % (self.index + 1, n)) + self._set_status(self._meta_text(item) or " ") + + def _go(self, delta): + n = len(self.engine.items) + if not n or self.page != "view": + return + self.index = (self.index + delta) % n + self._show_view_meta() + self._load_view(self.index) + + def _load_view(self, idx): + items = self.engine.items + if idx >= len(items) or self.view_img is None: + return + item = items[idx] + self._view_ref = None + self._view_loaded_at = None + gc.collect() + path = self.engine.cached_path(item, self.view_w, self.view_h) + if path: + self._apply_view(idx, path) + return + try: + self.view_img.set_src(None) + except Exception: + pass + _set_hidden(self.view_msg, False) + try: + self.view_msg.set_text("loading...") + except Exception: + pass + self._view_fetching = idx + engine = self.engine + vw, vh = self.view_w, self.view_h + + def _work(): + got = "" + try: + got = engine.thumbnail_path(item, vw, vh) or "" + except Exception as err: + engine.last_error = "photo: %s" % err + self._pending_view = (idx, got) + + self._run_bg(_work) + + def _apply_view(self, idx, path): + if self.page != "view" or idx != self.index or self.view_img is None: + return + self._view_fetching = None + if not path: + self._say_view(self.engine.last_error or "photo unavailable") + self._view_loaded_at = ticks_ms() # keep the slideshow moving + return + dsc, data, w, h = image_descriptor(path) + if dsc is None: + if not jpeg_supported(): + self._say_view("no JPEG decoder in this firmware\n(see README: jpegio)") + else: + self._say_view("could not decode image") + self._view_loaded_at = ticks_ms() + return + self._view_ref = (dsc, data) + try: + self.view_img.set_src(dsc) + _set_hidden(self.view_msg, True) + except Exception as err: + self._say_view("display: %s" % err) + self._view_loaded_at = ticks_ms() + self._prefetch(idx + 1) + + def _say_view(self, text): + if self.view_msg is None: + return + try: + self.view_msg.set_text(text) + _set_hidden(self.view_msg, False) + except Exception: + pass + + def _prefetch(self, idx): + """Warm the cache for the next photo so PLAY / NEXT is instant.""" + items = self.engine.items + if len(items) < 2: + return + idx = idx % len(items) + item = items[idx] + if self._prefetched == item.get("id"): + return + self._prefetched = item.get("id") + if self.engine.cached_path(item, self.view_w, self.view_h): + return + engine = self.engine + vw, vh = self.view_w, self.view_h + + def _work(): + try: + engine.thumbnail_path(item, vw, vh) + except Exception: + pass + + self._run_bg(_work) + + # ----- background queue + pump ---------------------------------------- + + def _run_bg(self, fn): + """Queue ``fn`` for the LVGL soft-pump (no ``_thread``).""" + self._bg_q.append(fn) + return True + + def _drain_bg(self): + """Run at most one queued job per pump tick.""" + if self._bg_busy or not self._bg_q: + return + self._bg_busy = True + job = self._bg_q.pop(0) + try: + job() + except Exception as err: + self._pending_status = "error: %s" % err + finally: + self._bg_busy = False + + def _pump(self, _timer=None): + self._drain_bg() + + if self._pending_status is not None: + text = self._pending_status + self._pending_status = None + if text: + self._set_status(text) + + if self._pending_list: + self._pending_list = False + self.list_offset = 0 + self.index = 0 + self._show_page("list") + n = len(self.engine.items) + self._set_status("%d picked" % n if n else "nothing was picked") + return + + if self._pending_connect: + self._pending_connect = False + if self.page == "connect": + self._show_page("connect") + + while self._pending_thumbs: + key, path = self._pending_thumbs.pop(0) + self._thumb_busy.pop(key, None) + if not path: + continue + dsc, data, _w, _h = image_descriptor(path) + if dsc is None: + if not jpeg_supported() and path.endswith(".jpg"): + self._set_status("no JPEG decoder in this firmware (see README)") + continue + self._images[key] = (dsc, data) + self._apply_tile(key, dsc) + + if self._pending_view is not None: + idx, path = self._pending_view + self._pending_view = None + self._apply_view(idx, path) + + if self.page == "connect": + session = self.engine.session + if session 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() + + if ( + self.page == "view" + and self.slideshow + and self._view_loaded_at is not None + and self._view_fetching is None + and not self._bg_q + and ticks_diff(ticks_ms(), self._view_loaded_at) >= self.slideshow_s * 1000 + ): + self._go(1) + + +# Most recent front end instance (harnesses / screenshots / REPL poking). +_LAST = None + + +def create(engine=None, start_page="list"): + """Build the LVGL front end (does not call ``App.run``).""" + global _LAST + _LAST = _GPhotosLvgl(engine=engine, start_page=start_page) + return _LAST + + +def run(engine=None, start_page="list"): + """Create the UI and let the app run itself.""" + create(engine=engine, start_page=start_page) + + +# Direct import / example kit: auto-start. ``google_photos`` sets +# ``gphotos_engine._LAUNCHER_OWNS_RUN`` and calls ``run()`` itself. +import gphotos_engine as _gphotos_engine # noqa: E402 + +if not getattr(_gphotos_engine, "_LAUNCHER_OWNS_RUN", False): + _engine = make_engine() + _engine.restore() + run(engine=_engine, start_page="list" if _engine.items else "connect") diff --git a/lib/examples/google_photos/gphotos_sim.py b/lib/examples/google_photos/gphotos_sim.py new file mode 100644 index 00000000..7a25182f --- /dev/null +++ b/lib/examples/google_photos/gphotos_sim.py @@ -0,0 +1,269 @@ +# gallery: skip +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +""" +gphotos_sim +==================================================== +Duck-typed Picker API simulator for gallery / offline demos. + +``make_engine()`` returns :class:`GPhotosSimEngine` when: + +* ``GPHOTOS_SIM`` is truthy (explicit), or +* PyScript (``emscripten`` / ``webassembly`` / ``import pyscript``), or +* no tokens file with ``client_id`` + ``refresh_token`` exists (see + ``tools/gphotos_auth.py``), or +* no usable network is detected. + +``GPHOTOS_SIM=0`` forces the real :class:`gphotos_engine.GPhotosEngine`. + +The sim pretends the user already picked a handful of photos, so the launcher +opens on the list. PICK shows a fake QR code and "picks" again by itself after +a few polls. Thumbnails come from ``assets/sim_NN_.jpg`` when those +files are present (git checkout); the front end paints a placeholder tile +when they are not (MIP / gallery installs carry Python files only). +""" + +import sys + +from gphotos_engine import GPhotosEngine, credentials_ok, load_tokens, socket as _engine_socket +from gphotos_engine import _env_get, _exists + +_PKG = __file__.replace("\\", "/").rsplit("/", 1)[0] if "/" in __file__.replace("\\", "/") else "." +ASSETS_DIR = _PKG + "/assets" + +SIM_PICKER_URI = "https://photospicker.google.com/simulator" + +# Canned "picked" photos. Even entries are landscape, odd entries portrait. +_SIM_ITEMS = ( + ("IMG_20240503_142211.jpg", "2024-05-03T14:22:11Z", 4032, 3024, "sim_01"), + ("PXL_20240612_091530.jpg", "2024-06-12T09:15:30Z", 3024, 4032, "sim_02"), + ("IMG_20240704_203045.jpg", "2024-07-04T20:30:45Z", 4000, 3000, "sim_03"), + ("PXL_20240815_171012.jpg", "2024-08-15T17:10:12Z", 3000, 4000, "sim_04"), + ("IMG_20240921_120000.jpg", "2024-09-21T12:00:00Z", 4032, 3024, "sim_05"), + ("PXL_20241031_183322.jpg", "2024-10-31T18:33:22Z", 3024, 4032, "sim_06"), + ("IMG_20241225_101500.jpg", "2024-12-25T10:15:00Z", 4032, 3024, "sim_01"), + ("PXL_20250101_000102.jpg", "2025-01-01T00:01:02Z", 3024, 4032, "sim_02"), +) + +# Plaque status when the sim runs because no credentials / network exist. +NO_CREDENTIALS_PLAQUE = "no tokens file - running simulator" +NO_NETWORK_PLAQUE = "no network detected - running simulator" + + +def _is_pyscript(): + if getattr(sys, "platform", "") in ("emscripten", "webassembly"): + return True + try: + import pyscript # noqa: F401 + + return True + except ImportError: + return False + + +def _network_available(): + """Best-effort: station Wi-Fi up on MCUs, any route on desktop hosts.""" + try: + import network + + try: + return bool(network.WLAN(network.STA_IF).isconnected()) + except Exception: + return True # a network module without WLAN: assume wired + except ImportError: + pass + try: + import wifi + + return bool(getattr(wifi.radio, "ipv4_address", None)) + except ImportError: + pass + if _engine_socket is None: + return False + try: + s = _engine_socket.socket(_engine_socket.AF_INET, _engine_socket.SOCK_DGRAM) + try: + s.connect(("8.8.8.8", 80)) # no packet is sent for UDP connect + return True + finally: + s.close() + except Exception: + return False + + +def sim_reason(tokens=None): + """Why the sim should run, or ``None`` for the real engine. + + Returns ``"env"``, ``"pyscript"``, ``"no_credentials"``, ``"no_network"`` + or ``None``. Explicit ``GPHOTOS_SIM`` wins. + """ + raw = _env_get("GPHOTOS_SIM") + if raw is not None and raw != "": + return "env" if str(raw).strip().lower() not in ("0", "false", "no", "off") else None + if _is_pyscript(): + return "pyscript" + if not credentials_ok(load_tokens() if tokens is None else tokens): + return "no_credentials" + if not _network_available(): + return "no_network" + return None + + +def want_sim(): + return sim_reason() is not None + + +def make_engine(**kwargs): + """Return :class:`GPhotosSimEngine` or :class:`GPhotosEngine` per :func:`sim_reason`.""" + reason = sim_reason(kwargs.get("tokens")) + if reason: + return GPhotosSimEngine(reason=reason, **kwargs) + return GPhotosEngine(**kwargs) + + +def _sim_items(rotate=0): + items = [] + n = len(_SIM_ITEMS) + for i in range(n): + name, created, w, h, asset = _SIM_ITEMS[(i + rotate) % n] + items.append( + { + "id": "sim-%s" % name, + "filename": name, + "createTime": created, + "type": "PHOTO", + "mime": "image/jpeg", + "width": w, + "height": h, + "baseUrl": "sim://" + asset, + "asset": asset, + "portrait": h > w, + } + ) + return items + + +class GPhotosSimEngine(GPhotosEngine): + """In-memory Picker stand-in — same surface as :class:`GPhotosEngine`.""" + + sim = True + + def __init__(self, reason="env", **kwargs): + super().__init__(**kwargs) + self.sim_reason = reason or "env" + if self.sim_reason == "no_credentials": + self.sim_notice = NO_CREDENTIALS_PLAQUE + elif self.sim_reason == "no_network": + self.sim_notice = NO_NETWORK_PLAQUE + else: + self.sim_notice = "simulator" + self._polls = 0 + self._picks = 0 + self.prefs = {} + + # No network, no files: everything below is in-memory. + + def has_credentials(self): + return True + + def ensure_token(self, force=False): + self.access_token = "sim" + return True + + def _save_prefs(self): + return True + + def _load_prefs(self): + if self.prefs is None: + self.prefs = {} + return self.prefs + + def create_session(self): + self._polls = 0 + self.session = { + "id": "sim-session-%d" % (self._picks + 1), + "pickerUri": SIM_PICKER_URI, + "expireTime": "", + "mediaItemsSet": False, + "pollInterval": 2.0, + "timeoutIn": 1800.0, + } + self.items = [] + self._urls_at = None + self.last_error = "" + return self.session + + def poll_session(self): + if not self.session: + self.last_error = "no session" + return False + self._polls += 1 + done = self._polls >= 3 + self.session["mediaItemsSet"] = done + return done + + def delete_session(self): + self.session = None + return True + + def list_items(self): + if not self.session: + self.last_error = "no session" + return None + self._picks += 1 + self.items = _sim_items(rotate=self._picks % len(_SIM_ITEMS)) + self._urls_at = 0 + self.last_error = "" + return self.items + + def urls_valid(self): + return bool(self.items) + + def refresh_urls(self): + return bool(self.items) + + def restore(self): + self.session = { + "id": "sim-session-0", + "pickerUri": SIM_PICKER_URI, + "expireTime": "", + "mediaItemsSet": True, + "pollInterval": 2.0, + "timeoutIn": 1800.0, + } + self.items = _sim_items() + self._urls_at = 0 + return True + + def forget(self, clear_cache=False): + self.session = None + self.items = [] + + def asset_path(self, item, w, h, crop=False): + """Best bundled JPEG for the requested box, or ``""`` when absent.""" + base = item.get("asset") or "sim_01" + if crop: + tag = "96" if min(int(w), int(h)) >= 96 else "64" + else: + portrait = bool(item.get("portrait")) + sizes = ((240, 320), (120, 160)) if portrait else ((320, 240), (160, 120)) + tag = None + for cw, ch in sizes: + if cw <= int(w) and ch <= int(h): + tag = "%dx%d" % (cw, ch) + break + if tag is None: + cw, ch = sizes[-1] + tag = "%dx%d" % (cw, ch) + path = "%s/%s_%s.jpg" % (ASSETS_DIR, base, tag) + return path if _exists(path) else "" + + def thumbnail_path(self, item, w, h, crop=False): + if not item or not item.get("id"): + self.last_error = "no item" + return "" + path = self.asset_path(item, w, h, crop) + if not path: + self.last_error = "simulator: sample images not installed" + return path diff --git a/packages/examples.json b/packages/examples.json index 191faa49..118c6519 100644 --- a/packages/examples.json +++ b/packages/examples.json @@ -400,6 +400,34 @@ "drum_seq/panel.py", "github:PyDevices/pydevices-examples/lib/examples/drum_seq/panel.py" ], + [ + "google_photos/__init__.py", + "github:PyDevices/pydevices-examples/lib/examples/google_photos/__init__.py" + ], + [ + "google_photos/__main__.py", + "github:PyDevices/pydevices-examples/lib/examples/google_photos/__main__.py" + ], + [ + "google_photos/google_photos.py", + "github:PyDevices/pydevices-examples/lib/examples/google_photos/google_photos.py" + ], + [ + "google_photos/gphotos_engine.py", + "github:PyDevices/pydevices-examples/lib/examples/google_photos/gphotos_engine.py" + ], + [ + "google_photos/gphotos_lvgl.py", + "github:PyDevices/pydevices-examples/lib/examples/google_photos/gphotos_lvgl.py" + ], + [ + "google_photos/gphotos_sim.py", + "github:PyDevices/pydevices-examples/lib/examples/google_photos/gphotos_sim.py" + ], + [ + "google_photos/assets/gen_sim_assets.py", + "github:PyDevices/pydevices-examples/lib/examples/google_photos/assets/gen_sim_assets.py" + ], [ "noto_fonts/NotoSansMono_32.py", "github:PyDevices/pydevices-examples/lib/examples/noto_fonts/NotoSansMono_32.py" diff --git a/packages/google_photos.json b/packages/google_photos.json new file mode 100644 index 00000000..add4deb3 --- /dev/null +++ b/packages/google_photos.json @@ -0,0 +1,33 @@ +{ + "urls": [ + [ + "google_photos/__init__.py", + "../lib/examples/google_photos/__init__.py" + ], + [ + "google_photos/__main__.py", + "../lib/examples/google_photos/__main__.py" + ], + [ + "google_photos/google_photos.py", + "../lib/examples/google_photos/google_photos.py" + ], + [ + "google_photos/gphotos_engine.py", + "../lib/examples/google_photos/gphotos_engine.py" + ], + [ + "google_photos/gphotos_lvgl.py", + "../lib/examples/google_photos/gphotos_lvgl.py" + ], + [ + "google_photos/gphotos_sim.py", + "../lib/examples/google_photos/gphotos_sim.py" + ], + [ + "google_photos/assets/gen_sim_assets.py", + "../lib/examples/google_photos/assets/gen_sim_assets.py" + ] + ], + "version": "0.0.1" +} diff --git a/pydevices-examples.toml b/pydevices-examples.toml index 6d417191..40b8a85f 100644 --- a/pydevices-examples.toml +++ b/pydevices-examples.toml @@ -71,6 +71,13 @@ "https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/font_simpletest.py" = "/examples/font_simpletest.py" "https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/fonts.py" = "/examples/fonts.py" "https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/framebuf_simpletest.py" = "/examples/framebuf_simpletest.py" +"https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/google_photos/__init__.py" = "/examples/google_photos/__init__.py" +"https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/google_photos/__main__.py" = "/examples/google_photos/__main__.py" +"https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/google_photos/assets/gen_sim_assets.py" = "/examples/google_photos/assets/gen_sim_assets.py" +"https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/google_photos/google_photos.py" = "/examples/google_photos/google_photos.py" +"https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/google_photos/gphotos_engine.py" = "/examples/google_photos/gphotos_engine.py" +"https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/google_photos/gphotos_lvgl.py" = "/examples/google_photos/gphotos_lvgl.py" +"https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/google_photos/gphotos_sim.py" = "/examples/google_photos/gphotos_sim.py" "https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/graphics_simpletest.py" = "/examples/graphics_simpletest.py" "https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/hello.py" = "/examples/hello.py" "https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/joystick_list_select.py" = "/examples/joystick_list_select.py" diff --git a/tests/test_gphotos_engine.py b/tests/test_gphotos_engine.py new file mode 100644 index 00000000..d90c87ee --- /dev/null +++ b/tests/test_gphotos_engine.py @@ -0,0 +1,602 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""Unit tests for ``lib/examples/google_photos`` (engine, simulator) and +``tools/gphotos_auth.py`` -- all network traffic is faked.""" + +import hashlib +import io +import json +import os +import sys +import tempfile +import threading +import unittest +from unittest import mock +import urllib.request + +import _env # noqa: F401 + +_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +_PKG = os.path.join(_REPO, "lib", "examples", "google_photos") +if _PKG not in sys.path: + sys.path.insert(0, _PKG) +_TOOLS = os.path.join(_REPO, "tools") +if _TOOLS not in sys.path: + sys.path.insert(0, _TOOLS) + +import gphotos_auth # noqa: E402 +import gphotos_engine as ge # noqa: E402 +import gphotos_sim as gs # noqa: E402 + +JPEG = b"\xff\xd8\xff\xe0" + b"\x00" * 60 +PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 60 +TOKENS = {"client_id": "cid", "client_secret": "sec", "refresh_token": "rt"} + + +def _picked(i, base="https://lh3.example/photo%d"): + return { + "id": "item-%d" % i, + "createTime": "2024-05-%02dT14:22:11Z" % (i + 1), + "type": "PHOTO", + "mediaFile": { + "baseUrl": base % i, + "mimeType": "image/jpeg", + "filename": "IMG_%d.jpg" % i, + "mediaFileMetadata": {"width": "4032", "height": "3024"}, + }, + } + + +class FakeTransport: + """Stands in for ``gphotos_engine._transport``; routes by (method, url prefix).""" + + def __init__(self): + self.calls = [] + self.routes = [] # (method, url_prefix, handler) + + def route(self, method, prefix, handler): + self.routes.append((method, prefix, handler)) + + def __call__(self, method, url, headers, body, timeout, sink=None): + self.calls.append((method, url, dict(headers or {}), body)) + for m, prefix, handler in self.routes: + if m == method and url.startswith(prefix): + status, payload = handler(url, headers or {}, body) + if isinstance(payload, (dict, list)): + payload = json.dumps(payload).encode("utf-8") + if sink is not None: + sink(payload) + return status, len(payload) + return status, payload + raise AssertionError("unexpected %s %s" % (method, url)) + + +def token_ok(url, headers, body): + return 200, {"access_token": "AT1", "expires_in": 3600, "token_type": "Bearer"} + + +class HelperTests(unittest.TestCase): + def test_quote_and_form(self): + self.assertEqual(ge.quote("a b/c~d"), "a%20b%2Fc~d") + self.assertEqual(ge.form_encode([("a", "1 2"), ("b", "x&y")]), "a=1%202&b=x%26y") + + def test_labels_and_durations(self): + self.assertEqual(ge.parse_duration_s("5s"), 5.0) + self.assertEqual(ge.parse_duration_s("1.5s"), 1.5) + self.assertEqual(ge.parse_duration_s(None, 7.0), 7.0) + self.assertEqual(ge.parse_duration_s("junk", 3.0), 3.0) + self.assertEqual(ge.date_label("2024-05-03T14:22:11Z"), "2024-05-03") + self.assertEqual(ge.time_label("2024-05-03T14:22:11Z"), "14:22") + self.assertEqual(ge.time_label("2024-05-03"), "") + self.assertEqual(len(ge.item_hash("anything")), 8) + self.assertNotEqual(ge.item_hash("a"), ge.item_hash("b")) + + def test_thumb_url_and_sniff(self): + self.assertEqual(ge.thumb_url("https://x/y", 64, 64, True), "https://x/y=w64-h64-c") + self.assertEqual(ge.thumb_url("https://x/y", 320, 400), "https://x/y=w320-h400") + self.assertEqual(ge.thumb_url("", 1, 1), "") + self.assertEqual(ge.sniff_ext(JPEG), ".jpg") + self.assertEqual(ge.sniff_ext(PNG), ".png") + self.assertEqual(ge.sniff_ext(b""), "") + + def test_normalize_item(self): + item = ge.normalize_item(_picked(3)) + self.assertEqual(item["id"], "item-3") + self.assertEqual(item["filename"], "IMG_3.jpg") + self.assertEqual(item["width"], 4032) + self.assertEqual(item["baseUrl"], "https://lh3.example/photo3") + self.assertEqual(ge.normalize_item("nope"), {}) + + def test_path_overrides(self): + with mock.patch.dict( + os.environ, {"GPHOTOS_TOKENS": "/tmp/t.json", "GPHOTOS_CACHE": "/tmp/c"} + ): + self.assertEqual(ge.tokens_path(), "/tmp/t.json") + self.assertEqual(ge.cache_dir(), "/tmp/c") + with mock.patch.dict(os.environ, {"HOME": "/home/x"}, clear=False): + os.environ.pop("GPHOTOS_PREFS", None) + self.assertEqual(ge.prefs_path(), "/home/x/.gphotos_prefs") + + +class EngineTestCase(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.transport = FakeTransport() + patcher = mock.patch.object(ge, "_transport", self.transport) + patcher.start() + self.addCleanup(patcher.stop) + + def make(self, tokens=TOKENS, **kw): + return ge.GPhotosEngine( + tokens=dict(tokens), + prefs_file=os.path.join(self.tmp.name, "prefs"), + cache_path=os.path.join(self.tmp.name, "cache"), + **kw, + ) + + +class TokenTests(EngineTestCase): + def test_missing_credentials(self): + eng = self.make(tokens={}) + self.assertFalse(eng.has_credentials()) + self.assertFalse(eng.ensure_token()) + self.assertIn("gphotos_auth", eng.last_error) + self.assertEqual(self.transport.calls, []) + + def test_refresh_then_cache_then_force(self): + self.transport.route("POST", ge.TOKEN_URL, token_ok) + eng = self.make() + self.assertTrue(eng.ensure_token()) + self.assertEqual(eng.access_token, "AT1") + _method, _url, headers, body = self.transport.calls[0] + self.assertEqual(headers["Content-Type"], "application/x-www-form-urlencoded") + self.assertIn(b"grant_type=refresh_token", body) + self.assertIn(b"refresh_token=rt", body) + self.assertIn(b"client_secret=sec", body) + self.assertTrue(eng.ensure_token()) + self.assertEqual(len(self.transport.calls), 1) + self.assertTrue(eng.ensure_token(force=True)) + self.assertEqual(len(self.transport.calls), 2) + + def test_refresh_failure_reports_google_error(self): + self.transport.route( + "POST", + ge.TOKEN_URL, + lambda u, h, b: ( + 400, + {"error": "invalid_grant", "error_description": "Token has been expired"}, + ), + ) + eng = self.make() + self.assertFalse(eng.ensure_token()) + self.assertEqual( + eng.last_error, "token refresh: HTTP 400 invalid_grant: Token has been expired" + ) + + def test_network_failure(self): + def boom(*a, **k): + raise ge.GPhotosError("dns") + + with mock.patch.object(ge, "_transport", boom): + eng = self.make() + self.assertFalse(eng.ensure_token()) + self.assertIn("dns", eng.last_error) + + +class SessionTests(EngineTestCase): + def setUp(self): + super().setUp() + self.transport.route("POST", ge.TOKEN_URL, token_ok) + self.session = { + "id": "sess/1", + "pickerUri": "https://photospicker.google.com/s/1", + "pollingConfig": {"pollInterval": "3s", "timeoutIn": "1800s"}, + "expireTime": "2030-01-01T00:00:00Z", + "mediaItemsSet": False, + } + self.polls = 0 + + def create(url, headers, body): + self.assertEqual(headers["Authorization"], "Bearer AT1") + self.assertEqual(body, b"{}") + return 200, self.session + + def poll(url, headers, body): + self.polls += 1 + data = dict(self.session) + data["mediaItemsSet"] = self.polls >= 2 + return 200, data + + def listing(url, headers, body): + if "pageToken=" in url: + return 200, {"mediaItems": [_picked(2)]} + return 200, {"mediaItems": [_picked(0), _picked(1)], "nextPageToken": "p2"} + + self.transport.route("POST", ge.PICKER_BASE + "/sessions", create) + self.transport.route("GET", ge.PICKER_BASE + "/sessions/", poll) + self.transport.route("GET", ge.PICKER_BASE + "/mediaItems?", listing) + self.transport.route("GET", "https://lh3.example/photo", lambda u, h, b: (200, JPEG)) + + def test_full_flow_and_prefs_roundtrip(self): + eng = self.make() + s = eng.create_session() + self.assertEqual(s["id"], "sess/1") + self.assertEqual(s["pollInterval"], 3.0) + self.assertEqual(eng.poll_interval_s(), 3.0) + self.assertFalse(eng.poll_session()) + self.assertTrue(eng.poll_session()) + items = eng.list_items() + self.assertEqual([i["id"] for i in items], ["item-0", "item-1", "item-2"]) + self.assertTrue(eng.urls_valid()) + list_calls = [c for c in self.transport.calls if "/mediaItems?" in c[1]] + self.assertEqual(len(list_calls), 2) + self.assertIn("sessionId=sess%2F1", list_calls[0][1]) + + # prefs: session + items persisted, urls not + with open(eng.prefs_file) as f: + prefs = json.load(f) + self.assertEqual(prefs["session"]["id"], "sess/1") + self.assertEqual(len(prefs["items"]), 3) + self.assertNotIn("baseUrl", prefs["items"][0]) + + other = self.make() + self.assertTrue(other.restore()) + self.assertEqual(other.session["pickerUri"], "https://photospicker.google.com/s/1") + self.assertEqual(len(other.items), 3) + self.assertFalse(other.urls_valid()) + self.assertEqual(other.count_label(), "3 photos") + + def test_thumbnail_download_cache_and_refresh(self): + eng = self.make() + eng.create_session() + eng.list_items() + item = eng.items[1] + path = eng.thumbnail_path(item, 64, 64, crop=True) + self.assertTrue(path.endswith("_64x64c.jpg"), path) + self.assertTrue(os.path.isfile(path)) + dl = [c for c in self.transport.calls if c[1].startswith("https://lh3.example")] + self.assertEqual(dl[-1][1], "https://lh3.example/photo1=w64-h64-c") + self.assertEqual(dl[-1][2]["Authorization"], "Bearer AT1") + n = len(self.transport.calls) + self.assertEqual(eng.thumbnail_path(item, 64, 64, crop=True), path) + self.assertEqual(len(self.transport.calls), n) # served from cache + + # restored engine has no urls: thumbnail fetch re-lists first + other = self.make() + other.restore() + before = len(self.transport.calls) + view = other.thumbnail_path(other.items[2], 320, 400) + self.assertTrue(view.endswith("_320x400.jpg")) + urls = [c[1] for c in self.transport.calls[before:]] + self.assertTrue(any("/mediaItems?" in u for u in urls)) + self.assertIn("https://lh3.example/photo2=w320-h400", urls) + + def test_png_and_bad_downloads(self): + self.transport.routes = [ + r for r in self.transport.routes if not r[1].startswith("https://lh3") + ] + self.transport.route("GET", "https://lh3.example/photo0", lambda u, h, b: (200, PNG)) + self.transport.route("GET", "https://lh3.example/photo1", lambda u, h, b: (403, b"denied")) + self.transport.route("GET", "https://lh3.example/photo2", lambda u, h, b: (200, b"")) + eng = self.make() + eng.create_session() + eng.list_items() + self.assertTrue(eng.thumbnail_path(eng.items[0], 96, 96, True).endswith(".png")) + self.assertEqual(eng.thumbnail_path(eng.items[1], 96, 96, True), "") + self.assertIn("403", eng.last_error) + self.assertEqual(eng.thumbnail_path(eng.items[2], 96, 96, True), "") + self.assertIn("not an image", eng.last_error) + leftovers = [n for n in os.listdir(eng.cache_path) if not n.endswith(".png")] + self.assertEqual(leftovers, []) + + def test_401_refreshes_token_once(self): + seen = {"n": 0} + + def flaky(url, headers, body): + seen["n"] += 1 + if seen["n"] == 1: + return 401, {"error": {"message": "expired"}} + return 200, self.session + + self.transport.routes = [ + r for r in self.transport.routes if not r[1].endswith("/sessions") + ] + self.transport.route("POST", ge.PICKER_BASE + "/sessions", flaky) + eng = self.make() + self.assertIsNotNone(eng.create_session()) + tokens = [c for c in self.transport.calls if c[1] == ge.TOKEN_URL] + self.assertEqual(len(tokens), 2) + + def test_session_gone(self): + eng = self.make() + eng.create_session() + self.transport.routes = [ + r for r in self.transport.routes if not r[1].endswith("/sessions/") + ] + self.transport.route( + "GET", + ge.PICKER_BASE + "/sessions/", + lambda u, h, b: (404, {"error": {"status": "NOT_FOUND", "message": "gone"}}), + ) + self.assertFalse(eng.poll_session()) + self.assertIsNone(eng.session) + self.assertIn("expired", eng.last_error) + self.assertIsNone(eng.list_items()) + self.assertEqual(eng.last_error, "no session") + + def test_api_error_message(self): + self.transport.routes = [ + r for r in self.transport.routes if not r[1].endswith("/sessions") + ] + self.transport.route( + "POST", + ge.PICKER_BASE + "/sessions", + lambda u, h, b: ( + 403, + {"error": {"status": "PERMISSION_DENIED", "message": "API disabled"}}, + ), + ) + eng = self.make() + self.assertIsNone(eng.create_session()) + self.assertEqual(eng.last_error, "create session: HTTP 403 API disabled") + + def test_max_items_and_eviction(self): + eng = self.make(max_items=2, cache_max=2) + eng.create_session() + self.assertEqual(len(eng.list_items()), 2) + for w in (32, 48, 64): + eng.thumbnail_path(eng.items[0], w, w, True) + self.assertLessEqual(len(os.listdir(eng.cache_path)), 2) + eng.forget(clear_cache=True) + self.assertEqual(os.listdir(eng.cache_path), []) + self.assertEqual(eng.items, []) + self.assertFalse(self.make().restore()) + + def test_delete_session(self): + self.transport.route("DELETE", ge.PICKER_BASE + "/sessions/", lambda u, h, b: (200, {})) + eng = self.make() + eng.create_session() + self.assertTrue(eng.delete_session()) + self.assertIsNone(eng.session) + self.assertEqual(self.transport.calls[-1][0], "DELETE") + + +class FakeSocketModule: + """Enough of ``socket`` for ``_socket_request`` (plain HTTP only).""" + + AF_INET = 2 + SOCK_STREAM = 1 + + def __init__(self, response): + self.response = response + self.sent = b"" + self.closed = False + + def getaddrinfo(self, host, port, family, socktype): + return [(self.AF_INET, socktype, 6, "", (host, port))] + + def socket(self, family, socktype, proto): + return self + + def settimeout(self, t): + pass + + def connect(self, addr): + self.addr = addr + + def sendall(self, data): + self.sent += bytes(data) + + def recv(self, n): + chunk, self.response = self.response[:n], self.response[n:] + return chunk + + def close(self): + self.closed = True + + +class SocketFallbackTests(unittest.TestCase): + def test_plain_http_roundtrip(self): + body = b'{"ok": true}' + raw = ( + b"HTTP/1.0 200 OK\r\nContent-Type: application/json\r\nContent-Length: %d\r\n\r\n" + % len(body) + + body + ) + fake = FakeSocketModule(raw) + with mock.patch.object(ge, "socket", fake): + status, out = ge._socket_request( + "POST", "http://api.test:8080/v1/x?y=1", {"Authorization": "Bearer t"}, b"{}", 5.0 + ) + self.assertEqual((status, out), (200, body)) + self.assertEqual(fake.addr, ("api.test", 8080)) + head = fake.sent.split(b"\r\n\r\n", 1)[0] + self.assertTrue(head.startswith(b"POST /v1/x?y=1 HTTP/1.0\r\nHost: api.test")) + self.assertIn(b"Authorization: Bearer t", head) + self.assertIn(b"Content-Length: 2", head) + self.assertTrue(fake.sent.endswith(b"{}")) + self.assertTrue(fake.closed) + + def test_streaming_sink_without_content_length(self): + raw = b"HTTP/1.0 200 OK\r\n\r\n" + JPEG + fake = FakeSocketModule(raw) + chunks = [] + with mock.patch.object(ge, "socket", fake): + status, n = ge._socket_request("GET", "http://h/p", {}, None, 5.0, sink=chunks.append) + self.assertEqual((status, n), (200, len(JPEG))) + self.assertEqual(b"".join(chunks), JPEG) + + +class RequestsBackendTests(unittest.TestCase): + """The MicroPython ``requests`` path (urllib disabled).""" + + class Resp: + def __init__(self, status, data): + self.status_code = status + self.content = data + self.raw = io.BytesIO(data) + self.closed = False + + def close(self): + self.closed = True + + def test_request_and_stream(self): + calls = [] + resp = [None] + + class Requests: + @staticmethod + def request(method, url, data=None, headers=None, stream=None, timeout=None): + calls.append((method, url, data, headers, stream, timeout)) + resp[0] = RequestsBackendTests.Resp(200, JPEG) + return resp[0] + + with mock.patch.object(ge, "_urllib", lambda: None), mock.patch.object( + ge, "_find_requests", lambda: Requests + ): + self.assertEqual( + ge.http_request("GET", "https://h/x", headers={"A": "b"}), (200, JPEG) + ) + self.assertEqual(calls[-1][3], {"A": "b"}) + self.assertEqual(calls[-1][4], False) + with tempfile.TemporaryDirectory() as d: + path = os.path.join(d, "f.bin") + self.assertEqual(ge.http_download("https://h/x", path), (200, len(JPEG))) + with open(path, "rb") as f: + self.assertEqual(f.read(), JPEG) + self.assertEqual(calls[-1][4], True) + self.assertTrue(resp[0].closed) + + +class SimTests(unittest.TestCase): + def test_sim_reason(self): + with mock.patch.dict(os.environ, {"GPHOTOS_SIM": "1"}): + self.assertEqual(gs.sim_reason(TOKENS), "env") + with mock.patch.dict(os.environ, {"GPHOTOS_SIM": "0"}): + self.assertIsNone(gs.sim_reason(TOKENS)) + with mock.patch.dict(os.environ, {"GPHOTOS_SIM": ""}): + os.environ.pop("GPHOTOS_SIM") + self.assertEqual(gs.sim_reason({}), "no_credentials") + with mock.patch.object(gs, "_network_available", lambda: False): + self.assertEqual(gs.sim_reason(TOKENS), "no_network") + with mock.patch.object(gs, "_network_available", lambda: True): + self.assertIsNone(gs.sim_reason(TOKENS)) + + def test_sim_flow(self): + with mock.patch.dict(os.environ, {"GPHOTOS_SIM": "1"}): + eng = gs.make_engine(tokens={}) + self.assertTrue(eng.sim) + self.assertTrue(eng.restore()) + first = eng.items[0]["id"] + self.assertTrue(eng.ensure_token()) + s = eng.create_session() + self.assertEqual(eng.items, []) + self.assertEqual(s["pickerUri"], gs.SIM_PICKER_URI) + self.assertFalse(eng.poll_session()) + self.assertFalse(eng.poll_session()) + self.assertTrue(eng.poll_session()) + items = eng.list_items() + self.assertEqual(len(items), len(gs._SIM_ITEMS)) + self.assertNotEqual(items[0]["id"], first) + self.assertTrue(eng.urls_valid()) + # bundled assets (git checkout) resolve by size class + tile = eng.thumbnail_path(items[0], 56, 56, crop=True) + self.assertTrue(tile.endswith("_64.jpg")) + self.assertTrue(eng.thumbnail_path(items[0], 120, 120, crop=True).endswith("_96.jpg")) + landscape = next(i for i in items if not i["portrait"]) + portrait = next(i for i in items if i["portrait"]) + self.assertTrue(eng.thumbnail_path(landscape, 320, 400).endswith("_320x240.jpg")) + self.assertTrue(eng.thumbnail_path(landscape, 200, 150).endswith("_160x120.jpg")) + self.assertTrue(eng.thumbnail_path(portrait, 240, 320).endswith("_240x320.jpg")) + self.assertTrue(eng.thumbnail_path(portrait, 100, 100).endswith("_120x160.jpg")) + for path in (tile,): + self.assertTrue(os.path.isfile(path)) + eng.delete_session() + self.assertIsNone(eng.session) + + +class AuthToolTests(unittest.TestCase): + def test_pkce(self): + verifier, challenge = gphotos_auth.pkce_pair() + self.assertGreaterEqual(len(verifier), 43) + expect = gphotos_auth.base64url(hashlib.sha256(verifier.encode()).digest()) + self.assertEqual(challenge, expect) + self.assertNotIn("=", challenge) + + def test_client_secrets_and_tokens_file(self): + with tempfile.TemporaryDirectory() as d: + path = os.path.join(d, "client.json") + with open(path, "w") as f: + json.dump({"installed": {"client_id": "cid.apps", "client_secret": "s"}}, f) + self.assertEqual(gphotos_auth.load_client_secrets(path), ("cid.apps", "s")) + with open(path, "w") as f: + json.dump({"client_id": "flat"}, f) + self.assertEqual(gphotos_auth.load_client_secrets(path), ("flat", "")) + with open(path, "w") as f: + json.dump({"web": {}}, f) + with self.assertRaises(gphotos_auth.AuthError): + gphotos_auth.load_client_secrets(path) + out = gphotos_auth.write_tokens(os.path.join(d, "sub", "t.json"), TOKENS) + self.assertEqual(gphotos_auth.read_tokens(out)["refresh_token"], "rt") + if os.name != "nt": + self.assertEqual(os.stat(out).st_mode & 0o777, 0o600) + # the engine reads what the tool writes + self.assertTrue(ge.credentials_ok(ge.load_tokens(out))) + + def test_auth_url(self): + url = gphotos_auth.build_auth_url("cid", "http://127.0.0.1:4321/", "chal", "st") + self.assertTrue(url.startswith(gphotos_auth.AUTH_URL + "?")) + query = dict(urllib.parse.parse_qsl(url.split("?", 1)[1])) + self.assertEqual(query["redirect_uri"], "http://127.0.0.1:4321/") + self.assertEqual(query["scope"], gphotos_auth.SCOPE) + self.assertEqual(query["code_challenge_method"], "S256") + self.assertEqual(query["access_type"], "offline") + self.assertEqual(query["prompt"], "consent") + self.assertEqual(query["state"], "st") + + def test_loopback_callback(self): + server = gphotos_auth.start_callback_server(0) + port = server.server_address[1] + result = {} + + def serve(): + result.update(gphotos_auth.wait_for_callback(server, timeout=10)) + + t = threading.Thread(target=serve) + t.start() + with urllib.request.urlopen( + "http://127.0.0.1:%d/?code=abc&state=xyz" % port, timeout=5 + ) as r: + self.assertIn(b"Signed in", r.read()) + t.join(5) + server.server_close() + self.assertEqual(result, {"code": "abc", "state": "xyz"}) + + def test_login_exchanges_code(self): + calls = [] + + def fake_post(url, fields, timeout=30.0): + calls.append((url, fields)) + return 200, {"access_token": "a", "refresh_token": "r", "scope": gphotos_auth.SCOPE} + + def fake_wait(server, timeout): + # base64url is patched below, so the tool's state is "fixedstate" + return {"code": "the-code", "state": "fixedstate"} + + with mock.patch.object(gphotos_auth, "_post_form", fake_post), mock.patch.object( + gphotos_auth, "wait_for_callback", fake_wait + ), mock.patch.object(gphotos_auth, "base64url", lambda b: "fixedstate"): + data = gphotos_auth.login("cid", "sec", open_browser=False, out=io.StringIO()) + self.assertEqual(data["refresh_token"], "r") + url, fields = calls[-1] + self.assertEqual(url, gphotos_auth.TOKEN_URL) + self.assertEqual(fields["grant_type"], "authorization_code") + self.assertEqual(fields["code"], "the-code") + self.assertEqual(fields["code_verifier"], "fixedstate") + self.assertTrue(fields["redirect_uri"].startswith("http://127.0.0.1:")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/example_test_manifest.toml b/tools/example_test_manifest.toml index 29c993df..2adaab1c 100644 --- a/tools/example_test_manifest.toml +++ b/tools/example_test_manifest.toml @@ -76,6 +76,22 @@ quit = "inject" deps = ["lvgl"] timeout_s = 45 +[examples.google_photos] +import = "google_photos.google_photos" +script = "examples/google_photos/google_photos.py" +kind = "lvgl" +quit = "inject" +deps = ["lvgl"] +timeout_s = 45 + +[examples.gphotos_lvgl] +import = "google_photos.gphotos_lvgl" +script = "examples/google_photos/gphotos_lvgl.py" +kind = "lvgl" +quit = "inject" +deps = ["lvgl"] +timeout_s = 45 + [examples.paint] import = "paint" script = "examples/paint.py" diff --git a/tools/gphotos_auth.py b/tools/gphotos_auth.py new file mode 100755 index 00000000..5c18af4d --- /dev/null +++ b/tools/gphotos_auth.py @@ -0,0 +1,397 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""One-time Google sign-in for ``lib/examples/google_photos`` (CPython, stdlib only). + +A microcontroller cannot complete Google's login by itself: the TV +"enter this code on your phone" flow refuses the Photos Picker scope, and a +LAN address cannot be registered as an OAuth redirect. So the browser login +runs here, once, and the resulting *refresh token* is copied to the device. +From then on the device only trades that refresh token for short-lived +access tokens. + +Usage:: + + python tools/gphotos_auth.py --client-secrets ~/Downloads/client_secret_XXX.json + python tools/gphotos_auth.py --client-id ID --client-secret SECRET + python tools/gphotos_auth.py --verify # re-check an existing tokens file + +Then copy the file to the board:: + + mpremote cp ~/.gphotos_tokens.json :/gphotos_tokens.json + +Google Cloud setup (details in ``lib/examples/google_photos/README.md``): + +1. APIs & Services -> Library -> enable **Google Photos Picker API**. +2. OAuth consent screen: user type External. Either publish the app to + *Production* (unverified is fine for personal use) or keep it in *Testing* + and add your account as a test user -- but Testing refresh tokens expire + after 7 days, so you would repeat this login weekly. +3. Credentials -> Create credentials -> OAuth client ID -> **Desktop app**; + download the JSON. + +The login uses PKCE and a loopback redirect (``http://127.0.0.1:/``), +the flow Google recommends for installed apps. ``--no-browser`` prints the +URL instead of opening a browser (copy it to any browser on this machine). +""" + +from __future__ import annotations + +import argparse +import base64 +import hashlib +import http.server +import json +import os +import sys +import threading +import urllib.error +import urllib.parse +import urllib.request +import webbrowser + +AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth" +TOKEN_URL = "https://oauth2.googleapis.com/token" +PICKER_SESSIONS_URL = "https://photospicker.googleapis.com/v1/sessions" +SCOPE = "https://www.googleapis.com/auth/photospicker.mediaitems.readonly" +DEFAULT_OUTPUT = "~/.gphotos_tokens.json" +LOGIN_TIMEOUT_S = 300 + +_DONE_HTML = ( + "" + "