feat(panel): make tiles actually control + entity naming + reorder feedback

Addresses "I arrange tiles but nothing changes":
- Demo devices have no hardware to echo a new state, so toggling looked dead.
  DeviceCommandService now simulates that echo for demo devices (writes the new
  state + broadcasts DeviceStateChanged) — the mock home is fully interactive
  (handoff §13.2). Real devices are untouched; their own status message applies.
- Panel tiles show the entity name (device · room as sub) and drive the shared
  TogglesEntities toggle; wire:loading guards double-taps.
- Drag-reorder now confirms with a "Layout saved" cue so it doesn't feel inert.

+1 test (demo toggle simulates echo; real device does not). Browser-verified:
tapping an "Aus" tile flips it to "An". Suite 52 green, 12/12 clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/phase-1-bootstrap
HomeOS Bootstrap 2026-07-18 08:37:17 +02:00
parent d7d8544e52
commit 2edffadbfd
6 changed files with 88 additions and 17 deletions

View File

@ -2,8 +2,8 @@
namespace App\Livewire;
use App\Livewire\Concerns\TogglesEntities;
use App\Models\Entity;
use App\Services\DeviceCommandService;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
@ -15,24 +15,20 @@ use Livewire\Component;
#[Layout('layouts.app')]
class Panel extends Component
{
use TogglesEntities;
#[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]);
}
// Confirm the save so the drag doesn't feel like a no-op.
$this->dispatch('panel-reordered');
}
public function render()

View File

@ -2,8 +2,10 @@
namespace App\Services;
use App\Events\DeviceStateChanged;
use App\Models\Command;
use App\Models\Device;
use App\Models\DeviceState;
use App\Models\Entity;
use App\Support\Drivers\DeviceDriver;
use App\Support\Drivers\ShellyMqttDriver;
@ -24,7 +26,7 @@ class DeviceCommandService
public function setOn(Entity $entity, bool $on, string $source = 'user'): Command
{
return $this->run(
$command = $this->run(
$entity->device,
$entity,
$on ? 'turn_on' : 'turn_off',
@ -32,6 +34,10 @@ class DeviceCommandService
$source,
fn (DeviceDriver $driver) => $on ? $driver->turnOn($entity) : $driver->turnOff($entity),
);
$this->simulateDemoEcho($command, $entity, ['on' => $on]);
return $command;
}
public function reboot(Device $device, string $source = 'user'): Command
@ -41,7 +47,33 @@ class DeviceCommandService
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));
$command = $this->run($entity->device, $entity, 'set_light', $params, $source, fn (DeviceDriver $driver) => $driver->setLight($entity, $params));
$this->simulateDemoEcho($command, $entity, $params);
return $command;
}
/**
* Demo devices have no real hardware to echo their new state back over MQTT, so the UI would
* look dead when you toggle them. For a demo device we synthesize that echo write the new
* state and broadcast DeviceStateChanged so the mock home is fully interactive (handoff
* §13.2, "mock first"). Real devices never take this path; their actual status message does.
*/
private function simulateDemoEcho(Command $command, Entity $entity, array $state): void
{
if ($command->result !== 'ok' || ! $entity->device->demo) {
return;
}
$merged = array_merge($entity->state?->state ?? [], $state);
DeviceState::updateOrCreate(
['entity_id' => $entity->id],
['state' => $merged, 'observed_at' => (int) round(microtime(true) * 1_000_000)],
);
DeviceStateChanged::dispatch($entity->device->uuid, $entity->key, $merged);
}
private function run(Device $device, ?Entity $entity, string $command, array $payload, string $source, callable $action): Command

View File

@ -4,6 +4,7 @@ return [
'subtitle' => 'Schnellzugriff — tippen zum Schalten.',
'empty' => 'Keine schaltbaren Geräte.',
'reorder_hint' => 'Kacheln am Griff ziehen, um sie anzuordnen.',
'reorder_saved' => 'Anordnung gespeichert',
'drag' => 'Verschieben',
'light_settings' => 'Lichtsteuerung',
'brightness' => 'Helligkeit',

View File

@ -4,6 +4,7 @@ return [
'subtitle' => 'Quick access — tap to switch.',
'empty' => 'No switchable devices.',
'reorder_hint' => 'Drag tiles by the handle to arrange them.',
'reorder_saved' => 'Layout saved',
'drag' => 'Move',
'light_settings' => 'Light control',
'brightness' => 'Brightness',

View File

@ -1,11 +1,16 @@
<div wire:poll.30s>
<div wire:poll.30s x-data="{ saved: false }" x-on:panel-reordered.window="saved = true; setTimeout(() => saved = false, 1500)">
<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 class="flex items-center gap-2 mb-3">
<p class="text-[11.5px] text-ink-3">{{ __('panel.reorder_hint') }}</p>
<span x-show="saved" x-cloak class="inline-flex items-center gap-1 text-[11.5px] font-semibold text-online">
<x-icon name="check" :size="13" /> {{ __('panel.reorder_saved') }}
</span>
</div>
<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)
@ -25,9 +30,9 @@
</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>
<button type="button" wire:click="toggleEntity({{ $tile->id }})" wire:loading.attr="disabled" class="text-left mt-auto pt-2">
<div class="text-[14px] font-bold text-ink truncate">{{ $tile->name ?: $tile->device->name }}</div>
<div class="text-[11px] text-ink-3 truncate">{{ $tile->device->name }}@if ($tile->device->room) · {{ $tile->device->room->name }}@endif</div>
<div @class(['mt-1 text-[13px] font-bold', 'text-accent' => $on, 'text-ink-3' => ! $on])>{{ $on ? __('devices.on') : __('devices.off') }}</div>
</button>

View File

@ -117,5 +117,41 @@ class MqttTest extends TestCase
$this->assertSame('turn_on', $command->command);
$this->assertSame('ok', $command->result);
$this->assertDatabaseHas('commands', ['command' => 'turn_on', 'source' => 'user', 'result' => 'ok']);
// A real (non-demo) device does NOT get a simulated echo — its own status message does.
$this->assertFalse(data_get($entity->refresh()->state->state, 'on'));
}
public function test_toggling_a_demo_device_simulates_the_echo(): void
{
// Demo devices have no hardware to echo back, so the command service simulates it.
$this->app->bind(ShellyMqttDriver::class, fn () => new class implements DeviceDriver
{
public function turnOn(Entity $entity): void {}
public function turnOff(Entity $entity): 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 capabilities(): array
{
return [];
}
});
\Illuminate\Support\Facades\Event::fake([\App\Events\DeviceStateChanged::class]);
$device = Device::create(['name' => 'Deckenlicht', 'vendor' => 'Shelly', 'protocol' => 'mqtt', 'status' => 'active', 'demo' => true]);
$entity = Entity::create(['device_id' => $device->id, 'type' => 'light', 'key' => 'light:0']);
DeviceState::create(['entity_id' => $entity->id, 'state' => ['on' => false]]);
app(DeviceCommandService::class)->toggle($entity->load('state', 'device'));
$this->assertTrue(data_get($entity->refresh()->state->state, 'on'));
\Illuminate\Support\Facades\Event::assertDispatched(\App\Events\DeviceStateChanged::class);
}
}