Tablet control panel: touch tiles, drag-reorder, light colour modal

New "Steuerung" page for tablet control:
- Large touch tiles for every light/switch — tap toggles (through
  DeviceCommandService), highlighted when on, live via Echo.
- Drag-to-reorder via SortableJS (handle per tile); order persisted to
  entities.panel_sort.
- Lights get a colour/brightness modal → Light.Set (brightness + rgb) on the
  Shelly, with preset swatches + a custom colour picker. Driver contract gains
  setLight(); command service audits it (H1).

Nav check 11/11 tabs clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/phase-1-bootstrap
HomeOS Bootstrap 2026-07-18 00:58:24 +02:00
parent a4edbbf801
commit 124e667a0a
19 changed files with 318 additions and 4 deletions

View File

@ -0,0 +1,68 @@
<?php
namespace App\Livewire\Modals;
use App\Models\Entity;
use App\Services\DeviceCommandService;
use Livewire\Attributes\Computed;
use LivewireUI\Modal\ModalComponent;
class LightControl extends ModalComponent
{
public string $entityUuid = '';
public int $brightness = 100;
public string $color = '#FFD9A0';
public static function modalMaxWidth(): string
{
return 'md';
}
public function mount(string $entity): void
{
$model = Entity::where('uuid', $entity)->firstOrFail();
$this->entityUuid = $model->uuid;
$state = $model->state?->state ?? [];
$this->brightness = (int) ($state['brightness'] ?? 100);
if (isset($state['rgb']) && is_array($state['rgb'])) {
$rgb = array_pad(array_map('intval', $state['rgb']), 3, 255);
$this->color = sprintf('#%02X%02X%02X', $rgb[0], $rgb[1], $rgb[2]);
}
}
#[Computed]
public function entity(): Entity
{
return Entity::with('device', 'state')->where('uuid', $this->entityUuid)->firstOrFail();
}
public function apply(): void
{
$rgb = sscanf($this->color, '#%02x%02x%02x');
app(DeviceCommandService::class)->setLight($this->entity(), [
'on' => true,
'brightness' => $this->brightness,
'rgb' => $rgb,
]);
$this->closeModal();
}
public function turnOff(): void
{
app(DeviceCommandService::class)->setOn($this->entity(), false);
$this->closeModal();
}
public function render()
{
return view('livewire.modals.light-control', [
'presets' => ['#FFD9A0', '#FFFFFF', '#FF6B6B', '#4FC1FF', '#34D399', '#A78BFA', '#FBBF24'],
]);
}
}

50
app/Livewire/Panel.php Normal file
View File

@ -0,0 +1,50 @@
<?php
namespace App\Livewire;
use App\Models\Entity;
use App\Services\DeviceCommandService;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
/**
* Tablet control panel big touch tiles for lights/switches with quick on/off,
* a colour/brightness modal for lights, and drag-to-reorder (persisted per entity).
*/
#[Layout('layouts.app')]
class Panel extends Component
{
#[On('echo-private:home,.DeviceStateChanged')]
public function onDeviceStateChanged(): void {}
public function toggle(int $entityId): void
{
$entity = Entity::with('device', 'state')->find($entityId);
if ($entity !== null && in_array($entity->type, ['switch', 'light'], true)) {
app(DeviceCommandService::class)->toggle($entity);
}
}
/** Persist a new tile order from the drag interaction. */
public function reorder(array $order): void
{
foreach (array_values($order) as $index => $uuid) {
Entity::where('uuid', $uuid)->update(['panel_sort' => $index]);
}
}
public function render()
{
$tiles = Entity::query()
->whereIn('type', ['switch', 'light'])
->where('panel_hidden', false)
->with('device.room', 'state')
->orderByRaw('panel_sort IS NULL, panel_sort')
->orderBy('id')
->get();
return view('livewire.panel', ['tiles' => $tiles]);
}
}

View File

@ -11,9 +11,9 @@ class Entity extends Model
{ {
use HasUuid; use HasUuid;
protected $fillable = ['uuid', 'device_id', 'type', 'key', 'name', 'capabilities']; protected $fillable = ['uuid', 'device_id', 'type', 'key', 'name', 'capabilities', 'panel_sort', 'panel_hidden'];
protected $casts = ['capabilities' => 'array']; protected $casts = ['capabilities' => 'array', 'panel_hidden' => 'boolean'];
public function device(): BelongsTo public function device(): BelongsTo
{ {

View File

@ -39,6 +39,11 @@ class DeviceCommandService
return $this->run($device, null, 'reboot', [], $source, fn (DeviceDriver $driver) => $driver->reboot($device)); return $this->run($device, null, 'reboot', [], $source, fn (DeviceDriver $driver) => $driver->reboot($device));
} }
public function setLight(Entity $entity, array $params, string $source = 'user'): Command
{
return $this->run($entity->device, $entity, 'set_light', $params, $source, fn (DeviceDriver $driver) => $driver->setLight($entity, $params));
}
private function run(Device $device, ?Entity $entity, string $command, array $payload, string $source, callable $action): Command private function run(Device $device, ?Entity $entity, string $command, array $payload, string $source, callable $action): Command
{ {
$result = 'ok'; $result = 'ok';

View File

@ -20,6 +20,9 @@ interface DeviceDriver
/** Apply a desired state (e.g. ['on' => true]). */ /** Apply a desired state (e.g. ['on' => true]). */
public function setState(Entity $entity, array $state): void; public function setState(Entity $entity, array $state): void;
/** Set a light: ['on' => bool, 'brightness' => 0-100, 'rgb' => [r,g,b]]. */
public function setLight(Entity $entity, array $params): void;
/** Reboot the whole device. */ /** Reboot the whole device. */
public function reboot(Device $device): void; public function reboot(Device $device): void;

View File

@ -39,6 +39,25 @@ class ShellyMqttDriver implements DeviceDriver
$this->rpc($device, 'Shelly.Reboot'); $this->rpc($device, 'Shelly.Reboot');
} }
/** Light.Set with optional brightness (0100) and rgb ([r,g,b]) for RGBW lights. */
public function setLight(Entity $entity, array $params): void
{
[, $id] = array_pad(explode(':', $entity->key, 2), 2, '0');
$rpc = ['id' => (int) $id];
if (array_key_exists('on', $params)) {
$rpc['on'] = (bool) $params['on'];
}
if (isset($params['brightness'])) {
$rpc['brightness'] = max(0, min(100, (int) $params['brightness']));
}
if (isset($params['rgb']) && is_array($params['rgb'])) {
$rpc['rgb'] = array_map(fn ($v) => max(0, min(255, (int) $v)), array_slice($params['rgb'], 0, 3));
}
$this->rpc($entity->device, 'Light.Set', $rpc);
}
public function capabilities(): array public function capabilities(): array
{ {
return ['switch', 'light']; return ['switch', 'light'];

View File

@ -0,0 +1,23 @@
<?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('entities', function (Blueprint $table) {
$table->integer('panel_sort')->nullable()->after('capabilities');
$table->boolean('panel_hidden')->default(false)->after('panel_sort');
});
}
public function down(): void
{
Schema::table('entities', function (Blueprint $table) {
$table->dropColumn(['panel_sort', 'panel_hidden']);
});
}
};

View File

@ -9,6 +9,7 @@ return [
// items // items
'dashboard' => 'Dashboard', 'dashboard' => 'Dashboard',
'panel' => 'Steuerung',
'rooms' => 'Räume', 'rooms' => 'Räume',
'devices' => 'Geräte', 'devices' => 'Geräte',
'persons' => 'Personen & Anwesenheit', 'persons' => 'Personen & Anwesenheit',

12
lang/de/panel.php Normal file
View File

@ -0,0 +1,12 @@
<?php
return [
'subtitle' => 'Schnellzugriff — tippen zum Schalten.',
'empty' => 'Keine schaltbaren Geräte.',
'reorder_hint' => 'Kacheln am Griff ziehen, um sie anzuordnen.',
'drag' => 'Verschieben',
'light_settings' => 'Lichtsteuerung',
'brightness' => 'Helligkeit',
'color' => 'Farbe',
'apply' => 'Übernehmen',
];

View File

@ -9,6 +9,7 @@ return [
// items // items
'dashboard' => 'Dashboard', 'dashboard' => 'Dashboard',
'panel' => 'Control',
'rooms' => 'Rooms', 'rooms' => 'Rooms',
'devices' => 'Devices', 'devices' => 'Devices',
'persons' => 'People & Presence', 'persons' => 'People & Presence',

12
lang/en/panel.php Normal file
View File

@ -0,0 +1,12 @@
<?php
return [
'subtitle' => 'Quick access — tap to switch.',
'empty' => 'No switchable devices.',
'reorder_hint' => 'Drag tiles by the handle to arrange them.',
'drag' => 'Move',
'light_settings' => 'Light control',
'brightness' => 'Brightness',
'color' => 'Color',
'apply' => 'Apply',
];

9
package-lock.json generated
View File

@ -7,7 +7,8 @@
"dependencies": { "dependencies": {
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"laravel-echo": "^2.4.0", "laravel-echo": "^2.4.0",
"pusher-js": "^8.5.0" "pusher-js": "^8.5.0",
"sortablejs": "^1.15.7"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
@ -1395,6 +1396,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/sortablejs": {
"version": "1.15.7",
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.7.tgz",
"integrity": "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==",
"license": "MIT"
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",

View File

@ -16,6 +16,7 @@
"dependencies": { "dependencies": {
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"laravel-echo": "^2.4.0", "laravel-echo": "^2.4.0",
"pusher-js": "^8.5.0" "pusher-js": "^8.5.0",
"sortablejs": "^1.15.7"
} }
} }

View File

@ -1,9 +1,27 @@
import Echo from 'laravel-echo'; import Echo from 'laravel-echo';
import Pusher from 'pusher-js'; import Pusher from 'pusher-js';
import { Chart, registerables } from 'chart.js'; import { Chart, registerables } from 'chart.js';
import Sortable from 'sortablejs';
Chart.register(...registerables); Chart.register(...registerables);
// Drag-to-reorder tile grid → persists order via $wire.reorder([uuid, …]).
document.addEventListener('alpine:init', () => {
window.Alpine.data('sortableGrid', () => ({
init() {
Sortable.create(this.$refs.grid, {
handle: '.drag-handle',
animation: 150,
ghostClass: 'opacity-40',
onEnd: () => {
const order = [...this.$refs.grid.children].map((c) => c.dataset.uuid).filter(Boolean);
this.$wire.reorder(order);
},
});
},
}));
});
// Themed line-chart island (Livewire bundles Alpine). Usage: // Themed line-chart island (Livewire bundles Alpine). Usage:
// <div x-data="lineChart(@js($config))"><canvas x-ref="canvas"></canvas></div> // <div x-data="lineChart(@js($config))"><canvas x-ref="canvas"></canvas></div>
// where $config = { labels: [...], series: [{ key, label, color, data, axis }], units: {...} } // where $config = { labels: [...], series: [{ key, label, color, data, axis }], units: {...} }

View File

@ -2,6 +2,7 @@
$groups = [ $groups = [
'nav.section_overview' => [ 'nav.section_overview' => [
['key' => 'dashboard', 'icon' => 'dashboard', 'route' => 'dashboard', 'lock' => false], ['key' => 'dashboard', 'icon' => 'dashboard', 'route' => 'dashboard', 'lock' => false],
['key' => 'panel', 'icon' => 'bolt', 'route' => 'panel', 'lock' => false],
['key' => 'rooms', 'icon' => 'rooms', 'route' => 'rooms.index', 'active' => 'rooms.*', 'lock' => false], ['key' => 'rooms', 'icon' => 'rooms', 'route' => 'rooms.index', 'active' => 'rooms.*', 'lock' => false],
['key' => 'devices', 'icon' => 'devices', 'route' => 'devices.index', 'active' => 'devices.*', 'lock' => false], ['key' => 'devices', 'icon' => 'devices', 'route' => 'devices.index', 'active' => 'devices.*', 'lock' => false],
], ],

View File

@ -0,0 +1,43 @@
<div class="flex flex-col">
<header class="flex items-center gap-3 px-5 py-4 border-b border-line-soft">
<span class="grid place-items-center w-9 h-9 rounded-lg bg-accent/10 text-accent shrink-0"><x-icon name="bolt" :size="18" /></span>
<h2 class="text-[15px] font-bold text-ink truncate">{{ $this->entity->device->name }}</h2>
<button type="button" wire:click="closeModal" class="ml-auto grid place-items-center w-9 h-9 rounded-lg text-ink-3 hover:bg-raised hover:text-ink transition-colors" aria-label="{{ __('common.close') }}">
<x-icon name="close" :size="18" />
</button>
</header>
<div class="p-5 flex flex-col gap-5">
{{-- brightness --}}
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between text-[12px] font-semibold text-ink-2">
<span>{{ __('panel.brightness') }}</span>
<span class="font-mono tabular-nums text-ink">{{ $brightness }}%</span>
</div>
<input type="range" min="1" max="100" wire:model.live="brightness"
class="w-full accent-[color:var(--color-accent)]">
</div>
{{-- color --}}
<div class="flex flex-col gap-2">
<span class="text-[12px] font-semibold text-ink-2">{{ __('panel.color') }}</span>
<div class="flex flex-wrap items-center gap-2">
@foreach ($presets as $preset)
<button type="button" wire:click="$set('color', '{{ $preset }}')"
@style(["background-color: {$preset}"])
@class(['w-8 h-8 rounded-full border-2 transition-transform hover:scale-110', 'border-ink' => strtoupper($color) === strtoupper($preset), 'border-line-soft' => strtoupper($color) !== strtoupper($preset)])
aria-label="{{ $preset }}"></button>
@endforeach
<label class="w-8 h-8 rounded-full border-2 border-line-soft grid place-items-center cursor-pointer overflow-hidden relative">
<input type="color" wire:model.live="color" class="absolute inset-0 opacity-0 cursor-pointer">
<x-icon name="plus" :size="14" class="text-ink-3" />
</label>
</div>
</div>
<div class="flex items-center gap-2 pt-1">
<button type="button" wire:click="turnOff" class="rounded-lg border border-line px-3.5 py-2.5 text-[13px] font-semibold text-ink-2 hover:text-ink hover:bg-raised transition-colors">{{ __('devices.off') }}</button>
<button type="button" wire:click="apply" class="ml-auto rounded-lg bg-accent px-4 py-2.5 text-[13px] font-bold text-base hover:brightness-110 transition-[filter]">{{ __('panel.apply') }}</button>
</div>
</div>
</div>

View File

@ -0,0 +1,46 @@
<div wire:poll.30s>
<x-topbar :title="__('nav.panel')" :subtitle="__('panel.subtitle')" />
<div class="px-5 lg:px-[26px] py-6 max-w-[1560px] mx-auto w-full">
@if ($tiles->isEmpty())
<x-panel><p class="text-[13px] text-ink-3 text-center py-8">{{ __('panel.empty') }}</p></x-panel>
@else
<p class="text-[11.5px] text-ink-3 mb-3">{{ __('panel.reorder_hint') }}</p>
<div x-data="sortableGrid()" x-ref="grid"
class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3">
@foreach ($tiles as $tile)
@php $on = data_get($tile->state?->state, 'on') === true; @endphp
<div wire:key="tile-{{ $tile->id }}" data-uuid="{{ $tile->uuid }}"
@class([
'relative rounded-card border p-4 min-h-[7.5rem] flex flex-col select-none transition-colors',
'border-accent/40 bg-accent/10' => $on,
'border-line-soft bg-surface' => ! $on,
])>
<div class="flex items-start">
<span @class(['grid place-items-center w-10 h-10 rounded-lg', 'bg-accent/20 text-accent' => $on, 'bg-inset text-ink-3' => ! $on])>
<x-icon :name="$tile->type === 'light' ? 'bolt' : 'devices'" :size="20" />
</span>
<button type="button" class="drag-handle ml-auto -mr-1 -mt-1 p-1 cursor-grab touch-none text-ink-3 hover:text-ink" aria-label="{{ __('panel.drag') }}">
<x-icon name="menu" :size="16" />
</button>
</div>
<button type="button" wire:click="toggle({{ $tile->id }})" class="text-left mt-auto pt-2">
<div class="text-[14px] font-bold text-ink truncate">{{ $tile->device->name }}</div>
<div class="text-[11px] text-ink-3 truncate">{{ $tile->device->room?->name ?? '—' }}</div>
<div @class(['mt-1 text-[13px] font-bold', 'text-accent' => $on, 'text-ink-3' => ! $on])>{{ $on ? __('devices.on') : __('devices.off') }}</div>
</button>
@if ($tile->type === 'light')
<button type="button"
wire:click="$dispatch('openModal', { component: 'modals.light-control', arguments: { entity: @js($tile->uuid) } })"
class="absolute bottom-3 right-3 grid place-items-center w-8 h-8 rounded-lg bg-raised text-ink-2 hover:text-ink transition-colors" aria-label="{{ __('panel.light_settings') }}">
<x-icon name="settings" :size="15" />
</button>
@endif
</div>
@endforeach
</div>
@endif
</div>
</div>

View File

@ -8,6 +8,7 @@ use App\Livewire\Devices\Index as DeviceIndex;
use App\Livewire\Devices\Show as DeviceShow; use App\Livewire\Devices\Show as DeviceShow;
use App\Livewire\Discovery\Index as DiscoveryIndex; use App\Livewire\Discovery\Index as DiscoveryIndex;
use App\Livewire\Host; use App\Livewire\Host;
use App\Livewire\Panel;
use App\Livewire\Persons\Index as PersonIndex; use App\Livewire\Persons\Index as PersonIndex;
use App\Livewire\Rooms\Index as RoomIndex; use App\Livewire\Rooms\Index as RoomIndex;
use App\Livewire\Rooms\Show as RoomShow; use App\Livewire\Rooms\Show as RoomShow;
@ -24,6 +25,7 @@ Route::middleware('guest')->group(function () {
Route::middleware('auth')->group(function () { Route::middleware('auth')->group(function () {
Route::get('/dashboard', Dashboard::class)->name('dashboard'); Route::get('/dashboard', Dashboard::class)->name('dashboard');
Route::get('/panel', Panel::class)->name('panel');
Route::get('/rooms', RoomIndex::class)->name('rooms.index'); Route::get('/rooms', RoomIndex::class)->name('rooms.index');
Route::get('/rooms/{room}', RoomShow::class)->name('rooms.show'); Route::get('/rooms/{room}', RoomShow::class)->name('rooms.show');
Route::get('/devices', DeviceIndex::class)->name('devices.index'); Route::get('/devices', DeviceIndex::class)->name('devices.index');

View File

@ -69,6 +69,8 @@ class MqttTest extends TestCase
public function setState(Entity $entity, array $state): void {} public function setState(Entity $entity, array $state): void {}
public function setLight(Entity $entity, array $params): void {}
public function reboot(Device $device): void {} public function reboot(Device $device): void {}
public function capabilities(): array public function capabilities(): array