feat: add sleep mode

This commit is contained in:
Benjamin Nussbaum 2025-07-04 21:47:55 +02:00
parent f767d39c8f
commit 4b748b102b
5 changed files with 232 additions and 59 deletions

View file

@ -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;
}
}
}

View file

@ -0,0 +1,31 @@
<?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->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']);
});
}
};

View file

@ -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 {
<flux:input label="Friendly ID" wire:model="friendly_id"/>
<flux:input label="MAC Address" wire:model="mac_address"/>
<flux:separator class="my-4" text="Advanced Device Settings" />
<div class="flex gap-4">
<flux:input label="Width (px)" wire:model="width" type="number" />
<flux:input label="Height (px)" wire:model="height" type="number"/>
@ -345,6 +364,28 @@ new class extends Component {
<flux:input label="Default Refresh Interval (seconds)" wire:model="default_refresh_interval"
type="number"/>
<flux:separator class="my-4" text="Special Functions" />
<flux:select label="Special Function" wire:model="special_function">
<flux:select.option value="identify">Identify</flux:select.option>
<flux:select.option value="sleep">Sleep</flux:select.option>
<flux:select.option value="add_wifi">Add WiFi</flux:select.option>
</flux:select>
<div class="flex items-center gap-4 mb-4">
<flux:switch wire:model.live="sleep_mode_enabled"/>
<div>
<div class="font-semibold">Sleep Mode</div>
<div class="text-zinc-500 text-sm">Enabling Sleep Mode extends battery life</div>
</div>
</div>
@if($sleep_mode_enabled)
<div class="flex gap-4 mb-4">
<flux:input type="time" label="From" wire:model="sleep_mode_from"/>
<flux:input type="time" label="To" wire:model="sleep_mode_to" />
</div>
@endif
<div class="flex">
<flux:spacer/>

View file

@ -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 = [

View file

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