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
parent
d7d8544e52
commit
2edffadbfd
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue