From 449533b60dc2fa35ac04318afa4d3f1cbd5a6ae3 Mon Sep 17 00:00:00 2001 From: HomeOS Bootstrap Date: Sat, 18 Jul 2026 09:03:00 +0200 Subject: [PATCH] fix: preserve raw input value on role/invert change; strict isOnline (R15) Codex R15 on the previous fix: - [P2] reclassifyInput derived the raw input level from the already-displayed (inverted) contact state, so toggling inversion twice didn't restore the original. The ingest + device page now carry the raw `on` inside the contact state, so flipping role/inversion is fully reversible. +1 round-trip test. - Latent: Device::isOnline() could return null (null demo, no last_seen) and break the type hint / views. Now returns a strict bool. Suite 58 green, 12/12 tabs clean. Co-Authored-By: Claude Opus 4.8 --- app/Jobs/IngestShellyMessage.php | 4 +- app/Livewire/Devices/Show.php | 13 ++++--- app/Models/Device.php | 2 +- tests/Feature/DeviceInputRoleTest.php | 55 +++++++++++++++++++++++++++ tests/Feature/MqttTest.php | 3 +- 5 files changed, 69 insertions(+), 8 deletions(-) create mode 100644 tests/Feature/DeviceInputRoleTest.php diff --git a/app/Jobs/IngestShellyMessage.php b/app/Jobs/IngestShellyMessage.php index 9de610e..70b9c83 100644 --- a/app/Jobs/IngestShellyMessage.php +++ b/app/Jobs/IngestShellyMessage.php @@ -138,10 +138,12 @@ class IngestShellyMessage implements ShouldQueue $on = (bool) ($update['state']['on'] ?? false); $open = ($role['invert'] ?? false) ? ! $on : $on; + // Keep the RAW input level (`on`) in the state so the device page can re-derive open/closed + // when the user flips inversion or the role, without the displayed value having been baked in. return [ 'key' => $update['key'], 'type' => 'contact', - 'state' => ['open' => $open, 'position' => $open ? 'open' : 'closed', 'kind' => $role['kind'] ?? 'window'], + 'state' => ['open' => $open, 'position' => $open ? 'open' : 'closed', 'kind' => $role['kind'] ?? 'window', 'on' => $on], ]; } diff --git a/app/Livewire/Devices/Show.php b/app/Livewire/Devices/Show.php index b3c9979..fab834e 100644 --- a/app/Livewire/Devices/Show.php +++ b/app/Livewire/Devices/Show.php @@ -173,15 +173,18 @@ class Show extends Component { $state = $entity->state?->state ?? []; + // Always work from the RAW input level. It's carried in `on` even on a contact (the ingest + // preserves it), so flipping inversion/role stays reversible instead of baking in the + // previously-displayed value. Fall back to deriving it only for legacy rows without `on`. + $on = array_key_exists('on', $state) + ? (bool) $state['on'] + : (($state['position'] ?? 'closed') !== 'closed'); + if ($role !== null) { - $on = array_key_exists('on', $state) - ? (bool) $state['on'] - : (($state['position'] ?? 'closed') !== 'closed'); $open = ($role['invert'] ?? false) ? ! $on : $on; - $newState = ['open' => $open, 'position' => $open ? 'open' : 'closed', 'kind' => $role['kind']]; + $newState = ['open' => $open, 'position' => $open ? 'open' : 'closed', 'kind' => $role['kind'], 'on' => $on]; $newType = 'contact'; } else { - $on = array_key_exists('on', $state) ? (bool) $state['on'] : (($state['position'] ?? 'closed') !== 'closed'); $newState = ['on' => $on]; $newType = 'input'; } diff --git a/app/Models/Device.php b/app/Models/Device.php index d6b8d5a..c1e4d77 100644 --- a/app/Models/Device.php +++ b/app/Models/Device.php @@ -47,7 +47,7 @@ class Device extends Model return $this->last_seen_at->gt(now()->subMinutes(10)); } - return $this->demo; + return (bool) $this->demo; } /** Cloud-bridged (e.g. Ring) — surfaced with a "Cloud" badge so the dependency is visible. */ diff --git a/tests/Feature/DeviceInputRoleTest.php b/tests/Feature/DeviceInputRoleTest.php new file mode 100644 index 0000000..a1eee03 --- /dev/null +++ b/tests/Feature/DeviceInputRoleTest.php @@ -0,0 +1,55 @@ + 'Shelly', 'vendor' => 'Shelly', 'protocol' => 'mqtt', 'status' => 'active', 'config' => ['mqtt_prefix' => 'shelly1-x']]); + $entity = Entity::create(['device_id' => $device->id, 'type' => 'input', 'key' => 'input:0']); + DeviceState::create(['entity_id' => $entity->id, 'state' => ['on' => true]]); // raw input = true + + return [$device, $entity]; + } + + public function test_assigning_and_inverting_a_contact_role_is_reversible(): void + { + $this->actingAs(User::factory()->create()); + [$device, $entity] = $this->inputDevice(); + + $page = Livewire::test(Show::class, ['device' => $device]); + + // Promote to a window contact (raw on=true, no invert → open). + $page->call('setInputRole', $entity->id, 'window'); + $entity->refresh()->load('state'); + $this->assertSame('contact', $entity->type); + $this->assertTrue($entity->state->state['open']); + + // Invert → closed (raw true, inverted). + $page->call('setInputInvert', $entity->id, true); + $this->assertFalse($entity->refresh()->state->state['open']); + + // Un-invert → open again: the raw value was preserved, so this round-trips. + $page->call('setInputInvert', $entity->id, false); + $this->assertTrue($entity->refresh()->state->state['open']); + + // Demote back to a plain input → raw on restored. + $page->call('setInputRole', $entity->id, ''); + $entity->refresh(); + $this->assertSame('input', $entity->type); + $this->assertSame(['on' => true], $entity->state->state); + $this->assertNull(data_get($device->fresh()->config, 'input_roles.0')); + } +} diff --git a/tests/Feature/MqttTest.php b/tests/Feature/MqttTest.php index 057b7f9..aa06dbd 100644 --- a/tests/Feature/MqttTest.php +++ b/tests/Feature/MqttTest.php @@ -119,7 +119,8 @@ class MqttTest extends TestCase $entity = $device->entities()->where('key', 'input:0')->first(); $this->assertSame('contact', $entity->type); - $this->assertSame(['open' => true, 'position' => 'open', 'kind' => 'window'], $entity->state->state); + // 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