From 2fa15decb465aed3233b5b53c4f38dbc890bf467 Mon Sep 17 00:00:00 2001 From: HomeOS Bootstrap Date: Sat, 18 Jul 2026 10:04:19 +0200 Subject: [PATCH] feat(devices): instant toggle, icon picker, real update check, transport switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Issue 6 (2-3s toggle lag): the command service now writes the expected state immediately for ALL devices (not just demo). The card flips within the Livewire round-trip; the real MQTT echo / post-command HTTP poll reconciles it (monotonic — newer observed_at wins). Toggling now feels instant. - Issue 7 (wrong icon): Device::displayIcon() with a per-device override (config.icon) + a picker on the device page (lamp/led/plug/window/door/ doorbell/sensor/…); cards use it. New icons added. - Issue 8 (dead "Update prüfen"): real Shelly.CheckForUpdate over HTTP now reports available version / up-to-date / unsupported instead of a demo string. - Issue 9 (no MQTT option): device page can switch a Shelly between Local (HTTP, probed) and MQTT transport; driverFor already routes by protocol. Suite 71 green, 12/12 tabs clean. Co-Authored-By: Claude Opus 4.8 --- app/Livewire/Devices/Show.php | 86 ++++++++++++++++++- app/Models/Device.php | 23 +++++ app/Services/DeviceCommandService.php | 16 ++-- lang/de/devices.php | 12 +++ lang/en/devices.php | 12 +++ .../views/components/device-card.blade.php | 8 +- resources/views/components/icon.blade.php | 5 ++ .../views/livewire/devices/show.blade.php | 38 ++++++++ tests/Feature/MqttTest.php | 5 +- 9 files changed, 187 insertions(+), 18 deletions(-) diff --git a/app/Livewire/Devices/Show.php b/app/Livewire/Devices/Show.php index b65a626..04d6b50 100644 --- a/app/Livewire/Devices/Show.php +++ b/app/Livewire/Devices/Show.php @@ -7,6 +7,7 @@ use App\Models\Entity; use App\Models\Room; use App\Services\DeviceCommandService; use App\Services\MqttCredentialProvisioner; +use App\Support\Shelly\ShellyRpc; use Livewire\Attributes\Layout; use Livewire\Attributes\On; use Livewire\Attributes\Validate; @@ -22,6 +23,11 @@ class Show extends Component public ?int $roomId = null; + /** 'mqtt' or 'local' (HTTP). */ + public string $transport = 'mqtt'; + + public string $ip = ''; + public ?string $flash = null; public bool $flashOk = true; @@ -34,6 +40,58 @@ class Show extends Component $this->device = $device; $this->name = $device->name; $this->roomId = $device->room_id; + $this->transport = $device->protocol === 'http' ? 'local' : 'mqtt'; + $this->ip = (string) data_get($device->config, 'ip', ''); + } + + /** Set (or clear) the device's display icon. */ + public function setIcon(string $icon): void + { + $config = $this->device->config ?? []; + if (in_array($icon, Device::ICON_CHOICES, true)) { + $config['icon'] = $icon; + } else { + unset($config['icon']); + } + $this->device->update(['config' => $config]); + $this->flashMessage(__('devices.saved')); + } + + /** Switch a Shelly between MQTT and local-HTTP transport. */ + public function setTransport(): void + { + $this->validate([ + 'transport' => 'required|in:mqtt,local', + 'ip' => ['nullable', 'string', 'max:64', 'regex:/^[a-zA-Z0-9.\-]+$/'], + ]); + + $config = $this->device->config ?? []; + + if ($this->transport === 'local') { + if (blank($this->ip)) { + $this->flashError(__('devices.transport_need_ip')); + + return; + } + if (! app(ShellyRpc::class)->reachable(trim($this->ip))) { + $this->flashError(__('devices.transport_unreachable')); + + return; + } + $config['ip'] = trim($this->ip); + $config['transport'] = 'local'; + $this->device->update(['protocol' => 'http', 'config' => $config]); + } else { + if (blank(data_get($config, 'mqtt_prefix'))) { + $this->flashError(__('devices.transport_need_prefix')); + + return; + } + unset($config['transport']); + $this->device->update(['protocol' => 'mqtt', 'config' => $config]); + } + + $this->flashMessage(__('devices.saved')); } /** @@ -216,9 +274,29 @@ class Show extends Component $this->flash = $ok ? __('devices.command_sent') : __('devices.command_failed'); } + /** Ask a local Shelly whether a firmware update is available (real, over its HTTP API). */ public function checkUpdate(): void { - $this->flashMessage(__('devices.no_update_demo')); + $ip = data_get($this->device->config, 'ip'); + + if ($this->device->protocol !== 'http' || blank($ip)) { + $this->flashMessage(__('devices.update_unsupported')); + + return; + } + + try { + $result = app(ShellyRpc::class)->call($ip, 'Shelly.CheckForUpdate'); + } catch (\Throwable) { + $this->flashError(__('devices.update_failed')); + + return; + } + + $version = data_get($result, 'stable.version'); + $version + ? $this->flashMessage(__('devices.update_available', ['version' => $version])) + : $this->flashMessage(__('devices.update_none')); } protected function flashMessage(string $message): void @@ -227,6 +305,12 @@ class Show extends Component $this->flashOk = true; } + protected function flashError(string $message): void + { + $this->flash = $message; + $this->flashOk = false; + } + public function render() { $this->device->load(['entities.state', 'room']); diff --git a/app/Models/Device.php b/app/Models/Device.php index c1e4d77..67ecff8 100644 --- a/app/Models/Device.php +++ b/app/Models/Device.php @@ -55,4 +55,27 @@ class Device extends Model { return (bool) data_get($this->config, 'cloud', false); } + + /** Icons the user can pick for a device (keys map to the x-icon set). */ + public const ICON_CHOICES = ['bolt', 'lamp', 'led', 'plug', 'window', 'door', 'doorbell', 'activity', 'temp', 'devices']; + + /** The device's display icon — a user override (config.icon) or one derived from its entities. */ + public function displayIcon(): string + { + $override = data_get($this->config, 'icon'); + if (filled($override) && in_array($override, self::ICON_CHOICES, true)) { + return $override; + } + + $types = $this->relationLoaded('entities') ? $this->entities->pluck('type') : collect(); + + return match (true) { + $this->isCloud() => 'doorbell', + $types->contains('light') => 'bolt', + $types->contains('switch') => 'plug', + $types->contains('contact') => 'window', + $types->contains('motion') => 'activity', + default => 'devices', + }; + } } diff --git a/app/Services/DeviceCommandService.php b/app/Services/DeviceCommandService.php index df3dcad..ca6b59b 100644 --- a/app/Services/DeviceCommandService.php +++ b/app/Services/DeviceCommandService.php @@ -37,7 +37,7 @@ class DeviceCommandService fn (DeviceDriver $driver) => $on ? $driver->turnOn($entity) : $driver->turnOff($entity), ); - $this->simulateDemoEcho($command, $entity, ['on' => $on]); + $this->applyOptimisticState($command, $entity, ['on' => $on]); return $command; } @@ -51,20 +51,20 @@ class DeviceCommandService { $command = $this->run($entity->device, $entity, 'set_light', $params, $source, fn (DeviceDriver $driver) => $driver->setLight($entity, $params)); - $this->simulateDemoEcho($command, $entity, $params); + $this->applyOptimisticState($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. + * Reflect the expected state IMMEDIATELY so the UI flips within the request instead of waiting + * 2-3s for the device's status echo. The real echo (MQTT status) or the post-command HTTP poll + * reconciles it a moment later — both carry a newer observed_at, so the monotonic upsert lets + * them override this optimistic value. For demo devices there is no echo, so this IS the state. */ - private function simulateDemoEcho(Command $command, Entity $entity, array $state): void + private function applyOptimisticState(Command $command, Entity $entity, array $state): void { - if ($command->result !== 'ok' || ! $entity->device->demo) { + if ($command->result !== 'ok') { return; } diff --git a/lang/de/devices.php b/lang/de/devices.php index c8778bd..5a5ad4a 100644 --- a/lang/de/devices.php +++ b/lang/de/devices.php @@ -24,6 +24,18 @@ return [ 'add_probing' => 'Verbinde …', 'add_unreachable'=> 'Gerät nicht erreichbar oder kein Shelly. IP prüfen.', 'add' => 'Gerät hinzufügen', + 'icon' => 'Symbol', + 'update_unsupported' => 'Update-Prüfung nur bei lokalen (HTTP) Geräten möglich.', + 'update_failed' => 'Update-Prüfung fehlgeschlagen. Gerät erreichbar?', + 'update_available' => 'Update verfügbar: :version', + 'update_none' => 'Firmware ist aktuell.', + 'transport' => 'Verbindung', + 'transport_local' => 'Lokal (HTTP)', + 'transport_mqtt' => 'MQTT', + 'transport_ip' => 'IP-Adresse', + 'transport_need_ip' => 'Für lokale Verbindung wird eine IP-Adresse benötigt.', + 'transport_unreachable'=> 'Gerät unter dieser IP nicht erreichbar.', + 'transport_need_prefix'=> 'Kein MQTT-Prefix bekannt — Gerät zuerst lokal verbinden.', 'delete' => 'Gerät entfernen', 'delete_confirm_title' => 'Gerät entfernen?', 'delete_confirm_body' => ':name wird mit allen Werten gelöscht. Meldet sich das Gerät erneut, taucht es wieder unter „Neue Geräte“ auf.', diff --git a/lang/en/devices.php b/lang/en/devices.php index 6f7358a..70f4bb1 100644 --- a/lang/en/devices.php +++ b/lang/en/devices.php @@ -24,6 +24,18 @@ return [ 'add_probing' => 'Connecting …', 'add_unreachable'=> 'Device unreachable or not a Shelly. Check the IP.', 'add' => 'Add device', + 'icon' => 'Icon', + 'update_unsupported' => 'Update check is only available for local (HTTP) devices.', + 'update_failed' => 'Update check failed. Is the device reachable?', + 'update_available' => 'Update available: :version', + 'update_none' => 'Firmware is up to date.', + 'transport' => 'Connection', + 'transport_local' => 'Local (HTTP)', + 'transport_mqtt' => 'MQTT', + 'transport_ip' => 'IP address', + 'transport_need_ip' => 'A local connection needs an IP address.', + 'transport_unreachable'=> 'Device not reachable at that IP.', + 'transport_need_prefix'=> 'No MQTT prefix known — connect the device locally first.', 'delete' => 'Remove device', 'delete_confirm_title' => 'Remove device?', 'delete_confirm_body' => ':name and all its values are deleted. If the device reports again it reappears under “New devices”.', diff --git a/resources/views/components/device-card.blade.php b/resources/views/components/device-card.blade.php index 994dfd8..3402207 100644 --- a/resources/views/components/device-card.blade.php +++ b/resources/views/components/device-card.blade.php @@ -6,13 +6,7 @@ $on = $primary ? (data_get($primary->state?->state, 'on') === true) : false; $secondary = $primary ? $entities->reject(fn ($e) => $e->id === $primary->id) : $entities; $online = $device->isOnline(); - $icon = match (true) { - $device->isCloud() => 'doorbell', - $primary?->type === 'light' => 'bolt', - $entities->contains(fn ($e) => $e->type === 'contact') => 'window', - $entities->contains(fn ($e) => $e->type === 'motion') => 'activity', - default => 'devices', - }; + $icon = $device->displayIcon(); @endphp
'', 'doorbell' => '', 'cloud' => '', + 'lamp' => '', + 'plug' => '', + 'led' => '', + 'door' => '', + 'temp' => '', 'dot' => '', ]; $inner = $paths[$name] ?? $paths['dot']; diff --git a/resources/views/livewire/devices/show.blade.php b/resources/views/livewire/devices/show.blade.php index 808cb8a..2db745b 100644 --- a/resources/views/livewire/devices/show.blade.php +++ b/resources/views/livewire/devices/show.blade.php @@ -165,6 +165,44 @@
+ {{-- Display icon --}} +
+ +
+ @foreach (\App\Models\Device::ICON_CHOICES as $ic) + + @endforeach +
+
+ + {{-- Connection transport (MQTT vs local HTTP) --}} + @if ($device->vendor === 'Shelly') +
+ +
+ + @if ($transport === 'local') + + @endif + +
+ @error('ip')

{{ $message }}

@enderror +
+ @endif +
{{ __('devices.active') }}
diff --git a/tests/Feature/MqttTest.php b/tests/Feature/MqttTest.php index aa06dbd..1a301ed 100644 --- a/tests/Feature/MqttTest.php +++ b/tests/Feature/MqttTest.php @@ -175,8 +175,9 @@ class MqttTest extends TestCase $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')); + // Optimistic: the state flips immediately so the UI is instant; the device's real status + // echo reconciles it a moment later (monotonic, newer observed_at wins). + $this->assertTrue(data_get($entity->refresh()->state->state, 'on')); } public function test_toggling_a_demo_device_simulates_the_echo(): void