156 lines
5.6 KiB
PHP
156 lines
5.6 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Automation;
|
|
use App\Models\Entity;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
/**
|
|
* Minimal trigger → condition → action engine (handoff §13.6). Every automation carries a
|
|
* cooldown (motion sensors flood the queue otherwise) and can run in dry-run mode, which
|
|
* logs the action instead of switching — never develop against the live home (H5).
|
|
*/
|
|
class AutomationEngine
|
|
{
|
|
public function __construct(private readonly DeviceCommandService $commands) {}
|
|
|
|
/** Evaluate state-change automations after a device entity changed. */
|
|
public function onStateChange(string $deviceUuid, string $entityKey, array $state): void
|
|
{
|
|
Automation::where('enabled', true)->where('trigger_type', 'state_change')->get()
|
|
->each(function (Automation $automation) use ($deviceUuid, $entityKey, $state) {
|
|
if ($this->matchesStateTrigger($automation, $deviceUuid, $entityKey, $state)) {
|
|
$this->maybeRun($automation);
|
|
}
|
|
});
|
|
}
|
|
|
|
/** Evaluate time-based automations (called every minute by the scheduler). */
|
|
public function tick(): void
|
|
{
|
|
$hhmm = now()->format('H:i');
|
|
|
|
Automation::where('enabled', true)->where('trigger_type', 'time')->get()
|
|
->each(function (Automation $automation) use ($hhmm) {
|
|
if (data_get($automation->trigger_config, 'at') === $hhmm) {
|
|
$this->maybeRun($automation);
|
|
}
|
|
});
|
|
}
|
|
|
|
private function matchesStateTrigger(Automation $automation, string $deviceUuid, string $entityKey, array $state): bool
|
|
{
|
|
$cfg = $automation->trigger_config ?? [];
|
|
|
|
if (($cfg['device'] ?? null) !== $deviceUuid || ($cfg['entity_key'] ?? null) !== $entityKey) {
|
|
return false;
|
|
}
|
|
|
|
$field = $cfg['field'] ?? 'on';
|
|
|
|
return array_key_exists($field, $state) && $state[$field] == ($cfg['equals'] ?? true);
|
|
}
|
|
|
|
private function maybeRun(Automation $automation): void
|
|
{
|
|
// Conditions gate the actions but must not touch the cooldown clock — a rule whose
|
|
// conditions are currently false should stay armed for its next legitimate trigger.
|
|
if (! $this->conditionsMet($automation)) {
|
|
return;
|
|
}
|
|
|
|
// Atomically claim the cooldown window: concurrent queue workers processing a burst of
|
|
// state changes would otherwise all read the same stale last_triggered_at and each fire
|
|
// the action. The conditional UPDATE lets exactly one worker win the race (H5).
|
|
if (! $this->claimCooldown($automation)) {
|
|
return;
|
|
}
|
|
|
|
foreach ($automation->actions ?? [] as $action) {
|
|
$this->runAction($automation, $action);
|
|
}
|
|
}
|
|
|
|
/** Every stored condition ({device, entity_key, field, equals}) must match current state (AND). */
|
|
private function conditionsMet(Automation $automation): bool
|
|
{
|
|
foreach ($automation->conditions ?? [] as $condition) {
|
|
$entity = $this->findEntity($condition['device'] ?? null, $condition['entity_key'] ?? null);
|
|
$state = $entity?->state?->state ?? [];
|
|
$field = $condition['field'] ?? 'on';
|
|
|
|
if (! array_key_exists($field, $state) || $state[$field] != ($condition['equals'] ?? true)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/** Reserve the run atomically; returns false when the cooldown is still active. */
|
|
private function claimCooldown(Automation $automation): bool
|
|
{
|
|
$now = Carbon::instance(now());
|
|
|
|
$query = Automation::query()->whereKey($automation->getKey());
|
|
|
|
if ($automation->cooldown_seconds > 0) {
|
|
$threshold = $now->copy()->subSeconds($automation->cooldown_seconds);
|
|
$query->where(fn ($q) => $q
|
|
->whereNull('last_triggered_at')
|
|
->orWhere('last_triggered_at', '<=', $threshold));
|
|
}
|
|
|
|
$claimed = $query->update(['last_triggered_at' => $now]) > 0;
|
|
|
|
if ($claimed) {
|
|
$automation->last_triggered_at = $now;
|
|
}
|
|
|
|
return $claimed;
|
|
}
|
|
|
|
private function runAction(Automation $automation, array $action): void
|
|
{
|
|
$type = $action['type'] ?? null;
|
|
|
|
if ($type === 'switch') {
|
|
$entity = $this->findEntity($action['device'] ?? null, $action['entity_key'] ?? null);
|
|
|
|
if ($entity === null) {
|
|
return;
|
|
}
|
|
|
|
if ($automation->dry_run) {
|
|
Log::info("[automation dry-run] {$automation->name}: switch {$entity->key}", $action);
|
|
|
|
return;
|
|
}
|
|
|
|
$this->commands->setOn($entity, (bool) ($action['on'] ?? true), 'automation');
|
|
} elseif ($type === 'notify') {
|
|
$message = $action['message'] ?? '';
|
|
Log::info('[automation'.($automation->dry_run ? ' dry-run' : '')."] {$automation->name}: ".$message);
|
|
|
|
// Also e-mail it if the SMTP add-on is set up and automation notifications are enabled.
|
|
if (! $automation->dry_run) {
|
|
app(\App\Services\NotificationService::class)->notify('automation', $automation->name, $message);
|
|
}
|
|
}
|
|
}
|
|
|
|
private function findEntity(?string $deviceUuid, ?string $entityKey): ?Entity
|
|
{
|
|
if ($deviceUuid === null || $entityKey === null) {
|
|
return null;
|
|
}
|
|
|
|
return Entity::query()
|
|
->whereHas('device', fn ($q) => $q->where('uuid', $deviceUuid))
|
|
->where('key', $entityKey)
|
|
->with('device', 'state')->first();
|
|
}
|
|
}
|