From b286a1f24156946a8689ff1ecf59d62443cbbae8 Mon Sep 17 00:00:00 2001 From: Matt Edholm Date: Wed, 13 May 2026 15:53:59 -0400 Subject: [PATCH] feat(devices): DeviceModel::V2 for Waveshare 13.3" Spectra-6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the second panel model alongside V1 (800x480, 7.3"). V2 is 1200x1600 panel-native (tall) — the inverse aspect ratio means its "natural" orientation is portrait, not landscape: - DeviceModel::nativeOrientation() — V1 returns Landscape, V2 returns Portrait. Render rotates the source image 90 CCW only when the user's orientation differs from the panel's native, so the .bin stays panel-native scan order without per-model branches. - DeviceModel::panelId() / fromPanelId() — string mapping for the firmware's X-Panel-Id header (matches -DPANEL_ID build flag). - DeviceImageController: on every poll, if X-Panel-Id maps to a known model and differs from the device's current model, auto-correct. New Devices are created with the V1 default, so a freshly-claimed 13.3" unit needs this correction before the first image render produces a wrong-dimension .bin the firmware would reject. 8 new DeviceModel unit tests, 3 new controller tests cover the header-correction behaviour (different, same, unknown panel-id). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Controller/DeviceImageController.php | 22 +++++ src/Enum/DeviceModel.php | 82 +++++++++++++++---- .../RenderImageMessageHandler.php | 20 +++-- .../Controller/DeviceImageControllerTest.php | 57 +++++++++++++ tests/Unit/Enum/DeviceModelTest.php | 55 +++++++++++-- 5 files changed, 207 insertions(+), 29 deletions(-) diff --git a/src/Controller/DeviceImageController.php b/src/Controller/DeviceImageController.php index 31df85e..15226d1 100644 --- a/src/Controller/DeviceImageController.php +++ b/src/Controller/DeviceImageController.php @@ -6,6 +6,7 @@ namespace App\Controller; use App\Entity\Device; use App\Entity\RenderedAsset; +use App\Enum\DeviceModel; use App\Enum\RenderStatus; use App\Service\DeviceSerializer; use App\Service\MercurePublisher; @@ -89,6 +90,27 @@ class DeviceImageController extends AbstractController $currentImageId = (int) $request->headers->get('X-Current-Image-Id', '-1'); $device->markSeen(); + // Auto-correct Device.model from the firmware's X-Panel-Id header. New + // Devices are created with the default V1 model (see Device entity), so + // a freshly-claimed 13.3" unit ends up wrongly flagged until its first + // poll reaches here. Mis-routed renders produce wrong-dimension .bin + // files that the firmware rejects on size mismatch, blocking the + // first-image-after-claim flow. Existing rendered assets at the old + // model stay in storage but become irrelevant — RotationService will + // dispatch a fresh render at the new model on the next image change. + $panelIdHeader = (string) $request->headers->get('X-Panel-Id', ''); + if ($panelIdHeader !== '') { + $detected = DeviceModel::fromPanelId($panelIdHeader); + if ($detected !== null && $detected !== $device->getModel()) { + $this->logger->info('[device] panel-id correction', [ + 'mac' => $device->getMac(), + 'from' => $device->getModel()->value, + 'to' => $detected->value, + ]); + $device->setModel($detected); + } + } + // Stamp when we expect the device to call back — the PWA reads this // directly so its "next sync" label reflects the schedule the device // is actually on, not the freshly-saved one that won't reach it diff --git a/src/Enum/DeviceModel.php b/src/Enum/DeviceModel.php index 194cc3b..a046339 100644 --- a/src/Enum/DeviceModel.php +++ b/src/Enum/DeviceModel.php @@ -6,27 +6,79 @@ namespace App\Enum; enum DeviceModel: string { - case V1 = 'v1'; // Waveshare 7.3" 800×480 + case V1 = 'v1'; // Waveshare 7.3" Spectra-6, 800 × 480 native (wide → landscape natural) + case V2 = 'v2'; // Waveshare 13.3" Spectra-6, 1200 × 1600 native (tall → portrait natural) - public function width(Orientation $orientation): int - { - return $orientation === Orientation::Portrait ? 480 : 800; - } - - public function height(Orientation $orientation): int - { - return $orientation === Orientation::Portrait ? 800 : 480; - } - - /** EPD's hardware scan-row width — independent of user orientation. */ public function nativeWidth(): int { - return 800; + return match ($this) { + self::V1 => 800, + self::V2 => 1200, + }; } - /** EPD's hardware scan-row count — independent of user orientation. */ public function nativeHeight(): int { - return 480; + return match ($this) { + self::V1 => 480, + self::V2 => 1600, + }; + } + + /** + * The orientation the panel scans natively. V1 is wide-native so its + * natural mounting is landscape; V2 is tall-native so its natural + * mounting is portrait. Rendering only rotates the source image when + * the user's chosen orientation differs from this. + */ + public function nativeOrientation(): Orientation + { + return match ($this) { + self::V1 => Orientation::Landscape, + self::V2 => Orientation::Portrait, + }; + } + + /** User-facing width (pre-rotation) for the given orientation. */ + public function width(Orientation $orientation): int + { + return $orientation === $this->nativeOrientation() + ? $this->nativeWidth() + : $this->nativeHeight(); + } + + /** User-facing height (pre-rotation) for the given orientation. */ + public function height(Orientation $orientation): int + { + return $orientation === $this->nativeOrientation() + ? $this->nativeHeight() + : $this->nativeWidth(); + } + + /** + * Panel-id string the firmware reports via X-Panel-Id on /image polls. + * Must match the -DPANEL_ID build flag in firmware/platformio.ini. + */ + public function panelId(): string + { + return match ($this) { + self::V1 => 'waveshare-7.3-spectra6', + self::V2 => 'waveshare-13.3-spectra6', + }; + } + + /** + * Reverse lookup for the X-Panel-Id header. Returns null if the + * firmware reports a panel-id the server doesn't recognise — the + * controller treats that as "no panel-id reported" and leaves the + * device's existing model alone. + */ + public static function fromPanelId(string $panelId): ?self + { + return match ($panelId) { + 'waveshare-7.3-spectra6' => self::V1, + 'waveshare-13.3-spectra6' => self::V2, + default => null, + }; } } diff --git a/src/MessageHandler/RenderImageMessageHandler.php b/src/MessageHandler/RenderImageMessageHandler.php index 516a50d..f95a96c 100644 --- a/src/MessageHandler/RenderImageMessageHandler.php +++ b/src/MessageHandler/RenderImageMessageHandler.php @@ -70,7 +70,7 @@ final class RenderImageMessageHandler : $this->projectDir . '/' . $image->getStoragePath(); $width = $model->width($orientation); $height = $model->height($orientation); - $bin = $this->renderToBin($originalPath, $width, $height, $orientation); + $bin = $this->renderToBin($originalPath, $width, $height, $orientation, $model); $relPath = 'var/storage/images/' . $image->getId() . '/' . $model->value . '_' . $orientation->value . '.bin'; @@ -87,7 +87,7 @@ final class RenderImageMessageHandler $this->em->flush(); } - private function renderToBin(string $path, int $width, int $height, Orientation $orientation): string + private function renderToBin(string $path, int $width, int $height, Orientation $orientation, DeviceModel $model): string { $imagick = new \Imagick($path); $imagick->setImageAlphaChannel(\Imagick::ALPHACHANNEL_REMOVE); @@ -136,13 +136,15 @@ final class RenderImageMessageHandler $imagick = $canvas; } - // Portrait: rotate the fitted photo 90° CCW so the packed .bin's row - // layout matches the EPD's native 800-pixel scan order. The frame is - // physically rotated 90° CW for portrait (ribbon on right from EPD's - // POV → on left from user's view), so the photo's top edge maps to the - // EPD's left column. Firmware streams bytes raw — no orientation - // awareness on-device. - if ($orientation === Orientation::Portrait) { + // Rotate the fitted photo 90° CCW when the user's orientation differs + // from the panel's natural scan orientation, so the packed .bin's row + // layout always matches the panel's native scan order. Firmware streams + // bytes raw — no orientation awareness on-device. + // + // V1 (7.3", wide-native) rotates for Portrait. V2 (13.3", tall-native) + // rotates for Landscape. The model's nativeOrientation() makes the + // pipeline panel-agnostic — no per-model branches in this file. + if ($orientation !== $model->nativeOrientation()) { $imagick->rotateImage(new \ImagickPixel('white'), -90); } diff --git a/tests/Functional/Controller/DeviceImageControllerTest.php b/tests/Functional/Controller/DeviceImageControllerTest.php index e26c8da..d12404e 100644 --- a/tests/Functional/Controller/DeviceImageControllerTest.php +++ b/tests/Functional/Controller/DeviceImageControllerTest.php @@ -596,4 +596,61 @@ class DeviceImageControllerTest extends AppWebTestCase $this->assertResponseStatusCodeSame(204); } + + // Poll with X-Panel-Id matching a different DeviceModel must auto-update + // the device's model. New Devices are created with the V1 default, so a + // 13.3" unit ends up wrongly flagged until the controller corrects it. + public function test_x_panel_id_header_updates_device_model(): void + { + $setup = $this->createTestSetup(true, false); + + $this->assertSame(DeviceModel::V1, $setup['device']->getModel()); + + $this->client->request( + 'GET', + '/api/device/' . self::MAC . '/image', + [], + [], + ['HTTP_X_PANEL_ID' => 'waveshare-13.3-spectra6'], + ); + + $this->em()->refresh($setup['device']); + $this->assertSame(DeviceModel::V2, $setup['device']->getModel()); + } + + // Same-model X-Panel-Id is a no-op — no churn on every poll. + public function test_x_panel_id_header_matching_current_model_does_not_change(): void + { + $setup = $this->createTestSetup(true, false); + + $this->client->request( + 'GET', + '/api/device/' . self::MAC . '/image', + [], + [], + ['HTTP_X_PANEL_ID' => 'waveshare-7.3-spectra6'], + ); + + $this->em()->refresh($setup['device']); + $this->assertSame(DeviceModel::V1, $setup['device']->getModel()); + } + + // Unknown panel-id strings must be ignored — never silently drop a known + // device into an unknown state because firmware reported an unrecognised + // panel. + public function test_x_panel_id_header_unknown_leaves_model_alone(): void + { + $setup = $this->createTestSetup(true, false); + + $this->client->request( + 'GET', + '/api/device/' . self::MAC . '/image', + [], + [], + ['HTTP_X_PANEL_ID' => 'totally-fake-panel'], + ); + + $this->em()->refresh($setup['device']); + $this->assertSame(DeviceModel::V1, $setup['device']->getModel()); + } } diff --git a/tests/Unit/Enum/DeviceModelTest.php b/tests/Unit/Enum/DeviceModelTest.php index 04c858a..ced3564 100644 --- a/tests/Unit/Enum/DeviceModelTest.php +++ b/tests/Unit/Enum/DeviceModelTest.php @@ -11,7 +11,10 @@ use PHPUnit\Framework\TestCase; /** * width()/height() must follow orientation, but nativeWidth()/nativeHeight() * are the EPD's hardware scan dimensions and must NOT depend on orientation - * (the renderer pre-rotates portrait images and streams raw bytes). + * (the renderer pre-rotates non-native images and streams raw bytes). + * + * V1 panel is wide-native (800×480 landscape); V2 is tall-native (1200×1600 + * portrait). nativeOrientation() decides whether rendering rotates. */ class DeviceModelTest extends TestCase { @@ -27,12 +30,54 @@ class DeviceModelTest extends TestCase $this->assertSame(800, DeviceModel::V1->height(Orientation::Portrait)); } - public function test_native_dimensions_ignore_orientation(): void + public function test_v1_native_dimensions_ignore_orientation(): void { - // The firmware streams 800x480 EPD-native rows regardless of how the - // photo was framed; renderer rotates the input photo, then writes in - // EPD scan order. $this->assertSame(800, DeviceModel::V1->nativeWidth()); $this->assertSame(480, DeviceModel::V1->nativeHeight()); } + + public function test_v1_native_orientation_is_landscape(): void + { + $this->assertSame(Orientation::Landscape, DeviceModel::V1->nativeOrientation()); + } + + public function test_v2_portrait_dimensions_are_1200x1600(): void + { + $this->assertSame(1200, DeviceModel::V2->width(Orientation::Portrait)); + $this->assertSame(1600, DeviceModel::V2->height(Orientation::Portrait)); + } + + public function test_v2_landscape_dimensions_are_swapped(): void + { + $this->assertSame(1600, DeviceModel::V2->width(Orientation::Landscape)); + $this->assertSame(1200, DeviceModel::V2->height(Orientation::Landscape)); + } + + public function test_v2_native_dimensions_ignore_orientation(): void + { + $this->assertSame(1200, DeviceModel::V2->nativeWidth()); + $this->assertSame(1600, DeviceModel::V2->nativeHeight()); + } + + public function test_v2_native_orientation_is_portrait(): void + { + $this->assertSame(Orientation::Portrait, DeviceModel::V2->nativeOrientation()); + } + + public function test_panel_id_round_trips(): void + { + $this->assertSame(DeviceModel::V1, DeviceModel::fromPanelId(DeviceModel::V1->panelId())); + $this->assertSame(DeviceModel::V2, DeviceModel::fromPanelId(DeviceModel::V2->panelId())); + } + + public function test_panel_ids_are_distinct(): void + { + $this->assertNotSame(DeviceModel::V1->panelId(), DeviceModel::V2->panelId()); + } + + public function test_from_panel_id_returns_null_for_unknown(): void + { + $this->assertNull(DeviceModel::fromPanelId('not-a-real-panel')); + $this->assertNull(DeviceModel::fromPanelId('')); + } }