diff --git a/app/Livewire/Panel.php b/app/Livewire/Panel.php index 8e4fac8..897d05e 100644 --- a/app/Livewire/Panel.php +++ b/app/Livewire/Panel.php @@ -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() diff --git a/app/Services/DeviceCommandService.php b/app/Services/DeviceCommandService.php index a7e3818..a61a159 100644 --- a/app/Services/DeviceCommandService.php +++ b/app/Services/DeviceCommandService.php @@ -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 diff --git a/lang/de/panel.php b/lang/de/panel.php index eb54f54..0701a45 100644 --- a/lang/de/panel.php +++ b/lang/de/panel.php @@ -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', diff --git a/lang/en/panel.php b/lang/en/panel.php index b6419dd..4ce89fb 100644 --- a/lang/en/panel.php +++ b/lang/en/panel.php @@ -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', diff --git a/resources/views/livewire/panel.blade.php b/resources/views/livewire/panel.blade.php index 2baa3bd..553170a 100644 --- a/resources/views/livewire/panel.blade.php +++ b/resources/views/livewire/panel.blade.php @@ -1,11 +1,16 @@ -
+
@if ($tiles->isEmpty())

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

@else -

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

+
+

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

+ + {{ __('panel.reorder_saved') }} + +
@foreach ($tiles as $tile) @@ -25,9 +30,9 @@
- diff --git a/tests/Feature/MqttTest.php b/tests/Feature/MqttTest.php index d8d07fc..7288c12 100644 --- a/tests/Feature/MqttTest.php +++ b/tests/Feature/MqttTest.php @@ -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); } }