From d10172ac265a5b3d7e665524e91dbdd1f89330d4 Mon Sep 17 00:00:00 2001 From: HomeOS Bootstrap Date: Sat, 18 Jul 2026 01:11:02 +0200 Subject: [PATCH] =?UTF-8?q?feat(automations):=20trigger=E2=86=92condition?= =?UTF-8?q?=E2=86=92action=20engine=20with=20cooldown=20+=20dry-run?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AutomationEngine: state_change + time triggers, cooldown debounce, dry-run logs instead of switching (H5), actions switch via DeviceCommandService (source=automation, audited H1) or notify via log. - EvaluateAutomations: queued listener on DeviceStateChanged (keeps ingest fast, H2). automations:tick command scheduled every minute for time rules. - CreateAutomation modal (wire-elements) wired to Automations index; DE/EN keys (R16). dry_run column migration + model cast. - AutomationTest: 6 cases (trigger match, mismatch, cooldown, dry-run, time tick, disabled). Full suite 23 green; 11/11 tabs clean (R12). Co-Authored-By: Claude Opus 4.8 --- .../Commands/AutomationsTickCommand.php | 21 +++ app/Listeners/EvaluateAutomations.php | 18 ++ app/Livewire/Modals/CreateAutomation.php | 129 ++++++++++++++ app/Models/Automation.php | 3 +- app/Providers/AppServiceProvider.php | 11 +- app/Services/AutomationEngine.php | 95 ++++++++++ ...7_18_040001_add_dry_run_to_automations.php | 23 +++ lang/de/automations.php | 19 ++ lang/en/automations.php | 19 ++ .../livewire/automations/index.blade.php | 9 +- .../modals/create-automation.blade.php | 119 +++++++++++++ routes/console.php | 3 + tests/Feature/AutomationTest.php | 168 ++++++++++++++++++ 13 files changed, 628 insertions(+), 9 deletions(-) create mode 100644 app/Console/Commands/AutomationsTickCommand.php create mode 100644 app/Listeners/EvaluateAutomations.php create mode 100644 app/Livewire/Modals/CreateAutomation.php create mode 100644 app/Services/AutomationEngine.php create mode 100644 database/migrations/2026_07_18_040001_add_dry_run_to_automations.php create mode 100644 resources/views/livewire/modals/create-automation.blade.php create mode 100644 tests/Feature/AutomationTest.php diff --git a/app/Console/Commands/AutomationsTickCommand.php b/app/Console/Commands/AutomationsTickCommand.php new file mode 100644 index 0000000..ab392d3 --- /dev/null +++ b/app/Console/Commands/AutomationsTickCommand.php @@ -0,0 +1,21 @@ +tick(); + + return self::SUCCESS; + } +} diff --git a/app/Listeners/EvaluateAutomations.php b/app/Listeners/EvaluateAutomations.php new file mode 100644 index 0000000..513a93a --- /dev/null +++ b/app/Listeners/EvaluateAutomations.php @@ -0,0 +1,18 @@ +engine->onStateChange($event->deviceUuid, $event->entityKey, $event->state); + } +} diff --git a/app/Livewire/Modals/CreateAutomation.php b/app/Livewire/Modals/CreateAutomation.php new file mode 100644 index 0000000..533094e --- /dev/null +++ b/app/Livewire/Modals/CreateAutomation.php @@ -0,0 +1,129 @@ + */ + public function getEntityOptionsProperty(): array + { + return Entity::with('device')->get() + ->filter(fn (Entity $e) => $e->device !== null) + ->map(fn (Entity $e) => [ + 'value' => $e->device->uuid.'|'.$e->key, + 'label' => trim(($e->device->name ?? '?').' · '.($e->name ?: $e->key)), + 'controllable' => in_array($e->type, ['switch', 'light'], true), + ]) + ->sortBy('label', SORT_NATURAL | SORT_FLAG_CASE) + ->values()->all(); + } + + public function save() + { + $rules = [ + 'name' => 'required|string|max:100', + 'triggerType' => 'required|in:state_change,time', + 'actionType' => 'required|in:switch,notify', + 'cooldownSeconds' => 'integer|min:0|max:86400', + ]; + + if ($this->triggerType === 'state_change') { + $rules['triggerEntity'] = 'required|string'; + } else { + $rules['triggerAt'] = ['required', 'regex:/^\d{2}:\d{2}$/']; + } + + if ($this->actionType === 'switch') { + $rules['actionEntity'] = 'required|string'; + } else { + $rules['actionMessage'] = 'required|string|max:200'; + } + + $this->validate($rules); + + Automation::create([ + 'name' => $this->name, + 'enabled' => true, + 'trigger_type' => $this->triggerType, + 'trigger_config' => $this->buildTriggerConfig(), + 'conditions' => [], + 'actions' => [$this->buildAction()], + 'cooldown_seconds' => $this->cooldownSeconds, + 'dry_run' => $this->dryRun, + ]); + + $this->closeModal(); + + return redirect()->route('automations.index'); + } + + /** @return array */ + private function buildTriggerConfig(): array + { + if ($this->triggerType === 'time') { + return ['at' => $this->triggerAt]; + } + + [$device, $key] = $this->split($this->triggerEntity); + + return ['device' => $device, 'entity_key' => $key, 'field' => 'on', 'equals' => $this->triggerEquals === '1']; + } + + /** @return array */ + private function buildAction(): array + { + if ($this->actionType === 'notify') { + return ['type' => 'notify', 'message' => $this->actionMessage]; + } + + [$device, $key] = $this->split($this->actionEntity); + + return ['type' => 'switch', 'device' => $device, 'entity_key' => $key, 'on' => $this->actionOn === '1']; + } + + /** @return array{0:?string,1:?string} */ + private function split(string $value): array + { + $parts = explode('|', $value, 2); + + return [$parts[0] ?? null, $parts[1] ?? null]; + } + + public function render() + { + return view('livewire.modals.create-automation'); + } +} diff --git a/app/Models/Automation.php b/app/Models/Automation.php index 75d4919..9ffcaee 100644 --- a/app/Models/Automation.php +++ b/app/Models/Automation.php @@ -11,11 +11,12 @@ class Automation extends Model protected $fillable = [ 'uuid', 'name', 'enabled', 'trigger_type', 'trigger_config', - 'conditions', 'actions', 'cooldown_seconds', 'last_triggered_at', + 'conditions', 'actions', 'cooldown_seconds', 'dry_run', 'last_triggered_at', ]; protected $casts = [ 'enabled' => 'boolean', + 'dry_run' => 'boolean', 'trigger_config' => 'array', 'conditions' => 'array', 'actions' => 'array', diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 452e6b6..68c7520 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,23 +2,20 @@ namespace App\Providers; +use App\Events\DeviceStateChanged; +use App\Listeners\EvaluateAutomations; +use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider { - /** - * Register any application services. - */ public function register(): void { // } - /** - * Bootstrap any application services. - */ public function boot(): void { - // + Event::listen(DeviceStateChanged::class, EvaluateAutomations::class); } } diff --git a/app/Services/AutomationEngine.php b/app/Services/AutomationEngine.php new file mode 100644 index 0000000..e58bf0c --- /dev/null +++ b/app/Services/AutomationEngine.php @@ -0,0 +1,95 @@ +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 + { + if ($automation->cooldown_seconds > 0 + && $automation->last_triggered_at !== null + && $automation->last_triggered_at->gt(now()->subSeconds($automation->cooldown_seconds))) { + return; + } + + foreach ($automation->actions ?? [] as $action) { + $this->runAction($automation, $action); + } + + $automation->forceFill(['last_triggered_at' => now()])->save(); + } + + private function runAction(Automation $automation, array $action): void + { + $type = $action['type'] ?? null; + + if ($type === 'switch') { + $entity = Entity::query() + ->whereHas('device', fn ($q) => $q->where('uuid', $action['device'] ?? null)) + ->where('key', $action['entity_key'] ?? null) + ->with('device', 'state')->first(); + + 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') { + Log::info("[automation".($automation->dry_run ? ' dry-run' : '')."] {$automation->name}: ".($action['message'] ?? '')); + } + } +} diff --git a/database/migrations/2026_07_18_040001_add_dry_run_to_automations.php b/database/migrations/2026_07_18_040001_add_dry_run_to_automations.php new file mode 100644 index 0000000..644ed24 --- /dev/null +++ b/database/migrations/2026_07_18_040001_add_dry_run_to_automations.php @@ -0,0 +1,23 @@ +boolean('dry_run')->default(false)->after('cooldown_seconds'); + }); + } + + public function down(): void + { + Schema::table('automations', function (Blueprint $table) { + $table->dropColumn('dry_run'); + }); + } +}; diff --git a/lang/de/automations.php b/lang/de/automations.php index 0f3aa0e..b6ae031 100644 --- a/lang/de/automations.php +++ b/lang/de/automations.php @@ -6,4 +6,23 @@ return [ 'empty_body' => 'Erstelle Regeln, die bei Zustandsänderungen oder zu bestimmten Zeiten Geräte schalten oder benachrichtigen.', 'trigger_state_change' => 'Bei Zustandsänderung', 'trigger_time' => 'Zeitgesteuert', + 'add' => 'Automation erstellen', + 'add_title' => 'Neue Automation', + 'name' => 'Name', + 'trigger' => 'Auslöser', + 'trigger_entity' => 'Gerät / Entität', + 'trigger_equals' => 'Zustand', + 'trigger_at' => 'Uhrzeit', + 'action' => 'Aktion', + 'action_switch' => 'Gerät schalten', + 'action_notify' => 'Benachrichtigen', + 'action_entity' => 'Ziel-Gerät', + 'action_on' => 'Schalten auf', + 'message' => 'Nachricht', + 'cooldown' => 'Abklingzeit (Sekunden)', + 'cooldown_hint' => 'Mindestabstand zwischen zwei Auslösungen. 0 = keine.', + 'dry_run' => 'Testlauf', + 'dry_run_hint' => 'Protokolliert die Aktion, ohne wirklich zu schalten.', + 'state_on' => 'An', + 'state_off' => 'Aus', ]; diff --git a/lang/en/automations.php b/lang/en/automations.php index 826359d..ecdebb7 100644 --- a/lang/en/automations.php +++ b/lang/en/automations.php @@ -6,4 +6,23 @@ return [ 'empty_body' => 'Create rules that switch devices or notify on state changes or at set times.', 'trigger_state_change' => 'On state change', 'trigger_time' => 'Time-based', + 'add' => 'Create automation', + 'add_title' => 'New automation', + 'name' => 'Name', + 'trigger' => 'Trigger', + 'trigger_entity' => 'Device / entity', + 'trigger_equals' => 'State', + 'trigger_at' => 'Time', + 'action' => 'Action', + 'action_switch' => 'Switch device', + 'action_notify' => 'Notify', + 'action_entity' => 'Target device', + 'action_on' => 'Switch to', + 'message' => 'Message', + 'cooldown' => 'Cooldown (seconds)', + 'cooldown_hint' => 'Minimum gap between two runs. 0 = none.', + 'dry_run' => 'Dry run', + 'dry_run_hint' => 'Logs the action without actually switching.', + 'state_on' => 'On', + 'state_off' => 'Off', ]; diff --git a/resources/views/livewire/automations/index.blade.php b/resources/views/livewire/automations/index.blade.php index f01c648..1a83293 100644 --- a/resources/views/livewire/automations/index.blade.php +++ b/resources/views/livewire/automations/index.blade.php @@ -1,5 +1,12 @@
- + + + + +
@if ($automations->isEmpty()) diff --git a/resources/views/livewire/modals/create-automation.blade.php b/resources/views/livewire/modals/create-automation.blade.php new file mode 100644 index 0000000..8406e69 --- /dev/null +++ b/resources/views/livewire/modals/create-automation.blade.php @@ -0,0 +1,119 @@ +
+
+ +

{{ __('automations.add_title') }}

+ +
+ +
+
+ + + @error('name')

{{ $message }}

@enderror +
+ + {{-- Trigger --}} +
+ {{ __('automations.trigger') }} + +
+ +
+ + @if ($triggerType === 'state_change') +
+ + + @error('triggerEntity')

{{ $message }}

@enderror +
+
+ + +
+ @else +
+ + + @error('triggerAt')

{{ $message }}

@enderror +
+ @endif +
+ + {{-- Action --}} +
+ {{ __('automations.action') }} + +
+ +
+ + @if ($actionType === 'switch') +
+ + + @error('actionEntity')

{{ $message }}

@enderror +
+
+ + +
+ @else +
+ + + @error('actionMessage')

{{ $message }}

@enderror +
+ @endif +
+ +
+ + +

{{ __('automations.cooldown_hint') }}

+ @error('cooldownSeconds')

{{ $message }}

@enderror +
+ + + +
+ + +
+
+
diff --git a/routes/console.php b/routes/console.php index eda4d86..ae228e1 100644 --- a/routes/console.php +++ b/routes/console.php @@ -13,3 +13,6 @@ Schedule::command('presence:poll')->everyMinute()->withoutOverlapping(); // Metrics sample for the charts (MQTT throughput + host CPU/memory). Schedule::command('metrics:sample')->everyMinute()->withoutOverlapping(); + +// Time-triggered automations (state-change ones fire off the DeviceStateChanged listener). +Schedule::command('automations:tick')->everyMinute()->withoutOverlapping(); diff --git a/tests/Feature/AutomationTest.php b/tests/Feature/AutomationTest.php new file mode 100644 index 0000000..86c8e5b --- /dev/null +++ b/tests/Feature/AutomationTest.php @@ -0,0 +1,168 @@ +app->bind(ShellyMqttDriver::class, fn () => new class implements DeviceDriver + { + public function turnOn(Entity $entity): void {} + + public function turnOff(Entity $entity): void {} + + public function setState(Entity $entity, array $state): void {} + + public function setLight(Entity $entity, array $params): void {} + + public function reboot(Device $device): void {} + + public function capabilities(): array + { + return []; + } + }); + } + + /** @return array{0:Device,1:Entity} */ + private function makeSwitch(): array + { + $device = Device::create(['name' => 'Lampe', 'vendor' => 'Shelly', 'protocol' => 'mqtt', 'status' => 'active']); + $entity = Entity::create(['device_id' => $device->id, 'type' => 'switch', 'key' => 'switch:0']); + DeviceState::create(['entity_id' => $entity->id, 'state' => ['on' => true]]); + + return [$device, $entity]; + } + + public function test_state_change_trigger_runs_switch_action_and_audits_as_automation(): void + { + [$trigger, $triggerEntity] = $this->makeSwitch(); + [$target, $targetEntity] = $this->makeSwitch(); + + Automation::create([ + 'name' => 'Motion turns light off', + 'enabled' => true, + 'trigger_type' => 'state_change', + 'trigger_config' => ['device' => $trigger->uuid, 'entity_key' => $triggerEntity->key, 'field' => 'on', 'equals' => true], + 'actions' => [['type' => 'switch', 'device' => $target->uuid, 'entity_key' => $targetEntity->key, 'on' => false]], + 'cooldown_seconds' => 0, + ]); + + app(AutomationEngine::class)->onStateChange($trigger->uuid, $triggerEntity->key, ['on' => true]); + + $this->assertDatabaseHas('commands', ['source' => 'automation', 'command' => 'turn_off', 'result' => 'ok']); + } + + public function test_trigger_does_not_fire_when_value_differs(): void + { + [$trigger, $triggerEntity] = $this->makeSwitch(); + [$target, $targetEntity] = $this->makeSwitch(); + + Automation::create([ + 'name' => 'On only', + 'enabled' => true, + 'trigger_type' => 'state_change', + 'trigger_config' => ['device' => $trigger->uuid, 'entity_key' => $triggerEntity->key, 'field' => 'on', 'equals' => true], + 'actions' => [['type' => 'switch', 'device' => $target->uuid, 'entity_key' => $targetEntity->key, 'on' => false]], + 'cooldown_seconds' => 0, + ]); + + app(AutomationEngine::class)->onStateChange($trigger->uuid, $triggerEntity->key, ['on' => false]); + + $this->assertDatabaseCount('commands', 0); + } + + public function test_cooldown_prevents_a_second_run(): void + { + [$trigger, $triggerEntity] = $this->makeSwitch(); + [$target, $targetEntity] = $this->makeSwitch(); + + Automation::create([ + 'name' => 'Debounced', + 'enabled' => true, + 'trigger_type' => 'state_change', + 'trigger_config' => ['device' => $trigger->uuid, 'entity_key' => $triggerEntity->key, 'field' => 'on', 'equals' => true], + 'actions' => [['type' => 'switch', 'device' => $target->uuid, 'entity_key' => $targetEntity->key, 'on' => false]], + 'cooldown_seconds' => 300, + ]); + + $engine = app(AutomationEngine::class); + $engine->onStateChange($trigger->uuid, $triggerEntity->key, ['on' => true]); + $engine->onStateChange($trigger->uuid, $triggerEntity->key, ['on' => true]); + + $this->assertDatabaseCount('commands', 1); + } + + public function test_dry_run_does_not_issue_a_command(): void + { + [$trigger, $triggerEntity] = $this->makeSwitch(); + [$target, $targetEntity] = $this->makeSwitch(); + + Automation::create([ + 'name' => 'Dry', + 'enabled' => true, + 'trigger_type' => 'state_change', + 'trigger_config' => ['device' => $trigger->uuid, 'entity_key' => $triggerEntity->key, 'field' => 'on', 'equals' => true], + 'actions' => [['type' => 'switch', 'device' => $target->uuid, 'entity_key' => $targetEntity->key, 'on' => false]], + 'cooldown_seconds' => 0, + 'dry_run' => true, + ]); + + app(AutomationEngine::class)->onStateChange($trigger->uuid, $triggerEntity->key, ['on' => true]); + + $this->assertDatabaseCount('commands', 0); + } + + public function test_time_trigger_fires_on_matching_minute(): void + { + [$target, $targetEntity] = $this->makeSwitch(); + + Automation::create([ + 'name' => 'Morning light', + 'enabled' => true, + 'trigger_type' => 'time', + 'trigger_config' => ['at' => now()->format('H:i')], + 'actions' => [['type' => 'switch', 'device' => $target->uuid, 'entity_key' => $targetEntity->key, 'on' => true]], + 'cooldown_seconds' => 0, + ]); + + app(AutomationEngine::class)->tick(); + + $this->assertDatabaseHas('commands', ['source' => 'automation', 'command' => 'turn_on']); + } + + public function test_disabled_automation_never_runs(): void + { + [$trigger, $triggerEntity] = $this->makeSwitch(); + [$target, $targetEntity] = $this->makeSwitch(); + + Automation::create([ + 'name' => 'Off', + 'enabled' => false, + 'trigger_type' => 'state_change', + 'trigger_config' => ['device' => $trigger->uuid, 'entity_key' => $triggerEntity->key, 'field' => 'on', 'equals' => true], + 'actions' => [['type' => 'switch', 'device' => $target->uuid, 'entity_key' => $targetEntity->key, 'on' => false]], + 'cooldown_seconds' => 0, + ]); + + app(AutomationEngine::class)->onStateChange($trigger->uuid, $triggerEntity->key, ['on' => true]); + + $this->assertDatabaseCount('commands', 0); + } +}