homeos/app/Services/AutomationEngine.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();
}
}