From 124e667a0a774fed73d63d9a8bd8cab1d2f82695 Mon Sep 17 00:00:00 2001 From: HomeOS Bootstrap Date: Sat, 18 Jul 2026 00:58:24 +0200 Subject: [PATCH] Tablet control panel: touch tiles, drag-reorder, light colour modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/Livewire/Modals/LightControl.php | 68 +++++++++++++++++++ app/Livewire/Panel.php | 50 ++++++++++++++ app/Models/Entity.php | 4 +- app/Services/DeviceCommandService.php | 5 ++ app/Support/Drivers/DeviceDriver.php | 3 + app/Support/Drivers/ShellyMqttDriver.php | 19 ++++++ ...026_07_18_030001_add_panel_to_entities.php | 23 +++++++ lang/de/nav.php | 1 + lang/de/panel.php | 12 ++++ lang/en/nav.php | 1 + lang/en/panel.php | 12 ++++ package-lock.json | 9 ++- package.json | 3 +- resources/js/app.js | 18 +++++ resources/views/components/sidebar.blade.php | 1 + .../livewire/modals/light-control.blade.php | 43 ++++++++++++ resources/views/livewire/panel.blade.php | 46 +++++++++++++ routes/web.php | 2 + tests/Feature/MqttTest.php | 2 + 19 files changed, 318 insertions(+), 4 deletions(-) create mode 100644 app/Livewire/Modals/LightControl.php create mode 100644 app/Livewire/Panel.php create mode 100644 database/migrations/2026_07_18_030001_add_panel_to_entities.php create mode 100644 lang/de/panel.php create mode 100644 lang/en/panel.php create mode 100644 resources/views/livewire/modals/light-control.blade.php create mode 100644 resources/views/livewire/panel.blade.php diff --git a/app/Livewire/Modals/LightControl.php b/app/Livewire/Modals/LightControl.php new file mode 100644 index 0000000..3414f13 --- /dev/null +++ b/app/Livewire/Modals/LightControl.php @@ -0,0 +1,68 @@ +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'], + ]); + } +} diff --git a/app/Livewire/Panel.php b/app/Livewire/Panel.php new file mode 100644 index 0000000..8e4fac8 --- /dev/null +++ b/app/Livewire/Panel.php @@ -0,0 +1,50 @@ +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]); + } +} diff --git a/app/Models/Entity.php b/app/Models/Entity.php index 985fdbe..3603001 100644 --- a/app/Models/Entity.php +++ b/app/Models/Entity.php @@ -11,9 +11,9 @@ class Entity extends Model { 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 { diff --git a/app/Services/DeviceCommandService.php b/app/Services/DeviceCommandService.php index 4211dd4..a7e3818 100644 --- a/app/Services/DeviceCommandService.php +++ b/app/Services/DeviceCommandService.php @@ -39,6 +39,11 @@ class DeviceCommandService 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 { $result = 'ok'; diff --git a/app/Support/Drivers/DeviceDriver.php b/app/Support/Drivers/DeviceDriver.php index 5bfd0b5..d18b94c 100644 --- a/app/Support/Drivers/DeviceDriver.php +++ b/app/Support/Drivers/DeviceDriver.php @@ -20,6 +20,9 @@ interface DeviceDriver /** Apply a desired state (e.g. ['on' => true]). */ 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. */ public function reboot(Device $device): void; diff --git a/app/Support/Drivers/ShellyMqttDriver.php b/app/Support/Drivers/ShellyMqttDriver.php index bf0bab2..f0fb8dd 100644 --- a/app/Support/Drivers/ShellyMqttDriver.php +++ b/app/Support/Drivers/ShellyMqttDriver.php @@ -39,6 +39,25 @@ class ShellyMqttDriver implements DeviceDriver $this->rpc($device, 'Shelly.Reboot'); } + /** Light.Set with optional brightness (0–100) 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 { return ['switch', 'light']; diff --git a/database/migrations/2026_07_18_030001_add_panel_to_entities.php b/database/migrations/2026_07_18_030001_add_panel_to_entities.php new file mode 100644 index 0000000..3bba18d --- /dev/null +++ b/database/migrations/2026_07_18_030001_add_panel_to_entities.php @@ -0,0 +1,23 @@ +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']); + }); + } +}; diff --git a/lang/de/nav.php b/lang/de/nav.php index a7f4472..0052b9e 100644 --- a/lang/de/nav.php +++ b/lang/de/nav.php @@ -9,6 +9,7 @@ return [ // items 'dashboard' => 'Dashboard', + 'panel' => 'Steuerung', 'rooms' => 'Räume', 'devices' => 'Geräte', 'persons' => 'Personen & Anwesenheit', diff --git a/lang/de/panel.php b/lang/de/panel.php new file mode 100644 index 0000000..eb54f54 --- /dev/null +++ b/lang/de/panel.php @@ -0,0 +1,12 @@ + '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', +]; diff --git a/lang/en/nav.php b/lang/en/nav.php index f902e8f..8aa242f 100644 --- a/lang/en/nav.php +++ b/lang/en/nav.php @@ -9,6 +9,7 @@ return [ // items 'dashboard' => 'Dashboard', + 'panel' => 'Control', 'rooms' => 'Rooms', 'devices' => 'Devices', 'persons' => 'People & Presence', diff --git a/lang/en/panel.php b/lang/en/panel.php new file mode 100644 index 0000000..b6419dd --- /dev/null +++ b/lang/en/panel.php @@ -0,0 +1,12 @@ + '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', +]; diff --git a/package-lock.json b/package-lock.json index c8f8e95..01e2ec3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,8 @@ "dependencies": { "chart.js": "^4.5.1", "laravel-echo": "^2.4.0", - "pusher-js": "^8.5.0" + "pusher-js": "^8.5.0", + "sortablejs": "^1.15.7" }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", @@ -1395,6 +1396,12 @@ "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": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/package.json b/package.json index 80569a2..fa6a5c2 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "dependencies": { "chart.js": "^4.5.1", "laravel-echo": "^2.4.0", - "pusher-js": "^8.5.0" + "pusher-js": "^8.5.0", + "sortablejs": "^1.15.7" } } diff --git a/resources/js/app.js b/resources/js/app.js index a0a45a3..8337e44 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1,9 +1,27 @@ import Echo from 'laravel-echo'; import Pusher from 'pusher-js'; import { Chart, registerables } from 'chart.js'; +import Sortable from 'sortablejs'; 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: //
// where $config = { labels: [...], series: [{ key, label, color, data, axis }], units: {...} } diff --git a/resources/views/components/sidebar.blade.php b/resources/views/components/sidebar.blade.php index c57abc5..55c80cb 100644 --- a/resources/views/components/sidebar.blade.php +++ b/resources/views/components/sidebar.blade.php @@ -2,6 +2,7 @@ $groups = [ 'nav.section_overview' => [ ['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' => 'devices', 'icon' => 'devices', 'route' => 'devices.index', 'active' => 'devices.*', 'lock' => false], ], diff --git a/resources/views/livewire/modals/light-control.blade.php b/resources/views/livewire/modals/light-control.blade.php new file mode 100644 index 0000000..8f08520 --- /dev/null +++ b/resources/views/livewire/modals/light-control.blade.php @@ -0,0 +1,43 @@ +
+
+ +

{{ $this->entity->device->name }}

+ +
+ +
+ {{-- brightness --}} +
+
+ {{ __('panel.brightness') }} + {{ $brightness }}% +
+ +
+ + {{-- color --}} +
+ {{ __('panel.color') }} +
+ @foreach ($presets as $preset) + + @endforeach + +
+
+ +
+ + +
+
+
diff --git a/resources/views/livewire/panel.blade.php b/resources/views/livewire/panel.blade.php new file mode 100644 index 0000000..2baa3bd --- /dev/null +++ b/resources/views/livewire/panel.blade.php @@ -0,0 +1,46 @@ +
+ + +
+ @if ($tiles->isEmpty()) +

{{ __('panel.empty') }}

+ @else +

{{ __('panel.reorder_hint') }}

+
+ @foreach ($tiles as $tile) + @php $on = data_get($tile->state?->state, 'on') === true; @endphp +
$on, + 'border-line-soft bg-surface' => ! $on, + ])> +
+ $on, 'bg-inset text-ink-3' => ! $on])> + + + +
+ + + + @if ($tile->type === 'light') + + @endif +
+ @endforeach +
+ @endif +
+
diff --git a/routes/web.php b/routes/web.php index b6145f0..d1a71c6 100644 --- a/routes/web.php +++ b/routes/web.php @@ -8,6 +8,7 @@ use App\Livewire\Devices\Index as DeviceIndex; use App\Livewire\Devices\Show as DeviceShow; use App\Livewire\Discovery\Index as DiscoveryIndex; use App\Livewire\Host; +use App\Livewire\Panel; use App\Livewire\Persons\Index as PersonIndex; use App\Livewire\Rooms\Index as RoomIndex; use App\Livewire\Rooms\Show as RoomShow; @@ -24,6 +25,7 @@ Route::middleware('guest')->group(function () { Route::middleware('auth')->group(function () { 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/{room}', RoomShow::class)->name('rooms.show'); Route::get('/devices', DeviceIndex::class)->name('devices.index'); diff --git a/tests/Feature/MqttTest.php b/tests/Feature/MqttTest.php index c810d6b..506ccd8 100644 --- a/tests/Feature/MqttTest.php +++ b/tests/Feature/MqttTest.php @@ -69,6 +69,8 @@ class MqttTest extends TestCase public function setState(Entity $entity, array $state): void {} + public function setLight(Entity $entity, array $params): void {} + public function reboot(Device $device): void {} public function capabilities(): array