homeos/tests/Feature/MqttTest.php

216 lines
9.4 KiB
PHP

<?php
namespace Tests\Feature;
use App\Jobs\IngestShellyMessage;
use App\Models\Device;
use App\Models\DeviceState;
use App\Models\Entity;
use App\Services\DeviceCommandService;
use App\Support\Drivers\DeviceDriver;
use App\Support\Drivers\ShellyMqttDriver;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class MqttTest extends TestCase
{
use RefreshDatabase;
public function test_ingesting_a_shelly_status_upserts_entity_state(): void
{
$device = Device::create([
'name' => 'Lampe', 'vendor' => 'Shelly', 'protocol' => 'mqtt',
'config' => ['mqtt_prefix' => 'shelly-lampe'], 'status' => 'active',
'last_seen_at' => now()->subHour(),
]);
(new IngestShellyMessage('shelly-lampe/status/switch:0', json_encode(['output' => true, 'apower' => 12.7])))->handle();
$switch = $device->entities()->where('key', 'switch:0')->first();
$power = $device->entities()->where('key', 'power:0')->first();
$this->assertTrue(data_get($switch->state->state, 'on'));
$this->assertSame(13, data_get($power->state->state, 'watts'));
$this->assertTrue($device->refresh()->last_seen_at->gt(now()->subMinute()));
}
public function test_out_of_order_messages_do_not_overwrite_newer_state(): void
{
$device = Device::create([
'name' => 'Lampe', 'vendor' => 'Shelly', 'protocol' => 'mqtt',
'config' => ['mqtt_prefix' => 'shelly-lampe'], 'status' => 'active',
]);
// Newer message (higher observed_at) says on=true …
(new IngestShellyMessage('shelly-lampe/status/switch:0', json_encode(['output' => true]), 2_000))->handle();
// … an older one arriving late must NOT overwrite it.
(new IngestShellyMessage('shelly-lampe/status/switch:0', json_encode(['output' => false]), 1_000))->handle();
$switch = $device->entities()->where('key', 'switch:0')->first();
$this->assertTrue(data_get($switch->state->state, 'on'));
}
public function test_unknown_device_auto_onboards_on_a_recognized_component(): void
{
(new IngestShellyMessage('shelly1minig3-abc/status/switch:0', json_encode(['output' => true])))->handle();
$device = Device::where('config->mqtt_prefix', 'shelly1minig3-abc')->first();
$this->assertNotNull($device);
$this->assertSame('Shelly', $device->vendor);
$this->assertSame('mqtt', $device->protocol);
$this->assertTrue((bool) data_get($device->config, 'auto_onboarded'));
$this->assertTrue(data_get($device->entities()->where('key', 'switch:0')->first()->state->state, 'on'));
}
public function test_housekeeping_topic_does_not_onboard_a_device(): void
{
// `sys`/`wifi`/`cloud` status is noise — it must not create a device on its own.
(new IngestShellyMessage('shelly1minig3-abc/status/sys', json_encode(['restart_required' => false])))->handle();
$this->assertDatabaseCount('devices', 0);
}
public function test_auto_onboard_can_be_disabled(): void
{
config()->set('homeos.mqtt.auto_onboard', false);
(new IngestShellyMessage('shelly1minig3-abc/status/switch:0', json_encode(['output' => true])))->handle();
$this->assertDatabaseCount('devices', 0);
}
public function test_reserved_prefixes_are_never_onboarded(): void
{
// A device authed as `shelly` can technically publish homeos/status/# (wildcard) — the
// ingest must refuse to create a bogus "homeos"/"ring" device from it.
(new IngestShellyMessage('homeos/status/switch:0', json_encode(['output' => true])))->handle();
(new IngestShellyMessage('ring/status/switch:0', json_encode(['output' => true])))->handle();
$this->assertDatabaseCount('devices', 0);
}
public function test_onboarding_respects_the_device_cap(): void
{
config()->set('homeos.mqtt.max_devices', 1);
Device::create(['name' => 'existing', 'vendor' => 'Shelly', 'protocol' => 'mqtt', 'config' => ['mqtt_prefix' => 'a']]);
(new IngestShellyMessage('flood-1/status/switch:0', json_encode(['output' => true])))->handle();
$this->assertDatabaseCount('devices', 1); // cap reached, no new device
}
public function test_input_is_a_plain_input_until_assigned_a_contact_role(): void
{
(new IngestShellyMessage('shelly1-x/status/input:0', json_encode(['id' => 0, 'state' => true])))->handle();
$entity = Device::where('config->mqtt_prefix', 'shelly1-x')->first()->entities()->where('key', 'input:0')->first();
$this->assertSame('input', $entity->type);
$this->assertTrue(data_get($entity->state->state, 'on'));
}
public function test_input_promoted_to_a_contact_via_device_config(): void
{
$device = Device::create([
'name' => 'Fenster', 'vendor' => 'Shelly', 'protocol' => 'mqtt', 'status' => 'active',
'config' => ['mqtt_prefix' => 'shelly1-fenster', 'input_roles' => ['0' => ['kind' => 'window', 'invert' => false]]],
]);
(new IngestShellyMessage('shelly1-fenster/status/input:0', json_encode(['id' => 0, 'state' => true])))->handle();
$entity = $device->entities()->where('key', 'input:0')->first();
$this->assertSame('contact', $entity->type);
// Raw `on` is preserved so the device page can re-derive open/closed on invert changes.
$this->assertSame(['open' => true, 'position' => 'open', 'kind' => 'window', 'on' => true], $entity->state->state);
}
public function test_input_contact_role_respects_inversion(): void
{
$device = Device::create([
'name' => 'Fenster', 'vendor' => 'Shelly', 'protocol' => 'mqtt', 'status' => 'active',
'config' => ['mqtt_prefix' => 'shelly1-inv', 'input_roles' => ['0' => ['kind' => 'door', 'invert' => true]]],
]);
// Inverted: reed closed (state true) means the door is CLOSED.
(new IngestShellyMessage('shelly1-inv/status/input:0', json_encode(['id' => 0, 'state' => true])))->handle();
$this->assertFalse(data_get($device->entities()->where('key', 'input:0')->first()->state->state, 'open'));
}
public function test_concurrent_onboarding_does_not_duplicate(): void
{
(new IngestShellyMessage('shelly-dup/status/switch:0', json_encode(['output' => true])))->handle();
(new IngestShellyMessage('shelly-dup/status/switch:0', json_encode(['output' => false]), 2_000))->handle();
$this->assertSame(1, Device::where('config->mqtt_prefix', 'shelly-dup')->count());
}
public function test_command_service_audits_every_command(): void
{
// Fake driver so the test never touches 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 [];
}
});
$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' => false]]);
$command = app(DeviceCommandService::class)->toggle($entity->load('state'));
$this->assertSame('turn_on', $command->command);
$this->assertSame('ok', $command->result);
$this->assertDatabaseHas('commands', ['command' => 'turn_on', 'source' => 'user', 'result' => 'ok']);
// Optimistic: the state flips immediately so the UI is instant; the device's real status
// echo reconciles it a moment later (monotonic, newer observed_at wins).
$this->assertTrue(data_get($entity->refresh()->state->state, 'on'));
}
public function test_toggling_a_demo_device_simulates_the_echo(): void
{
// Demo devices have no hardware to echo back, so the command service simulates it.
$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 [];
}
});
\Illuminate\Support\Facades\Event::fake([\App\Events\DeviceStateChanged::class]);
$device = Device::create(['name' => 'Deckenlicht', 'vendor' => 'Shelly', 'protocol' => 'mqtt', 'status' => 'active', 'demo' => true]);
$entity = Entity::create(['device_id' => $device->id, 'type' => 'light', 'key' => 'light:0']);
DeviceState::create(['entity_id' => $entity->id, 'state' => ['on' => false]]);
app(DeviceCommandService::class)->toggle($entity->load('state', 'device'));
$this->assertTrue(data_get($entity->refresh()->state->state, 'on'));
\Illuminate\Support\Facades\Event::assertDispatched(\App\Events\DeviceStateChanged::class);
}
}