fix: address CodeRabbit review on #580

- Preview fallbacks (SSE stream and /display/current) use logical_size({})
  (128x32, the shared default) instead of a hard-coded 128x64.
- display_geometry treats a non-mapping display/hardware block as missing,
  so a malformed config.json falls back to defaults instead of raising
  AttributeError (which turned the Starlark render into an HTTP 500).
- Docs: the static update interval falls back manifest -> plugin config
  -> 60s, in both the API reference and the architecture spec.

Not taken: validating double_sided copies against chain_length/parallel.
An orientation Rotate: or U-mapper pixel mapper decides which axis panels
lie on, so the counts would reject working setups (the existing
vertical-split test is one).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-14 17:51:11 -04:00
co-authored by Claude Opus 5
parent c11fff9760
commit a9e1bd0b72
6 changed files with 42 additions and 18 deletions
+3 -2
View File
@@ -38,8 +38,9 @@ self.enabled # Boolean enabled status
Fetch/update data for this plugin. Called on the plugin's update interval:
the value `get_update_interval()` returns when it returns a number, otherwise
the `update_interval` in the plugin's manifest (see
[`get_update_interval()`](#get_update_interval---optionalfloat) below).
the static interval: the `update_interval` in the plugin's manifest, else
`update_interval` in the plugin's section of `config.json`, else 60 seconds
(see [`get_update_interval()`](#get_update_interval---optionalfloat) below).
**Example**:
```python
+2 -1
View File
@@ -190,7 +190,8 @@ class BasePlugin(ABC):
"""
Fetch/update data for this plugin.
Called every get_update_interval() seconds when that returns a
number, otherwise every manifest update_interval seconds.
number, otherwise at the static interval: the manifest's
update_interval, else the plugin config's update_interval, else 60s.
"""
pass
+15 -4
View File
@@ -23,9 +23,16 @@ DEFAULT_CHAIN_LENGTH = 2
DEFAULT_PARALLEL = 1
def _display(config: Optional[Mapping[str, Any]]) -> Mapping[str, Any]:
# A hand-edited config.json can hold anything here; treat a non-mapping
# like a missing block so callers get the defaults, not AttributeError.
display = (config or {}).get('display')
return display if isinstance(display, Mapping) else {}
def _hardware(config: Optional[Mapping[str, Any]]) -> Mapping[str, Any]:
display = (config or {}).get('display') or {}
return display.get('hardware') or {}
hw = _display(config).get('hardware')
return hw if isinstance(hw, Mapping) else {}
def physical_size(config: Optional[Mapping[str, Any]]) -> Tuple[int, int]:
@@ -54,6 +61,10 @@ def resolve_double_sided(physical_width: int, physical_height: int,
config is logged and disabled rather than raised — a misconfigured panel
should still light up.
Only pixels are checked, not whole panels: ``chain_length`` and
``parallel`` don't say which axis a panel lies on once an orientation
``Rotate:`` or U-mapper ``pixel_mapper_config`` rearranges the chain.
``quiet`` suppresses the log lines, for callers that run on every web
request and would otherwise repeat them on each poll.
"""
@@ -118,8 +129,8 @@ def logical_size(config: Optional[Mapping[str, Any]],
``DisplayManager.width``/``height`` give.
"""
width, height = physical_size(config)
display = (config or {}).get('display') or {}
ds = resolve_double_sided(width, height, display.get('double_sided') or {},
ds = resolve_double_sided(width, height,
_display(config).get('double_sided') or {},
quiet=quiet)
if ds is not None:
return ds['logical_width'], ds['logical_height']
+16
View File
@@ -71,6 +71,13 @@ def test_disabled_double_sided_is_ignored():
assert logical_size(cfg) == (256, 32)
@pytest.mark.parametrize('display', ['oops', ['a'], 1, {'hardware': 'oops'},
{'hardware': ['a']}])
def test_non_mapping_display_config_uses_the_defaults(display):
assert physical_size({'display': display}) == (128, 32)
assert logical_size({'display': display}) == (128, 32)
@pytest.fixture
def display_client(monkeypatch):
from web_interface.blueprints.api_v3 import api_v3
@@ -103,6 +110,15 @@ def test_display_current_defaults_chain_length_like_display_manager(display_clie
assert (data['width'], data['height']) == (64 * DEFAULT_CHAIN_LENGTH, 32)
def test_display_current_falls_back_to_the_shared_default(display_client):
client, config_manager = display_client
config_manager.load_config.side_effect = ValueError('unreadable')
data = client.get('/api/v3/display/current').get_json()['data']
assert (data['width'], data['height']) == logical_size({}) == (128, 32)
def test_preview_callers_do_not_rederive_the_size():
"""Every preview/size caller goes through display_geometry, so none of
them can drift back to a private chain_length default."""
+2 -3
View File
@@ -750,12 +750,11 @@ def display_preview_generator():
# Get display dimensions from config: the logical size DisplayManager
# renders at, so double-sided setups preview one screen
from src.display_geometry import logical_size
try:
from src.display_geometry import logical_size
width, height = logical_size(config_manager.load_config())
except (KeyError, TypeError, ValueError, AttributeError, ConfigError):
width = 128
height = 64
width, height = logical_size({})
while True:
try:
+4 -8
View File
@@ -27,16 +27,12 @@ def get_display_current():
# Get display dimensions from config: the logical size DisplayManager
# renders at, so double-sided setups preview one screen
from src.display_geometry import logical_size
try:
if api_v3.config_manager:
from src.display_geometry import logical_size
width, height = logical_size(api_v3.config_manager.load_config())
else:
width = 128
height = 64
config = api_v3.config_manager.load_config() if api_v3.config_manager else {}
width, height = logical_size(config)
except Exception:
width = 128
height = 64
width, height = logical_size({})
# Try to read snapshot file
image_data = None