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); + } +}