Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .site/gallery/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,16 @@ <h3>Fonts</h3>
<h3>Framebuf Simpletest</h3>
<p>Simple test example to demonstrate pygraphics.FrameBuffer.</p>
</a>
<a class="card" href="micropython.html?manifests=google_photos">
<div class="card-top">
<span class="card-icon"><img src="../pyscript/thumbnails/google_photos.png" alt="" loading="lazy"></span>
<span class="card-badges">
<span class="badge dep tag-tier-3">lvgl</span>
</span>
</div>
<h3>Google Photos</h3>
<p>Pick Google Photos on your phone; the board lists, views, and slideshows them.</p>
</a>
<a class="card" href="micropython.html?modules=graphics_simpletest">
<div class="card-top">
<span class="card-icon"><img src="../pyscript/thumbnails/graphics_simpletest.png" alt="" loading="lazy"></span>
Expand Down
7 changes: 7 additions & 0 deletions .site/gallery/python-files.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,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",
Expand Down
10 changes: 10 additions & 0 deletions .site/pyscript/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,16 @@ <h3>Fonts</h3>
<h3>Framebuf Simpletest</h3>
<p>Simple test example to demonstrate pygraphics.FrameBuffer.</p>
</a>
<a class="card" href="../pyscript/pyodide.html?manifests=google_photos&deps=pydevices-lvgl">
<div class="card-top">
<span class="card-icon"><img src="../pyscript/thumbnails/google_photos.png" alt="" loading="lazy"></span>
<span class="card-badges">
<span class="badge dep tag-tier-3">lvgl</span>
</span>
</div>
<h3>Google Photos</h3>
<p>Pick Google Photos on your phone; the board lists, views, and slideshows them.</p>
</a>
<a class="card" href="../pyscript/pyodide.html?modules=graphics_simpletest&deps=pydevices-palettes%2Cpydevices-pygraphics">
<div class="card-top">
<span class="card-icon"><img src="../pyscript/thumbnails/graphics_simpletest.png" alt="" loading="lazy"></span>
Expand Down
Binary file added .site/pyscript/thumbnails/google_photos.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 2 additions & 1 deletion docs/screenshots/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) | |
| ![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) | |
Binary file added docs/screenshots/google_photos.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/google_photos_connect.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/google_photos_view.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
170 changes: 170 additions & 0 deletions lib/examples/google_photos/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
# google_photos — Google Photos on a PyDevices display

Pick photos from your phone, browse them as a thumbnail list on the board,
view them full screen, or run a slideshow. LVGL front end; runs on
MicroPython, CircuitPython, and CPython wherever a `board_config` and the
PyDevices LVGL binding are available.

| List | View | Pairing |
|:--:|:--:|:--:|
| ![list](../../../docs/screenshots/google_photos.png) | ![view](../../../docs/screenshots/google_photos_view.png) | ![connect](../../../docs/screenshots/google_photos_connect.png) |

```bash
cd lib
micropython -m examples.google_photos # or python / circuitpython
```

Without a tokens file (below) the launcher runs a **simulator** with sample
photos, so the UI can be explored anywhere — including the
[PyScript gallery](https://PyDevices.github.io/pydevices-examples/pyscript/).
Set `GPHOTOS_SIM=1` to force it, `GPHOTOS_SIM=0` to force the real client.

## How it works

Since March 31, 2025 the Google Photos *Library* API only returns media that
the calling app uploaded itself, so a third-party app can no longer browse a
whole library. The self-serve path is the **Picker API**:

1. The device creates a *picker session* and shows its link as a QR code.
2. You scan it with your phone, choose photos in Google Photos, tap **Done**.
3. The device polls the session, lists the picked items, and downloads
sized thumbnails (list tiles are exact-size center crops, the viewer asks
for an image that fits the panel).

Google returns only what you picked; nothing else in the library is visible
to the device. Picks persist on the device (see *Files*) so a reboot lands on
the list, and thumbnails are cached on flash so they download once.

The Google login cannot run on the microcontroller: Google's TV
"enter this code on your phone" flow refuses the Picker scope, and a LAN
address cannot be registered as an OAuth redirect. So the browser login runs
**once on a PC** (`tools/gphotos_auth.py`) and its output — a refresh token —
is copied to the board. From then on the device only trades that refresh
token for hour-long access tokens.

## Setup (one time)

1. **Google Cloud project** — <https://console.cloud.google.com/>: 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/` | `<hash>_<w>x<h>[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.
7 changes: 7 additions & 0 deletions lib/examples/google_photos/__init__.py
Original file line number Diff line number Diff line change
@@ -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
13 changes: 13 additions & 0 deletions lib/examples/google_photos/__main__.py
Original file line number Diff line number Diff line change
@@ -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
110 changes: 110 additions & 0 deletions lib/examples/google_photos/assets/gen_sim_assets.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added lib/examples/google_photos/assets/sim_01_64.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added lib/examples/google_photos/assets/sim_01_96.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added lib/examples/google_photos/assets/sim_02_64.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added lib/examples/google_photos/assets/sim_02_96.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added lib/examples/google_photos/assets/sim_03_64.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added lib/examples/google_photos/assets/sim_03_96.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added lib/examples/google_photos/assets/sim_04_64.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added lib/examples/google_photos/assets/sim_04_96.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added lib/examples/google_photos/assets/sim_05_64.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added lib/examples/google_photos/assets/sim_05_96.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added lib/examples/google_photos/assets/sim_06_64.jpg
Binary file added lib/examples/google_photos/assets/sim_06_96.jpg
Loading
Loading