homeos/app/Livewire/Devices/Show.php

220 lines
6.9 KiB
PHP

<?php
namespace App\Livewire\Devices;
use App\Models\Device;
use App\Models\Entity;
use App\Models\Room;
use App\Services\DeviceCommandService;
use App\Services\MqttCredentialProvisioner;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Validate;
use Livewire\Component;
#[Layout('layouts.app')]
class Show extends Component
{
public Device $device;
#[Validate('required|string|max:100')]
public string $name = '';
public ?int $roomId = null;
public ?string $flash = null;
public bool $flashOk = true;
/** One-time per-device MQTT credential, shown after the user generates one (advanced). */
public ?array $mqttCredential = null;
public function mount(Device $device): void
{
$this->device = $device;
$this->name = $device->name;
$this->roomId = $device->room_id;
}
/**
* 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' => config('homeos.mqtt.device_host') ?: __('devices.mqtt_host_hint'),
'port' => (int) config('mqtt-client.connections.default.port', 1883),
];
}
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);
}
/**
* 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 ?? [];
if ($role !== null) {
$on = array_key_exists('on', $state)
? (bool) $state['on']
: (($state['position'] ?? 'closed') !== 'closed');
$open = ($role['invert'] ?? false) ? ! $on : $on;
$newState = ['open' => $open, 'position' => $open ? 'open' : 'closed', 'kind' => $role['kind']];
$newType = 'contact';
} else {
$on = array_key_exists('on', $state) ? (bool) $state['on'] : (($state['position'] ?? 'closed') !== 'closed');
$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');
}
public function checkUpdate(): void
{
$this->flashMessage(__('devices.no_update_demo'));
}
protected function flashMessage(string $message): void
{
$this->flash = $message;
$this->flashOk = true;
}
public function render()
{
$this->device->load(['entities.state', 'room']);
return view('livewire.devices.show', [
'rooms' => Room::orderBy('sort')->orderBy('name')->get(),
]);
}
}