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 <noreply@anthropic.com>
feat/phase-1-bootstrap
HomeOS Bootstrap 2026-07-18 09:03:00 +02:00
parent 71b3028a8e
commit 449533b60d
5 changed files with 69 additions and 8 deletions

View File

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

View File

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

View File

@ -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. */

View File

@ -0,0 +1,55 @@
<?php
namespace Tests\Feature;
use App\Livewire\Devices\Show;
use App\Models\Device;
use App\Models\DeviceState;
use App\Models\Entity;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
class DeviceInputRoleTest extends TestCase
{
use RefreshDatabase;
private function inputDevice(): array
{
$device = Device::create(['name' => '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'));
}
}

View File

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