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_condition_blocks_action_when_false(): void { [$trigger, $triggerEntity] = $this->makeSwitch(); [$target, $targetEntity] = $this->makeSwitch(); // A separate sensor whose state the condition inspects. [$sensor, $sensorEntity] = $this->makeSwitch(); $sensorEntity->state()->update(['state' => ['on' => false]]); Automation::create([ 'name' => 'Only when sensor on', 'enabled' => true, 'trigger_type' => 'state_change', 'trigger_config' => ['device' => $trigger->uuid, 'entity_key' => $triggerEntity->key, 'field' => 'on', 'equals' => true], 'conditions' => [['device' => $sensor->uuid, 'entity_key' => $sensorEntity->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); } public function test_condition_allows_action_when_true(): void { [$trigger, $triggerEntity] = $this->makeSwitch(); [$target, $targetEntity] = $this->makeSwitch(); [$sensor, $sensorEntity] = $this->makeSwitch(); $sensorEntity->state()->update(['state' => ['on' => true]]); Automation::create([ 'name' => 'Only when sensor on', 'enabled' => true, 'trigger_type' => 'state_change', 'trigger_config' => ['device' => $trigger->uuid, 'entity_key' => $triggerEntity->key, 'field' => 'on', 'equals' => true], 'conditions' => [['device' => $sensor->uuid, 'entity_key' => $sensorEntity->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']); } public function test_failed_condition_does_not_start_the_cooldown(): void { [$trigger, $triggerEntity] = $this->makeSwitch(); [$target, $targetEntity] = $this->makeSwitch(); [$sensor, $sensorEntity] = $this->makeSwitch(); $sensorEntity->state()->update(['state' => ['on' => false]]); $automation = Automation::create([ 'name' => 'Armed', 'enabled' => true, 'trigger_type' => 'state_change', 'trigger_config' => ['device' => $trigger->uuid, 'entity_key' => $triggerEntity->key, 'field' => 'on', 'equals' => true], 'conditions' => [['device' => $sensor->uuid, 'entity_key' => $sensorEntity->key, 'field' => 'on', 'equals' => true]], 'actions' => [['type' => 'switch', 'device' => $target->uuid, 'entity_key' => $targetEntity->key, 'on' => false]], 'cooldown_seconds' => 300, ]); // Condition false → nothing happens and the cooldown must stay unarmed … app(AutomationEngine::class)->onStateChange($trigger->uuid, $triggerEntity->key, ['on' => true]); $this->assertNull($automation->refresh()->last_triggered_at); // … so once the condition becomes true, the very next trigger fires immediately. $sensorEntity->state()->update(['state' => ['on' => true]]); app(AutomationEngine::class)->onStateChange($trigger->uuid, $triggerEntity->key, ['on' => true]); $this->assertDatabaseCount('commands', 1); } 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); } }