feat(display): double-sided mode — mirror one screen across the panel chain - #375
Conversation
…panel chain
Renders a plugin once at a logical (per-screen) size, then tiles the
rendered frame across the full physical chain so two (or more) panels show
identical content. A 128x32 chain configured with 2 copies drives two 64x32
screens; vertical axis splits parallel outputs instead of the chain.
Plugins size themselves from matrix.width/height, so a thin _LogicalMatrix
proxy reports the logical size while delegating every real operation
(CreateFrameCanvas, SwapOnVSync, brightness, Clear) to the physical matrix —
no plugin changes required. Duplication is a single PIL paste per copy in
update_display(), so render cost is unchanged.
Config: display.double_sided { enabled, copies, axis }. Invalid config
(non-divisible dimension, bad axis/copies) logs a warning and falls back to
single-screen rather than failing to light up.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>Warning Review limit reached
More reviews will be available in 42 minutes and 45 seconds. Learn how PR review limits work. Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file). ⌛ How to resolve this issue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits. 🚦 How do rate limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan refill rate. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, the refill rate gradually slows as usage increases. The highest same-day bursts are limited more strictly. Please see our Fair Usage Limits Policy for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (4)
📝 WalkthroughWalkthroughAdds a ChangesDouble-sided display mode
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Up to standards ✅🟢 Issues |
| Metric | Results |
|---|---|
| Complexity | 21 |
| Duplication | 0 |
NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewerTIP This summary will be updated as you push new changes.
There was a problem hiding this comment.
🧹 Nitpick comments (2)
src/display_manager.py (2)
58-58: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueOptional: sort
__slots__to clear the Ruff RUF023 warning.Static analysis flags the tuple ordering; reorder to silence it and keep lint clean.
♻️ Proposed change
- __slots__ = ("_matrix", "_logical_width", "_logical_height")+ __slots__ = ("_logical_height", "_logical_width", "_matrix")🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/display_manager.py` at line 58, The __slots__ tuple is not sorted in alphabetical order, which triggers the Ruff RUF023 warning. Reorder the items in the __slots__ tuple that contains "_matrix", "_logical_width", and "_logical_height" to be in alphabetical order to resolve the linting warning.Source: Linters/SAST tools
60-71: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd return type hints to the new public-ish helpers.
The coding guidelines require type hints for parameters and return values.
_resolve_double_sidedreturnsOptional[Dict[str, Any]], and the_LogicalMatrixconstructor/properties are unannotated. Annotating clarifies the logical/physical contract for future maintainers.As per coding guidelines: "Use type hints for function parameters and return values".♻️ Suggested annotations
- def __init__(self, matrix, logical_width, logical_height):+ def __init__(self, matrix, logical_width: int, logical_height: int) -> None: object.__setattr__(self, "_matrix", matrix) object.__setattr__(self, "_logical_width", logical_width) object.__setattr__(self, "_logical_height", logical_height) `@property` - def width(self):+ def width(self) -> int: return self._logical_width `@property` - def height(self):+ def height(self) -> int: return self._logical_heightdef _resolve_double_sided(physical_width: int, physical_height: int, - ds_config: Dict[str, Any]):+ ds_config: Dict[str, Any]) -> Optional[Dict[str, Any]]:Also applies to: 83-84
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/display_manager.py` around lines 60 - 71, The `__init__` method of the _LogicalMatrix class is missing type hints for its parameters (matrix, logical_width, logical_height) and return type annotation, and the `width` and `height` properties are missing return type hints. Add appropriate type annotations to the `__init__` method parameters based on their expected types, add a return type annotation (None) to the `__init__` method, and add return type hints to the `width` and `height` properties to clarify the data types they return, following the project's coding guidelines that require type hints for function parameters and return values.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@src/display_manager.py`:
- Line 58: The __slots__ tuple is not sorted in alphabetical order, which
triggers the Ruff RUF023 warning. Reorder the items in the __slots__ tuple that
contains "_matrix", "_logical_width", and "_logical_height" to be in
alphabetical order to resolve the linting warning.
- Around line 60-71: The `__init__` method of the _LogicalMatrix class is
missing type hints for its parameters (matrix, logical_width, logical_height)
and return type annotation, and the `width` and `height` properties are missing
return type hints. Add appropriate type annotations to the `__init__` method
parameters based on their expected types, add a return type annotation (None) to
the `__init__` method, and add return type hints to the `width` and `height`
properties to clarify the data types they return, following the project's coding
guidelines that require type hints for function parameters and return values.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: acec5af0-b227-4d82-8f7f-df6f9541a46a
📒 Files selected for processing (3)
config/config.template.jsonsrc/display_manager.pytest/test_display_manager.py
Adds a Double-Sided Display section to the Display settings page (enabled checkbox, copies, horizontal/vertical axis) and wires the save handler to persist it under display.double_sided. Validates copies (2-8) and axis, returning 400 on bad input; an omitted checkbox is saved as disabled. Like the other hardware fields, changes take effect after a display restart. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addresses CodeRabbit nits: sort _LogicalMatrix.__slots__ (Ruff RUF023), annotate the proxy's __init__/properties/dunders and _resolve_double_sided's return type, and add docstrings to the property/dunder methods. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Uh oh!
There was an error while loading. Please reload this page.
… size (#539) * fix(display): pin one text layout engine, and give the 5x7 face a size Two ways a font could render differently on two machines running the same code, both found while diagnosing four plugins whose golden images passed on the machine that generated them and failed everywhere else. **Layout engine.** `ImageFont.truetype` picks its engine at load time: Raqm where the host Pillow was built with libraqm, Basic otherwise. The two round fractional glyph advances differently. `PressStart2P-Regular.ttf` at 8px has whole-pixel advances, so they agree — which is why most of the fleet matched everywhere and hid this. `4x6-font.ttf` at 6px does not: glyph positions drift cumulatively along a run, and the four plugins that draw body text in it (geochron, of-the-day, christmas-countdown, ledmatrix-weather's almanac) are exactly the four whose goldens travelled badly. Every core font load now goes through `src/common/font_layout.load_truetype`, which pins the Basic engine, so a render depends on the font file and the size and nothing else. Basic gives up complex-script shaping and kerning pairs; neither applies to bitmap-grid faces on an LED panel. Output is unchanged on a host without libraqm. **Zero font height.** `DisplayManager` built the 5x7 BDF face with `freetype.Face(path)` and never called `set_char_size`, so `face.size.height` stayed 0 and `get_font_height()` returned 0 for it — callers stacking rows by `prev_y + prev_height + gap` drew two lines on top of each other. The start-up line `Calendar font size: 0 pixels` has been printing the symptom all along. `font_manager._load_bdf_font` already called `set_char_size`, so whether measurement worked depended on which path loaded the face. `DisplayManager` now sets it too, and `get_font_height()` falls back to the strike the file declares rather than returning a zero line height. FixesChuckBuilds/ledmatrix-plugins#397 Refs ChuckBuilds/ledmatrix-plugins#371, #375, #378, #391 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(display): give the startup banner a rung that fits a full address at 64px CI caught what pinning the layout engine exposed rather than caused. `_fitting_font` walks PressStart2P then 4x6 at 6px, and "255.255.255.255" -- the widest thing the startup banner ever shows -- measures 66px at 4x6/6px against the 62 a 64x32 panel has to give. It used to squeak in only because the measurement depended on which layout engine the host Pillow happened to have; with the engine pinned it does not, so the rung the worst case actually needs is now in the ladder instead of implied: 4x6 at 5px, which measures 51. The fallback was wrong in the same place. When nothing in the ladder fit, it returned `self.font` -- the *widest* option, and precisely how "Initializing" came to run off the side of a 64px panel to begin with. It returns the narrowest face that loaded now. test/test_initializing_screen.py: 34 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(display): name the exceptions the BDF strike read can raise Codacy flagged the try/except/pass. It was already narrow in intent -- a malformed strike table on the measurement path must degrade to "size unknown" rather than take the display down -- but a bare `except Exception: pass` says neither of those things and hides a genuinely broken font behind a silent 8px fallback. It now catches what reading `available_sizes` can actually raise and logs which face failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore: drop logo PNGs the render harness downloaded into the worktree These are fetched at runtime by the logo cache; they are not source, and they rode in on a `git add -A` while I was running check_plugin.py against this branch. Nothing in the change needs them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
What
Adds an optional double-sided mode: render a plugin once at a logical (per-screen) size, then duplicate the rendered frame across the full physical panel chain so multiple panels show identical content.
horizontal) or parallel outputs (vertical)Why it's cheap
Plugins render once at the logical size — no extra render CPU. The only per-frame cost is a single PIL
pasteper copy inupdate_display()(an ~8 KB memcpy for 64×32), so it's effectively free.How it works
Plugins size themselves from
matrix.width/matrix.height(the documented convention), so a thin_LogicalMatrixproxy reports the logical size while delegating every real operation (CreateFrameCanvas,SwapOnVSync,brightness,Clear, …) to the underlying physical matrix. No plugin changes required. The full-chain buffer is reused each frame.The web preview snapshot reads the logical image, so the dashboard shows one clean screen rather than the duplicated output.
Config
axis: "horizontal"splits the chain (panels side by side);"vertical"splitsparallel(stacked).copies, badaxis/copies) logs a warning and falls back to single-screen rather than failing to light up.Web UI
The Settings → Display page gets a Double-Sided Display section (enabled checkbox, copies, axis select). The save handler validates and persists it under
display.double_sided. Like the other hardware settings on that page, it takes effect after a display restart.Tests
TestDisplayManagerDoubleSided(6 cases): logical-dimension reporting, pixel-level horizontal and vertical tiling through the realupdate_displaypath, divisibility fallback, disabled pass-through, and brightness write forwarding through the proxy.TestConfigAPIdouble-sided cases (4): form persistence, unchecked-disables, and 400s for invalid copies/axis.test_display_manager.py+test_web_api.pypass (48/48).🤖 Generated with Claude Code