homeos/app/Services/DeviceCommandService.php

117 lines
4.0 KiB
PHP

<?php
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;
/**
* The one path from UI/automations to a device (H1). Resolves the driver, executes the
* command and writes an audit row to `commands` — always, success or failure.
*/
class DeviceCommandService
{
public function toggle(Entity $entity, string $source = 'user'): Command
{
$current = (bool) data_get($entity->state?->state, 'on', false);
return $this->setOn($entity, ! $current, $source);
}
public function setOn(Entity $entity, bool $on, string $source = 'user'): Command
{
$command = $this->run(
$entity->device,
$entity,
$on ? 'turn_on' : 'turn_off',
['on' => $on],
$source,
fn (DeviceDriver $driver) => $on ? $driver->turnOn($entity) : $driver->turnOff($entity),
);
$this->simulateDemoEcho($command, $entity, ['on' => $on]);
return $command;
}
public function reboot(Device $device, string $source = 'user'): Command
{
return $this->run($device, null, 'reboot', [], $source, fn (DeviceDriver $driver) => $driver->reboot($device));
}
public function setLight(Entity $entity, array $params, string $source = 'user'): Command
{
$command = $this->run($entity->device, $entity, 'set_light', $params, $source, fn (DeviceDriver $driver) => $driver->setLight($entity, $params));
$this->simulateDemoEcho($command, $entity, $params);
return $command;
}
/**
* Demo devices have no real hardware to echo their new state back over MQTT, so the UI would
* look dead when you toggle them. For a demo device we synthesize that echo — write the new
* state and broadcast DeviceStateChanged — so the mock home is fully interactive (handoff
* §13.2, "mock first"). Real devices never take this path; their actual status message does.
*/
private function simulateDemoEcho(Command $command, Entity $entity, array $state): void
{
if ($command->result !== 'ok' || ! $entity->device->demo) {
return;
}
$merged = array_merge($entity->state?->state ?? [], $state);
DeviceState::updateOrCreate(
['entity_id' => $entity->id],
['state' => $merged, 'observed_at' => (int) round(microtime(true) * 1_000_000)],
);
DeviceStateChanged::dispatch($entity->device->uuid, $entity->key, $merged);
}
private function run(Device $device, ?Entity $entity, string $command, array $payload, string $source, callable $action): Command
{
$result = 'ok';
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();
}
return Command::create([
'device_id' => $device->id,
'entity_id' => $entity?->id,
'source' => $source,
'user_id' => Auth::id(),
'command' => $command,
'payload' => $payload,
'result' => $result,
]);
}
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}."),
};
}
}