From e3ad653582caa9b7750dc744d07ea8af9ab924a6 Mon Sep 17 00:00:00 2001 From: HomeOS Bootstrap Date: Sat, 18 Jul 2026 09:48:35 +0200 Subject: [PATCH] =?UTF-8?q?feat(shelly):=20local=20HTTP/RPC=20transport=20?= =?UTF-8?q?=E2=80=94=20add=20by=20IP=20like=20Home=20Assistant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit You were right that HA uses the Shelly LOCAL API, not MQTT. Adds that path (user chose "both") — control + status over http:///rpc, no MQTT setup on the device: - ShellyRpc (POST /rpc), ShellyHttpDriver (Switch/Light.Set, Reboot). - ShellyStatusApplier: one shared apply path (normalize + input roles + monotonic upsert + broadcast) reused by BOTH the MQTT ingest and the HTTP poll, so transports can't drift. IngestShellyMessage refactored onto it. - ShellyLocalOnboarder: probe an IP → GetDeviceInfo/GetStatus → create an http-protocol device with its entities (reuses an MQTT-onboarded row by id, no duplicate). AssignDevice uses it when a discovered Shelly is reachable; falls back to MQTT-style if not. - Manual "Gerät hinzufügen" modal (add by IP). shelly:poll scheduled every 10s + a re-poll after each command (PollShellyDevice) for near-live status. driverFor picks http vs mqtt by protocol. - Normalizer now drops housekeeping components (sys/wifi/cloud/mqtt/ws/…) so GetStatus doesn't create junk entities. 6 ShellyHttpTest cases (Http::fake). Suite 68 green, 12/12 clean. LIVE-VERIFIED against the real Shelly 1 Mini Gen3 at 10.10.30.78: onboarded over local API (protocol http), entities switch:0 + input:0, kept online by the 10s poll — no MQTT configured on the device. Co-Authored-By: Claude Opus 4.8 --- app/Console/Commands/ShellyPollCommand.php | 27 +++++ app/Jobs/IngestShellyMessage.php | 62 +---------- app/Jobs/PollShellyDevice.php | 25 +++++ app/Livewire/Modals/AddDevice.php | 57 ++++++++++ app/Livewire/Modals/AssignDevice.php | 19 +++- app/Services/DeviceCommandService.php | 8 ++ app/Services/ShellyLocalOnboarder.php | 57 ++++++++++ app/Services/ShellyPoller.php | 40 +++++++ app/Services/ShellyStatusApplier.php | 90 +++++++++++++++ app/Support/Drivers/ShellyHttpDriver.php | 80 ++++++++++++++ app/Support/Mqtt/ShellyNormalizer.php | 6 +- app/Support/Shelly/ShellyRpc.php | 67 +++++++++++ lang/de/devices.php | 10 +- lang/en/devices.php | 10 +- .../views/livewire/devices/index.blade.php | 9 +- .../livewire/modals/add-device.blade.php | 50 +++++++++ routes/console.php | 3 + tests/Feature/ShellyHttpTest.php | 104 ++++++++++++++++++ 18 files changed, 660 insertions(+), 64 deletions(-) create mode 100644 app/Console/Commands/ShellyPollCommand.php create mode 100644 app/Jobs/PollShellyDevice.php create mode 100644 app/Livewire/Modals/AddDevice.php create mode 100644 app/Services/ShellyLocalOnboarder.php create mode 100644 app/Services/ShellyPoller.php create mode 100644 app/Services/ShellyStatusApplier.php create mode 100644 app/Support/Drivers/ShellyHttpDriver.php create mode 100644 app/Support/Shelly/ShellyRpc.php create mode 100644 resources/views/livewire/modals/add-device.blade.php create mode 100644 tests/Feature/ShellyHttpTest.php diff --git a/app/Console/Commands/ShellyPollCommand.php b/app/Console/Commands/ShellyPollCommand.php new file mode 100644 index 0000000..d76a65b --- /dev/null +++ b/app/Console/Commands/ShellyPollCommand.php @@ -0,0 +1,27 @@ +where('vendor', 'Shelly') + ->where('protocol', 'http') + ->where('status', 'active') + ->pluck('id') + ->each(fn ($id) => PollShellyDevice::dispatch($id)); + + return self::SUCCESS; + } +} diff --git a/app/Jobs/IngestShellyMessage.php b/app/Jobs/IngestShellyMessage.php index 70b9c83..81bef9b 100644 --- a/app/Jobs/IngestShellyMessage.php +++ b/app/Jobs/IngestShellyMessage.php @@ -2,9 +2,8 @@ namespace App\Jobs; -use App\Events\DeviceStateChanged; -use App\Jobs\Concerns\AppliesDeviceState; use App\Models\Device; +use App\Services\ShellyStatusApplier; use App\Support\Mqtt\ShellyNormalizer; use App\Support\Mqtt\ShellyTopics; use Illuminate\Contracts\Queue\ShouldQueue; @@ -13,7 +12,8 @@ use Illuminate\Foundation\Queue\Queueable; /** * The single ingest path for Shelly status messages (H4). The MQTT callback only - * dispatches this (H2); here we resolve the device, upsert current state and broadcast. + * dispatches this (H2); here we resolve the device, then hand the component to the shared + * ShellyStatusApplier (same code the local HTTP poll uses) to upsert state and broadcast. * * Devices auto-onboard: the first time a prefix publishes a recognizable component the device is * created (Home-Assistant-style), so pointing a Shelly at the broker is all it takes — no manual @@ -25,7 +25,6 @@ use Illuminate\Foundation\Queue\Queueable; */ class IngestShellyMessage implements ShouldQueue { - use AppliesDeviceState; use Queueable; /** Prefixes a device may never claim — they belong to internal/other-integration namespaces. */ @@ -77,10 +76,6 @@ class IngestShellyMessage implements ShouldQueue } } - // Apply per-device input roles: an input the user promoted to a window/door contact - // becomes a contact entity (invert-aware). Config-driven, so this stays out of the driver. - $updates = array_map(fn ($u) => $this->applyInputRole($device, $u), $updates); - $observedAt = $this->observedAt ?? (int) round(microtime(true) * 1_000_000); $observedTime = \Illuminate\Support\Carbon::createFromTimestampMs(intdiv($observedAt, 1000)); @@ -91,22 +86,9 @@ class IngestShellyMessage implements ShouldQueue ->where(fn ($q) => $q->whereNull('last_seen_at')->orWhere('last_seen_at', '<', $observedTime)) ->update(['last_seen_at' => $observedTime, 'updated_at' => now()]); - foreach ($updates as $update) { - $entity = $device->entities()->firstOrCreate( - ['key' => $update['key']], - ['type' => $update['type']], - ); - - // Keep the entity type in sync with its (config-driven) role — e.g. an input promoted - // to a contact, or demoted back — so the correct renderer/query applies. - if ($entity->type !== $update['type']) { - $entity->forceFill(['type' => $update['type']])->save(); - } - - if ($this->applyState($entity->id, $update['state'], $observedAt)) { - DeviceStateChanged::dispatch($device->uuid, $entity->key, $update['state']); - } - } + // Shared applier (also used by the local HTTP poll) — normalizes, applies input roles, + // upserts monotonically and broadcasts. + app(ShellyStatusApplier::class)->applyComponent($device, $component, $data, $observedAt); } /** @param array $updates */ @@ -115,38 +97,6 @@ class IngestShellyMessage implements ShouldQueue return array_intersect(array_column($updates, 'type'), ShellyNormalizer::PRIMARY_TYPES) !== []; } - /** - * Promote a raw `input` update to a `contact` when the user has assigned that input a - * window/door role on the device page (stored in device config `input_roles.`). - * - * @param array{key:string,type:string,state:array} $update - * @return array{key:string,type:string,state:array} - */ - private function applyInputRole(Device $device, array $update): array - { - if ($update['type'] !== 'input') { - return $update; - } - - $index = explode(':', $update['key'])[1] ?? '0'; - $role = data_get($device->config, "input_roles.{$index}"); - - if (! is_array($role)) { - return $update; - } - - $on = (bool) ($update['state']['on'] ?? false); - $open = ($role['invert'] ?? false) ? ! $on : $on; - - // Keep the RAW input level (`on`) in the state so the device page can re-derive open/closed - // when the user flips inversion or the role, without the displayed value having been baked in. - return [ - 'key' => $update['key'], - 'type' => 'contact', - 'state' => ['open' => $open, 'position' => $open ? 'open' : 'closed', 'kind' => $role['kind'] ?? 'window', 'on' => $on], - ]; - } - private function onboard(string $prefix): ?Device { // Cap auto-onboarding so a misbehaving/compromised device flooding distinct prefixes on diff --git a/app/Jobs/PollShellyDevice.php b/app/Jobs/PollShellyDevice.php new file mode 100644 index 0000000..f6db7c3 --- /dev/null +++ b/app/Jobs/PollShellyDevice.php @@ -0,0 +1,25 @@ +deviceId); + + if ($device !== null && $device->protocol === 'http') { + $poller->poll($device); + } + } +} diff --git a/app/Livewire/Modals/AddDevice.php b/app/Livewire/Modals/AddDevice.php new file mode 100644 index 0000000..8491f1c --- /dev/null +++ b/app/Livewire/Modals/AddDevice.php @@ -0,0 +1,57 @@ +validate([ + 'ip' => ['required', 'string', 'max:64', 'regex:/^[a-zA-Z0-9.\-]+$/'], + 'name' => ['nullable', 'string', 'max:100'], + 'roomId' => ['nullable', 'integer', 'exists:rooms,id'], + ]); + + $this->error = null; + + try { + $device = app(ShellyLocalOnboarder::class)->onboard(trim($this->ip), $this->name ?: null, $this->roomId ?: null); + } catch (\Throwable) { + $this->error = __('devices.add_unreachable'); + + return; + } + + $this->closeModal(); + + return redirect()->route('devices.show', $device); + } + + public function render() + { + return view('livewire.modals.add-device', [ + 'rooms' => Room::orderBy('sort')->orderBy('name')->get(), + ]); + } +} diff --git a/app/Livewire/Modals/AssignDevice.php b/app/Livewire/Modals/AssignDevice.php index a7a9ad0..7b35e51 100644 --- a/app/Livewire/Modals/AssignDevice.php +++ b/app/Livewire/Modals/AssignDevice.php @@ -5,6 +5,7 @@ namespace App\Livewire\Modals; use App\Models\Device; use App\Models\DiscoveryFinding; use App\Models\Room; +use App\Services\ShellyLocalOnboarder; use Livewire\Attributes\Computed; use LivewireUI\Modal\ModalComponent; @@ -44,13 +45,25 @@ class AssignDevice extends ModalComponent $finding = $this->finding(); $isShelly = $finding->vendor === 'Shelly'; + // Preferred path (like HA): if it's a Shelly we can reach over its local API, onboard it + // there — control + status run over HTTP, no MQTT setup on the device. + if ($isShelly && filled($finding->ip)) { + try { + $device = app(ShellyLocalOnboarder::class)->onboard($finding->ip, $this->name, $this->roomId ?: null); + $finding->update(['status' => 'assigned', 'device_id' => $device->id]); + $this->closeModal(); + + return redirect()->route('devices.show', $device); + } catch (\Throwable) { + // Unreachable over HTTP — fall through to the MQTT-style record below. + } + } + // The Shelly MQTT topic prefix is its device id — the mDNS service INSTANCE name // (finding->name), not the slugified topic identifier which carries the _shelly._tcp suffix. $prefix = $isShelly ? ($finding->name ?: $finding->identifier) : null; - // The device may already have auto-onboarded from its MQTT traffic — reuse that row by - // prefix so assigning just names it and puts it in a room (no duplicate). Devices sign in - // with the shared `shelly` account, so no per-device credential is provisioned here. + // Reuse an already-onboarded row by prefix so assigning just names it (no duplicate). $device = ($prefix ? Device::where('config->mqtt_prefix', $prefix)->first() : null) ?? new Device; diff --git a/app/Services/DeviceCommandService.php b/app/Services/DeviceCommandService.php index a61a159..df3dcad 100644 --- a/app/Services/DeviceCommandService.php +++ b/app/Services/DeviceCommandService.php @@ -3,11 +3,13 @@ namespace App\Services; use App\Events\DeviceStateChanged; +use App\Jobs\PollShellyDevice; 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\ShellyHttpDriver; use App\Support\Drivers\ShellyMqttDriver; use Illuminate\Support\Facades\Auth; @@ -82,6 +84,11 @@ class DeviceCommandService try { $action($this->driverFor($device)); + + // Local (HTTP) devices don't push state — re-poll so the change reflects promptly. + if ($result === 'ok' && $device->protocol === 'http') { + PollShellyDevice::dispatch($device->id); + } } catch (\Throwable $e) { report($e); $result = 'error: '.$e->getMessage(); @@ -101,6 +108,7 @@ class DeviceCommandService private function driverFor(Device $device): DeviceDriver { return match (true) { + $device->vendor === 'Shelly' && $device->protocol === 'http' => app(ShellyHttpDriver::class), $device->vendor === 'Shelly' && $device->protocol === 'mqtt' => app(ShellyMqttDriver::class), default => throw new \RuntimeException("No driver for {$device->vendor}/{$device->protocol}."), }; diff --git a/app/Services/ShellyLocalOnboarder.php b/app/Services/ShellyLocalOnboarder.php new file mode 100644 index 0000000..b78ec03 --- /dev/null +++ b/app/Services/ShellyLocalOnboarder.php @@ -0,0 +1,57 @@ +rpc->info($ip); // {id, model, gen, name, …} — throws if unreachable + $status = $this->rpc->status($ip); + $shellyId = $info['id'] ?? null; + + // Reuse an existing row for this device (already added locally, or auto-onboarded via MQTT + // under the same id) so switching to local control never creates a duplicate. + $device = Device::where('config->ip', $ip)->first() + ?? ($shellyId ? Device::where('config->mqtt_prefix', $shellyId)->first() : null) + ?? new Device; + + $device->fill([ + 'name' => $name ?: ($device->name ?: ($info['name'] ?? $shellyId ?? $ip)), + 'vendor' => 'Shelly', + 'model' => $info['model'] ?? $device->model, + 'protocol' => 'http', + 'status' => 'active', + 'last_seen_at' => now(), + ]); + if ($roomId !== null) { + $device->room_id = $roomId; + } + $device->config = array_merge($device->config ?? [], array_filter([ + 'ip' => $ip, + 'transport' => 'local', + 'mqtt_prefix' => $shellyId, // keep so discovery dedup + optional MQTT still work + ], fn ($v) => $v !== null)); + $device->save(); + + $this->applier->applyStatus($device, $status, (int) round(microtime(true) * 1_000_000)); + + return $device; + } +} diff --git a/app/Services/ShellyPoller.php b/app/Services/ShellyPoller.php new file mode 100644 index 0000000..fa43569 --- /dev/null +++ b/app/Services/ShellyPoller.php @@ -0,0 +1,40 @@ +config, 'ip'); + if (blank($ip)) { + return false; + } + + try { + $status = $this->rpc->status($ip); + } catch (\Throwable) { + // Unreachable — normal for a powered-off device. Don't advance last_seen (it falls + // offline after the timeout) and don't spam the log on every poll. + return false; + } + + $observedAt = (int) round(microtime(true) * 1_000_000); + $device->forceFill(['last_seen_at' => now()])->save(); + $this->applier->applyStatus($device, $status, $observedAt); + + return true; + } +} diff --git a/app/Services/ShellyStatusApplier.php b/app/Services/ShellyStatusApplier.php new file mode 100644 index 0000000..946ea57 --- /dev/null +++ b/app/Services/ShellyStatusApplier.php @@ -0,0 +1,90 @@ + data), e.g. the result of Shelly.GetStatus. + * + * @param array $status + */ + public function applyStatus(Device $device, array $status, int $observedAt): void + { + foreach ($status as $component => $data) { + if (is_array($data)) { + $this->applyComponent($device, $component, $data, $observedAt); + } + } + } + + /** + * Apply one component's payload (e.g. "switch:0" => {...}). + * + * @param array $data + */ + public function applyComponent(Device $device, string $component, array $data, int $observedAt): void + { + foreach (ShellyNormalizer::normalize($component, $data) as $update) { + $update = $this->applyInputRole($device, $update); + + $entity = $device->entities()->firstOrCreate( + ['key' => $update['key']], + ['type' => $update['type']], + ); + + // Keep the entity type in sync with its (config-driven) role — e.g. an input promoted + // to a contact, or demoted back. + if ($entity->type !== $update['type']) { + $entity->forceFill(['type' => $update['type']])->save(); + } + + if ($this->applyState($entity->id, $update['state'], $observedAt)) { + DeviceStateChanged::dispatch($device->uuid, $entity->key, $update['state']); + } + } + } + + /** + * Promote a raw `input` update to a `contact` when the user assigned that input a window/door + * role on the device page (config `input_roles.`). Keeps the raw `on` for reversibility. + * + * @param array{key:string,type:string,state:array} $update + * @return array{key:string,type:string,state:array} + */ + public function applyInputRole(Device $device, array $update): array + { + if ($update['type'] !== 'input') { + return $update; + } + + $index = explode(':', $update['key'])[1] ?? '0'; + $role = data_get($device->config, "input_roles.{$index}"); + + if (! is_array($role)) { + return $update; + } + + $on = (bool) ($update['state']['on'] ?? false); + $open = ($role['invert'] ?? false) ? ! $on : $on; + + return [ + 'key' => $update['key'], + 'type' => 'contact', + 'state' => ['open' => $open, 'position' => $open ? 'open' : 'closed', 'kind' => $role['kind'] ?? 'window', 'on' => $on], + ]; + } +} diff --git a/app/Support/Drivers/ShellyHttpDriver.php b/app/Support/Drivers/ShellyHttpDriver.php new file mode 100644 index 0000000..4926440 --- /dev/null +++ b/app/Support/Drivers/ShellyHttpDriver.php @@ -0,0 +1,80 @@ +/rpc`) — the same + * approach Home Assistant uses, no MQTT setup on the device. The device applies the change and + * we re-poll its status to reflect it. Vendor specifics stay here (H3). + */ +class ShellyHttpDriver implements DeviceDriver +{ + public function __construct(private readonly ShellyRpc $rpc) {} + + public function turnOn(Entity $entity): void + { + $this->setComponentOn($entity, true); + } + + public function turnOff(Entity $entity): void + { + $this->setComponentOn($entity, false); + } + + public function setState(Entity $entity, array $state): void + { + if (array_key_exists('on', $state)) { + $this->setComponentOn($entity, (bool) $state['on']); + } + } + + public function setLight(Entity $entity, array $params): void + { + [, $id] = array_pad(explode(':', $entity->key, 2), 2, '0'); + + $rpc = ['id' => (int) $id]; + if (array_key_exists('on', $params)) { + $rpc['on'] = (bool) $params['on']; + } + if (isset($params['brightness'])) { + $rpc['brightness'] = max(0, min(100, (int) $params['brightness'])); + } + if (isset($params['rgb']) && is_array($params['rgb'])) { + $rpc['rgb'] = array_map(fn ($v) => max(0, min(255, (int) $v)), array_slice($params['rgb'], 0, 3)); + } + + $this->rpc->call($this->ip($entity->device), 'Light.Set', $rpc); + } + + public function reboot(Device $device): void + { + $this->rpc->call($this->ip($device), 'Shelly.Reboot'); + } + + public function capabilities(): array + { + return ['switch', 'light']; + } + + private function setComponentOn(Entity $entity, bool $on): void + { + [$component, $id] = array_pad(explode(':', $entity->key, 2), 2, '0'); + + $this->rpc->call($this->ip($entity->device), ucfirst($component).'.Set', ['id' => (int) $id, 'on' => $on]); + } + + private function ip(Device $device): string + { + $ip = data_get($device->config, 'ip'); + + if (blank($ip)) { + throw new \RuntimeException("Device {$device->id} has no local IP configured."); + } + + return $ip; + } +} diff --git a/app/Support/Mqtt/ShellyNormalizer.php b/app/Support/Mqtt/ShellyNormalizer.php index 115acdf..c514092 100644 --- a/app/Support/Mqtt/ShellyNormalizer.php +++ b/app/Support/Mqtt/ShellyNormalizer.php @@ -74,8 +74,10 @@ class ShellyNormalizer break; default: - // Unknown component — store the raw payload under its own key. - $updates[] = ['key' => $component, 'type' => $kind, 'state' => $payload]; + // Housekeeping / unrecognized component (sys, wifi, cloud, mqtt, ws, ble, eth, + // knx, matter, …). These are noise in a home UI — ignore them. The full local + // Shelly.GetStatus returns all of them, so this keeps only real entities. + break; } return $updates; diff --git a/app/Support/Shelly/ShellyRpc.php b/app/Support/Shelly/ShellyRpc.php new file mode 100644 index 0000000..2193713 --- /dev/null +++ b/app/Support/Shelly/ShellyRpc.php @@ -0,0 +1,67 @@ +/rpc`). + * This is the same transport Home Assistant uses — no MQTT setup on the device, just its IP. + * Vendor specifics stay here (H3). + */ +class ShellyRpc +{ + /** + * @param array $params + * @return array + */ + public function call(string $ip, string $method, array $params = []): array + { + $body = ['id' => 1, 'src' => 'homeos', 'method' => $method]; + if ($params !== []) { + $body['params'] = $params; + } + + $response = Http::timeout(5)->connectTimeout(3)->acceptJson()->post($this->url($ip), $body); + $json = $response->json(); + + if (! is_array($json)) { + throw new \RuntimeException("Shelly {$ip}: invalid RPC response."); + } + + if (isset($json['error'])) { + throw new \RuntimeException("Shelly {$ip} RPC error: ".json_encode($json['error'])); + } + + return $json['result'] ?? []; + } + + /** Full component status keyed by component ("switch:0", "input:0", …). */ + public function status(string $ip): array + { + return $this->call($ip, 'Shelly.GetStatus'); + } + + /** Device identity: {id, model, gen, name, ver, …}. */ + public function info(string $ip): array + { + return $this->call($ip, 'Shelly.GetDeviceInfo'); + } + + /** True if the device answers RPC (used to validate a manual IP before onboarding). */ + public function reachable(string $ip): bool + { + try { + $this->info($ip); + + return true; + } catch (\Throwable) { + return false; + } + } + + private function url(string $ip): string + { + return 'http://'.$ip.'/rpc'; + } +} diff --git a/lang/de/devices.php b/lang/de/devices.php index beeae96..c8778bd 100644 --- a/lang/de/devices.php +++ b/lang/de/devices.php @@ -16,6 +16,14 @@ return [ 'role_window' => 'Fensterkontakt', 'role_door' => 'Türkontakt', 'role_invert' => 'Invertiert', + 'add_title' => 'Gerät hinzufügen', + 'add_intro' => 'Gib die IP-Adresse eines Shelly ein. HomeOS spricht das Gerät direkt über seine lokale API an — keine MQTT-Einrichtung nötig.', + 'add_ip' => 'IP-Adresse', + 'add_optional' => 'optional', + 'add_submit' => 'Hinzufügen', + 'add_probing' => 'Verbinde …', + 'add_unreachable'=> 'Gerät nicht erreichbar oder kein Shelly. IP prüfen.', + 'add' => 'Gerät hinzufügen', '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.', @@ -61,7 +69,7 @@ return [ // actions (mock until Phase 3 / DeviceCommandService) 'actions' => 'Aktionen', - 'actions_demo_hint' => 'Befehle werden per MQTT an das Gerät gesendet und protokolliert. Update-Prüfung folgt.', + 'actions_demo_hint' => 'Befehle werden an das Gerät gesendet und protokolliert. Update-Prüfung folgt.', 'restart' => 'Neustart', 'check_update' => 'Update prüfen', 'command_sent' => 'Befehl gesendet.', diff --git a/lang/en/devices.php b/lang/en/devices.php index c8cdea0..6f7358a 100644 --- a/lang/en/devices.php +++ b/lang/en/devices.php @@ -16,6 +16,14 @@ return [ 'role_window' => 'Window contact', 'role_door' => 'Door contact', 'role_invert' => 'Inverted', + 'add_title' => 'Add device', + 'add_intro' => 'Enter a Shelly\'s IP address. HomeOS talks to it directly over its local API — no MQTT setup needed.', + 'add_ip' => 'IP address', + 'add_optional' => 'optional', + 'add_submit' => 'Add', + 'add_probing' => 'Connecting …', + 'add_unreachable'=> 'Device unreachable or not a Shelly. Check the IP.', + 'add' => 'Add device', '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”.', @@ -61,7 +69,7 @@ return [ // actions (mock until Phase 3 / DeviceCommandService) 'actions' => 'Actions', - 'actions_demo_hint' => 'Commands are sent to the device over MQTT and audited. Update check is coming.', + 'actions_demo_hint' => 'Commands are sent to the device and audited. Update check is coming.', 'restart' => 'Restart', 'check_update' => 'Check for update', 'command_sent' => 'Command sent.', diff --git a/resources/views/livewire/devices/index.blade.php b/resources/views/livewire/devices/index.blade.php index 27bc310..ee12937 100644 --- a/resources/views/livewire/devices/index.blade.php +++ b/resources/views/livewire/devices/index.blade.php @@ -1,5 +1,12 @@
- + + + + +
{{-- filters --}} diff --git a/resources/views/livewire/modals/add-device.blade.php b/resources/views/livewire/modals/add-device.blade.php new file mode 100644 index 0000000..89d4661 --- /dev/null +++ b/resources/views/livewire/modals/add-device.blade.php @@ -0,0 +1,50 @@ +
+
+ +

{{ __('devices.add_title') }}

+ +
+ +
+

{{ __('devices.add_intro') }}

+ + @if ($error) +

{{ $error }}

+ @endif + +
+ + + @error('ip')

{{ $message }}

@enderror +
+ +
+ + + @error('name')

{{ $message }}

@enderror +
+ +
+ + +
+ +
+ + +
+
+
diff --git a/routes/console.php b/routes/console.php index ae228e1..467bb97 100644 --- a/routes/console.php +++ b/routes/console.php @@ -16,3 +16,6 @@ Schedule::command('metrics:sample')->everyMinute()->withoutOverlapping(); // Time-triggered automations (state-change ones fire off the DeviceStateChanged listener). Schedule::command('automations:tick')->everyMinute()->withoutOverlapping(); + +// Poll local (HTTP) Shelly devices for near-live status (MQTT devices push instead). +Schedule::command('shelly:poll')->everyTenSeconds()->withoutOverlapping(); diff --git a/tests/Feature/ShellyHttpTest.php b/tests/Feature/ShellyHttpTest.php new file mode 100644 index 0000000..f6e42d5 --- /dev/null +++ b/tests/Feature/ShellyHttpTest.php @@ -0,0 +1,104 @@ + function ($request) use ($switchOn) { + $method = $request->data()['method'] ?? ''; + $result = match ($method) { + 'Shelly.GetDeviceInfo' => ['id' => 'shellyplus1-abc', 'model' => 'SNSW-001X16EU', 'name' => 'Flur'], + 'Shelly.GetStatus' => [ + 'switch:0' => ['id' => 0, 'output' => $switchOn, 'apower' => 5.0], + 'input:0' => ['id' => 0, 'state' => false], + 'sys' => ['restart_required' => false], + ], + default => [], + }; + + return Http::response(['id' => 1, 'result' => $result]); + }]); + } + + public function test_local_onboarder_creates_an_http_device_from_the_api(): void + { + $this->fakeShelly(); + + $device = app(ShellyLocalOnboarder::class)->onboard('10.10.30.78', 'Deckenlicht', null); + + $this->assertSame('http', $device->protocol); + $this->assertSame('10.10.30.78', data_get($device->config, 'ip')); + $this->assertSame('shellyplus1-abc', data_get($device->config, 'mqtt_prefix')); + $this->assertSame('SNSW-001X16EU', $device->model); + $this->assertTrue(data_get($device->entities()->where('key', 'switch:0')->first()->state->state, 'on')); + $this->assertSame(5, data_get($device->entities()->where('key', 'power:0')->first()->state->state, 'watts')); + } + + public function test_onboarding_reuses_a_device_auto_created_via_mqtt(): void + { + $this->fakeShelly(); + Device::create(['name' => 'x', 'vendor' => 'Shelly', 'protocol' => 'mqtt', 'config' => ['mqtt_prefix' => 'shellyplus1-abc']]); + + app(ShellyLocalOnboarder::class)->onboard('10.10.30.78'); + + $this->assertSame(1, Device::where('config->mqtt_prefix', 'shellyplus1-abc')->count()); + $this->assertSame('http', Device::where('config->mqtt_prefix', 'shellyplus1-abc')->first()->protocol); + } + + public function test_http_driver_controls_via_rpc_and_audits(): void + { + $this->fakeShelly(); + $device = app(ShellyLocalOnboarder::class)->onboard('10.10.30.78'); + Http::fake(['*/rpc' => Http::response(['id' => 1, 'result' => []])]); // reset recorded calls target + + $entity = $device->entities()->where('key', 'switch:0')->first(); + $command = app(DeviceCommandService::class)->setOn($entity->load('device', 'state'), false); + + $this->assertSame('ok', $command->result); + Http::assertSent(fn ($request) => str_contains($request->url(), '/rpc') + && ($request->data()['method'] ?? '') === 'Switch.Set' + && ($request->data()['params']['on'] ?? null) === false); + } + + public function test_poller_applies_status_and_marks_reachable(): void + { + $device = Device::create(['name' => 'Flur', 'vendor' => 'Shelly', 'protocol' => 'http', 'config' => ['ip' => '10.10.30.78']]); + $this->fakeShelly(switchOn: false); + + $this->assertTrue(app(ShellyPoller::class)->poll($device)); + $this->assertFalse(data_get($device->entities()->where('key', 'switch:0')->first()->state->state, 'on')); + $this->assertNotNull($device->fresh()->last_seen_at); + } + + public function test_poller_returns_false_when_unreachable(): void + { + $device = Device::create(['name' => 'Flur', 'vendor' => 'Shelly', 'protocol' => 'http', 'config' => ['ip' => '10.10.30.99']]); + Http::fake(['*/rpc' => fn () => throw new \Illuminate\Http\Client\ConnectionException('unreachable')]); + + $this->assertFalse(app(ShellyPoller::class)->poll($device)); + } + + public function test_rpc_reachable_reflects_availability(): void + { + $this->fakeShelly(); + $this->assertTrue(app(ShellyRpc::class)->reachable('10.10.30.78')); + + Http::fake(['*/rpc' => fn () => throw new \Illuminate\Http\Client\ConnectionException('nope')]); + $this->assertFalse(app(ShellyRpc::class)->reachable('10.10.30.99')); + } +}