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