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\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
+67 -15
View File
@@ -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,
};
}
}
@@ -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);
}