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
HomeOS Bootstrap 2026-07-18 08:25:29 +02:00
parent fef393954c
commit 5df94dddf0
5 changed files with 110 additions and 3 deletions

View File

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

View File

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

View File

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

View File

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

View File

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