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
@@ -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());
}
}
+50 -5
View File
@@ -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(''));
}
}