feat(devices): DeviceModel::V2 for Waveshare 13.3" Spectra-6
CI / test (push) Has been cancelled

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-13 15:53:59 -04:00
parent 2adb07518c
commit b286a1f241
5 changed files with 207 additions and 29 deletions
+22
View File
@@ -6,6 +6,7 @@ namespace App\Controller;
use App\Entity\Device; use App\Entity\Device;
use App\Entity\RenderedAsset; use App\Entity\RenderedAsset;
use App\Enum\DeviceModel;
use App\Enum\RenderStatus; use App\Enum\RenderStatus;
use App\Service\DeviceSerializer; use App\Service\DeviceSerializer;
use App\Service\MercurePublisher; use App\Service\MercurePublisher;
@@ -89,6 +90,27 @@ class DeviceImageController extends AbstractController
$currentImageId = (int) $request->headers->get('X-Current-Image-Id', '-1'); $currentImageId = (int) $request->headers->get('X-Current-Image-Id', '-1');
$device->markSeen(); $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 // Stamp when we expect the device to call back — the PWA reads this
// directly so its "next sync" label reflects the schedule the device // directly so its "next sync" label reflects the schedule the device
// is actually on, not the freshly-saved one that won't reach it // is actually on, not the freshly-saved one that won't reach it
+67 -15
View File
@@ -6,27 +6,79 @@ namespace App\Enum;
enum DeviceModel: string 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 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 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,
};
} }
} }
@@ -70,7 +70,7 @@ final class RenderImageMessageHandler
: $this->projectDir . '/' . $image->getStoragePath(); : $this->projectDir . '/' . $image->getStoragePath();
$width = $model->width($orientation); $width = $model->width($orientation);
$height = $model->height($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() $relPath = 'var/storage/images/' . $image->getId()
. '/' . $model->value . '_' . $orientation->value . '.bin'; . '/' . $model->value . '_' . $orientation->value . '.bin';
@@ -87,7 +87,7 @@ final class RenderImageMessageHandler
$this->em->flush(); $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 = new \Imagick($path);
$imagick->setImageAlphaChannel(\Imagick::ALPHACHANNEL_REMOVE); $imagick->setImageAlphaChannel(\Imagick::ALPHACHANNEL_REMOVE);
@@ -136,13 +136,15 @@ final class RenderImageMessageHandler
$imagick = $canvas; $imagick = $canvas;
} }
// Portrait: rotate the fitted photo 90° CCW so the packed .bin's row // Rotate the fitted photo 90° CCW when the user's orientation differs
// layout matches the EPD's native 800-pixel scan order. The frame is // from the panel's natural scan orientation, so the packed .bin's row
// physically rotated 90° CW for portrait (ribbon on right from EPD's // layout always matches the panel's native scan order. Firmware streams
// POV → on left from user's view), so the photo's top edge maps to the // bytes raw — no orientation awareness on-device.
// EPD's left column. Firmware streams bytes raw — no orientation //
// awareness on-device. // V1 (7.3", wide-native) rotates for Portrait. V2 (13.3", tall-native)
if ($orientation === Orientation::Portrait) { // 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); $imagick->rotateImage(new \ImagickPixel('white'), -90);
} }
@@ -596,4 +596,61 @@ class DeviceImageControllerTest extends AppWebTestCase
$this->assertResponseStatusCodeSame(204); $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());
}
} }
+50 -5
View File
@@ -11,7 +11,10 @@ use PHPUnit\Framework\TestCase;
/** /**
* width()/height() must follow orientation, but nativeWidth()/nativeHeight() * width()/height() must follow orientation, but nativeWidth()/nativeHeight()
* are the EPD's hardware scan dimensions and must NOT depend on orientation * 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 class DeviceModelTest extends TestCase
{ {
@@ -27,12 +30,54 @@ class DeviceModelTest extends TestCase
$this->assertSame(800, DeviceModel::V1->height(Orientation::Portrait)); $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(800, DeviceModel::V1->nativeWidth());
$this->assertSame(480, DeviceModel::V1->nativeHeight()); $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(''));
}
} }