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');
+});