mirror of
https://github.com/usetrmnl/byos_laravel.git
synced 2026-01-14 07:27:47 +00:00
feat(#18): added support for device mirroring
This commit is contained in:
parent
929e7fc4c0
commit
56210405ff
8 changed files with 186 additions and 37 deletions
|
|
@ -4,6 +4,7 @@ namespace App\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
class Device extends Model
|
class Device extends Model
|
||||||
|
|
@ -103,4 +104,14 @@ class Device extends Model
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function playlist(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Playlist::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mirrorDevice(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Device::class, 'mirror_device_id');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@ use Illuminate\Database\Migrations\Migration;
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
use Illuminate\Support\Facades\Schema;
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
return new class extends Migration {
|
return new class extends Migration
|
||||||
|
{
|
||||||
public function up(): void
|
public function up(): void
|
||||||
{
|
{
|
||||||
Schema::table('devices', function (Blueprint $table) {
|
Schema::table('devices', function (Blueprint $table) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('devices', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -44,7 +44,20 @@ new class extends Component {
|
||||||
|
|
||||||
<h1 class="text-xl font-medium dark:text-zinc-200">{{ $device->name }}</h1>
|
<h1 class="text-xl font-medium dark:text-zinc-200">{{ $device->name }}</h1>
|
||||||
<p class="text-sm dark:text-zinc-400">{{$device->mac_address}}</p>
|
<p class="text-sm dark:text-zinc-400">{{$device->mac_address}}</p>
|
||||||
@if($current_image_path)
|
@if($device->mirror_device_id)
|
||||||
|
<flux:separator class="mt-2 mb-4"/>
|
||||||
|
<flux:callout variant="info">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<flux:icon name="link" class="h-5 w-5"/>
|
||||||
|
<div>
|
||||||
|
This device is mirrored from
|
||||||
|
<a href="{{ route('devices.configure', $device->mirrorDevice) }}" class="font-medium hover:underline">
|
||||||
|
{{ $device->mirrorDevice->name }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</flux:callout>
|
||||||
|
@elseif($current_image_path)
|
||||||
<flux:separator class="mt-2 mb-4"/>
|
<flux:separator class="mt-2 mb-4"/>
|
||||||
<img src="{{ asset($current_image_path) }}" alt="Current Image"/>
|
<img src="{{ asset($current_image_path) }}" alt="Current Image"/>
|
||||||
@endif
|
@endif
|
||||||
|
|
|
||||||
|
|
@ -312,6 +312,7 @@ new class extends Component {
|
||||||
</flux:modal>
|
</flux:modal>
|
||||||
|
|
||||||
|
|
||||||
|
@if(!$device->mirror_device_id)
|
||||||
@if($current_image_path)
|
@if($current_image_path)
|
||||||
<flux:separator class="mt-6 mb-6" text="Next Screen"/>
|
<flux:separator class="mt-6 mb-6" text="Next Screen"/>
|
||||||
<img src="{{ asset($current_image_path) }}" alt="Next Image"/>
|
<img src="{{ asset($current_image_path) }}" alt="Next Image"/>
|
||||||
|
|
@ -325,6 +326,21 @@ new class extends Component {
|
||||||
<flux:button icon="plus" variant="primary">Create Playlist</flux:button>
|
<flux:button icon="plus" variant="primary">Create Playlist</flux:button>
|
||||||
</flux:modal.trigger>
|
</flux:modal.trigger>
|
||||||
</div>
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="mt-6 mb-6">
|
||||||
|
<flux:callout variant="info">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<flux:icon name="link" class="h-5 w-5"/>
|
||||||
|
<div>
|
||||||
|
This device is mirrored from
|
||||||
|
<a href="{{ route('devices.configure', $device->mirrorDevice) }}" class="font-medium hover:underline">
|
||||||
|
{{ $device->mirrorDevice->name }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</flux:callout>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
<flux:modal name="create-playlist" class="md:w-96">
|
<flux:modal name="create-playlist" class="md:w-96">
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,15 @@ new class extends Component {
|
||||||
|
|
||||||
public $friendly_id;
|
public $friendly_id;
|
||||||
|
|
||||||
|
public $is_mirror = false;
|
||||||
|
|
||||||
|
public $mirror_device_id = null;
|
||||||
|
|
||||||
protected $rules = [
|
protected $rules = [
|
||||||
'mac_address' => 'required',
|
'mac_address' => 'required',
|
||||||
'api_key' => 'required',
|
'api_key' => 'required',
|
||||||
'default_refresh_interval' => 'required|integer',
|
'default_refresh_interval' => 'required|integer',
|
||||||
|
'mirror_device_id' => 'required_if:is_mirror,true',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function mount()
|
public function mount()
|
||||||
|
|
@ -35,6 +40,13 @@ new class extends Component {
|
||||||
{
|
{
|
||||||
$this->validate();
|
$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([
|
Device::create([
|
||||||
'name' => $this->name,
|
'name' => $this->name,
|
||||||
'mac_address' => $this->mac_address,
|
'mac_address' => $this->mac_address,
|
||||||
|
|
@ -42,6 +54,7 @@ new class extends Component {
|
||||||
'default_refresh_interval' => $this->default_refresh_interval,
|
'default_refresh_interval' => $this->default_refresh_interval,
|
||||||
'friendly_id' => $this->friendly_id,
|
'friendly_id' => $this->friendly_id,
|
||||||
'user_id' => auth()->id(),
|
'user_id' => auth()->id(),
|
||||||
|
'mirror_device_id' => $this->is_mirror ? $this->mirror_device_id : null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->reset();
|
$this->reset();
|
||||||
|
|
@ -123,6 +136,24 @@ new class extends Component {
|
||||||
class="block mt-1 w-full" type="number" name="default_refresh_interval"
|
class="block mt-1 w-full" type="number" name="default_refresh_interval"
|
||||||
autofocus/>
|
autofocus/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<flux:checkbox wire:model.live="is_mirror" label="Mirrors Device" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if($is_mirror)
|
||||||
|
<div class="mb-4">
|
||||||
|
<flux:select wire:model="mirror_device_id" label="Select Device to Mirror">
|
||||||
|
<flux:select.option value="">Select a device</flux:select.option>
|
||||||
|
@foreach(auth()->user()->devices->where('mirror_device_id', null) as $device)
|
||||||
|
<flux:select.option value="{{ $device->id }}">
|
||||||
|
{{ $device->name }} ({{ $device->friendly_id }})
|
||||||
|
</flux:select.option>
|
||||||
|
@endforeach
|
||||||
|
</flux:select>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<flux:spacer/>
|
<flux:spacer/>
|
||||||
<flux:button type="submit" variant="primary">Create Device</flux:button>
|
<flux:button type="submit" variant="primary">Create Device</flux:button>
|
||||||
|
|
@ -193,7 +224,9 @@ new class extends Component {
|
||||||
position="bottom">
|
position="bottom">
|
||||||
<flux:switch wire:model.live="proxy_cloud"
|
<flux:switch wire:model.live="proxy_cloud"
|
||||||
wire:click="toggleProxyCloud({{ $device->id }})"
|
wire:click="toggleProxyCloud({{ $device->id }})"
|
||||||
:checked="$device->proxy_cloud" label="☁️ Proxy"/>
|
:checked="$device->proxy_cloud"
|
||||||
|
:disabled="$device->mirror_device_id !== null"
|
||||||
|
label="☁️ Proxy"/>
|
||||||
</flux:tooltip>
|
</flux:tooltip>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ use App\Jobs\GenerateScreenJob;
|
||||||
use App\Models\Device;
|
use App\Models\Device;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Blade;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
|
@ -41,8 +42,10 @@ Route::get('/display', function (Request $request) {
|
||||||
'last_firmware_version' => $request->header('fw-version'),
|
'last_firmware_version' => $request->header('fw-version'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Get current screen image from mirror device or continue if not available
|
||||||
|
if (! $image_uuid = $device->mirrorDevice?->current_screen_image) {
|
||||||
$refreshTimeOverride = null;
|
$refreshTimeOverride = null;
|
||||||
// Skip if cloud proxy is enabled for device
|
// Skip if cloud proxy is enabled for the device
|
||||||
if (! $device->proxy_cloud || $device->getNextPlaylistItem()) {
|
if (! $device->proxy_cloud || $device->getNextPlaylistItem()) {
|
||||||
$playlistItem = $device->getNextPlaylistItem();
|
$playlistItem = $device->getNextPlaylistItem();
|
||||||
|
|
||||||
|
|
@ -69,6 +72,7 @@ Route::get('/display', function (Request $request) {
|
||||||
|
|
||||||
$device->refresh();
|
$device->refresh();
|
||||||
$image_uuid = $device->current_screen_image;
|
$image_uuid = $device->current_screen_image;
|
||||||
|
}
|
||||||
if (! $image_uuid) {
|
if (! $image_uuid) {
|
||||||
$image_path = 'images/setup-logo.bmp';
|
$image_path = 'images/setup-logo.bmp';
|
||||||
$filename = 'setup-logo.bmp';
|
$filename = 'setup-logo.bmp';
|
||||||
|
|
|
||||||
|
|
@ -309,3 +309,45 @@ test('display status endpoint requires valid device_id', function () {
|
||||||
$response->assertStatus(422)
|
$response->assertStatus(422)
|
||||||
->assertJsonValidationErrors(['device_id']);
|
->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');
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue