From 56210405ff0f72bc8bb92c093e7dbec4ed917e3e Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Mon, 5 May 2025 18:06:54 +0200 Subject: [PATCH] feat(#18): added support for device mirroring --- app/Models/Device.php | 11 +++++ ...add_device_dimensions_to_devices_table.php | 3 +- ..._add_mirror_device_id_to_devices_table.php | 29 +++++++++++ .../views/livewire/device-dashboard.blade.php | 15 +++++- .../livewire/devices/configure.blade.php | 40 +++++++++++----- .../views/livewire/devices/manage.blade.php | 35 +++++++++++++- routes/api.php | 48 ++++++++++--------- tests/Feature/Api/DeviceEndpointsTest.php | 42 ++++++++++++++++ 8 files changed, 186 insertions(+), 37 deletions(-) create mode 100644 database/migrations/2025_05_05_174926_add_mirror_device_id_to_devices_table.php diff --git a/app/Models/Device.php b/app/Models/Device.php index 291a04c..065793b 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\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; class Device extends Model @@ -103,4 +104,14 @@ class Device extends Model return null; } + + public function playlist(): BelongsTo + { + return $this->belongsTo(Playlist::class); + } + + public function mirrorDevice(): BelongsTo + { + return $this->belongsTo(Device::class, 'mirror_device_id'); + } } diff --git a/database/migrations/2025_05_05_151823_add_device_dimensions_to_devices_table.php b/database/migrations/2025_05_05_151823_add_device_dimensions_to_devices_table.php index e3de199..00defd1 100644 --- a/database/migrations/2025_05_05_151823_add_device_dimensions_to_devices_table.php +++ b/database/migrations/2025_05_05_151823_add_device_dimensions_to_devices_table.php @@ -4,7 +4,8 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class extends Migration { +return new class extends Migration +{ public function up(): void { Schema::table('devices', function (Blueprint $table) { diff --git a/database/migrations/2025_05_05_174926_add_mirror_device_id_to_devices_table.php b/database/migrations/2025_05_05_174926_add_mirror_device_id_to_devices_table.php new file mode 100644 index 0000000..f19ea47 --- /dev/null +++ b/database/migrations/2025_05_05_174926_add_mirror_device_id_to_devices_table.php @@ -0,0 +1,29 @@ +foreignId('mirror_device_id')->nullable()->constrained('devices')->nullOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('devices', function (Blueprint $table) { + $table->dropForeign(['mirror_device_id']); + $table->dropColumn('mirror_device_id'); + }); + } +}; diff --git a/resources/views/livewire/device-dashboard.blade.php b/resources/views/livewire/device-dashboard.blade.php index 82ff49a..eb9d6cd 100644 --- a/resources/views/livewire/device-dashboard.blade.php +++ b/resources/views/livewire/device-dashboard.blade.php @@ -44,7 +44,20 @@ new class extends Component {

{{ $device->name }}

{{$device->mac_address}}

- @if($current_image_path) + @if($device->mirror_device_id) + + +
+ +
+ This device is mirrored from + + {{ $device->mirrorDevice->name }} + +
+
+
+ @elseif($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 f8be29b..4501732 100644 --- a/resources/views/livewire/devices/configure.blade.php +++ b/resources/views/livewire/devices/configure.blade.php @@ -312,20 +312,36 @@ new class extends Component { - @if($current_image_path) - - Next Image + @if(!$device->mirror_device_id) + @if($current_image_path) + + Next Image + @endif + + + +
+

Device Playlists

+ + Create Playlist + +
+ @else +
+ +
+ +
+ This device is mirrored from + + {{ $device->mirrorDevice->name }} + +
+
+
+
@endif - - -
-

Device Playlists

- - Create Playlist - -
-
diff --git a/resources/views/livewire/devices/manage.blade.php b/resources/views/livewire/devices/manage.blade.php index abc1b1c..ccda828 100644 --- a/resources/views/livewire/devices/manage.blade.php +++ b/resources/views/livewire/devices/manage.blade.php @@ -19,10 +19,15 @@ new class extends Component { public $friendly_id; + public $is_mirror = false; + + public $mirror_device_id = null; + protected $rules = [ 'mac_address' => 'required', 'api_key' => 'required', 'default_refresh_interval' => 'required|integer', + 'mirror_device_id' => 'required_if:is_mirror,true', ]; public function mount() @@ -35,6 +40,13 @@ new class extends Component { { $this->validate(); + if ($this->is_mirror) { + // Verify the mirror device belongs to the user and is not a mirror device itself + $mirrorDevice = auth()->user()->devices()->find($this->mirror_device_id); + abort_unless($mirrorDevice, 403, 'Invalid mirror device selected'); + abort_if($mirrorDevice->mirror_device_id !== null, 403, 'Cannot mirror a device that is already a mirror device'); + } + Device::create([ 'name' => $this->name, 'mac_address' => $this->mac_address, @@ -42,6 +54,7 @@ new class extends Component { 'default_refresh_interval' => $this->default_refresh_interval, 'friendly_id' => $this->friendly_id, 'user_id' => auth()->id(), + 'mirror_device_id' => $this->is_mirror ? $this->mirror_device_id : null, ]); $this->reset(); @@ -123,6 +136,24 @@ new class extends Component { class="block mt-1 w-full" type="number" name="default_refresh_interval" autofocus/>
+ +
+ +
+ + @if($is_mirror) +
+ + Select a device + @foreach(auth()->user()->devices->where('mirror_device_id', null) as $device) + + {{ $device->name }} ({{ $device->friendly_id }}) + + @endforeach + +
+ @endif +
Create Device @@ -193,7 +224,9 @@ new class extends Component { position="bottom"> + :checked="$device->proxy_cloud" + :disabled="$device->mirror_device_id !== null" + label="☁️ Proxy"/>
diff --git a/routes/api.php b/routes/api.php index 0d405fe..7d0e9cc 100644 --- a/routes/api.php +++ b/routes/api.php @@ -4,6 +4,7 @@ use App\Jobs\GenerateScreenJob; use App\Models\Device; use App\Models\User; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Route; use Illuminate\Support\Str; @@ -41,34 +42,37 @@ Route::get('/display', function (Request $request) { 'last_firmware_version' => $request->header('fw-version'), ]); - $refreshTimeOverride = null; - // Skip if cloud proxy is enabled for device - if (! $device->proxy_cloud || $device->getNextPlaylistItem()) { - $playlistItem = $device->getNextPlaylistItem(); + // Get current screen image from mirror device or continue if not available + if (! $image_uuid = $device->mirrorDevice?->current_screen_image) { + $refreshTimeOverride = null; + // Skip if cloud proxy is enabled for the device + if (! $device->proxy_cloud || $device->getNextPlaylistItem()) { + $playlistItem = $device->getNextPlaylistItem(); - if ($playlistItem) { - $refreshTimeOverride = $playlistItem->playlist()->first()->refresh_time; + if ($playlistItem) { + $refreshTimeOverride = $playlistItem->playlist()->first()->refresh_time; - $plugin = $playlistItem->plugin; + $plugin = $playlistItem->plugin; - // Check and update stale data if needed - if ($plugin->isDataStale()) { - $plugin->updateDataPayload(); + // 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); } - - $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); } - } - $device->refresh(); - $image_uuid = $device->current_screen_image; + $device->refresh(); + $image_uuid = $device->current_screen_image; + } if (! $image_uuid) { $image_path = 'images/setup-logo.bmp'; $filename = 'setup-logo.bmp'; diff --git a/tests/Feature/Api/DeviceEndpointsTest.php b/tests/Feature/Api/DeviceEndpointsTest.php index 09643f2..a887cb9 100644 --- a/tests/Feature/Api/DeviceEndpointsTest.php +++ b/tests/Feature/Api/DeviceEndpointsTest.php @@ -309,3 +309,45 @@ test('display status endpoint requires valid device_id', function () { $response->assertStatus(422) ->assertJsonValidationErrors(['device_id']); }); + +test('device can mirror another device', function () { + // Create source device with a playlist and image + $sourceDevice = Device::factory()->create([ + 'mac_address' => '00:11:22:33:44:55', + 'api_key' => 'source-api-key', + 'current_screen_image' => 'source-image', + ]); + + // Create mirroring device + $mirrorDevice = Device::factory()->create([ + 'mac_address' => 'AA:BB:CC:DD:EE:FF', + 'api_key' => 'mirror-api-key', + 'mirror_device_id' => $sourceDevice->id, + ]); + + // Make request from mirror device + $response = $this->withHeaders([ + 'id' => $mirrorDevice->mac_address, + 'access-token' => $mirrorDevice->api_key, + 'rssi' => -70, + 'battery_voltage' => 3.8, + 'fw-version' => '1.0.0', + ])->get('/api/display'); + + $response->assertOk() + ->assertJson([ + 'status' => '0', + 'filename' => 'source-image.bmp', + 'refresh_rate' => 900, + 'reset_firmware' => false, + 'update_firmware' => false, + 'firmware_url' => null, + 'special_function' => 'sleep', + ]); + + // Verify mirror device stats were updated + expect($mirrorDevice->fresh()) + ->last_rssi_level->toBe(-70) + ->last_battery_voltage->toBe(3.8) + ->last_firmware_version->toBe('1.0.0'); +});