From 5df94dddf0c175dbb383357e4d735eff644b4ebb Mon Sep 17 00:00:00 2001 From: HomeOS Bootstrap Date: Sat, 18 Jul 2026 08:25:29 +0200 Subject: [PATCH] feat(sensors): window/door contacts (open/closed/tilted) from Shelly MQTT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/Support/Mqtt/ShellyNormalizer.php | 43 +++++++++++++++ lang/de/devices.php | 4 ++ lang/en/devices.php | 4 ++ .../views/components/entity-state.blade.php | 9 ++-- tests/Feature/ShellyNormalizerTest.php | 53 +++++++++++++++++++ 5 files changed, 110 insertions(+), 3 deletions(-) create mode 100644 tests/Feature/ShellyNormalizerTest.php diff --git a/app/Support/Mqtt/ShellyNormalizer.php b/app/Support/Mqtt/ShellyNormalizer.php index 46c8fdd..6252770 100644 --- a/app/Support/Mqtt/ShellyNormalizer.php +++ b/app/Support/Mqtt/ShellyNormalizer.php @@ -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 $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]; + } } diff --git a/lang/de/devices.php b/lang/de/devices.php index 70702a7..2264c21 100644 --- a/lang/de/devices.php +++ b/lang/de/devices.php @@ -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', diff --git a/lang/en/devices.php b/lang/en/devices.php index 34cd7eb..610f356 100644 --- a/lang/en/devices.php +++ b/lang/en/devices.php @@ -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', diff --git a/resources/views/components/entity-state.blade.php b/resources/views/components/entity-state.blade.php index 91fce80..4d98829 100644 --- a/resources/views/components/entity-state.blade.php +++ b/resources/views/components/entity-state.blade.php @@ -14,9 +14,12 @@ @break @case('contact') - @php $open = ($s['open'] ?? false) === true; @endphp - - {{ $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 + + {{ $entity->name }} · {{ __('devices.contact_'.$pos) }} @break diff --git a/tests/Feature/ShellyNormalizerTest.php b/tests/Feature/ShellyNormalizerTest.php new file mode 100644 index 0000000..fb5bc1d --- /dev/null +++ b/tests/Feature/ShellyNormalizerTest.php @@ -0,0 +1,53 @@ + 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); + } +}