Fenster & Türen page: contact/motion sensor overview

Activates the windows nav tab. Summary banner (how many open) + per-room sensor
list with open/closed state and battery, live via Echo. Nav check 5/5 clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/phase-1-bootstrap
HomeOS Bootstrap 2026-07-18 00:07:44 +02:00
parent f4d8f27db4
commit cc81e309b3
6 changed files with 104 additions and 1 deletions

33
app/Livewire/Windows.php Normal file
View File

@ -0,0 +1,33 @@
<?php
namespace App\Livewire;
use App\Models\Entity;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
#[Layout('layouts.app')]
class Windows extends Component
{
#[On('echo-private:home,.DeviceStateChanged')]
public function onDeviceStateChanged(): void {}
public function render()
{
$sensors = Entity::query()
->whereIn('type', ['contact', 'motion'])
->with(['device.room', 'device.entities.state', 'state'])
->get()
->sortBy(fn ($e) => ($e->device->room?->name ?? 'zzz').$e->device->name)
->values();
$open = $sensors->filter(fn ($e) => $e->type === 'contact' && data_get($e->state, 'state.open') === true)->count();
return view('livewire.windows', [
'groups' => $sensors->groupBy(fn ($e) => $e->device->room?->name ?? __('devices.no_room')),
'open' => $open,
'total' => $sensors->count(),
]);
}
}

9
lang/de/windows.php Normal file
View File

@ -0,0 +1,9 @@
<?php
return [
'subtitle' => 'Kontakte an Fenstern und Türen.',
'empty' => 'Keine Kontaktsensoren.',
'all_closed' => 'Alles geschlossen',
'open_count' => '{1}1 offen|[2,*]:count offen',
'sensor_count' => '{0}Keine Sensoren|{1}1 Sensor|[2,*]:count Sensoren',
];

9
lang/en/windows.php Normal file
View File

@ -0,0 +1,9 @@
<?php
return [
'subtitle' => 'Contacts on windows and doors.',
'empty' => 'No contact sensors.',
'all_closed' => 'All closed',
'open_count' => '{1}1 open|[2,*]:count open',
'sensor_count' => '{0}No sensors|{1}1 sensor|[2,*]:count sensors',
];

View File

@ -9,7 +9,7 @@
['key' => 'persons', 'icon' => 'persons', 'route' => null, 'lock' => false], ['key' => 'persons', 'icon' => 'persons', 'route' => null, 'lock' => false],
], ],
'nav.section_security' => [ 'nav.section_security' => [
['key' => 'windows', 'icon' => 'window', 'route' => null, 'lock' => false], ['key' => 'windows', 'icon' => 'window', 'route' => 'windows', 'lock' => false],
['key' => 'access', 'icon' => 'shield', 'route' => null, 'lock' => true], ['key' => 'access', 'icon' => 'shield', 'route' => null, 'lock' => true],
], ],
'nav.section_system' => [ 'nav.section_system' => [

View File

@ -0,0 +1,50 @@
<div wire:poll.30s>
<x-topbar :title="__('nav.windows')" :subtitle="__('windows.subtitle')" />
<div class="px-5 lg:px-[26px] py-6 flex flex-col gap-5 max-w-[1560px] mx-auto w-full">
@if ($total === 0)
<x-panel><p class="text-[13px] text-ink-3 text-center py-6">{{ __('windows.empty') }}</p></x-panel>
@else
{{-- summary --}}
<section class="relative overflow-hidden rounded-card border border-line-soft {{ $open > 0 ? 'bg-warning/10' : 'bg-online/10' }}">
<span class="absolute inset-y-0 left-0 w-[3px] {{ $open > 0 ? 'bg-warning' : 'bg-online' }}"></span>
<div class="flex items-center gap-3.5 p-4 pl-5">
<span class="grid place-items-center w-10 h-10 rounded-lg bg-base/40 {{ $open > 0 ? 'text-warning' : 'text-online' }} shrink-0">
<x-icon :name="$open > 0 ? 'window' : 'check'" :size="20" />
</span>
<div>
<h2 class="text-[15px] font-bold text-ink">
{{ $open > 0 ? trans_choice('windows.open_count', $open, ['count' => $open]) : __('windows.all_closed') }}
</h2>
<p class="text-[12.5px] text-ink-2">{{ trans_choice('windows.sensor_count', $total, ['count' => $total]) }}</p>
</div>
</div>
</section>
@foreach ($groups as $roomName => $sensors)
<x-panel :title="$roomName">
<div class="flex flex-col divide-y divide-line-soft -my-1">
@foreach ($sensors as $sensor)
@php
$battery = $sensor->device->entities->firstWhere('type', 'battery');
$pct = $battery ? (int) data_get($battery->state, 'state.percent', 0) : null;
@endphp
<div class="flex items-center gap-3 py-3 first:pt-1 last:pb-1">
<span class="grid place-items-center w-8 h-8 rounded-lg bg-inset text-ink-2 shrink-0">
<x-icon :name="$sensor->type === 'motion' ? 'activity' : 'window'" :size="16" />
</span>
<a href="{{ route('devices.show', $sensor->device) }}" wire:navigate class="text-[13px] font-semibold text-ink hover:text-accent transition-colors truncate">{{ $sensor->device->name }}</a>
<div class="ml-auto flex items-center gap-2 shrink-0">
@if (! is_null($pct))
<x-status-pill :state="$pct < 20 ? 'offline' : 'neutral'">{{ __('devices.battery') }} · <span class="font-mono">{{ $pct }}%</span></x-status-pill>
@endif
<x-entity-state :entity="$sensor" />
</div>
</div>
@endforeach
</div>
</x-panel>
@endforeach
@endif
</div>
</div>

View File

@ -7,6 +7,7 @@ use App\Livewire\Devices\Show as DeviceShow;
use App\Livewire\Host; use App\Livewire\Host;
use App\Livewire\Rooms\Index as RoomIndex; use App\Livewire\Rooms\Index as RoomIndex;
use App\Livewire\Rooms\Show as RoomShow; use App\Livewire\Rooms\Show as RoomShow;
use App\Livewire\Windows;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
@ -22,6 +23,7 @@ Route::middleware('auth')->group(function () {
Route::get('/rooms/{room}', RoomShow::class)->name('rooms.show'); Route::get('/rooms/{room}', RoomShow::class)->name('rooms.show');
Route::get('/devices', DeviceIndex::class)->name('devices.index'); Route::get('/devices', DeviceIndex::class)->name('devices.index');
Route::get('/devices/{device}', DeviceShow::class)->name('devices.show'); Route::get('/devices/{device}', DeviceShow::class)->name('devices.show');
Route::get('/windows', Windows::class)->name('windows');
Route::get('/host', Host::class)->name('host'); Route::get('/host', Host::class)->name('host');
Route::post('/logout', function () { Route::post('/logout', function () {