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
parent
71b3028a8e
commit
449533b60d
|
|
@ -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],
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue