feat(#16): refactor

This commit is contained in:
Benjamin Nussbaum 2025-05-12 07:19:03 +02:00
parent 580a5833a8
commit cc63c8cce2
9 changed files with 148 additions and 75 deletions

View file

@ -36,8 +36,7 @@ class ScreenGeneratorCommand extends Command
return 1; return 1;
} }
GenerateScreenJob::dispatchSync($deviceId, null, $markup);
GenerateScreenJob::dispatchSync($deviceId, $markup);
$this->info('Screen generation job finished.'); $this->info('Screen generation job finished.');

View file

@ -1,37 +0,0 @@
<?php
namespace App\Jobs;
use App\Models\Plugin;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class GeneratePluginJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Create a new job instance.
*/
public function __construct(
private readonly int $pluginId,
private readonly string $markup
) {}
/**
* Execute the job.
*/
public function handle(): void
{
$newImageUuid = CommonFunctions::generateImage($this->markup);
Plugin::find($this->pluginId)->update(['current_image' => $newImageUuid]);
\Log::info("Plugin $this->pluginId: updated with new image: $newImageUuid");
CommonFunctions::cleanupFolder();
}
}

View file

@ -3,6 +3,8 @@
namespace App\Jobs; namespace App\Jobs;
use App\Models\Device; use App\Models\Device;
use App\Models\Plugin;
use App\Services\ImageGenerationService;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
@ -18,6 +20,7 @@ class GenerateScreenJob implements ShouldQueue
*/ */
public function __construct( public function __construct(
private readonly int $deviceId, private readonly int $deviceId,
private readonly ?int $pluginId,
private readonly string $markup private readonly string $markup
) {} ) {}
@ -26,11 +29,16 @@ class GenerateScreenJob implements ShouldQueue
*/ */
public function handle(): void public function handle(): void
{ {
$newImageUuid = CommonFunctions::generateImage($this->markup); $newImageUuid = ImageGenerationService::generateImage($this->markup);
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"); \Log::info("Device $this->deviceId: updated with new image: $newImageUuid");
CommonFunctions::cleanupFolder(); if ($this->pluginId) {
// cache current image
Plugin::find($this->pluginId)->update(['current_image' => $newImageUuid]);
}
ImageGenerationService::cleanupFolder();
} }
} }

View file

@ -17,7 +17,6 @@ class Plugin extends Model
'data_payload' => 'json', 'data_payload' => 'json',
'data_payload_updated_at' => 'datetime', 'data_payload_updated_at' => 'datetime',
'is_native' => 'boolean', 'is_native' => 'boolean',
'current_image' => 'string',
]; ];
protected static function boot() protected static function boot()

View file

@ -1,6 +1,6 @@
<?php <?php
namespace App\Jobs; namespace App\Services;
use App\Models\Device; use App\Models\Device;
use App\Models\Plugin; use App\Models\Plugin;
@ -9,7 +9,7 @@ use Ramsey\Uuid\Uuid;
use Spatie\Browsershot\Browsershot; use Spatie\Browsershot\Browsershot;
use Wnx\SidecarBrowsershot\BrowsershotLambda; use Wnx\SidecarBrowsershot\BrowsershotLambda;
class CommonFunctions class ImageGenerationService
{ {
public static function generateImage(string $markup): string { public static function generateImage(string $markup): string {
$uuid = Uuid::uuid4()->toString(); $uuid = Uuid::uuid4()->toString();
@ -37,7 +37,7 @@ class CommonFunctions
} }
try { try {
CommonFunctions::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);
} }

View file

@ -33,7 +33,7 @@ new class extends Component {
try { try {
$rendered = Blade::render($this->blade_code); $rendered = Blade::render($this->blade_code);
foreach ($this->checked_devices as $device) { foreach ($this->checked_devices as $device) {
GenerateScreenJob::dispatchSync($device, $rendered); GenerateScreenJob::dispatchSync($device, null, $rendered);
} }
} catch (\Exception $e) { } catch (\Exception $e) {
$this->addError('error', $e->getMessage()); $this->addError('error', $e->getMessage());

View file

@ -1,7 +1,6 @@
<?php <?php
use App\Jobs\GenerateScreenJob; use App\Jobs\GenerateScreenJob;
use App\Jobs\GeneratePluginJob;
use App\Models\Device; use App\Models\Device;
use App\Models\User; use App\Models\User;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@ -47,31 +46,32 @@ Route::get('/display', function (Request $request) {
// Get current screen image from mirror device or continue if not available // Get current screen image from mirror device or continue if not available
if (! $image_uuid = $device->mirrorDevice?->current_screen_image) { if (! $image_uuid = $device->mirrorDevice?->current_screen_image) {
$refreshTimeOverride = null; $refreshTimeOverride = null;
$nextPlaylistItem = $device->getNextPlaylistItem();
// Skip if cloud proxy is enabled for the device // Skip if cloud proxy is enabled for the device
if (! $device->proxy_cloud && $nextPlaylistItem) { if (! $device->proxy_cloud || $device->getNextPlaylistItem()) {
$refreshTimeOverride = $nextPlaylistItem->playlist()->first()->refresh_time; $playlistItem = $device->getNextPlaylistItem();
$plugin = $nextPlaylistItem->plugin; if ($playlistItem) {
$refreshTimeOverride = $playlistItem->playlist()->first()->refresh_time;
$plugin = $playlistItem->plugin;
// Check and update stale data if needed // Check and update stale data if needed
if ($plugin->isDataStale() || $plugin->current_image == null) { if ($plugin->isDataStale() || $plugin->current_image == null) {
$plugin->updateDataPayload(); $plugin->updateDataPayload();
if ($plugin->render_markup) { if ($plugin->render_markup) {
$markup = Blade::render($plugin->render_markup, ['data' => $plugin->data_payload]); $markup = Blade::render($plugin->render_markup, ['data' => $plugin->data_payload]);
} elseif ($plugin->render_markup_view) { } elseif ($plugin->render_markup_view) {
$markup = view($plugin->render_markup_view, ['data' => $plugin->data_payload])->render(); $markup = view($plugin->render_markup_view, ['data' => $plugin->data_payload])->render();
}
GenerateScreenJob::dispatchSync($device->id, $plugin->id, $markup);
} }
GeneratePluginJob::dispatchSync($plugin->id, $markup); $plugin->refresh();
}
$plugin->refresh(); if ($plugin->current_image != null) {
$playlistItem->update(['last_displayed_at' => now()]);
if ($plugin->current_image != null) $device->update(['current_screen_image' => $plugin->current_image]);
{ }
$nextPlaylistItem->update(['last_displayed_at' => now()]);
$device->update(['current_screen_image' => $plugin->current_image]);
} }
} }
@ -198,11 +198,11 @@ Route::get('/devices', function (Request $request) {
'friendly_id', 'friendly_id',
'mac_address', 'mac_address',
'last_battery_voltage as battery_voltage', 'last_battery_voltage as battery_voltage',
'last_rssi_level as rssi' 'last_rssi_level as rssi',
]); ]);
return response()->json([ return response()->json([
'data' => $devices 'data' => $devices,
]); ]);
})->middleware('auth:sanctum'); })->middleware('auth:sanctum');
@ -217,7 +217,7 @@ Route::post('/display/update', function (Request $request) {
$view = Blade::render($request['markup']); $view = Blade::render($request['markup']);
GenerateScreenJob::dispatchSync($deviceId, $view); GenerateScreenJob::dispatchSync($deviceId, null, $view);
response()->json([ response()->json([
'message' => 'success', 'message' => 'success',

View file

@ -469,7 +469,7 @@ test('authenticated user can fetch their devices', function () {
]); ]);
}); });
test('plugin doesn\'t update image unless required', function () { test('plugin caches image until data is stale', function () {
// Create source device with a playlist // Create source device with a playlist
$device = Device::factory()->create([ $device = Device::factory()->create([
'mac_address' => '55:11:22:33:44:55', 'mac_address' => '55:11:22:33:44:55',
@ -479,11 +479,11 @@ test('plugin doesn\'t update image unless required', function () {
$plugin = Plugin::factory()->create([ $plugin = Plugin::factory()->create([
'name' => 'Zen Quotes', 'name' => 'Zen Quotes',
'polling_url' => 'https://zenquotes.io/api/today', 'polling_url' => null,
'data_stale_minutes' => 1, 'data_stale_minutes' => 1,
'data_strategy' => 'polling', 'data_strategy' => 'polling',
'polling_verb' => 'get', 'polling_verb' => 'get',
'render_markup_view' => 'recipes.zen', 'render_markup_view' => 'trmnl',
'is_native' => false, 'is_native' => false,
'data_payload_updated_at' => null, 'data_payload_updated_at' => null,
]); ]);
@ -543,3 +543,107 @@ test('plugin doesn\'t update image unless required', function () {
expect($thirdResponse['filename']) expect($thirdResponse['filename'])
->not->toBe($firstResponse['filename']); ->not->toBe($firstResponse['filename']);
})->skipOnGitHubActions(); })->skipOnGitHubActions();
test('plugins in playlist are rendered in order', function () {
// Create source device with a playlist
$device = Device::factory()->create([
'mac_address' => '55:11:22:33:44:55',
'api_key' => 'source-api-key',
'proxy_cloud' => true,
]);
// Create two plugins
$firstPlugin = Plugin::factory()->create([
'name' => 'First Plugin',
'polling_url' => null,
'data_stale_minutes' => 1,
'data_strategy' => 'polling',
'polling_verb' => 'get',
'render_markup_view' => 'trmnl',
'is_native' => false,
'data_payload_updated_at' => null,
]);
$secondPlugin = Plugin::factory()->create([
'name' => 'Second Plugin',
'polling_url' => null,
'data_stale_minutes' => 1,
'data_strategy' => 'polling',
'polling_verb' => 'get',
'render_markup_view' => 'trmnl',
'is_native' => false,
'data_payload_updated_at' => null,
]);
// Create playlist
$playlist = Playlist::factory()->create([
'device_id' => $device->id,
'name' => 'Two Plugins Test',
'is_active' => true,
'weekdays' => null,
'active_from' => null,
'active_until' => null,
]);
// Add plugins to playlist in specific order
PlaylistItem::factory()->create([
'playlist_id' => $playlist->id,
'plugin_id' => $firstPlugin->id,
'order' => 1,
'is_active' => true,
'last_displayed_at' => null,
]);
PlaylistItem::factory()->create([
'playlist_id' => $playlist->id,
'plugin_id' => $secondPlugin->id,
'order' => 2,
'is_active' => true,
'last_displayed_at' => null,
]);
// First request should show the first plugin
$firstResponse = $this->withHeaders([
'id' => $device->mac_address,
'access-token' => $device->api_key,
])->get('/api/display');
$firstResponse->assertOk();
$firstImageFilename = $firstResponse['filename'];
expect($firstImageFilename)->not->toBe('setup-logo.bmp');
// Get the first plugin's playlist item and verify it was marked as displayed
$firstPluginItem = PlaylistItem::where('plugin_id', $firstPlugin->id)->first();
expect($firstPluginItem->last_displayed_at)->not->toBeNull();
// Second request should show the second plugin
$secondResponse = $this->withHeaders([
'id' => $device->mac_address,
'access-token' => $device->api_key,
'rssi' => -70,
'battery_voltage' => 3.8,
'fw-version' => '1.0.0',
])->get('/api/display');
$secondResponse->assertOk();
expect($secondResponse['filename'])
->not->toBe($firstImageFilename)
->not->toBe('setup-logo.bmp');
// Get the second plugin's playlist item and verify it was marked as displayed
$secondPluginItem = PlaylistItem::where('plugin_id', $secondPlugin->id)->first();
expect($secondPluginItem->last_displayed_at)->not->toBeNull();
// Third request should show the first plugin again
$thirdResponse = $this->withHeaders([
'id' => $device->mac_address,
'access-token' => $device->api_key,
'rssi' => -70,
'battery_voltage' => 3.8,
'fw-version' => '1.0.0',
])->get('/api/display');
$thirdResponse->assertOk();
expect($thirdResponse['filename'])
->not->toBe($secondResponse['filename']);
})->skipOnGitHubActions();

View file

@ -13,7 +13,7 @@ beforeEach(function () {
test('it generates screen images and updates device', function () { test('it generates screen images and updates device', function () {
$device = Device::factory()->create(); $device = Device::factory()->create();
$job = new GenerateScreenJob($device->id, view('trmnl')->render()); $job = new GenerateScreenJob($device->id, null, view('trmnl')->render());
$job->handle(); $job->handle();
// Assert the device was updated with a new image UUID // Assert the device was updated with a new image UUID
@ -39,7 +39,7 @@ test('it cleans up unused images', function () {
Storage::disk('public')->put('/images/generated/inactive-uuid.bmp', 'test'); Storage::disk('public')->put('/images/generated/inactive-uuid.bmp', 'test');
// Run a job which will trigger cleanup // Run a job which will trigger cleanup
$job = new GenerateScreenJob($activeDevice->id, '<div>Test</div>'); $job = new GenerateScreenJob($activeDevice->id, null, '<div>Test</div>');
$job->handle(); $job->handle();
Storage::disk('public')->assertMissing('/images/generated/uuid-to-be-replaced.png'); Storage::disk('public')->assertMissing('/images/generated/uuid-to-be-replaced.png');
@ -52,7 +52,7 @@ test('it preserves gitignore file during cleanup', function () {
Storage::disk('public')->put('/images/generated/.gitignore', '*'); Storage::disk('public')->put('/images/generated/.gitignore', '*');
$device = Device::factory()->create(); $device = Device::factory()->create();
$job = new GenerateScreenJob($device->id, '<div>Test</div>'); $job = new GenerateScreenJob($device->id, null, '<div>Test</div>');
$job->handle(); $job->handle();
Storage::disk('public')->assertExists('/images/generated/.gitignore'); Storage::disk('public')->assertExists('/images/generated/.gitignore');