From 41952694141a66e06670fff6b1dbc0369c0d93c0 Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Tue, 11 Mar 2025 22:34:28 +0100 Subject: [PATCH] feat: add plugin model initial implementation of playlist feat: added support for playlists --- app/Models/Device.php | 26 ++ app/Models/Playlist.php | 94 +++++ app/Models/PlaylistItem.php | 29 ++ app/Models/Plugin.php | 29 ++ database/factories/PlaylistFactory.php | 28 ++ database/factories/PlaylistItemFactory.php | 28 ++ ...25_03_12_151937_create_playlists_table.php | 33 ++ ..._12_191757_create_playlist_items_table.php | 32 ++ .../components/layouts/app/header.blade.php | 4 +- .../views/livewire/device-dashboard.blade.php | 10 +- .../livewire/devices/configure.blade.php | 12 +- .../views/livewire/devices/manage.blade.php | 10 +- .../views/livewire/plugins/index.blade.php | 2 +- .../views/livewire/plugins/receipt.blade.php | 325 ++++++++++++++++++ routes/api.php | 15 +- routes/web.php | 1 + tests/Pest.php | 6 + 17 files changed, 669 insertions(+), 15 deletions(-) create mode 100644 app/Models/Playlist.php create mode 100644 app/Models/PlaylistItem.php create mode 100644 app/Models/Plugin.php create mode 100644 database/factories/PlaylistFactory.php create mode 100644 database/factories/PlaylistItemFactory.php create mode 100644 database/migrations/2025_03_12_151937_create_playlists_table.php create mode 100644 database/migrations/2025_03_12_191757_create_playlist_items_table.php create mode 100644 resources/views/livewire/plugins/receipt.blade.php diff --git a/app/Models/Device.php b/app/Models/Device.php index d4b5745..a57c0ee 100644 --- a/app/Models/Device.php +++ b/app/Models/Device.php @@ -4,6 +4,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; class Device extends Model { @@ -50,4 +51,29 @@ class Device extends Model return 3; // Strong signal (3 bars) } } + + public function playlists(): HasMany + { + return $this->hasMany(Playlist::class); + } + + public function getNextPlaylistItem(): ?PlaylistItem + { + // Get all active playlists + $playlists = $this->playlists() + ->where('is_active', true) + ->get(); + + // Find the first active playlist with an available item + foreach ($playlists as $playlist) { + if ($playlist->isActiveNow()) { + $nextItem = $playlist->getNextPlaylistItem(); + if ($nextItem) { + return $nextItem; + } + } + } + + return null; + } } diff --git a/app/Models/Playlist.php b/app/Models/Playlist.php new file mode 100644 index 0000000..c3744e9 --- /dev/null +++ b/app/Models/Playlist.php @@ -0,0 +1,94 @@ + 'boolean', + 'weekdays' => 'array', + 'active_from' => 'datetime:H:i', + 'active_until' => 'datetime:H:i', + ]; + + public function device(): BelongsTo + { + return $this->belongsTo(Device::class); + } + + public function items(): HasMany + { + return $this->hasMany(PlaylistItem::class); + } + + public function isActiveNow(): bool + { + if (! $this->is_active) { + return false; + } + + // Check weekday + if ($this->weekdays !== null) { + if (! in_array(now()->dayOfWeek, $this->weekdays)) { + return false; + } + } + // Check time range + if ($this->active_from !== null && $this->active_until !== null) { + if (! now()->between($this->active_from, $this->active_until)) { + return false; + } + } + + return true; + } + + public function getNextPlaylistItem(): ?PlaylistItem + { + if (! $this->isActiveNow()) { + return null; + } + + // Get active playlist items ordered by display order + $playlistItems = $this->items() + ->where('is_active', true) + ->orderBy('order') + ->get(); + + if ($playlistItems->isEmpty()) { + return null; + } + + // Get the last displayed item + $lastDisplayed = $playlistItems + ->sortByDesc('last_displayed_at') + ->first(); + + if (! $lastDisplayed || ! $lastDisplayed->last_displayed_at) { + // If no item has been displayed yet, return the first one + return $playlistItems->first(); + } + + // Find the next item in sequence + $currentOrder = $lastDisplayed->order; + $nextItem = $playlistItems + ->where('order', '>', $currentOrder) + ->first(); + + // If there's no next item, loop back to the first one + if (! $nextItem) { + return $playlistItems->first(); + } + + return $nextItem; + } +} diff --git a/app/Models/PlaylistItem.php b/app/Models/PlaylistItem.php new file mode 100644 index 0000000..4eba877 --- /dev/null +++ b/app/Models/PlaylistItem.php @@ -0,0 +1,29 @@ + 'boolean', + 'last_displayed_at' => 'datetime', + ]; + + public function playlist(): BelongsTo + { + return $this->belongsTo(Playlist::class); + } + + public function plugin(): BelongsTo + { + return $this->belongsTo(Plugin::class); + } +} diff --git a/app/Models/Plugin.php b/app/Models/Plugin.php new file mode 100644 index 0000000..6042f23 --- /dev/null +++ b/app/Models/Plugin.php @@ -0,0 +1,29 @@ + 'json', + ]; + + protected static function boot() + { + parent::boot(); + + static::creating(function ($model) { + if (empty($model->uuid)) { + $model->uuid = Str::uuid(); + } + }); + } +} diff --git a/database/factories/PlaylistFactory.php b/database/factories/PlaylistFactory.php new file mode 100644 index 0000000..d955064 --- /dev/null +++ b/database/factories/PlaylistFactory.php @@ -0,0 +1,28 @@ + $this->faker->randomNumber(), + 'is_active' => $this->faker->boolean(), + 'last_displayed_at' => Carbon::now(), + 'created_at' => Carbon::now(), + 'updated_at' => Carbon::now(), + + 'device_id' => Device::factory(), + 'plugin_id' => Plugin::factory(), + ]; + } +} diff --git a/database/factories/PlaylistItemFactory.php b/database/factories/PlaylistItemFactory.php new file mode 100644 index 0000000..5e4fba5 --- /dev/null +++ b/database/factories/PlaylistItemFactory.php @@ -0,0 +1,28 @@ + $this->faker->randomNumber(), + 'is_active' => $this->faker->boolean(), + 'last_displayed_at' => Carbon::now(), + 'created_at' => Carbon::now(), + 'updated_at' => Carbon::now(), + + 'playlist_id' => Playlist::factory(), + 'plugin_id' => Plugin::factory(), + ]; + } +} diff --git a/database/migrations/2025_03_12_151937_create_playlists_table.php b/database/migrations/2025_03_12_151937_create_playlists_table.php new file mode 100644 index 0000000..2cc6570 --- /dev/null +++ b/database/migrations/2025_03_12_151937_create_playlists_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('device_id')->constrained()->onDelete('cascade'); + $table->string('name'); + $table->boolean('is_active')->default(true); + $table->json('weekdays')->nullable(); // Array of weekday numbers (0-6) + $table->time('active_from')->nullable(); + $table->time('active_until')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('playlists'); + } +}; diff --git a/database/migrations/2025_03_12_191757_create_playlist_items_table.php b/database/migrations/2025_03_12_191757_create_playlist_items_table.php new file mode 100644 index 0000000..366d8ae --- /dev/null +++ b/database/migrations/2025_03_12_191757_create_playlist_items_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('playlist_id')->constrained()->onDelete('cascade'); + $table->foreignId('plugin_id')->constrained()->onDelete('cascade'); + $table->integer('order')->default(0); + $table->boolean('is_active')->default(true); + $table->timestamp('last_displayed_at')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('playlist_items'); + } +}; diff --git a/resources/views/components/layouts/app/header.blade.php b/resources/views/components/layouts/app/header.blade.php index 513798d..18ff125 100644 --- a/resources/views/components/layouts/app/header.blade.php +++ b/resources/views/components/layouts/app/header.blade.php @@ -17,11 +17,11 @@ Dashboard + :current="request()->routeIs(['devices', 'devices.configure'])"> Devices + :current="request()->routeIs(['plugins.index', 'plugins.markup', 'plugins.api', 'plugins.receipt'])"> Plugins diff --git a/resources/views/livewire/device-dashboard.blade.php b/resources/views/livewire/device-dashboard.blade.php index 7afe67b..e83de90 100644 --- a/resources/views/livewire/device-dashboard.blade.php +++ b/resources/views/livewire/device-dashboard.blade.php @@ -34,13 +34,17 @@ new class extends Component {
@php $current_image_uuid =$device->current_screen_image; - file_exists('storage/images/generated/' . $current_image_uuid . '.png') ? $file_extension = 'png' : $file_extension = 'bmp'; - $current_image_path = 'storage/images/generated/' . $current_image_uuid . '.' . $file_extension; + if($current_image_uuid) { + file_exists('storage/images/generated/' . $current_image_uuid . '.png') ? $file_extension = 'png' : $file_extension = 'bmp'; + $current_image_path = 'storage/images/generated/' . $current_image_uuid . '.' . $file_extension; + } else { + $current_image_path = 'storage/images/setup-logo.bmp'; + } @endphp

{{ $device->name }}

{{$device->mac_address}}

- @if($current_image_uuid) + @if($current_image_path) Current Image @endif diff --git a/resources/views/livewire/devices/configure.blade.php b/resources/views/livewire/devices/configure.blade.php index 18ba84f..2ee3aad 100644 --- a/resources/views/livewire/devices/configure.blade.php +++ b/resources/views/livewire/devices/configure.blade.php @@ -68,8 +68,12 @@ new class extends Component {
@php $current_image_uuid =$device->current_screen_image; - file_exists('storage/images/generated/' . $current_image_uuid . '.png') ? $file_extension = 'png' : $file_extension = 'bmp'; - $current_image_path = 'storage/images/generated/' . $current_image_uuid . '.' . $file_extension; + if($current_image_uuid) { + file_exists('storage/images/generated/' . $current_image_uuid . '.png') ? $file_extension = 'png' : $file_extension = 'bmp'; + $current_image_path = 'storage/images/generated/' . $current_image_uuid . '.' . $file_extension; + } else { + $current_image_path = 'storage/images/setup-logo.bmp'; + } @endphp
@@ -125,7 +129,7 @@ new class extends Component { - +
@@ -153,7 +157,7 @@ new class extends Component { - @if($current_image_uuid) + @if($current_image_path) Next Image @endif diff --git a/resources/views/livewire/devices/manage.blade.php b/resources/views/livewire/devices/manage.blade.php index e3935d0..abc1b1c 100644 --- a/resources/views/livewire/devices/manage.blade.php +++ b/resources/views/livewire/devices/manage.blade.php @@ -77,8 +77,12 @@ new class extends Component {
@if (session()->has('message')) -
- {{ session('message') }} +
+ + + + +
@endif @@ -116,7 +120,7 @@ new class extends Component {
diff --git a/resources/views/livewire/plugins/index.blade.php b/resources/views/livewire/plugins/index.blade.php index 88138fa..240e4f9 100644 --- a/resources/views/livewire/plugins/index.blade.php +++ b/resources/views/livewire/plugins/index.blade.php @@ -124,7 +124,7 @@ new class extends Component { @foreach($plugins as $plugin)
- +

{{$plugin['name']}}

diff --git a/resources/views/livewire/plugins/receipt.blade.php b/resources/views/livewire/plugins/receipt.blade.php new file mode 100644 index 0000000..24f5bf1 --- /dev/null +++ b/resources/views/livewire/plugins/receipt.blade.php @@ -0,0 +1,325 @@ +user()->plugins->contains($this->plugin), 403); + $this->blade_code = $this->plugin->render_markup; + + $this->fillformFields(); + } + + public function fillFormFields(): void + { + $this->name = $this->plugin->name; + $this->data_stale_minutes = $this->plugin->data_stale_minutes; + $this->data_strategy = $this->plugin->data_strategy; + $this->polling_url = $this->plugin->polling_url; + $this->polling_verb = $this->plugin->polling_verb; + $this->polling_header = $this->plugin->polling_header; + $this->data_payload = json_encode($this->plugin->data_payload); + } + + public function saveMarkup(): void + { + abort_unless(auth()->user()->plugins->contains($this->plugin), 403); + $this->validate(); + $this->plugin->update(['render_markup' => $this->blade_code]); + } + + protected array $rules = [ + 'name' => 'required|string|max:255', + 'data_stale_minutes' => 'required|integer|min:1', + 'data_strategy' => 'required|string|in:polling,webhook', + 'polling_url' => 'required|url', + 'polling_verb' => 'required|string|in:get,post', + 'polling_header' => 'nullable|string|max:255', + 'blade_code' => 'nullable|string', + 'checked_devices' => 'array', + 'playlist_name' => 'required_if:selected_playlist,new|string|max:255', + 'selected_weekdays' => 'array', + 'active_from' => 'nullable|date_format:H:i', + 'active_until' => 'nullable|date_format:H:i', + 'selected_playlist' => 'nullable|string', + ]; + + public function editSettings() + { + abort_unless(auth()->user()->plugins->contains($this->plugin), 403); + $validated = $this->validate(); + $this->plugin->update($validated); + } + + public function updateData() + { + if ($this->plugin->data_strategy === 'polling') { + $response = Http::get($this->plugin->polling_url)->json(); + $this->plugin->update(['data_payload' => $response]); + + $this->data_payload = json_encode($response); + } + } + + public function addToPlaylist() + { + $this->validate([ + 'checked_devices' => 'required|array|min:1', + 'selected_playlist' => 'required|string', + ]); + + foreach ($this->checked_devices as $deviceId) { + $playlist = null; + + if ($this->selected_playlist === 'new') { + // Create new playlist + $this->validate([ + 'playlist_name' => 'required|string|max:255', + ]); + + $playlist = \App\Models\Playlist::create([ + 'device_id' => $deviceId, + 'name' => $this->playlist_name, + 'weekdays' => !empty($this->selected_weekdays) ? $this->selected_weekdays : null, + 'active_from' => $this->active_from ?: null, + 'active_until' => $this->active_until ?: null, + ]); + } else { + $playlist = \App\Models\Playlist::findOrFail($this->selected_playlist); + } + + // Add plugin to playlist + $maxOrder = $playlist->items()->max('order') ?? 0; + $playlist->items()->create([ + 'plugin_id' => $this->plugin->id, + 'order' => $maxOrder + 1, + ]); + } + + $this->reset(['checked_devices', 'playlist_name', 'selected_weekdays', 'active_from', 'active_until', 'selected_playlist']); + Flux::modal('add-plugin')->close(); + } + + public function getDevicePlaylists($deviceId) + { + return \App\Models\Playlist::where('device_id', $deviceId)->get(); + } + + public function renderExample(string $example) + { + switch ($example) { + case 'layoutTitle': + $markup = $this->renderLayoutWithTitleBar(); + break; + case 'layout': + $markup = $this->renderLayoutBlank(); + break; + default: + $markup = '

Hello World!

'; + break; + } + $this->blade_code = $markup; + } + + public function renderLayoutWithTitleBar(): string + { + return << + + + + + +HTML; + } + + public function renderLayoutBlank(): string + { + return << + + + + +HTML; + } +} + +?> + +
diff --git a/routes/api.php b/routes/api.php index 48f3c9e..c402c3a 100644 --- a/routes/api.php +++ b/routes/api.php @@ -8,7 +8,6 @@ use Illuminate\Support\Facades\Route; use Illuminate\Support\Str; Route::get('/display', function (Request $request) { - $mac_address = $request->header('id'); $access_token = $request->header('access-token'); $device = Device::where('mac_address', $mac_address) @@ -42,6 +41,18 @@ Route::get('/display', function (Request $request) { 'last_firmware_version' => $request->header('fw-version'), ]); + // Skip if cloud proxy is enabled for device + if (! $device->proxy_cloud) { + $playlistItem = $device->getNextPlaylistItem(); + + if ($playlistItem) { + $playlistItem->update(['last_displayed_at' => now()]); + $markup = Blade::render($playlistItem->plugin->render_markup, ['data' => $playlistItem->plugin->data_payload]); + + GenerateScreenJob::dispatchSync($device->id, $markup); + } + } + $image_uuid = $device->current_screen_image; if (! $image_uuid) { $image_path = 'images/setup-logo.bmp'; @@ -55,7 +66,7 @@ Route::get('/display', function (Request $request) { 'status' => '0', 'image_url' => url('storage/'.$image_path), 'filename' => $filename, - 'refresh_rate' => 900, + 'refresh_rate' => $device->default_refresh_interval, 'reset_firmware' => false, 'update_firmware' => false, 'firmware_url' => null, diff --git a/routes/web.php b/routes/web.php index 355b892..8738bb5 100644 --- a/routes/web.php +++ b/routes/web.php @@ -20,6 +20,7 @@ Route::middleware(['auth'])->group(function () { Volt::route('plugins', 'plugins.index')->name('plugins.index'); + Volt::route('plugins/receipt/{plugin}', 'plugins.receipt')->name('plugins.receipt'); Volt::route('plugins/markup', 'plugins.markup')->name('plugins.markup'); Volt::route('plugins/api', 'plugins.api')->name('plugins.api'); }); diff --git a/tests/Pest.php b/tests/Pest.php index b93193e..bf47dd2 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -16,6 +16,12 @@ pest()->extend(Tests\TestCase::class) ->in('Feature'); registerSpatiePestHelpers(); + +arch()->preset()->laravel(); + +arch() + ->expect('App') + ->not->toUse(['die', 'dd', 'dump']); /* |-------------------------------------------------------------------------- | Expectations