feat(sensors): window/door contacts (open/closed/tilted) from Shelly MQTT
Addresses "no way to read window/door contacts": ShellyNormalizer never emitted
contact entities, so the Fenster page was empty for real hardware.
- Map the Shelly `input` component (digital reed on a Shelly input) → contact
entity {open, position}; analog/count inputs are skipped.
- Dedicated `contact`/`window` components → 3-state {open, position} with
closed/open/**tilted** support (`gekippt`) when the sensor reports it.
- entity-state renders the 3-state pill (closed=neutral, tilted=warning,
open=offline); DE/EN labels. `contact` is a primary auto-onboarding type, so a
window sensor on an input onboards itself.
ShellyNormalizerTest (5 cases). Live-verified: input state true/false →
contact open/closed on an auto-onboarded device. Suite 51 green, 12/12 clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/phase-1-bootstrap
parent
fef393954c
commit
5df94dddf0
|
|
@ -41,6 +41,22 @@ class ShellyNormalizer
|
|||
$updates[] = ['key' => "cover:{$index}", 'type' => 'cover', 'state' => ['position' => $payload['current_pos'] ?? null, 'state' => $payload['state'] ?? null]];
|
||||
break;
|
||||
|
||||
case 'input':
|
||||
// A reed/switch input — a window or door contact wired to a Shelly input.
|
||||
// Only the digital (switch) mode is a contact; analog/count inputs are skipped.
|
||||
if (! is_bool($payload['state'] ?? null)) {
|
||||
break;
|
||||
}
|
||||
$open = (bool) $payload['state'];
|
||||
$updates[] = ['key' => "input:{$index}", 'type' => 'contact', 'state' => ['open' => $open, 'position' => $open ? 'open' : 'closed']];
|
||||
break;
|
||||
|
||||
case 'contact':
|
||||
case 'window':
|
||||
// Dedicated contact/window sensor — supports a 3-state position (closed/open/tilted).
|
||||
$updates[] = ['key' => "contact:{$index}", 'type' => 'contact', 'state' => self::contactState($payload)];
|
||||
break;
|
||||
|
||||
case 'temperature':
|
||||
$updates[] = ['key' => "temperature:{$index}", 'type' => 'temperature', 'state' => ['celsius' => $payload['tC'] ?? null]];
|
||||
break;
|
||||
|
|
@ -62,4 +78,31 @@ class ShellyNormalizer
|
|||
|
||||
return $updates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a contact/window payload to {open, position}. `position` is the richer signal
|
||||
* (closed | open | tilted) when the sensor reports it; otherwise it is derived from a boolean.
|
||||
*
|
||||
* @param array<string,mixed> $payload
|
||||
* @return array{open:bool,position:string}
|
||||
*/
|
||||
private static function contactState(array $payload): array
|
||||
{
|
||||
// Prefer an explicit 3-state position/tilt string when the sensor provides one.
|
||||
$position = $payload['position'] ?? $payload['tilt'] ?? null;
|
||||
|
||||
if (is_string($position)) {
|
||||
$position = strtolower($position);
|
||||
$position = in_array($position, ['closed', 'open', 'tilted'], true) ? $position : null;
|
||||
} else {
|
||||
$position = null;
|
||||
}
|
||||
|
||||
if ($position === null) {
|
||||
$open = (bool) ($payload['open'] ?? $payload['state'] ?? false);
|
||||
$position = $open ? 'open' : 'closed';
|
||||
}
|
||||
|
||||
return ['open' => $position !== 'closed', 'position' => $position];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@ return [
|
|||
'off' => 'Aus',
|
||||
'open' => 'Offen',
|
||||
'closed' => 'Geschlossen',
|
||||
'tilted' => 'Gekippt',
|
||||
'contact_open' => 'Offen',
|
||||
'contact_closed' => 'Geschlossen',
|
||||
'contact_tilted' => 'Gekippt',
|
||||
'battery' => 'Batterie',
|
||||
'motion' => 'Bewegung',
|
||||
'no_motion' => 'Ruhe',
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@ return [
|
|||
'off' => 'Off',
|
||||
'open' => 'Open',
|
||||
'closed' => 'Closed',
|
||||
'tilted' => 'Tilted',
|
||||
'contact_open' => 'Open',
|
||||
'contact_closed' => 'Closed',
|
||||
'contact_tilted' => 'Tilted',
|
||||
'battery' => 'Battery',
|
||||
'motion' => 'Motion',
|
||||
'no_motion' => 'Idle',
|
||||
|
|
|
|||
|
|
@ -14,9 +14,12 @@
|
|||
@break
|
||||
|
||||
@case('contact')
|
||||
@php $open = ($s['open'] ?? false) === true; @endphp
|
||||
<x-status-pill :state="$open ? 'warning' : 'neutral'">
|
||||
{{ $entity->name }} · {{ $open ? __('devices.open') : __('devices.closed') }}
|
||||
@php
|
||||
$pos = $s['position'] ?? (($s['open'] ?? false) === true ? 'open' : 'closed');
|
||||
$pillState = match ($pos) { 'open' => 'offline', 'tilted' => 'warning', default => 'neutral' };
|
||||
@endphp
|
||||
<x-status-pill :state="$pillState">
|
||||
{{ $entity->name }} · {{ __('devices.contact_'.$pos) }}
|
||||
</x-status-pill>
|
||||
@break
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Support\Mqtt\ShellyNormalizer;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ShellyNormalizerTest extends TestCase
|
||||
{
|
||||
public function test_digital_input_becomes_a_contact(): void
|
||||
{
|
||||
$open = ShellyNormalizer::normalize('input:0', ['id' => 0, 'state' => true]);
|
||||
$this->assertSame('input:0', $open[0]['key']);
|
||||
$this->assertSame('contact', $open[0]['type']);
|
||||
$this->assertSame(['open' => true, 'position' => 'open'], $open[0]['state']);
|
||||
|
||||
$closed = ShellyNormalizer::normalize('input:1', ['id' => 1, 'state' => false]);
|
||||
$this->assertSame(['open' => false, 'position' => 'closed'], $closed[0]['state']);
|
||||
}
|
||||
|
||||
public function test_analog_or_count_input_is_not_a_contact(): void
|
||||
{
|
||||
$this->assertSame([], ShellyNormalizer::normalize('input:0', ['id' => 0, 'percent' => 40]));
|
||||
$this->assertSame([], ShellyNormalizer::normalize('input:0', ['id' => 0, 'state' => null]));
|
||||
}
|
||||
|
||||
public function test_contact_component_supports_three_state_tilt(): void
|
||||
{
|
||||
$tilted = ShellyNormalizer::normalize('contact:0', ['position' => 'tilted']);
|
||||
$this->assertSame('contact', $tilted[0]['type']);
|
||||
$this->assertSame(['open' => true, 'position' => 'tilted'], $tilted[0]['state']);
|
||||
|
||||
$window = ShellyNormalizer::normalize('window:0', ['open' => true]);
|
||||
$this->assertSame(['open' => true, 'position' => 'open'], $window[0]['state']);
|
||||
|
||||
$closed = ShellyNormalizer::normalize('contact:0', ['state' => false]);
|
||||
$this->assertSame(['open' => false, 'position' => 'closed'], $closed[0]['state']);
|
||||
}
|
||||
|
||||
public function test_switch_with_power_still_normalizes(): void
|
||||
{
|
||||
$updates = ShellyNormalizer::normalize('switch:0', ['output' => true, 'apower' => 12.7]);
|
||||
$this->assertSame('switch', $updates[0]['type']);
|
||||
$this->assertTrue($updates[0]['state']['on']);
|
||||
$this->assertSame('power', $updates[1]['type']);
|
||||
$this->assertSame(13, $updates[1]['state']['watts']);
|
||||
}
|
||||
|
||||
public function test_contact_is_a_primary_onboarding_type(): void
|
||||
{
|
||||
$this->assertContains('contact', ShellyNormalizer::PRIMARY_TYPES);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue