From 52f4323a7c8beb872780235700dac46cd9833574 Mon Sep 17 00:00:00 2001 From: Antigravity Agent Date: Tue, 28 Apr 2026 14:33:13 +0100 Subject: [PATCH 1/2] feat: implement orientation-aware scene processing --- eink/backend/background/mqtt.py | 2 +- eink/backend/background/scene_processor.py | 26 ++++- eink/backend/tests/unit/test_mqtt_logic.py | 2 +- .../tests/unit/test_scene_processor.py | 107 ++++++++++++++++++ eink/converter/index.js | 18 ++- 5 files changed, 147 insertions(+), 8 deletions(-) diff --git a/eink/backend/background/mqtt.py b/eink/backend/background/mqtt.py index 949f5c22..0e4d43ee 100644 --- a/eink/backend/background/mqtt.py +++ b/eink/backend/background/mqtt.py @@ -382,7 +382,7 @@ async def _call_ha_upload(self, device_id, filename): "media_content_type": "image/png", }, "dither_mode": "none", - "rotation": 90, + "rotation": 0, "fit_mode": "crop", } diff --git a/eink/backend/background/scene_processor.py b/eink/backend/background/scene_processor.py index 78cec57b..99bb3e26 100644 --- a/eink/backend/background/scene_processor.py +++ b/eink/backend/background/scene_processor.py @@ -208,10 +208,27 @@ async def process_slice( draw_w = (image_record.width * image_meta.get("scaling_factor", 100)) / 100 draw_h = (image_record.height * image_meta.get("scaling_factor", 100)) / 100 - # Output pixels - is_portrait = panel.get("orientation") == "portrait" - out_w = panel["dt"].height_px if is_portrait else panel["dt"].width_px - out_h = panel["dt"].width_px if is_portrait else panel["dt"].height_px + # 211: Output pixels + panel_portrait = panel["dt"].panel_orientation == "portrait" + + # Determine panel's native pixel dimensions + # Note: The DB currently stores width_px as the larger dimension + # regardless of panel_orientation because the DisplayTypeHandler + # enforces landscape. + if panel_portrait: + out_w = panel["dt"].height_px + out_h = panel["dt"].width_px + else: + out_w = panel["dt"].width_px + out_h = panel["dt"].height_px + + # Determine if we need to rotate to match the layout's orientation + layout_orientation = panel.get("orientation", "landscape") + rotation = 0 + if layout_orientation != panel["dt"].panel_orientation: + # If layout is portrait but panel is landscape, we need a 90 deg rotation. + # If layout is landscape but panel is portrait, we also need a 90 deg rotation. + rotation = 90 # Paths src_path = os.path.join(get_storage_path("image"), image_record.file_path) @@ -237,6 +254,7 @@ async def process_slice( "contrast": image_record.contrast, "saturation": image_record.saturation, "conversion": image_record.conversion, + "rotation": rotation, } # Call node converter diff --git a/eink/backend/tests/unit/test_mqtt_logic.py b/eink/backend/tests/unit/test_mqtt_logic.py index 589abc80..d6113493 100644 --- a/eink/backend/tests/unit/test_mqtt_logic.py +++ b/eink/backend/tests/unit/test_mqtt_logic.py @@ -119,5 +119,5 @@ async def test_handle_layout_update_logic(mock_app): assert data["device_id"] == device_id assert filename in data["image"]["media_content_id"] assert data["dither_mode"] == "none" - assert data["rotation"] == 90 + assert data["rotation"] == 0 assert data["fit_mode"] == "crop" diff --git a/eink/backend/tests/unit/test_scene_processor.py b/eink/backend/tests/unit/test_scene_processor.py index 5e0526de..c5218ed1 100644 --- a/eink/backend/tests/unit/test_scene_processor.py +++ b/eink/backend/tests/unit/test_scene_processor.py @@ -283,3 +283,110 @@ def side_effect(*args, **kwargs): result = await session.execute(stmt) updated_record = result.scalar_one() assert updated_record.file_hash is not None + + +@pytest.mark.asyncio +async def test_scene_processor_rotation(db_setup, tmp_path): + """Test that scene_processor calculates rotation correctly when orientations mismatch.""" + + async with database.get_session() as session: + # 1. Setup Display Type (Landscape panel) + dt = models.DisplayType( + id="dt-landscape", + name="Native Landscape", + panel_orientation="landscape", + width_px=800, + height_px=480, + panel_width_mm=160, + panel_height_mm=100, + width_mm=160, + height_mm=100, + colour_type="BW", + frame={}, + mat={}, + ) + session.add(dt) + + # 2. Setup Layout using it as PORTRAIT + layout = models.Layout( + id="layout-p", + name="Portrait Layout", + canvas_width_mm=100, + canvas_height_mm=160, + items=[ + { + "id": "d1", + "display_type_id": "dt-landscape", + "x_mm": 0, + "y_mm": 0, + "orientation": "portrait", # MISMATCH! + } + ], + status="active", + ) + session.add(layout) + + # 3. Setup Image + img = models.Image( + id="img1", + name="I", + file_path="t.png", + width=100, + height=100, + file_type="png", + status="ACTIVE", + file_hash="h1", + ) + session.add(img) + await session.commit() + await session.refresh(img) + + # 4. Setup Scene + scene = models.Scene( + id="scene1", + name="S", + layout_id="layout-p", + status="active", + items=[ + { + "id": "item1", + "type": "image", + "displays": ["d1"], + "images": [ + { + "image_id": "img1", + "scaling_factor": 100, + "offset": {"x": 0, "y": 0}, + } + ], + } + ], + ) + session.add(scene) + await session.commit() + await session.refresh(scene) + await scene_handler._update_scene_queue(scene, session) + await session.commit() + + # Mock converter + with patch("asyncio.create_subprocess_exec") as mock_exec: + mock_process = AsyncMock() + mock_process.communicate.return_value = (b"Done", b"") + mock_process.returncode = 0 + mock_exec.return_value = mock_process + + with patch( + "backend.background.scene_processor.get_storage_path", + return_value=str(tmp_path), + ): + await check_for_scene_work() + + assert mock_exec.call_count == 1 + args, _ = mock_exec.call_args + config = json.loads(args[2]) + + # Output dimensions should match NATIVE panel (landscape) + assert config["width"] == 800 + assert config["height"] == 480 + # Rotation should be 90 because landscape panel used as portrait + assert config["rotation"] == 90 diff --git a/eink/converter/index.js b/eink/converter/index.js index 0ecc3e13..37a47d98 100644 --- a/eink/converter/index.js +++ b/eink/converter/index.js @@ -22,7 +22,8 @@ async function main() { const { src, dest, palette, brightness, contrast, saturation, conversion, width, height, background_color, - draw_x, draw_y, draw_w, draw_h + draw_x, draw_y, draw_w, draw_h, + rotation } = config; if (!src || !dest || !palette) { @@ -45,7 +46,19 @@ async function main() { sourceCtx.fillRect(0, 0, finalWidth, finalHeight); } - // 2. Draw image with scaling and offset + // 2. Draw image with scaling, offset, and optional rotation + sourceCtx.save(); + if (rotation === 90) { + sourceCtx.translate(finalWidth, 0); + sourceCtx.rotate(Math.PI / 2); + } else if (rotation === 180) { + sourceCtx.translate(finalWidth, finalHeight); + sourceCtx.rotate(Math.PI); + } else if (rotation === 270 || rotation === -90) { + sourceCtx.translate(0, finalHeight); + sourceCtx.rotate(-Math.PI / 2); + } + sourceCtx.drawImage( img, draw_x ?? 0, @@ -53,6 +66,7 @@ async function main() { draw_w ?? img.width, draw_h ?? img.height ); + sourceCtx.restore(); const destCanvas = createCanvas(finalWidth, finalHeight); From 4d613abd1988c087b50d8e6eb6a062f2292bea2a Mon Sep 17 00:00:00 2001 From: Antigravity Agent Date: Tue, 28 Apr 2026 14:52:35 +0100 Subject: [PATCH 2/2] chore: simplify rotation logic in converter --- eink/converter/index.js | 6 ------ 1 file changed, 6 deletions(-) diff --git a/eink/converter/index.js b/eink/converter/index.js index 37a47d98..1fe3cb7a 100644 --- a/eink/converter/index.js +++ b/eink/converter/index.js @@ -51,12 +51,6 @@ async function main() { if (rotation === 90) { sourceCtx.translate(finalWidth, 0); sourceCtx.rotate(Math.PI / 2); - } else if (rotation === 180) { - sourceCtx.translate(finalWidth, finalHeight); - sourceCtx.rotate(Math.PI); - } else if (rotation === 270 || rotation === -90) { - sourceCtx.translate(0, finalHeight); - sourceCtx.rotate(-Math.PI / 2); } sourceCtx.drawImage(