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')); } /** * Advanced: generate a dedicated MQTT credential for THIS device (username = its prefix), * bound to just its own topics by the `pattern %u` ACL. The default onboarding uses the * shared `shelly` account (Settings → Geräte-MQTT); this is opt-in hardening. */ public function generateMqttCredential(): void { $prefix = data_get($this->device->config, 'mqtt_prefix'); if (blank($prefix)) { $this->flashMessage(__('devices.mqtt_no_prefix')); return; } $password = app(MqttCredentialProvisioner::class)->provision($prefix); $this->mqttCredential = [ 'username' => $prefix, 'password' => $password, 'host' => \App\Support\HostAddress::broker(), 'port' => \App\Support\HostAddress::port(), ]; } public function saveName(): void { $this->validate(); $this->device->update(['name' => $this->name]); $this->flashMessage(__('devices.saved')); } public function saveRoom(): void { $this->validate(['roomId' => ['nullable', 'integer', 'exists:rooms,id']]); $this->device->update(['room_id' => $this->roomId ?: null]); $this->flashMessage(__('devices.saved')); } public function toggleStatus(): void { $this->device->update([ 'status' => $this->device->status === 'active' ? 'ignored' : 'active', ]); $this->flashMessage(__('devices.saved')); } /** Live update: refresh when this device's state changes on the bus. */ #[On('echo-private:home,.DeviceStateChanged')] public function onDeviceStateChanged(): void { // no-op — triggers a re-render with fresh entity state. } /** Toggle a switch/light entity through the command service (H1 — audited + driver). */ public function toggleEntity(int $entityId): void { $entity = $this->device->entities()->with('state')->find($entityId); if ($entity === null || ! in_array($entity->type, ['switch', 'light'], true)) { return; } $command = app(DeviceCommandService::class)->toggle($entity); $this->flashCommand($command->result); } #[On('restartDevice')] public function restart(): void { $command = app(DeviceCommandService::class)->reboot($this->device); $this->flashCommand($command->result); } /** Delete the device (cascades entities/state) and free its discovery finding to be re-added. */ #[On('deleteDevice')] public function deleteDevice() { $prefix = data_get($this->device->config, 'mqtt_prefix'); \App\Models\DiscoveryFinding::query() ->where('device_id', $this->device->id) ->when($prefix, fn ($q) => $q->orWhere('name', $prefix)->orWhere('identifier', $prefix)) ->update(['status' => 'new', 'device_id' => null]); $this->device->delete(); return redirect()->route('devices.index'); } /** * Assign a Shelly input a role: '' = plain input, 'window'/'door' = a contact sensor. * Stored in device config so the ingest maps future messages accordingly; the entity is * reclassified now so the page reflects it immediately. */ public function setInputRole(int $entityId, string $kind): void { $entity = $this->inputEntity($entityId); if ($entity === null) { return; } $index = explode(':', $entity->key)[1] ?? '0'; $config = $this->device->config ?? []; if ($kind === '') { unset($config['input_roles'][$index]); $role = null; } else { $role = [ 'kind' => in_array($kind, ['window', 'door'], true) ? $kind : 'window', 'invert' => (bool) data_get($config, "input_roles.{$index}.invert", false), ]; $config['input_roles'][$index] = $role; } $this->device->update(['config' => $config]); $this->reclassifyInput($entity, $role); $this->flashMessage(__('devices.saved')); } public function setInputInvert(int $entityId, bool $invert): void { $entity = $this->inputEntity($entityId); if ($entity === null) { return; } $index = explode(':', $entity->key)[1] ?? '0'; $config = $this->device->config ?? []; if (! isset($config['input_roles'][$index])) { return; } $config['input_roles'][$index]['invert'] = $invert; $this->device->update(['config' => $config]); $this->reclassifyInput($entity, $config['input_roles'][$index]); $this->flashMessage(__('devices.saved')); } private function inputEntity(int $entityId): ?Entity { $entity = $this->device->entities()->with('state')->find($entityId); return ($entity !== null && str_starts_with($entity->key, 'input:')) ? $entity : null; } /** Reclassify an input entity to contact (role set) or back to input, reformatting its state. */ private function reclassifyInput(Entity $entity, ?array $role): void { $state = $entity->state?->state ?? []; // Always work from the RAW input level. It's carried in `on` even on a contact (the ingest // preserves it), so flipping inversion/role stays reversible instead of baking in the // previously-displayed value. Fall back to deriving it only for legacy rows without `on`. $on = array_key_exists('on', $state) ? (bool) $state['on'] : (($state['position'] ?? 'closed') !== 'closed'); if ($role !== null) { $open = ($role['invert'] ?? false) ? ! $on : $on; $newState = ['open' => $open, 'position' => $open ? 'open' : 'closed', 'kind' => $role['kind'], 'on' => $on]; $newType = 'contact'; } else { $newState = ['on' => $on]; $newType = 'input'; } $entity->forceFill(['type' => $newType])->save(); $entity->state?->update(['state' => $newState]); } protected function flashCommand(string $result): void { $ok = $result === 'ok'; $this->flashOk = $ok; $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 { $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 { $this->flash = $message; $this->flashOk = true; } protected function flashError(string $message): void { $this->flash = $message; $this->flashOk = false; } public function render() { $this->device->load(['entities.state', 'room']); return view('livewire.devices.show', [ 'rooms' => Room::orderBy('sort')->orderBy('name')->get(), ]); } }