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
parent
124e667a0a
commit
d10172ac26
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'] ?? ''));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue