323 lines
10 KiB
PHP
323 lines
10 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 App\Support\Shelly\ShellyRpc;
|
|
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;
|
|
|
|
/** 'mqtt' or 'local' (HTTP). */
|
|
public string $transport = 'mqtt';
|
|
|
|
public string $ip = '';
|
|
|
|
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;
|
|
$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(),
|
|
]);
|
|
}
|
|
}
|