117 lines
4.0 KiB
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}."),
|
|
};
|
|
}
|
|
}
|