feat: prefer png format on firmware versions >=1.5.2

This commit is contained in:
Benjamin Nussbaum 2025-05-10 01:39:41 +02:00
parent cc63c8cce2
commit ad5ff5d2c9
12 changed files with 215 additions and 27 deletions

View file

@ -29,10 +29,9 @@ class GenerateScreenJob implements ShouldQueue
*/ */
public function handle(): void public function handle(): void
{ {
$newImageUuid = ImageGenerationService::generateImage($this->markup); $newImageUuid = ImageGenerationService::generateImage($this->markup, $this->deviceId);
Device::find($this->deviceId)->update(['current_screen_image' => $newImageUuid]); Device::find($this->deviceId)->update(['current_screen_image' => $newImageUuid]);
\Log::info("Device $this->deviceId: updated with new image: $newImageUuid");
if ($this->pluginId) { if ($this->pluginId) {
// cache current image // cache current image

View file

@ -17,6 +17,9 @@ class Device extends Model
'proxy_cloud' => 'boolean', 'proxy_cloud' => 'boolean',
'last_log_request' => 'json', 'last_log_request' => 'json',
'proxy_cloud_response' => 'json', 'proxy_cloud_response' => 'json',
'width' => 'integer',
'height' => 'integer',
'rotate' => 'integer',
]; ];
public function getBatteryPercentAttribute() public function getBatteryPercentAttribute()

View file

@ -25,7 +25,7 @@ class User extends Authenticatable // implements MustVerifyEmail
'email', 'email',
'password', 'password',
'assign_new_devices', 'assign_new_devices',
'assign_new_device_id' 'assign_new_device_id',
]; ];
/** /**

View file

@ -5,13 +5,16 @@ namespace App\Services;
use App\Models\Device; use App\Models\Device;
use App\Models\Plugin; use App\Models\Plugin;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use ImagickPixel;
use Ramsey\Uuid\Uuid; use Ramsey\Uuid\Uuid;
use Spatie\Browsershot\Browsershot; use Spatie\Browsershot\Browsershot;
use Wnx\SidecarBrowsershot\BrowsershotLambda; use Wnx\SidecarBrowsershot\BrowsershotLambda;
class ImageGenerationService class ImageGenerationService
{ {
public static function generateImage(string $markup): string { public static function generateImage(string $markup, $deviceId): string
{
$device = Device::find($deviceId);
$uuid = Uuid::uuid4()->toString(); $uuid = Uuid::uuid4()->toString();
$pngPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.png'); $pngPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.png');
$bmpPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.bmp'); $bmpPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.bmp');
@ -20,7 +23,7 @@ class ImageGenerationService
if (config('app.puppeteer_mode') === 'sidecar-aws') { if (config('app.puppeteer_mode') === 'sidecar-aws') {
try { try {
BrowsershotLambda::html($markup) BrowsershotLambda::html($markup)
->windowSize($device->width ?? 800, $device->height ?? 480) ->windowSize(800, 480)
->save($pngPath); ->save($pngPath);
} catch (\Exception $e) { } catch (\Exception $e) {
throw new \RuntimeException('Failed to generate PNG: '.$e->getMessage(), 0, $e); throw new \RuntimeException('Failed to generate PNG: '.$e->getMessage(), 0, $e);
@ -29,18 +32,30 @@ class ImageGenerationService
try { try {
Browsershot::html($markup) Browsershot::html($markup)
->setOption('args', config('app.puppeteer_docker') ? ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu'] : []) ->setOption('args', config('app.puppeteer_docker') ? ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu'] : [])
->windowSize($device->width ?? 800, $device->height ?? 480) ->windowSize(800, 480)
->save($pngPath); ->save($pngPath);
} catch (\Exception $e) { } catch (\Exception $e) {
throw new \RuntimeException('Failed to generate PNG: '.$e->getMessage(), 0, $e); throw new \RuntimeException('Failed to generate PNG: '.$e->getMessage(), 0, $e);
} }
} }
if (isset($device->last_firmware_version)
&& version_compare($device->last_firmware_version, '1.5.2', '<')) {
try { try {
ImageGenerationService::convertToBmpImageMagick($pngPath, $bmpPath); ImageGenerationService::convertToBmpImageMagick($pngPath, $bmpPath);
} catch (\ImagickException $e) { } catch (\ImagickException $e) {
throw new \RuntimeException('Failed to convert image to BMP: '.$e->getMessage(), 0, $e); throw new \RuntimeException('Failed to convert image to BMP: '.$e->getMessage(), 0, $e);
} }
} else {
try {
ImageGenerationService::convertToPngImageMagick($pngPath, $device->width, $device->height, $device->rotate);
} catch (\ImagickException $e) {
throw new \RuntimeException('Failed to convert image to PNG: '.$e->getMessage(), 0, $e);
}
}
$device->update(['current_screen_image' => $uuid]);
\Log::info("Device $device->id: updated with new image: $uuid");
return $uuid; return $uuid;
} }
@ -59,6 +74,28 @@ class ImageGenerationService
$imagick->clear(); $imagick->clear();
} }
/**
* @throws \ImagickException
*/
private static function convertToPngImageMagick(string $pngPath, ?int $width, ?int $height, ?int $rotate): void
{
$imagick = new \Imagick($pngPath);
if ($width !== 800 || $height !== 480) {
$imagick->resizeImage($width, $height, \Imagick::FILTER_LANCZOS, 1, true);
}
if ($rotate !== null && $rotate !== 0) {
$imagick->rotateImage(new ImagickPixel('black'), $rotate);
}
$imagick->setImageType(\Imagick::IMGTYPE_GRAYSCALE);
$imagick->quantizeImage(2, \Imagick::COLORSPACE_GRAY, 0, true, false);
$imagick->setImageDepth(8);
$imagick->stripImage();
$imagick->setFormat('png');
$imagick->writeImage($pngPath);
$imagick->clear();
}
public static function cleanupFolder(): void public static function cleanupFolder(): void
{ {
$activeDeviceImageUuids = Device::pluck('current_screen_image')->filter()->toArray(); $activeDeviceImageUuids = Device::pluck('current_screen_image')->filter()->toArray();

View file

@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('devices', function (Blueprint $table) {
$table->integer('rotate')->nullable()->default(0)->after('width');
});
}
public function down(): void
{
Schema::table('devices', function (Blueprint $table) {
$table->dropColumn('rotate');
});
}
};

View file

@ -59,7 +59,7 @@ new class extends Component {
</flux:callout> </flux:callout>
@elseif($current_image_path) @elseif($current_image_path)
<flux:separator class="mt-2 mb-4"/> <flux:separator class="mt-2 mb-4"/>
<img src="{{ asset($current_image_path) }}" alt="Current Image"/> <img src="{{ asset($current_image_path) }}" class="max-h-[480px]" alt="Current Image"/>
@endif @endif
</div> </div>
</div> </div>

View file

@ -15,6 +15,7 @@ new class extends Component {
public $default_refresh_interval; public $default_refresh_interval;
public $width; public $width;
public $height; public $height;
public $rotate;
// Playlist properties // Playlist properties
public $playlists; public $playlists;
@ -39,6 +40,7 @@ new class extends Component {
$this->default_refresh_interval = $device->default_refresh_interval; $this->default_refresh_interval = $device->default_refresh_interval;
$this->width = $device->width; $this->width = $device->width;
$this->height = $device->height; $this->height = $device->height;
$this->rotate = $device->rotate;
$this->playlists = $device->playlists()->with('items.plugin')->orderBy('created_at')->get(); $this->playlists = $device->playlists()->with('items.plugin')->orderBy('created_at')->get();
return view('livewire.devices.configure', [ return view('livewire.devices.configure', [
@ -65,6 +67,7 @@ new class extends Component {
'default_refresh_interval' => 'required|integer|min:1', 'default_refresh_interval' => 'required|integer|min:1',
'width' => 'required|integer|min:1', 'width' => 'required|integer|min:1',
'height' => 'required|integer|min:1', 'height' => 'required|integer|min:1',
'rotate' => 'required|integer|min:0|max:359',
]); ]);
$this->device->update([ $this->device->update([
@ -74,6 +77,7 @@ new class extends Component {
'default_refresh_interval' => $this->default_refresh_interval, 'default_refresh_interval' => $this->default_refresh_interval,
'width' => $this->width, 'width' => $this->width,
'height' => $this->height, 'height' => $this->height,
'rotate' => $this->rotate,
]); ]);
Flux::modal('edit-device')->close(); Flux::modal('edit-device')->close();
@ -215,7 +219,7 @@ new class extends Component {
<div class="flex flex-col gap-6"> <div class="flex flex-col gap-6">
<div <div
class="rounded-xl border bg-white dark:bg-stone-950 dark:border-stone-800 text-stone-800 shadow-xs"> class="rounded-xl border bg-white dark:bg-stone-950 dark:border-stone-800 text-stone-800 shadow-xs">
<div class="px-10 py-8 min-w-lg"> <div class="px-10 py-8">
@php @php
$current_image_uuid =$device->current_screen_image; $current_image_uuid =$device->current_screen_image;
if($current_image_uuid) { if($current_image_uuid) {
@ -226,7 +230,7 @@ new class extends Component {
} }
@endphp @endphp
<div class="flex items-center justify-between"> <div class="flex items-center justify-between gap-4">
<flux:tooltip content="Friendly ID: {{$device->friendly_id}}" position="bottom"> <flux:tooltip content="Friendly ID: {{$device->friendly_id}}" position="bottom">
<h1 class="text-xl font-medium dark:text-zinc-200">{{ $device->name }}</h1> <h1 class="text-xl font-medium dark:text-zinc-200">{{ $device->name }}</h1>
</flux:tooltip> </flux:tooltip>
@ -282,6 +286,7 @@ new class extends Component {
<div class="flex gap-4"> <div class="flex gap-4">
<flux:input label="Width (px)" wire:model="width" type="number" /> <flux:input label="Width (px)" wire:model="width" type="number" />
<flux:input label="Height (px)" wire:model="height" type="number"/> <flux:input label="Height (px)" wire:model="height" type="number"/>
<flux:input label="Rotate °" wire:model="rotate" type="number"/>
</div> </div>
<flux:input label="Default Refresh Interval (seconds)" wire:model="default_refresh_interval" <flux:input label="Default Refresh Interval (seconds)" wire:model="default_refresh_interval"
type="number"/> type="number"/>
@ -315,7 +320,7 @@ new class extends Component {
@if(!$device->mirror_device_id) @if(!$device->mirror_device_id)
@if($current_image_path) @if($current_image_path)
<flux:separator class="mt-6 mb-6" text="Next Screen"/> <flux:separator class="mt-6 mb-6" text="Next Screen"/>
<img src="{{ asset($current_image_path) }}" alt="Next Image"/> <img src="{{ asset($current_image_path) }}" class="max-h-[480px]" alt="Next Image"/>
@endif @endif
<flux:separator class="mt-6 mb-6" text="Playlists"/> <flux:separator class="mt-6 mb-6" text="Playlists"/>

View file

@ -82,9 +82,11 @@ Route::get('/display', function (Request $request) {
$image_path = 'images/setup-logo.bmp'; $image_path = 'images/setup-logo.bmp';
$filename = 'setup-logo.bmp'; $filename = 'setup-logo.bmp';
} else { } else {
if (file_exists(storage_path('app/public/images/generated/'.$image_uuid.'.bmp'))) { if (isset($device->last_firmware_version)
&& version_compare($device->last_firmware_version, '1.5.2', '<')
&& Storage::disk('public')->exists('images/generated/'.$image_uuid.'.bmp')) {
$image_path = 'images/generated/'.$image_uuid.'.bmp'; $image_path = 'images/generated/'.$image_uuid.'.bmp';
} elseif (file_exists(storage_path('app/public/images/generated/'.$image_uuid.'.png'))) { } elseif (Storage::disk('public')->exists('images/generated/'.$image_uuid.'.png')) {
$image_path = 'images/generated/'.$image_uuid.'.png'; $image_path = 'images/generated/'.$image_uuid.'.png';
} else { } else {
$image_path = 'images/generated/'.$image_uuid.'.bmp'; $image_path = 'images/generated/'.$image_uuid.'.bmp';
@ -106,7 +108,6 @@ Route::get('/display', function (Request $request) {
if (config('services.trmnl.image_url_timeout')) { if (config('services.trmnl.image_url_timeout')) {
$response['image_url_timeout'] = config('services.trmnl.image_url_timeout'); $response['image_url_timeout'] = config('services.trmnl.image_url_timeout');
} }
// If update_firmware is true, reset it after returning it, to avoid upgrade loop // If update_firmware is true, reset it after returning it, to avoid upgrade loop
if ($device->update_firmware) { if ($device->update_firmware) {
$device->resetUpdateFirmwareFlag(); $device->resetUpdateFirmwareFlag();
@ -271,9 +272,11 @@ Route::get('/current_screen', function (Request $request) {
$image_path = 'images/setup-logo.bmp'; $image_path = 'images/setup-logo.bmp';
$filename = 'setup-logo.bmp'; $filename = 'setup-logo.bmp';
} else { } else {
if (file_exists(storage_path('app/public/images/generated/'.$image_uuid.'.bmp'))) { if (isset($device->last_firmware_version)
&& version_compare($device->last_firmware_version, '1.5.2', '<')
&& Storage::disk('public')->exists('images/generated/'.$image_uuid.'.bmp')) {
$image_path = 'images/generated/'.$image_uuid.'.bmp'; $image_path = 'images/generated/'.$image_uuid.'.bmp';
} elseif (file_exists(storage_path('app/public/images/generated/'.$image_uuid.'.png'))) { } elseif (Storage::disk('public')->exists('images/generated/'.$image_uuid.'.png')) {
$image_path = 'images/generated/'.$image_uuid.'.png'; $image_path = 'images/generated/'.$image_uuid.'.png';
} else { } else {
$image_path = 'images/generated/'.$image_uuid.'.bmp'; $image_path = 'images/generated/'.$image_uuid.'.bmp';

View file

@ -448,9 +448,9 @@ test('authenticated user can fetch their devices', function () {
'friendly_id', 'friendly_id',
'mac_address', 'mac_address',
'battery_voltage', 'battery_voltage',
'rssi' 'rssi',
] ],
] ],
]) ])
->assertJsonCount(2, 'data'); ->assertJsonCount(2, 'data');
@ -463,9 +463,9 @@ test('authenticated user can fetch their devices', function () {
'friendly_id' => $devices[0]->friendly_id, 'friendly_id' => $devices[0]->friendly_id,
'mac_address' => $devices[0]->mac_address, 'mac_address' => $devices[0]->mac_address,
'battery_voltage' => 3.72, 'battery_voltage' => 3.72,
'rssi' => -63 'rssi' => -63,
] ],
] ],
]); ]);
}); });

View file

@ -0,0 +1,120 @@
<?php
use App\Models\Device;
use Illuminate\Support\Facades\Storage;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
beforeEach(function () {
Storage::fake('public');
Storage::disk('public')->makeDirectory('/images/generated');
});
test('device with firmware version 1.5.1 gets bmp format', function () {
$device = Device::factory()->create([
'mac_address' => '00:11:22:33:44:55',
'api_key' => 'test-api-key',
'current_screen_image' => 'test-image',
'last_firmware_version' => '1.5.1',
]);
// Create both bmp and png files
Storage::disk('public')->put('images/generated/test-image.bmp', 'fake bmp content');
Storage::disk('public')->put('images/generated/test-image.png', 'fake png content');
// Test /api/display endpoint
$displayResponse = $this->withHeaders([
'id' => $device->mac_address,
'access-token' => $device->api_key,
'rssi' => -70,
'battery_voltage' => 3.8,
'fw-version' => '1.5.1',
])->get('/api/display');
$displayResponse->assertOk()
->assertJson([
'filename' => 'test-image.bmp',
]);
// Test /api/current_screen endpoint
$currentScreenResponse = $this->withHeaders([
'access-token' => $device->api_key,
])->get('/api/current_screen');
$currentScreenResponse->assertOk()
->assertJson([
'filename' => 'test-image.bmp',
]);
});
test('device with firmware version 1.5.2 gets png format', function () {
$device = Device::factory()->create([
'mac_address' => '00:11:22:33:44:55',
'api_key' => 'test-api-key',
'current_screen_image' => 'test-image',
'last_firmware_version' => '1.5.2',
]);
// Create both bmp and png files
Storage::disk('public')->put('images/generated/test-image.png', 'fake bmp content');
// Test /api/display endpoint
$displayResponse = $this->withHeaders([
'id' => $device->mac_address,
'access-token' => $device->api_key,
'rssi' => -70,
'battery_voltage' => 3.8,
'fw-version' => '1.5.2',
])->get('/api/display');
$displayResponse->assertOk()
->assertJson([
'filename' => 'test-image.png',
]);
// Test /api/current_screen endpoint
$currentScreenResponse = $this->withHeaders([
'access-token' => $device->api_key,
])->get('/api/current_screen');
$currentScreenResponse->assertOk()
->assertJson([
'filename' => 'test-image.png',
]);
});
test('device falls back to bmp when png does not exist', function () {
$device = Device::factory()->create([
'mac_address' => '00:11:22:33:44:55',
'api_key' => 'test-api-key',
'current_screen_image' => 'test-image',
'last_firmware_version' => '1.5.2',
]);
// Create only bmp file
Storage::disk('public')->put('images/generated/test-image.bmp', 'fake bmp content');
// Test /api/display endpoint
$displayResponse = $this->withHeaders([
'id' => $device->mac_address,
'access-token' => $device->api_key,
'rssi' => -70,
'battery_voltage' => 3.8,
'fw-version' => '1.5.2',
])->get('/api/display');
$displayResponse->assertOk()
->assertJson([
'filename' => 'test-image.bmp',
]);
// Test /api/current_screen endpoint
$currentScreenResponse = $this->withHeaders([
'access-token' => $device->api_key,
])->get('/api/current_screen');
$currentScreenResponse->assertOk()
->assertJson([
'filename' => 'test-image.bmp',
]);
});

View file

@ -23,7 +23,6 @@ test('it generates screen images and updates device', function () {
// Assert both PNG and BMP files were created // Assert both PNG and BMP files were created
$uuid = $device->current_screen_image; $uuid = $device->current_screen_image;
Storage::disk('public')->assertExists("/images/generated/{$uuid}.png"); Storage::disk('public')->assertExists("/images/generated/{$uuid}.png");
Storage::disk('public')->assertExists("/images/generated/{$uuid}.bmp");
})->skipOnGitHubActions(); })->skipOnGitHubActions();
test('it cleans up unused images', function () { test('it cleans up unused images', function () {