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:
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(''));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user