feat(devices): instant toggle, icon picker, real update check, transport switch

- 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 <noreply@anthropic.com>
feat/phase-1-bootstrap
HomeOS Bootstrap 2026-07-18 10:04:19 +02:00
parent da249022cd
commit 2fa15decb4
9 changed files with 187 additions and 18 deletions

View File

@ -7,6 +7,7 @@ use App\Models\Entity;
use App\Models\Room; use App\Models\Room;
use App\Services\DeviceCommandService; use App\Services\DeviceCommandService;
use App\Services\MqttCredentialProvisioner; use App\Services\MqttCredentialProvisioner;
use App\Support\Shelly\ShellyRpc;
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
use Livewire\Attributes\On; use Livewire\Attributes\On;
use Livewire\Attributes\Validate; use Livewire\Attributes\Validate;
@ -22,6 +23,11 @@ class Show extends Component
public ?int $roomId = null; public ?int $roomId = null;
/** 'mqtt' or 'local' (HTTP). */
public string $transport = 'mqtt';
public string $ip = '';
public ?string $flash = null; public ?string $flash = null;
public bool $flashOk = true; public bool $flashOk = true;
@ -34,6 +40,58 @@ class Show extends Component
$this->device = $device; $this->device = $device;
$this->name = $device->name; $this->name = $device->name;
$this->roomId = $device->room_id; $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'); $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 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 protected function flashMessage(string $message): void
@ -227,6 +305,12 @@ class Show extends Component
$this->flashOk = true; $this->flashOk = true;
} }
protected function flashError(string $message): void
{
$this->flash = $message;
$this->flashOk = false;
}
public function render() public function render()
{ {
$this->device->load(['entities.state', 'room']); $this->device->load(['entities.state', 'room']);

View File

@ -55,4 +55,27 @@ class Device extends Model
{ {
return (bool) data_get($this->config, 'cloud', false); 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',
};
}
} }

View File

@ -37,7 +37,7 @@ class DeviceCommandService
fn (DeviceDriver $driver) => $on ? $driver->turnOn($entity) : $driver->turnOff($entity), fn (DeviceDriver $driver) => $on ? $driver->turnOn($entity) : $driver->turnOff($entity),
); );
$this->simulateDemoEcho($command, $entity, ['on' => $on]); $this->applyOptimisticState($command, $entity, ['on' => $on]);
return $command; 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)); $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; return $command;
} }
/** /**
* Demo devices have no real hardware to echo their new state back over MQTT, so the UI would * Reflect the expected state IMMEDIATELY so the UI flips within the request instead of waiting
* look dead when you toggle them. For a demo device we synthesize that echo write the new * 2-3s for the device's status echo. The real echo (MQTT status) or the post-command HTTP poll
* state and broadcast DeviceStateChanged so the mock home is fully interactive (handoff * reconciles it a moment later both carry a newer observed_at, so the monotonic upsert lets
* §13.2, "mock first"). Real devices never take this path; their actual status message does. * 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; return;
} }

View File

@ -24,6 +24,18 @@ return [
'add_probing' => 'Verbinde …', 'add_probing' => 'Verbinde …',
'add_unreachable'=> 'Gerät nicht erreichbar oder kein Shelly. IP prüfen.', 'add_unreachable'=> 'Gerät nicht erreichbar oder kein Shelly. IP prüfen.',
'add' => 'Gerät hinzufügen', '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' => 'Gerät entfernen',
'delete_confirm_title' => '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.', 'delete_confirm_body' => ':name wird mit allen Werten gelöscht. Meldet sich das Gerät erneut, taucht es wieder unter „Neue Geräte“ auf.',

View File

@ -24,6 +24,18 @@ return [
'add_probing' => 'Connecting …', 'add_probing' => 'Connecting …',
'add_unreachable'=> 'Device unreachable or not a Shelly. Check the IP.', 'add_unreachable'=> 'Device unreachable or not a Shelly. Check the IP.',
'add' => 'Add device', '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' => 'Remove device',
'delete_confirm_title' => '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”.', 'delete_confirm_body' => ':name and all its values are deleted. If the device reports again it reappears under “New devices”.',

View File

@ -6,13 +6,7 @@
$on = $primary ? (data_get($primary->state?->state, 'on') === true) : false; $on = $primary ? (data_get($primary->state?->state, 'on') === true) : false;
$secondary = $primary ? $entities->reject(fn ($e) => $e->id === $primary->id) : $entities; $secondary = $primary ? $entities->reject(fn ($e) => $e->id === $primary->id) : $entities;
$online = $device->isOnline(); $online = $device->isOnline();
$icon = match (true) { $icon = $device->displayIcon();
$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',
};
@endphp @endphp
<div @class([ <div @class([

View File

@ -27,6 +27,11 @@
'package' => '<path d="M16.5 9.4 7.5 4.21"/><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>', 'package' => '<path d="M16.5 9.4 7.5 4.21"/><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>',
'doorbell' => '<rect x="7" y="2" width="10" height="20" rx="3"/><circle cx="12" cy="9" r="2.2"/><path d="M10.5 15.5h3"/>', 'doorbell' => '<rect x="7" y="2" width="10" height="20" rx="3"/><circle cx="12" cy="9" r="2.2"/><path d="M10.5 15.5h3"/>',
'cloud' => '<path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z"/>', 'cloud' => '<path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z"/>',
'lamp' => '<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.6A6 6 0 0 0 6 8c0 1.4.5 2.7 1.5 3.6.8.8 1.3 1.5 1.5 2.4"/><path d="M9 18h6"/><path d="M10 22h4"/>',
'plug' => '<path d="M12 22v-5"/><path d="M9 8V2"/><path d="M15 8V2"/><path d="M18 8v4a6 6 0 0 1-12 0V8Z"/>',
'led' => '<rect x="2" y="9" width="20" height="6" rx="2"/><path d="M6 9v6M10 9v6M14 9v6M18 9v6"/>',
'door' => '<path d="M13 4h3a2 2 0 0 1 2 2v14"/><path d="M2 20h20"/><path d="M13 20V4a1 1 0 0 0-1.2-1l-5 1.4A1 1 0 0 0 6 5.4V20"/><path d="M10 12v.5"/>',
'temp' => '<path d="M14 4v10.5a4 4 0 1 1-4 0V4a2 2 0 0 1 4 0Z"/>',
'dot' => '<circle cx="12" cy="12" r="4"/>', 'dot' => '<circle cx="12" cy="12" r="4"/>',
]; ];
$inner = $paths[$name] ?? $paths['dot']; $inner = $paths[$name] ?? $paths['dot'];

View File

@ -165,6 +165,44 @@
</div> </div>
</div> </div>
{{-- Display icon --}}
<div class="flex flex-col gap-1.5 border-t border-line-soft pt-3">
<label class="text-[12px] font-semibold text-ink-2">{{ __('devices.icon') }}</label>
<div class="flex flex-wrap gap-1.5">
@foreach (\App\Models\Device::ICON_CHOICES as $ic)
<button type="button" wire:click="setIcon('{{ $ic }}')"
@class([
'grid place-items-center w-9 h-9 rounded-lg border transition-colors',
'border-accent bg-accent/15 text-accent' => $device->displayIcon() === $ic,
'border-line-soft bg-raised text-ink-2 hover:text-ink' => $device->displayIcon() !== $ic,
])>
<x-icon :name="$ic" :size="17" />
</button>
@endforeach
</div>
</div>
{{-- Connection transport (MQTT vs local HTTP) --}}
@if ($device->vendor === 'Shelly')
<div class="flex flex-col gap-2 border-t border-line-soft pt-3">
<label class="text-[12px] font-semibold text-ink-2">{{ __('devices.transport') }}</label>
<div class="flex flex-wrap gap-2">
<select wire:model.live="transport"
class="rounded-lg bg-raised border border-line px-3 py-2 text-sm text-ink outline-none focus:border-accent">
<option value="local">{{ __('devices.transport_local') }}</option>
<option value="mqtt">{{ __('devices.transport_mqtt') }}</option>
</select>
@if ($transport === 'local')
<input wire:model="ip" type="text" inputmode="decimal" placeholder="10.10.30.78"
class="flex-1 min-w-[8rem] rounded-lg bg-raised border border-line px-3 py-2 text-sm font-mono text-ink outline-none focus:border-accent">
@endif
<button type="button" wire:click="setTransport"
class="rounded-lg border border-line px-3.5 py-2 text-[13px] font-semibold text-ink-2 hover:text-ink hover:bg-raised transition-colors">{{ __('devices.save') }}</button>
</div>
@error('ip') <p class="text-[12px] text-offline">{{ $message }}</p> @enderror
</div>
@endif
<div class="flex items-center gap-3 border-t border-line-soft pt-3"> <div class="flex items-center gap-3 border-t border-line-soft pt-3">
<div class="min-w-0"> <div class="min-w-0">
<div class="text-[13px] font-semibold text-ink">{{ __('devices.active') }}</div> <div class="text-[13px] font-semibold text-ink">{{ __('devices.active') }}</div>

View File

@ -175,8 +175,9 @@ class MqttTest extends TestCase
$this->assertSame('ok', $command->result); $this->assertSame('ok', $command->result);
$this->assertDatabaseHas('commands', ['command' => 'turn_on', 'source' => 'user', 'result' => 'ok']); $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. // Optimistic: the state flips immediately so the UI is instant; the device's real status
$this->assertFalse(data_get($entity->refresh()->state->state, 'on')); // 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 public function test_toggling_a_demo_device_simulates_the_echo(): void