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(); } }