Grids

July 23, 2026 · View on GitHub

A grid is a virtual canvas in physical centimetres on which one or more Inky displays are arranged so they jointly show slices of a single source image. The API pre-renders each device's slice at its native pixel resolution and pushes it through the existing MQTT DisplayCommand flow — controllers need no grid-specific code.

Grids are also the universal target for display jobs (see motd.md) and image groups: every kind of content flows through one content queue per grid. An image group is a panel spread: each member image is assigned a grid slot (exactly one image per slot — a group is a frozen set, nothing rotates within it) and all panels update together — worker runs and operators produce the same shape (cover-cropping one image across all panels is not a group; that stays a loose pool image). The queue interleaves groups with loose pool images; fresh (never-shown) entries play first in the operator's order, then the least recently shown entry replays.

Grids do not rotate on an interval. A grid shows content only when its display schedule fires (display_schedule_enabled + display_cron, the same five-field cron mechanism the jobs use, evaluated in display_timezone; display_next_at is the lease the queue tick fires on) or when an operator acts ("show now" / "next"). Each display steps the queue one entry forward and holds it for display_duration_seconds (or until an explicit release when unset); when the hold expires the member panels are released back to their own solo rotation. A single display participates in that system by living in a one-panel grid.

Mental model

            grid.width_cm  (computed)
   ┌──────────────────────────────────┐
   │ ┌─────────┐┌─────────┐┌────────┐ │
   │ │ row 0   ││ row 0   ││ row 0  │ │  grid.height_cm
   │ │ col 0   ││ col 1   ││ col 2  │ │  (computed)
   │ └─────────┘└─────────┘└────────┘ │
   │      ┌─────────┐┌─────────┐      │
   │      │ row 1   ││ row 1   │      │
   │      │ col 0   ││ col 1   │      │
   │      └─────────┘└─────────┘      │
   └──────────────────────────────────┘
  • A grid is defined as a tile layout: rows of devices, top-down and left-to-right, assumed to sit flush against each other (no white space).
  • Every cm value is computed from the device profiles' physical dimensions: canvas width = widest row, canvas height = sum of row heights. Mixed sizes are centred — a shorter panel is centred vertically within its row, a narrower row centred horizontally on the canvas.
  • Each placement carries its slot address (row, col) — the stable, user-facing handle a display job uses to map content onto panels — plus the computed cm-rectangle used by the crop math.
  • The source image is cover-fitted to the canvas (preserves aspect; centre-crops overflow on one axis), and each device's cm-rectangle projects onto the source pixels. The API uploads the resulting JPEG to S3 and pushes a normal display command to the controller.

Decisions

These are the choices made during design — read here before changing the contract.

Layout in, centimetres stored

Users arrange tiles; the server computes and persists cm-rectangles.

  • Real-world wall arrangement is measured in cm and the crop math needs cm, but nobody should have to type coordinates: panel dimensions are known per profile, so a flush tile arrangement determines every position.
  • The cm model is kept underneath (rather than replaced by a pure cell grid) so gapped or bezel-compensated layouts remain possible later without a schema change.
  • The rect is snapshotted at layout time: a later profile-dimension correction does not silently shift existing placements.

Physical dimensions live on DeviceProfile

physical_width_cm / physical_height_cm are stored once per panel model (Pimoroni's published active-area specs). Devices and grid placements both derive their physical size from the profile. Per-mounting overrides aren't supported; if your bezel/frame changes effective area, correct the profile (or extend the model).

One grid per device

A device belongs to at most one grid's layout — enforced when a layout is applied. This keeps slot addressing unambiguous and lets jobs treat "the panel at row 1, col 0" as a stable target.

API-side cropping, push pre-rendered slices

The display path uploads grids/{grid_id}/{image_id}/{device_id}.jpg and sends each controller a normal DisplayCommand pointing at its slice. Alternatives considered:

  • Cropping in the controller via a new "crop box" command field — requires every controller to download the full (potentially multi-megapixel) source, duplicates work N times, needs a controller release.
  • Stitching at display time on the device side — same downsides plus controller complexity.

The chosen path keeps controllers dumb, makes failures observable (each slice is an inspectable JPEG in S3), and storage cost is trivial for e-ink resolutions.

Cover (crop-to-fill) is the only fit mode

Matches existing per-device behaviour, produces edge-to-edge results without configuration, and avoids the "what background colour fills the letterbox?" question. fit_mode = contain|cover can be added later if it becomes desirable.

Grid-mode arbitration: one claim per device at a time

The mechanism is the devices.claimed_by_grid_id column:

  • NULL = the device runs its own solo rotation.
  • Non-NULL = the named grid currently owns it (via its content queue); solo rotation skips this device until the claim is released.

Claims end automatically when the display hold expires, or via POST /api/grids/{id}/release — both make the panels due immediately, so the next rotation tick repaints them all; that mass rotation spreads their following refreshes evenly across the refresh interval (the stagger_rotation app setting, on by default) so they don't keep flashing in lockstep. Removing the device from the layout also releases it. A manual image push while a group is showing is an operator override: the held group is dropped and the pushed image is held instead.

Explicit image-to-grid assignment

An Image row has an optional target_grid_id. Solo per-device rotation excludes images with that field set; the grid's queue pool is exactly the set of images carrying its id. Images with a group_id leave both solo rotation and the pool — their group plays as one queue entry instead.

A many-to-many table (images ↔ grids) was considered and rejected on the grounds that grids tend to be long-lived and image curation is per-grid in practice. Upgrading to M2M later is straightforward.

API surface

All under /api/grids.

EndpointPurpose
GET /List grids (?include_devices=true to embed)
POST /Create grid from a tile layout (name, rows)
GET /{id}Detail, includes placements with slot addresses
PUT /{id}Rename, edit the display schedule, or replace the layout
DELETE /{id}Delete; releases claims; clears target_grid_id
POST /{id}/display ({image_id})Render slices + push to every member device
POST /{id}/display-group ({group_id})Show a group now, held per the grid's duration
POST /{id}/nextShow the next queue entry now (empty queue: release the panels)
POST /{id}/releaseEnd the display; panels repaint next tick, refreshes staggered
GET /{id}/queueThe queue in predicted playback order
PUT /{id}/queuePersist the operator's queue order (groups + images share one sequence)
GET /{id}/display-statusCurrent group/hold + per-panel content

rows is a list of lists of device UUIDs — the visual arrangement. The response embeds each placement's row/col slot plus its computed bottom-left (Y-up) cm coordinates for the canvas preview.

The image router also has a target_grid_id filter on GET /api/images and accepts the field on upload (POST /api/images) and update.

Display flow

operator                                  API                              MQTT broker            controller(s)
   │                                       │                                    │                       │
   │ POST /api/grids/{id}/display          │                                    │                       │
   │──────────────────────────────────────▶│                                    │                       │
   │                                       │ load placements                    │                       │
   │                                       │ fetch source image from S3         │                       │
   │                                       │ for each device:                   │                       │
   │                                       │   compute crop box in cm           │                       │
   │                                       │   crop + resize to device px       │                       │
   │                                       │   upload grids/<id>/<img>/<dev>.jpg│                       │
   │                                       │   set claimed_by_grid_id           │                       │
   │                                       │   publish DisplayCommand ───────────▶                       │
   │                                       │                                    │ ───────────────────▶ controller A
   │                                       │                                    │ ───────────────────▶ controller B
   │ 200 OK                                │                                    │                       │
   │◀──────────────────────────────────────│                                    │                       │
   │                                       │              acks                  │ ◀─────────────────────│
   │                                       │◀───────────────────────────────────│                       │

Worked example

A 2-display wall of 13.3" panels side by side:

# 1. Create the grid from the layout — one row, two panels. The canvas
#    (54.2 x 20.3 cm) and both placements are computed from the profiles.
curl -X POST localhost:8000/api/grids \
  -H "content-type: application/json" \
  -d '{"name": "living-wall", "rows": [["<device_a_uuid>", "<device_b_uuid>"]]}'
# → {"id": "<grid_id>", "width_cm": 54.2, "height_cm": 20.3, "devices": [...]}

# 2. Upload an image targeted at the grid (UI: pick target grid in the upload form).
#    Or update an existing image:
curl -X PUT localhost:8000/api/images/<image_id> \
  -H "content-type: application/json" \
  -d '{"target_grid_id": "<grid_id>"}'

# 3. Display.
curl -X POST localhost:8000/api/grids/<grid_id>/display \
  -H "content-type: application/json" \
  -d '{"image_id": "<image_id>"}'

# 4. Later, hand the devices back to solo rotation:
curl -X POST localhost:8000/api/grids/<grid_id>/release

Operational notes

  • The queue tick (every 30 s) drives grids. It starts the scheduled display when its cron lease elapses — one queue entry, shown and held — and releases the panels when a hold expires. An empty queue does not advance the lease, so the tick retries until content exists. A group's slot-assigned images show simultaneously, one per panel; images without a slot assignment are not shown (a group with none is skipped entirely); assign panels in the Groups overview on the Images page.
  • Panels are only claimed while content is held. When the hold expires (or on POST /release) the member devices are due immediately and repaint on the next rotation tick; that mass rotation staggers their following refreshes evenly across the interval (unless the stagger_rotation app setting is off), so several panels releasing together don't flash in lockstep afterwards.
  • Controllers are unchanged. No grid-specific code lives on the device side; a controller just receives a normal display command with a path pointing into the grids/ prefix.

Out of scope (today)

  • Fit modes other than cover.
  • Gaps / bezel-mullion compensation between adjacent panels — the layout assumes panels sit flush. (The stored cm model already supports gaps; only the layout computation would need a spacing input.)
  • Many-to-many image-to-grid pools.
  • Per-mounting physical-size overrides.
  • Auto-detect "this image fits grid X" — image assignment is explicit.