feat(automations): trigger→condition→action engine with cooldown + dry-run

- 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 <noreply@anthropic.com>
feat/phase-1-bootstrap
HomeOS Bootstrap 2026-07-18 01:11:02 +02:00
parent 124e667a0a
commit d10172ac26
13 changed files with 628 additions and 9 deletions

View File

@ -0,0 +1,21 @@
<?php
namespace App\Console\Commands;
use App\Services\AutomationEngine;
use Illuminate\Console\Command;
/** Evaluates time-based automations (scheduled every minute). */
class AutomationsTickCommand extends Command
{
protected $signature = 'automations:tick';
protected $description = 'Evaluate time-based automations.';
public function handle(AutomationEngine $engine): int
{
$engine->tick();
return self::SUCCESS;
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace App\Listeners;
use App\Events\DeviceStateChanged;
use App\Services\AutomationEngine;
use Illuminate\Contracts\Queue\ShouldQueue;
/** Runs state-change automations off the queue so ingestion stays fast (H2). */
class EvaluateAutomations implements ShouldQueue
{
public function __construct(private readonly AutomationEngine $engine) {}
public function handle(DeviceStateChanged $event): void
{
$this->engine->onStateChange($event->deviceUuid, $event->entityKey, $event->state);
}
}

View File

@ -0,0 +1,129 @@
<?php
namespace App\Livewire\Modals;
use App\Models\Automation;
use App\Models\Entity;
use LivewireUI\Modal\ModalComponent;
class CreateAutomation extends ModalComponent
{
public string $name = '';
public string $triggerType = 'state_change';
/** "deviceUuid|entityKey" of the entity whose change fires the rule. */
public string $triggerEntity = '';
public string $triggerEquals = '1';
public string $triggerAt = '';
public string $actionType = 'switch';
/** "deviceUuid|entityKey" of the entity to switch. */
public string $actionEntity = '';
public string $actionOn = '1';
public string $actionMessage = '';
public int $cooldownSeconds = 0;
public bool $dryRun = false;
public static function modalMaxWidth(): string
{
return 'lg';
}
/** @return array<int, array{value:string,label:string,controllable:bool}> */
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<string, mixed> */
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<string, mixed> */
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');
}
}

View File

@ -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',

View File

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

View File

@ -0,0 +1,95 @@
<?php
namespace App\Services;
use App\Models\Automation;
use App\Models\Entity;
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
{
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'] ?? ''));
}
}
}

View File

@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('automations', function (Blueprint $table) {
// Dry-run logs the action instead of switching — never develop against the live home (H5).
$table->boolean('dry_run')->default(false)->after('cooldown_seconds');
});
}
public function down(): void
{
Schema::table('automations', function (Blueprint $table) {
$table->dropColumn('dry_run');
});
}
};

View File

@ -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',
];

View File

@ -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',
];

View File

@ -1,5 +1,12 @@
<div>
<x-topbar :title="__('nav.automations')" :subtitle="__('automations.subtitle')" />
<x-topbar :title="__('nav.automations')" :subtitle="__('automations.subtitle')">
<x-slot:actions>
<button type="button" wire:click="$dispatch('openModal', { component: 'modals.create-automation' })"
class="inline-flex items-center gap-1.5 rounded-lg bg-accent px-3 py-1.5 text-[12px] font-bold text-base hover:brightness-110 transition-[filter]">
<x-icon name="plus" :size="15" /> <span class="hidden sm:inline">{{ __('automations.add') }}</span>
</button>
</x-slot:actions>
</x-topbar>
<div class="px-5 lg:px-[26px] py-6 flex flex-col gap-5 max-w-[1560px] mx-auto w-full">
@if ($automations->isEmpty())

View File

@ -0,0 +1,119 @@
<div class="flex flex-col">
<header class="flex items-center gap-3 px-5 py-4 border-b border-line-soft">
<span class="grid place-items-center w-9 h-9 rounded-lg bg-accent/10 text-accent shrink-0"><x-icon name="automation" :size="18" /></span>
<h2 class="text-[15px] font-bold text-ink">{{ __('automations.add_title') }}</h2>
<button type="button" wire:click="closeModal" class="ml-auto grid place-items-center w-9 h-9 rounded-lg text-ink-3 hover:bg-raised hover:text-ink transition-colors" aria-label="{{ __('common.close') }}">
<x-icon name="close" :size="18" />
</button>
</header>
<form wire:submit="save" class="p-5 flex flex-col gap-4">
<div class="flex flex-col gap-1.5">
<label for="a-name" class="text-[12px] font-semibold text-ink-2">{{ __('automations.name') }}</label>
<input wire:model="name" id="a-name" type="text" autofocus
class="w-full rounded-lg bg-raised border border-line px-3 py-2.5 text-sm text-ink outline-none focus:border-accent focus:ring-1 focus:ring-accent">
@error('name') <p class="text-[12px] text-offline">{{ $message }}</p> @enderror
</div>
{{-- Trigger --}}
<fieldset class="flex flex-col gap-3 rounded-xl border border-line-soft p-3.5">
<legend class="px-1.5 text-[11px] font-bold uppercase tracking-wide text-ink-3">{{ __('automations.trigger') }}</legend>
<div class="flex flex-col gap-1.5">
<select wire:model.live="triggerType" class="w-full rounded-lg bg-raised border border-line px-3 py-2.5 text-sm text-ink outline-none focus:border-accent">
<option value="state_change">{{ __('automations.trigger_state_change') }}</option>
<option value="time">{{ __('automations.trigger_time') }}</option>
</select>
</div>
@if ($triggerType === 'state_change')
<div class="flex flex-col gap-1.5">
<label for="a-tent" class="text-[12px] font-semibold text-ink-2">{{ __('automations.trigger_entity') }}</label>
<select wire:model="triggerEntity" id="a-tent" class="w-full rounded-lg bg-raised border border-line px-3 py-2.5 text-sm text-ink outline-none focus:border-accent">
<option value=""></option>
@foreach ($this->entityOptions as $o)
<option value="{{ $o['value'] }}">{{ $o['label'] }}</option>
@endforeach
</select>
@error('triggerEntity') <p class="text-[12px] text-offline">{{ $message }}</p> @enderror
</div>
<div class="flex flex-col gap-1.5">
<label for="a-teq" class="text-[12px] font-semibold text-ink-2">{{ __('automations.trigger_equals') }}</label>
<select wire:model="triggerEquals" id="a-teq" class="w-full rounded-lg bg-raised border border-line px-3 py-2.5 text-sm text-ink outline-none focus:border-accent">
<option value="1">{{ __('automations.state_on') }}</option>
<option value="0">{{ __('automations.state_off') }}</option>
</select>
</div>
@else
<div class="flex flex-col gap-1.5">
<label for="a-at" class="text-[12px] font-semibold text-ink-2">{{ __('automations.trigger_at') }}</label>
<input wire:model="triggerAt" id="a-at" type="time"
class="w-full rounded-lg bg-raised border border-line px-3 py-2.5 text-sm text-ink outline-none focus:border-accent focus:ring-1 focus:ring-accent">
@error('triggerAt') <p class="text-[12px] text-offline">{{ $message }}</p> @enderror
</div>
@endif
</fieldset>
{{-- Action --}}
<fieldset class="flex flex-col gap-3 rounded-xl border border-line-soft p-3.5">
<legend class="px-1.5 text-[11px] font-bold uppercase tracking-wide text-ink-3">{{ __('automations.action') }}</legend>
<div class="flex flex-col gap-1.5">
<select wire:model.live="actionType" class="w-full rounded-lg bg-raised border border-line px-3 py-2.5 text-sm text-ink outline-none focus:border-accent">
<option value="switch">{{ __('automations.action_switch') }}</option>
<option value="notify">{{ __('automations.action_notify') }}</option>
</select>
</div>
@if ($actionType === 'switch')
<div class="flex flex-col gap-1.5">
<label for="a-aent" class="text-[12px] font-semibold text-ink-2">{{ __('automations.action_entity') }}</label>
<select wire:model="actionEntity" id="a-aent" class="w-full rounded-lg bg-raised border border-line px-3 py-2.5 text-sm text-ink outline-none focus:border-accent">
<option value=""></option>
@foreach ($this->entityOptions as $o)
@if ($o['controllable'])
<option value="{{ $o['value'] }}">{{ $o['label'] }}</option>
@endif
@endforeach
</select>
@error('actionEntity') <p class="text-[12px] text-offline">{{ $message }}</p> @enderror
</div>
<div class="flex flex-col gap-1.5">
<label for="a-aon" class="text-[12px] font-semibold text-ink-2">{{ __('automations.action_on') }}</label>
<select wire:model="actionOn" id="a-aon" class="w-full rounded-lg bg-raised border border-line px-3 py-2.5 text-sm text-ink outline-none focus:border-accent">
<option value="1">{{ __('automations.state_on') }}</option>
<option value="0">{{ __('automations.state_off') }}</option>
</select>
</div>
@else
<div class="flex flex-col gap-1.5">
<label for="a-msg" class="text-[12px] font-semibold text-ink-2">{{ __('automations.message') }}</label>
<input wire:model="actionMessage" id="a-msg" type="text"
class="w-full rounded-lg bg-raised border border-line px-3 py-2.5 text-sm text-ink outline-none focus:border-accent focus:ring-1 focus:ring-accent">
@error('actionMessage') <p class="text-[12px] text-offline">{{ $message }}</p> @enderror
</div>
@endif
</fieldset>
<div class="flex flex-col gap-1.5">
<label for="a-cd" class="text-[12px] font-semibold text-ink-2">{{ __('automations.cooldown') }}</label>
<input wire:model="cooldownSeconds" id="a-cd" type="number" min="0" max="86400"
class="w-full rounded-lg bg-raised border border-line px-3 py-2.5 text-sm text-ink outline-none focus:border-accent focus:ring-1 focus:ring-accent">
<p class="text-[11.5px] text-ink-3">{{ __('automations.cooldown_hint') }}</p>
@error('cooldownSeconds') <p class="text-[12px] text-offline">{{ $message }}</p> @enderror
</div>
<label class="flex items-start gap-3 cursor-pointer">
<input wire:model="dryRun" type="checkbox" class="mt-0.5 w-4 h-4 rounded border-line text-accent focus:ring-accent">
<span class="flex flex-col">
<span class="text-[13px] font-semibold text-ink">{{ __('automations.dry_run') }}</span>
<span class="text-[11.5px] text-ink-3">{{ __('automations.dry_run_hint') }}</span>
</span>
</label>
<div class="flex items-center justify-end gap-2 pt-1">
<button type="button" wire:click="closeModal" class="rounded-lg border border-line px-3.5 py-2 text-[13px] font-semibold text-ink-2 hover:text-ink hover:bg-raised transition-colors">{{ __('common.cancel') }}</button>
<button type="submit" class="rounded-lg bg-accent px-3.5 py-2 text-[13px] font-bold text-base hover:brightness-110 transition-[filter]">{{ __('automations.add') }}</button>
</div>
</form>
</div>

View File

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

View File

@ -0,0 +1,168 @@
<?php
namespace Tests\Feature;
use App\Models\Automation;
use App\Models\Device;
use App\Models\DeviceState;
use App\Models\Entity;
use App\Services\AutomationEngine;
use App\Support\Drivers\DeviceDriver;
use App\Support\Drivers\ShellyMqttDriver;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class AutomationTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
// Fake driver so automations never touch a real broker.
$this->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);
}
}