mirror of
https://github.com/usetrmnl/byos_laravel.git
synced 2026-01-13 15:07:49 +00:00
feat: add function to pause screen generation for up to 480min
chore: code quality
This commit is contained in:
parent
4fb5f54e18
commit
7e355c2d92
10 changed files with 207 additions and 25 deletions
|
|
@ -2,6 +2,8 @@
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use DateTimeInterface;
|
||||||
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\BelongsTo;
|
||||||
|
|
@ -27,6 +29,7 @@ class Device extends Model
|
||||||
'sleep_mode_from' => 'datetime:H:i',
|
'sleep_mode_from' => 'datetime:H:i',
|
||||||
'sleep_mode_to' => 'datetime:H:i',
|
'sleep_mode_to' => 'datetime:H:i',
|
||||||
'special_function' => 'string',
|
'special_function' => 'string',
|
||||||
|
'pause_until' => 'datetime',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function getBatteryPercentAttribute()
|
public function getBatteryPercentAttribute()
|
||||||
|
|
@ -190,35 +193,41 @@ class Device extends Model
|
||||||
return $this->belongsTo(User::class);
|
return $this->belongsTo(User::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isSleepModeActive(?\DateTimeInterface $now = null): bool
|
public function isSleepModeActive(?DateTimeInterface $now = null): bool
|
||||||
{
|
{
|
||||||
if (!$this->sleep_mode_enabled || !$this->sleep_mode_from || !$this->sleep_mode_to) {
|
if (! $this->sleep_mode_enabled || ! $this->sleep_mode_from || ! $this->sleep_mode_to) {
|
||||||
return false;
|
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);
|
$now = $now ? Carbon::instance($now) : now();
|
||||||
$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)
|
// Handle overnight ranges (e.g. 22:00 to 06:00)
|
||||||
return $from < $to
|
return $this->sleep_mode_from < $this->sleep_mode_to
|
||||||
? $now->between($from, $to)
|
? $now->between($this->sleep_mode_from, $this->sleep_mode_to)
|
||||||
: ($now->gte($from) || $now->lte($to));
|
: ($now->gte($this->sleep_mode_from) || $now->lte($this->sleep_mode_to));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getSleepModeEndsInSeconds(?\DateTimeInterface $now = null): ?int
|
public function getSleepModeEndsInSeconds(?DateTimeInterface $now = null): ?int
|
||||||
{
|
{
|
||||||
if (!$this->sleep_mode_enabled || !$this->sleep_mode_from || !$this->sleep_mode_to) {
|
if (! $this->sleep_mode_enabled || ! $this->sleep_mode_from || ! $this->sleep_mode_to) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$now = $now ? \Carbon\Carbon::instance($now) : now();
|
$now = $now ? 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);
|
$from = $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);
|
$to = $this->sleep_mode_to;
|
||||||
|
|
||||||
// Handle overnight ranges (e.g. 22:00 to 06:00)
|
// Handle overnight ranges (e.g. 22:00 to 06:00)
|
||||||
if ($from < $to) {
|
if ($this->sleep_mode_from < $to) {
|
||||||
return $now->between($from, $to) ? $now->diffInSeconds($to, false) : null;
|
return $now->between($from, $to) ? (int) $now->diffInSeconds($to, false) : null;
|
||||||
} else {
|
|
||||||
return ($now->gte($from) || $now->lt($to)) ? $now->diffInSeconds($to->addDay(), false) : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return ($now->gte($from) || $now->lt($to)) ? (int) $now->diffInSeconds($to->addDay(), false) : null;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isPauseActive(): bool
|
||||||
|
{
|
||||||
|
return $this->pause_until && $this->pause_until->isFuture();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ class Playlist extends Model
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ use GuzzleHttp\Psr7\Response;
|
||||||
use Illuminate\Notifications\Notification;
|
use Illuminate\Notifications\Notification;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
|
|
||||||
class WebhookChannel
|
class WebhookChannel extends Notification
|
||||||
{
|
{
|
||||||
/** @var Client */
|
/** @var Client */
|
||||||
protected $client;
|
protected $client;
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@
|
||||||
|
|
||||||
namespace App\Notifications\Messages;
|
namespace App\Notifications\Messages;
|
||||||
|
|
||||||
final class WebhookMessage
|
use Illuminate\Notifications\Notification;
|
||||||
|
|
||||||
|
final class WebhookMessage extends Notification
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* The GET parameters of the request.
|
* The GET parameters of the request.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('devices', function (Blueprint $table) {
|
||||||
|
$table->dateTime('pause_until')->nullable()->after('last_refreshed_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('devices', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('pause_until');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -314,6 +314,13 @@ new class extends Component {
|
||||||
<flux:separator vertical/>
|
<flux:separator vertical/>
|
||||||
<x-responsive-icons.battery :percent="$device->batteryPercent"/>
|
<x-responsive-icons.battery :percent="$device->batteryPercent"/>
|
||||||
@endif
|
@endif
|
||||||
|
@if($device->isPauseActive())
|
||||||
|
<flux:separator vertical/>
|
||||||
|
<flux:tooltip content="Pause active until {{$device->pause_until?->format('H:i')}}"
|
||||||
|
position="bottom">
|
||||||
|
<flux:icon name="pause-circle" variant="solid"/>
|
||||||
|
</flux:tooltip>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<flux:modal.trigger name="edit-device">
|
<flux:modal.trigger name="edit-device">
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ new class extends Component {
|
||||||
|
|
||||||
public $mirror_device_id = null;
|
public $mirror_device_id = null;
|
||||||
|
|
||||||
|
public ?int $pause_duration;
|
||||||
|
|
||||||
protected $rules = [
|
protected $rules = [
|
||||||
'mac_address' => 'required',
|
'mac_address' => 'required',
|
||||||
'api_key' => 'required',
|
'api_key' => 'required',
|
||||||
|
|
@ -75,6 +77,20 @@ new class extends Component {
|
||||||
// \App\Jobs\FetchProxyCloudResponses::dispatch();
|
// \App\Jobs\FetchProxyCloudResponses::dispatch();
|
||||||
// }
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function pauseDevice($deviceId): void
|
||||||
|
{
|
||||||
|
$this->validate([
|
||||||
|
'pause_duration' => 'required|integer',
|
||||||
|
]);
|
||||||
|
$device = auth()->user()->devices()->findOrFail($deviceId);
|
||||||
|
$pauseUntil = now()->addMinutes($this->pause_duration);
|
||||||
|
$device->update(['pause_until' => $pauseUntil]);
|
||||||
|
$this->reset('pause_duration');
|
||||||
|
\Flux::modal('pause-device-' . $deviceId)->close();
|
||||||
|
$this->devices = auth()->user()->devices;
|
||||||
|
session()->flash('message', 'Device paused until ' . $pauseUntil->format('H:i'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
?>
|
?>
|
||||||
|
|
@ -93,7 +109,8 @@ new class extends Component {
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<flux:callout variant="success" icon="check-circle" heading=" {{ session('message') }}">
|
<flux:callout variant="success" icon="check-circle" heading=" {{ session('message') }}">
|
||||||
<x-slot name="controls">
|
<x-slot name="controls">
|
||||||
<flux:button icon="x-mark" variant="ghost" x-on:click="$el.closest('[data-flux-callout]').remove()" />
|
<flux:button icon="x-mark" variant="ghost"
|
||||||
|
x-on:click="$el.closest('[data-flux-callout]').remove()"/>
|
||||||
</x-slot>
|
</x-slot>
|
||||||
</flux:callout>
|
</flux:callout>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -138,7 +155,7 @@ new class extends Component {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<flux:checkbox wire:model.live="is_mirror" label="Mirrors Device" />
|
<flux:checkbox wire:model.live="is_mirror" label="Mirrors Device"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if($is_mirror)
|
@if($is_mirror)
|
||||||
|
|
@ -216,14 +233,27 @@ new class extends Component {
|
||||||
<td class="py-3 px-3 first:pl-0 last:pr-0 text-sm whitespace-nowrap font-medium text-zinc-800 dark:text-white"
|
<td class="py-3 px-3 first:pl-0 last:pr-0 text-sm whitespace-nowrap font-medium text-zinc-800 dark:text-white"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<flux:button href="{{ route('devices.configure', $device) }}" wire:navigate icon="eye">
|
<flux:button.group>
|
||||||
|
|
||||||
|
<flux:button href="{{ route('devices.configure', $device) }}" wire:navigate icon="eye" iconVariant="outline">
|
||||||
</flux:button>
|
</flux:button>
|
||||||
|
@if($device->isPauseActive())
|
||||||
|
<flux:tooltip content="Device paused until: {{ $device->pause_until?->format('H:i') }}">
|
||||||
|
<flux:button icon="pause-circle"/>
|
||||||
|
</flux:tooltip>
|
||||||
|
@else
|
||||||
|
<flux:modal.trigger name="pause-device-{{ $device->id }}">
|
||||||
|
<flux:button icon="pause-circle" iconVariant="outline">
|
||||||
|
</flux:button>
|
||||||
|
</flux:modal.trigger>
|
||||||
|
@endif
|
||||||
|
</flux:button.group>
|
||||||
|
|
||||||
<flux:tooltip
|
<flux:tooltip
|
||||||
content="Proxies images from the TRMNL Cloud service when no image is set (available in TRMNL DEV Edition only)."
|
content="Proxies images from the TRMNL Cloud service when no image is set (available in TRMNL DEV Edition only)."
|
||||||
position="bottom">
|
position="bottom">
|
||||||
<flux:switch wire:click="toggleProxyCloud({{ $device->id }})"
|
<flux:switch wire:click="toggleProxyCloud({{ $device->id }})"
|
||||||
:checked="$device->proxy_cloud"
|
:checked="$device->proxy_cloud"
|
||||||
:disabled="$device->mirror_device_id !== null"
|
:disabled="$device->mirror_device_id !== null"
|
||||||
label="☁️ Proxy"/>
|
label="☁️ Proxy"/>
|
||||||
</flux:tooltip>
|
</flux:tooltip>
|
||||||
|
|
@ -238,4 +268,34 @@ new class extends Component {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@foreach ($devices as $device)
|
||||||
|
<flux:modal name="pause-device-{{ $device->id }}">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<flux:heading size="lg">Pause</flux:heading>
|
||||||
|
<div class="text-sm text-zinc-500 mt-2">Select how long to pause screen generation for <span
|
||||||
|
class="font-semibold">{{ $device->name }}</span>.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form wire:submit="pauseDevice({{ $device->id }})">
|
||||||
|
<div class="mb-4">
|
||||||
|
<flux:radio.group wire:model.live="pause_duration" label="Pause Duration" variant="segmented">
|
||||||
|
<flux:radio value="30" label="30 min"/>
|
||||||
|
<flux:radio value="60" label="60 min"/>
|
||||||
|
<flux:radio value="120" label="120 min"/>
|
||||||
|
<flux:radio value="240" label="240 min"/>
|
||||||
|
<flux:radio value="480" label="480 min"/>
|
||||||
|
</flux:radio.group>
|
||||||
|
</div>
|
||||||
|
<div class="flex">
|
||||||
|
<flux:spacer/>
|
||||||
|
<flux:modal.close>
|
||||||
|
<flux:button variant="ghost">Cancel</flux:button>
|
||||||
|
</flux:modal.close>
|
||||||
|
<flux:button type="submit" variant="primary">Save</flux:button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</flux:modal>
|
||||||
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ new class extends Component {
|
||||||
|
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
<p>
|
<p>
|
||||||
<flux:badge>GET</flux:badge>
|
<flux:badge>GET</flux:badge><flux:badge>POST</flux:badge>
|
||||||
<span class="ml-2 font-mono">{{ route('display.status') }}?device_id={{ $selected_device }}</span>
|
<span class="ml-2 font-mono">{{ route('display.status') }}?device_id={{ $selected_device }}</span>
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
|
|
@ -88,6 +88,14 @@ new class extends Component {
|
||||||
</flux:button>
|
</flux:button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mt-4">
|
||||||
|
<h3 class="text-lg">Body <flux:badge size="sm">POST</flux:badge></h3>
|
||||||
|
<div class="font-mono">
|
||||||
|
<pre>
|
||||||
|
{"default_refresh_interval": 900, "sleep_mode_enabled": true, "pause_until": "2025-07-10T22:00:00+02:00"}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,11 @@ Route::get('/display', function (Request $request) {
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($device->isSleepModeActive()) {
|
if ($device->isPauseActive()) {
|
||||||
|
$image_path = 'images/sleep.png';
|
||||||
|
$filename = 'sleep.png';
|
||||||
|
$refreshTimeOverride = (int) now()->diffInSeconds($device->pause_until);
|
||||||
|
} elseif ($device->isSleepModeActive()) {
|
||||||
$image_path = 'images/sleep.png';
|
$image_path = 'images/sleep.png';
|
||||||
$filename = 'sleep.png';
|
$filename = 'sleep.png';
|
||||||
$refreshTimeOverride = $device->getSleepModeEndsInSeconds() ?? $device->default_refresh_interval;
|
$refreshTimeOverride = $device->getSleepModeEndsInSeconds() ?? $device->default_refresh_interval;
|
||||||
|
|
@ -293,6 +297,11 @@ Route::get('/display/status', function (Request $request) {
|
||||||
'wifi_strength',
|
'wifi_strength',
|
||||||
'current_screen_image',
|
'current_screen_image',
|
||||||
'default_refresh_interval',
|
'default_refresh_interval',
|
||||||
|
'sleep_mode_enabled',
|
||||||
|
'sleep_mode_from',
|
||||||
|
'sleep_mode_to',
|
||||||
|
'special_function',
|
||||||
|
'pause_until',
|
||||||
'updated_at',
|
'updated_at',
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|
@ -300,6 +309,48 @@ Route::get('/display/status', function (Request $request) {
|
||||||
->name('display.status')
|
->name('display.status')
|
||||||
->middleware('auth:sanctum');
|
->middleware('auth:sanctum');
|
||||||
|
|
||||||
|
Route::post('/display/status', function (Request $request) {
|
||||||
|
$request->validate([
|
||||||
|
'device_id' => 'required|exists:devices,id',
|
||||||
|
'name' => 'string|max:255',
|
||||||
|
'default_refresh_interval' => 'integer|min:1',
|
||||||
|
'sleep_mode_enabled' => 'boolean',
|
||||||
|
'sleep_mode_from' => 'nullable|date_format:H:i',
|
||||||
|
'sleep_mode_to' => 'nullable|date_format:H:i',
|
||||||
|
'pause_until' => 'nullable|date|after:now',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$deviceId = $request['device_id'];
|
||||||
|
abort_unless($request->user()->devices->contains($deviceId), 403);
|
||||||
|
|
||||||
|
$fieldsToUpdate = $request->only(['name', 'default_refresh_interval', 'sleep_mode_enabled', 'sleep_mode_from', 'sleep_mode_to', 'pause_until']);
|
||||||
|
Device::find($deviceId)->update($fieldsToUpdate);
|
||||||
|
|
||||||
|
return response()->json(
|
||||||
|
Device::find($deviceId)->only([
|
||||||
|
'id',
|
||||||
|
'mac_address',
|
||||||
|
'name',
|
||||||
|
'friendly_id',
|
||||||
|
'last_rssi_level',
|
||||||
|
'last_battery_voltage',
|
||||||
|
'last_firmware_version',
|
||||||
|
'battery_percent',
|
||||||
|
'wifi_strength',
|
||||||
|
'current_screen_image',
|
||||||
|
'default_refresh_interval',
|
||||||
|
'sleep_mode_enabled',
|
||||||
|
'sleep_mode_from',
|
||||||
|
'sleep_mode_to',
|
||||||
|
'special_function',
|
||||||
|
'pause_until',
|
||||||
|
'updated_at',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
->name('display.status.post')
|
||||||
|
->middleware('auth:sanctum');
|
||||||
|
|
||||||
Route::get('/current_screen', function (Request $request) {
|
Route::get('/current_screen', function (Request $request) {
|
||||||
$access_token = $request->header('access-token');
|
$access_token = $request->header('access-token');
|
||||||
$device = Device::where('api_key', $access_token)->first();
|
$device = Device::where('api_key', $access_token)->first();
|
||||||
|
|
|
||||||
|
|
@ -816,3 +816,25 @@ test('device not in sleep mode returns normal image', function () {
|
||||||
|
|
||||||
\Carbon\Carbon::setTestNow(); // Clear test time
|
\Carbon\Carbon::setTestNow(); // Clear test time
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('device returns sleep.png and correct refresh time when paused', function () {
|
||||||
|
$device = Device::factory()->create([
|
||||||
|
'mac_address' => '00:11:22:33:44:55',
|
||||||
|
'api_key' => 'test-api-key',
|
||||||
|
'pause_until' => now()->addMinutes(60),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$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();
|
||||||
|
$json = $response->json();
|
||||||
|
expect($json['filename'])->toBe('sleep.png');
|
||||||
|
expect($json['image_url'])->toContain('sleep.png');
|
||||||
|
expect($json['refresh_rate'])->toBeLessThanOrEqual(3600); // ~60 min
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue