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
parent
da249022cd
commit
2fa15decb4
|
|
@ -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']);
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.',
|
||||||
|
|
|
||||||
|
|
@ -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”.',
|
||||||
|
|
|
||||||
|
|
@ -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([
|
||||||
|
|
|
||||||
|
|
@ -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'];
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue