diff --git a/app/Jobs/CommonFunctions.php b/app/Jobs/CommonFunctions.php new file mode 100644 index 0000000..f8ede1b --- /dev/null +++ b/app/Jobs/CommonFunctions.php @@ -0,0 +1,81 @@ +toString(); + $pngPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.png'); + $bmpPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.bmp'); + + // Generate PNG + if (config('app.puppeteer_mode') === 'sidecar-aws') { + try { + BrowsershotLambda::html($markup) + ->windowSize($device->width ?? 800, $device->height ?? 480) + ->save($pngPath); + } catch (\Exception $e) { + throw new \RuntimeException('Failed to generate PNG: '.$e->getMessage(), 0, $e); + } + } else { + try { + Browsershot::html($markup) + ->setOption('args', config('app.puppeteer_docker') ? ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu'] : []) + ->windowSize($device->width ?? 800, $device->height ?? 480) + ->save($pngPath); + } catch (\Exception $e) { + throw new \RuntimeException('Failed to generate PNG: '.$e->getMessage(), 0, $e); + } + } + + try { + CommonFunctions::convertToBmpImageMagick($pngPath, $bmpPath); + } catch (\ImagickException $e) { + throw new \RuntimeException('Failed to convert image to BMP: '.$e->getMessage(), 0, $e); + } + return $uuid; + } + + /** + * @throws \ImagickException + */ + private static function convertToBmpImageMagick(string $pngPath, string $bmpPath): void + { + $imagick = new \Imagick($pngPath); + $imagick->setImageType(\Imagick::IMGTYPE_GRAYSCALE); + $imagick->quantizeImage(2, \Imagick::COLORSPACE_GRAY, 0, true, false); + $imagick->setImageDepth(1); + $imagick->stripImage(); + $imagick->setFormat('BMP3'); + $imagick->writeImage($bmpPath); + $imagick->clear(); + } + + public static function cleanupFolder(): void + { + $activeDeviceImageUuids = Device::pluck('current_screen_image')->filter()->toArray(); + $activePluginImageUuids = Plugin::pluck('current_image')->filter()->toArray(); + $activeImageUuids = array_merge($activeDeviceImageUuids, $activePluginImageUuids); + + $files = Storage::disk('public')->files('/images/generated/'); + foreach ($files as $file) { + if (basename($file) === '.gitignore') { + continue; + } + // Get filename without path and extension + $fileUuid = pathinfo($file, PATHINFO_FILENAME); + // If the UUID is not in use by any device, move it to archive + if (! in_array($fileUuid, $activeImageUuids)) { + Storage::disk('public')->delete($file); + } + } + } +} diff --git a/app/Jobs/GeneratePluginJob.php b/app/Jobs/GeneratePluginJob.php new file mode 100644 index 0000000..d1e254b --- /dev/null +++ b/app/Jobs/GeneratePluginJob.php @@ -0,0 +1,37 @@ +markup); + + Plugin::find($this->pluginId)->update(['current_image' => $newImageUuid]); + \Log::info("Plugin $this->pluginId: updated with new image: $newImageUuid"); + + CommonFunctions::cleanupFolder(); + } +} + diff --git a/app/Jobs/GenerateScreenJob.php b/app/Jobs/GenerateScreenJob.php index 4dcf876..166af8d 100644 --- a/app/Jobs/GenerateScreenJob.php +++ b/app/Jobs/GenerateScreenJob.php @@ -8,10 +8,6 @@ use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -use Illuminate\Support\Facades\Storage; -use Ramsey\Uuid\Uuid; -use Spatie\Browsershot\Browsershot; -use Wnx\SidecarBrowsershot\BrowsershotLambda; class GenerateScreenJob implements ShouldQueue { @@ -30,72 +26,11 @@ class GenerateScreenJob implements ShouldQueue */ public function handle(): void { - $device = Device::find($this->deviceId); - $uuid = Uuid::uuid4()->toString(); - $pngPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.png'); - $bmpPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.bmp'); + $newImageUuid = CommonFunctions::generateImage($this->markup); - // Generate PNG - if (config('app.puppeteer_mode') === 'sidecar-aws') { - try { - BrowsershotLambda::html($this->markup) - ->windowSize($device->width ?? 800, $device->height ?? 480) - ->save($pngPath); - } catch (\Exception $e) { - throw new \RuntimeException('Failed to generate PNG: '.$e->getMessage(), 0, $e); - } - } else { - try { - Browsershot::html($this->markup) - ->setOption('args', config('app.puppeteer_docker') ? ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu'] : []) - ->windowSize($device->width ?? 800, $device->height ?? 480) - ->save($pngPath); - } catch (\Exception $e) { - throw new \RuntimeException('Failed to generate PNG: '.$e->getMessage(), 0, $e); - } - } + Device::find($this->deviceId)->update(['current_screen_image' => $newImageUuid]); + \Log::info("Device $this->deviceId: updated with new image: $newImageUuid"); - try { - $this->convertToBmpImageMagick($pngPath, $bmpPath); - } catch (\ImagickException $e) { - throw new \RuntimeException('Failed to convert image to BMP: '.$e->getMessage(), 0, $e); - } - $device->update(['current_screen_image' => $uuid]); - \Log::info("Device $this->deviceId: updated with new image: $uuid"); - - $this->cleanupFolder(); - } - - /** - * @throws \ImagickException - */ - private function convertToBmpImageMagick(string $pngPath, string $bmpPath): void - { - $imagick = new \Imagick($pngPath); - $imagick->setImageType(\Imagick::IMGTYPE_GRAYSCALE); - $imagick->quantizeImage(2, \Imagick::COLORSPACE_GRAY, 0, true, false); - $imagick->setImageDepth(1); - $imagick->stripImage(); - $imagick->setFormat('BMP3'); - $imagick->writeImage($bmpPath); - $imagick->clear(); - } - - private function cleanupFolder(): void - { - $activeImageUuids = Device::pluck('current_screen_image')->filter()->toArray(); - - $files = Storage::disk('public')->files('/images/generated/'); - foreach ($files as $file) { - if (basename($file) === '.gitignore') { - continue; - } - // Get filename without path and extension - $fileUuid = pathinfo($file, PATHINFO_FILENAME); - // If the UUID is not in use by any device, move it to archive - if (! in_array($fileUuid, $activeImageUuids)) { - Storage::disk('public')->delete($file); - } - } + CommonFunctions::cleanupFolder(); } } diff --git a/app/Models/Plugin.php b/app/Models/Plugin.php index fa5dbd6..63ac7ba 100644 --- a/app/Models/Plugin.php +++ b/app/Models/Plugin.php @@ -17,6 +17,7 @@ class Plugin extends Model 'data_payload' => 'json', 'data_payload_updated_at' => 'datetime', 'is_native' => 'boolean', + 'current_image' => 'string', ]; protected static function boot() diff --git a/database/migrations/2025_05_10_182724_add_plugin_cache.php b/database/migrations/2025_05_10_182724_add_plugin_cache.php new file mode 100644 index 0000000..a24f436 --- /dev/null +++ b/database/migrations/2025_05_10_182724_add_plugin_cache.php @@ -0,0 +1,28 @@ +string('current_image')->nullable()->after('data_payload_updated_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('plugins', function (Blueprint $table) { + $table->dropColumn('current_image'); + }); + } +}; diff --git a/routes/api.php b/routes/api.php index f884059..50f0d12 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,6 +1,7 @@ mirrorDevice?->current_screen_image) { $refreshTimeOverride = null; + $nextPlaylistItem = $device->getNextPlaylistItem(); // Skip if cloud proxy is enabled for the device - if (! $device->proxy_cloud || $device->getNextPlaylistItem()) { - $playlistItem = $device->getNextPlaylistItem(); + if (! $device->proxy_cloud && $nextPlaylistItem) { + $refreshTimeOverride = $nextPlaylistItem->playlist()->first()->refresh_time; + $plugin = $nextPlaylistItem->plugin; - if ($playlistItem) { - $refreshTimeOverride = $playlistItem->playlist()->first()->refresh_time; + // Check and update stale data if needed + if ($plugin->isDataStale() || $plugin->current_image == null) { + $plugin->updateDataPayload(); - $plugin = $playlistItem->plugin; - - // Check and update stale data if needed - if ($plugin->isDataStale()) { - $plugin->updateDataPayload(); - } - - $playlistItem->update(['last_displayed_at' => now()]); if ($plugin->render_markup) { $markup = Blade::render($plugin->render_markup, ['data' => $plugin->data_payload]); } elseif ($plugin->render_markup_view) { $markup = view($plugin->render_markup_view, ['data' => $plugin->data_payload])->render(); } - GenerateScreenJob::dispatchSync($device->id, $markup); + GeneratePluginJob::dispatchSync($plugin->id, $markup); + } + + $plugin->refresh(); + + if ($plugin->current_image != null) + { + $nextPlaylistItem->update(['last_displayed_at' => now()]); + $device->update(['current_screen_image' => $plugin->current_image]); } } diff --git a/tests/Feature/Api/DeviceEndpointsTest.php b/tests/Feature/Api/DeviceEndpointsTest.php index 54d0514..f1b75e8 100644 --- a/tests/Feature/Api/DeviceEndpointsTest.php +++ b/tests/Feature/Api/DeviceEndpointsTest.php @@ -1,6 +1,9 @@ makeDirectory('/images/generated'); }); test('device can fetch display data with valid credentials', function () { @@ -464,3 +468,78 @@ test('authenticated user can fetch their devices', function () { ] ]); }); + +test('plugin doesn\'t update image unless required', 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' => false, + ]); + + $plugin = Plugin::factory()->create([ + 'name' => 'Zen Quotes', + 'polling_url' => 'https://zenquotes.io/api/today', + 'data_stale_minutes' => 1, + 'data_strategy' => 'polling', + 'polling_verb' => 'get', + 'render_markup_view' => 'recipes.zen', + 'is_native' => false, + 'data_payload_updated_at' => null, + ]); + + $playlist = Playlist::factory()->create([ + 'device_id' => $device->id, + 'name' => 'update_test', + 'is_active' => true, + 'weekdays' => null, + 'active_from' => null, + 'active_until' => null, + ]); + + PlaylistItem::factory()->create([ + 'playlist_id' => $playlist->id, + 'plugin_id' => $plugin->id, + 'order' => 1, + 'is_active' => true, + 'last_displayed_at' => null, + ]); + + // initial request, generates the image + $firstResponse = $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'); + + $firstResponse->assertOk(); + expect($firstResponse['filename'])->not->toBe('setup-logo.bmp'); + + // second request after 15 seconds, shouldn't generate a new image + $plugin->update(['data_payload_updated_at' => now()->addSeconds(-15)]); + $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'); + + expect($secondResponse['filename']) + ->toBe($firstResponse['filename']); + + // third request after 75 seconds, should generate a new image + $plugin->update(['data_payload_updated_at' => now()->addSeconds(-75)]); + $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'); + + expect($thirdResponse['filename']) + ->not->toBe($firstResponse['filename']); +})->skipOnGitHubActions();