diff --git a/app/Models/Device.php b/app/Models/Device.php index 2b8de5f..6d5993b 100644 --- a/app/Models/Device.php +++ b/app/Models/Device.php @@ -23,6 +23,10 @@ class Device extends Model 'height' => 'integer', 'rotate' => 'integer', 'last_refreshed_at' => 'datetime', + 'sleep_mode_enabled' => 'boolean', + 'sleep_mode_from' => 'datetime:H:i', + 'sleep_mode_to' => 'datetime:H:i', + 'special_function' => 'string', ]; public function getBatteryPercentAttribute() @@ -185,4 +189,36 @@ class Device extends Model { return $this->belongsTo(User::class); } + + public function isSleepModeActive(?\DateTimeInterface $now = null): bool + { + if (!$this->sleep_mode_enabled || !$this->sleep_mode_from || !$this->sleep_mode_to) { + return false; + } + $now = $now ? \Carbon\Carbon::instance($now) : now(); + $from = $this->sleep_mode_from instanceof \Carbon\Carbon ? $this->sleep_mode_from : \Carbon\Carbon::createFromFormat('H:i:s', $this->sleep_mode_from); + $to = $this->sleep_mode_to instanceof \Carbon\Carbon ? $this->sleep_mode_to : \Carbon\Carbon::createFromFormat('H:i:s', $this->sleep_mode_to); + // Handle overnight ranges (e.g. 22:00 to 06:00) + return $from < $to + ? $now->between($from, $to) + : ($now->gte($from) || $now->lte($to)); + } + + public function getSleepModeEndsInSeconds(?\DateTimeInterface $now = null): ?int + { + if (!$this->sleep_mode_enabled || !$this->sleep_mode_from || !$this->sleep_mode_to) { + return null; + } + + $now = $now ? \Carbon\Carbon::instance($now) : now(); + $from = $this->sleep_mode_from instanceof \Carbon\Carbon ? $this->sleep_mode_from : \Carbon\Carbon::createFromFormat('H:i:s', $this->sleep_mode_from); + $to = $this->sleep_mode_to instanceof \Carbon\Carbon ? $this->sleep_mode_to : \Carbon\Carbon::createFromFormat('H:i:s', $this->sleep_mode_to); + + // Handle overnight ranges (e.g. 22:00 to 06:00) + if ($from < $to) { + return $now->between($from, $to) ? $now->diffInSeconds($to, false) : null; + } else { + return ($now->gte($from) || $now->lt($to)) ? $now->diffInSeconds($to->addDay(), false) : null; + } + } } diff --git a/database/migrations/2025_07_08_191003_add_sleep_mode_and_special_function_to_devices_table.php b/database/migrations/2025_07_08_191003_add_sleep_mode_and_special_function_to_devices_table.php new file mode 100644 index 0000000..adc74e7 --- /dev/null +++ b/database/migrations/2025_07_08_191003_add_sleep_mode_and_special_function_to_devices_table.php @@ -0,0 +1,31 @@ +boolean('sleep_mode_enabled')->default(false); + $table->time('sleep_mode_from')->nullable(); + $table->time('sleep_mode_to')->nullable(); + $table->string('special_function')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('devices', function (Blueprint $table) { + $table->dropColumn(['sleep_mode_enabled', 'sleep_mode_from', 'sleep_mode_to', 'special_function']); + }); + } +}; diff --git a/resources/views/livewire/devices/configure.blade.php b/resources/views/livewire/devices/configure.blade.php index 3273524..4403d88 100644 --- a/resources/views/livewire/devices/configure.blade.php +++ b/resources/views/livewire/devices/configure.blade.php @@ -20,6 +20,12 @@ new class extends Component { public $rotate; public $image_format; + // Sleep mode and special function + public $sleep_mode_enabled = false; + public $sleep_mode_from; + public $sleep_mode_to; + public $special_function; + // Playlist properties public $playlists; public $playlist_name; @@ -53,6 +59,10 @@ new class extends Component { $this->playlists = $device->playlists()->with('items.plugin')->orderBy('created_at')->get(); $this->firmwares = \App\Models\Firmware::orderBy('latest', 'desc')->orderBy('created_at', 'desc')->get(); $this->selected_firmware_id = $this->firmwares->where('latest', true)->first()?->id; + $this->sleep_mode_enabled = $device->sleep_mode_enabled ?? false; + $this->sleep_mode_from = optional($device->sleep_mode_from)->format('H:i'); + $this->sleep_mode_to = optional($device->sleep_mode_to)->format('H:i'); + $this->special_function = $device->special_function; return view('livewire.devices.configure', [ 'image' => ($current_image_uuid) ? url($current_image_path) : null, @@ -80,6 +90,10 @@ new class extends Component { 'height' => 'required|integer|min:1', 'rotate' => 'required|integer|min:0|max:359', 'image_format' => 'required|string', + 'sleep_mode_enabled' => 'boolean', + 'sleep_mode_from' => 'nullable|date_format:H:i', + 'sleep_mode_to' => 'nullable|date_format:H:i', + 'special_function' => 'nullable|string', ]); $this->device->update([ @@ -91,6 +105,10 @@ new class extends Component { 'height' => $this->height, 'rotate' => $this->rotate, 'image_format' => $this->image_format, + 'sleep_mode_enabled' => $this->sleep_mode_enabled, + 'sleep_mode_from' => $this->sleep_mode_from, + 'sleep_mode_to' => $this->sleep_mode_to, + 'special_function' => $this->special_function, ]); Flux::modal('edit-device')->close(); @@ -332,6 +350,7 @@ new class extends Component { +
@@ -345,6 +364,28 @@ new class extends Component { + + + Identify + Sleep + Add WiFi + + + +
+ +
+
Sleep Mode
+
Enabling Sleep Mode extends battery life
+
+
+ @if($sleep_mode_enabled) +
+ + +
+ @endif +
diff --git a/routes/api.php b/routes/api.php index 250de76..33d8308 100644 --- a/routes/api.php +++ b/routes/api.php @@ -56,76 +56,82 @@ Route::get('/display', function (Request $request) { ]); } - // Get current screen image from a 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 ($device->isSleepModeActive()) { + $image_path = 'images/sleep.png'; + $filename = 'sleep.png'; + $refreshTimeOverride = $device->getSleepModeEndsInSeconds() ?? $device->default_refresh_interval; + } else { + // Get current screen image from a 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 && ! $playlistItem->isMashup()) { - $refreshTimeOverride = $playlistItem->playlist()->first()->refresh_time; - $plugin = $playlistItem->plugin; + if ($playlistItem && ! $playlistItem->isMashup()) { + $refreshTimeOverride = $playlistItem->playlist()->first()->refresh_time; + $plugin = $playlistItem->plugin; - // Reset cache if Devices with different dimensions exist - ImageGenerationService::resetIfNotCacheable($plugin); - - // Check and update stale data if needed - if ($plugin->isDataStale() || $plugin->current_image === null) { - $plugin->updateDataPayload(); - $markup = $plugin->render(); - - GenerateScreenJob::dispatchSync($device->id, $plugin->id, $markup); - } - - $plugin->refresh(); - - if ($plugin->current_image !== null) { - $playlistItem->update(['last_displayed_at' => now()]); - $device->update(['current_screen_image' => $plugin->current_image]); - } - } elseif ($playlistItem) { - $refreshTimeOverride = $playlistItem->playlist()->first()->refresh_time; - - // Get all plugins for the mashup - $plugins = Plugin::whereIn('id', $playlistItem->getMashupPluginIds())->get(); - - foreach ($plugins as $plugin) { // Reset cache if Devices with different dimensions exist ImageGenerationService::resetIfNotCacheable($plugin); + + // Check and update stale data if needed if ($plugin->isDataStale() || $plugin->current_image === null) { $plugin->updateDataPayload(); + $markup = $plugin->render(); + + GenerateScreenJob::dispatchSync($device->id, $plugin->id, $markup); + } + + $plugin->refresh(); + + if ($plugin->current_image !== null) { + $playlistItem->update(['last_displayed_at' => now()]); + $device->update(['current_screen_image' => $plugin->current_image]); + } + } elseif ($playlistItem) { + $refreshTimeOverride = $playlistItem->playlist()->first()->refresh_time; + + // Get all plugins for the mashup + $plugins = Plugin::whereIn('id', $playlistItem->getMashupPluginIds())->get(); + + foreach ($plugins as $plugin) { + // Reset cache if Devices with different dimensions exist + ImageGenerationService::resetIfNotCacheable($plugin); + if ($plugin->isDataStale() || $plugin->current_image === null) { + $plugin->updateDataPayload(); + } + } + + $markup = $playlistItem->render(); + GenerateScreenJob::dispatchSync($device->id, null, $markup); + + $device->refresh(); + + if ($device->current_screen_image !== null) { + $playlistItem->update(['last_displayed_at' => now()]); } } - - $markup = $playlistItem->render(); - GenerateScreenJob::dispatchSync($device->id, null, $markup); - - $device->refresh(); - - if ($device->current_screen_image !== null) { - $playlistItem->update(['last_displayed_at' => now()]); - } } - } - $device->refresh(); - $image_uuid = $device->current_screen_image; - } - if (! $image_uuid) { - $image_path = 'images/setup-logo.bmp'; - $filename = 'setup-logo.bmp'; - } else { - if (isset($device->last_firmware_version) - && version_compare($device->last_firmware_version, '1.5.2', '<') - && Storage::disk('public')->exists('images/generated/'.$image_uuid.'.bmp')) { - $image_path = 'images/generated/'.$image_uuid.'.bmp'; - } elseif (Storage::disk('public')->exists('images/generated/'.$image_uuid.'.png')) { - $image_path = 'images/generated/'.$image_uuid.'.png'; - } else { - $image_path = 'images/generated/'.$image_uuid.'.bmp'; + $device->refresh(); + $image_uuid = $device->current_screen_image; + } + if (! $image_uuid) { + $image_path = 'images/setup-logo.bmp'; + $filename = 'setup-logo.bmp'; + } else { + if (isset($device->last_firmware_version) + && version_compare($device->last_firmware_version, '1.5.2', '<') + && Storage::disk('public')->exists('images/generated/'.$image_uuid.'.bmp')) { + $image_path = 'images/generated/'.$image_uuid.'.bmp'; + } elseif (Storage::disk('public')->exists('images/generated/'.$image_uuid.'.png')) { + $image_path = 'images/generated/'.$image_uuid.'.png'; + } else { + $image_path = 'images/generated/'.$image_uuid.'.bmp'; + } + $filename = basename($image_path); } - $filename = basename($image_path); } $response = [ diff --git a/tests/Feature/Api/DeviceEndpointsTest.php b/tests/Feature/Api/DeviceEndpointsTest.php index 805fc92..7cecb65 100644 --- a/tests/Feature/Api/DeviceEndpointsTest.php +++ b/tests/Feature/Api/DeviceEndpointsTest.php @@ -757,3 +757,62 @@ test('display endpoint handles mashup playlist items correctly', function () { $playlistItem->refresh(); expect($playlistItem->last_displayed_at)->not->toBeNull(); })->skipOnGitHubActions(); + +test('device in sleep mode returns sleep image and correct refresh rate', function () { + $device = Device::factory()->create([ + 'mac_address' => '00:11:22:33:44:55', + 'api_key' => 'test-api-key', + 'sleep_mode_enabled' => true, + 'sleep_mode_from' => '19:00', + 'sleep_mode_to' => '23:00', + 'current_screen_image' => 'test-image', + ]); + + // Freeze time to 20:00 (within sleep window) + \Carbon\Carbon::setTestNow(\Carbon\Carbon::parse('2000-01-01 20:00:00')); + + $response = $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'); + + $response->assertOk() + ->assertJson([ + 'filename' => 'sleep.png', + ]); + expect($response['refresh_rate'])->toBeGreaterThan(0); + + \Carbon\Carbon::setTestNow(); // Clear test time +}); + +test('device not in sleep mode returns normal image', function () { + $device = Device::factory()->create([ + 'mac_address' => '00:11:22:33:44:55', + 'api_key' => 'test-api-key', + 'sleep_mode_enabled' => true, + 'sleep_mode_from' => '19:00', + 'sleep_mode_to' => '23:00', + 'current_screen_image' => 'test-image', + ]); + + // Freeze time to 18:00 (outside sleep window) + \Carbon\Carbon::setTestNow(\Carbon\Carbon::parse('2000-01-01 18:00:00')); + + $response = $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'); + + $response->assertOk() + ->assertJson([ + 'filename' => 'test-image.bmp', + ]); + + \Carbon\Carbon::setTestNow(); // Clear test time +});