feat(presence): who's-home overview, person edit/delete, avatars, faster away

Issue 1 — presence was half-built:
- Away detection: the debounce was 8 min (you disconnect, still "home"). Now
  3 min and configurable (homeos.presence.away_debounce_minutes /
  PRESENCE_AWAY_DEBOUNCE).
- Person management: ManagePerson modal handles BOTH add and edit (name, UniFi
  client, avatar). Delete from the person card (confirm). Replaces AddPerson.
- Avatar upload: image stored on the public disk (storage:link), shown on cards
  + the who's-home strip; initials fallback (green when home).
- Who's home: a summary strip ("N Zuhause · M abwesend" with avatars) + clear
  per-person home/away, so you can see at a glance who is in.

5 tests (debounce, create+avatar, edit, delete+avatar removal). Suite 78 green,
12/12 tabs clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/phase-1-bootstrap
HomeOS Bootstrap 2026-07-18 10:20:49 +02:00
parent f3ae29d65a
commit 60d5275819
13 changed files with 376 additions and 76 deletions

View File

@ -101,3 +101,6 @@ MQTT_HOST_PORT=1883
HOMEOS_ADMIN_EMAIL=admin@homeos.local
HOMEOS_ADMIN_NAME=Admin
HOMEOS_ADMIN_PASSWORD=homeos-dev
# Minutes off-WLAN before a person flips to "away" (rides out phone WLAN sleeps)
PRESENCE_AWAY_DEBOUNCE=3

View File

@ -18,8 +18,6 @@ class PresencePollCommand extends Command
protected $description = 'Poll UniFi for client presence and update persons.';
private const AWAY_DEBOUNCE_MINUTES = 8;
public function handle(UnifiClient $unifi): int
{
if (! $unifi->isConfigured()) {
@ -38,7 +36,7 @@ class PresencePollCommand extends Command
}
$this->info('UniFi active clients: '.count($connected));
$debounce = now()->subMinutes(self::AWAY_DEBOUNCE_MINUTES);
$debounce = now()->subMinutes((int) config('homeos.presence.away_debounce_minutes', 3));
foreach (Person::whereNotNull('mac')->get() as $person) {
$home = isset($connected[strtolower($person->mac)]);

View File

@ -1,63 +0,0 @@
<?php
namespace App\Livewire\Modals;
use App\Models\Person;
use App\Services\UnifiClient;
use LivewireUI\Modal\ModalComponent;
class AddPerson extends ModalComponent
{
public string $name = '';
public string $mac = '';
/** @var array<int, array{mac:string,label:string}> */
public array $clientOptions = [];
public bool $unifiError = false;
public static function modalMaxWidth(): string
{
return 'lg';
}
public function mount(UnifiClient $unifi): void
{
try {
$this->clientOptions = collect($unifi->clients())
->map(fn ($c) => [
'mac' => strtolower($c->mac ?? ''),
'label' => $c->name ?? $c->hostname ?? ($c->mac ?? '?'),
])
->filter(fn ($o) => $o['mac'] !== '')
->sortBy('label', SORT_NATURAL | SORT_FLAG_CASE)
->values()->all();
} catch (\Throwable) {
$this->unifiError = true;
}
}
public function save()
{
$this->validate([
'name' => 'required|string|max:100',
'mac' => ['nullable', 'string', 'max:32', 'unique:persons,mac'],
]);
Person::create([
'name' => $this->name,
'mac' => $this->mac ?: null,
'presence' => 'unknown',
]);
$this->closeModal();
return redirect()->route('persons.index');
}
public function render()
{
return view('livewire.modals.add-person');
}
}

View File

@ -0,0 +1,94 @@
<?php
namespace App\Livewire\Modals;
use App\Models\Person;
use App\Services\UnifiClient;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\Rule;
use Livewire\Features\SupportFileUploads\WithFileUploads;
use LivewireUI\Modal\ModalComponent;
/** Add or edit a person — name, the device (UniFi client MAC) that marks presence, and an avatar. */
class ManagePerson extends ModalComponent
{
use WithFileUploads;
public ?string $personUuid = null;
public bool $editing = false;
public string $name = '';
public string $mac = '';
public $avatar = null;
public ?string $currentAvatarUrl = null;
/** @var array<int, array{mac:string,label:string}> */
public array $clientOptions = [];
public bool $unifiError = false;
public static function modalMaxWidth(): string
{
return 'lg';
}
public function mount(UnifiClient $unifi, ?string $person = null): void
{
try {
$this->clientOptions = collect($unifi->clients())
->map(fn ($c) => ['mac' => strtolower($c->mac ?? ''), 'label' => $c->name ?? $c->hostname ?? ($c->mac ?? '?')])
->filter(fn ($o) => $o['mac'] !== '')
->sortBy('label', SORT_NATURAL | SORT_FLAG_CASE)
->values()->all();
} catch (\Throwable) {
$this->unifiError = true;
}
if ($person !== null) {
$model = Person::where('uuid', $person)->firstOrFail();
$this->editing = true;
$this->personUuid = $model->uuid;
$this->name = $model->name;
$this->mac = $model->mac ?? '';
$this->currentAvatarUrl = $model->avatarUrl();
}
}
public function save()
{
$this->validate([
'name' => 'required|string|max:100',
'mac' => ['nullable', 'string', 'max:32', Rule::unique('persons', 'mac')->ignore($this->personUuid, 'uuid')],
'avatar' => ['nullable', 'image', 'max:4096'],
]);
$person = $this->editing
? Person::where('uuid', $this->personUuid)->firstOrFail()
: new Person(['presence' => 'unknown']);
$person->name = $this->name;
$person->mac = $this->mac ?: null;
if ($this->avatar !== null) {
if ($person->avatar_path) {
Storage::disk('public')->delete($person->avatar_path);
}
$person->avatar_path = $this->avatar->store('avatars', 'public');
}
$person->save();
$this->closeModal();
return redirect()->route('persons.index');
}
public function render()
{
return view('livewire.modals.manage-person');
}
}

View File

@ -3,6 +3,7 @@
namespace App\Livewire\Persons;
use App\Models\Person;
use Illuminate\Support\Facades\Storage;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
@ -10,13 +11,38 @@ use Livewire\Component;
#[Layout('layouts.app')]
class Index extends Component
{
/** Person awaiting a delete confirmation. */
public ?string $pendingDelete = null;
#[On('echo-private:presence,.PresenceChanged')]
public function onPresenceChanged(): void {}
#[On('delete-person')]
public function deletePerson(): void
{
if ($this->pendingDelete === null) {
return;
}
$person = Person::where('uuid', $this->pendingDelete)->first();
if ($person !== null) {
if ($person->avatar_path) {
Storage::disk('public')->delete($person->avatar_path);
}
$person->delete();
}
$this->pendingDelete = null;
}
public function render()
{
$persons = Person::orderBy('name')->get();
return view('livewire.persons.index', [
'persons' => Person::orderBy('name')->get(),
'persons' => $persons,
'home' => $persons->where('presence', 'home')->values(),
'awayCount' => $persons->where('presence', '!=', 'home')->count(),
]);
}
}

View File

@ -5,6 +5,8 @@ namespace App\Models;
use App\Models\Concerns\HasUuid;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class Person extends Model
{
@ -13,7 +15,7 @@ class Person extends Model
protected $table = 'persons';
protected $fillable = [
'uuid', 'name', 'avatar_color', 'user_id', 'mac', 'presence', 'presence_changed_at', 'last_seen_home_at',
'uuid', 'name', 'avatar_color', 'avatar_path', 'user_id', 'mac', 'presence', 'presence_changed_at', 'last_seen_home_at',
];
protected $casts = [
@ -30,4 +32,15 @@ class Person extends Model
{
return $this->presence === 'home';
}
public function avatarUrl(): ?string
{
return $this->avatar_path ? Storage::disk('public')->url($this->avatar_path) : null;
}
public function initials(): string
{
return Str::of($this->name)->explode(' ')->take(2)
->map(fn ($w) => Str::upper(Str::substr($w, 0, 1)))->implode('');
}
}

View File

@ -27,4 +27,15 @@ return [
// exhaust the DB via auto-onboarding. 0 disables the cap.
'max_devices' => (int) env('MQTT_MAX_DEVICES', 250),
],
/*
|----------------------------------------------------------------------
| Presence (UniFi)
|----------------------------------------------------------------------
*/
'presence' => [
// Minutes a person must be off the WLAN before flipping to "away". A short debounce
// rides out brief phone WLAN sleeps without leaving you "home" long after you left.
'away_debounce_minutes' => (int) env('PRESENCE_AWAY_DEBOUNCE', 3),
],
];

View File

@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('persons', function (Blueprint $table) {
$table->string('avatar_path')->nullable()->after('avatar_color');
});
}
public function down(): void
{
Schema::table('persons', function (Blueprint $table) {
$table->dropColumn('avatar_path');
});
}
};

View File

@ -13,4 +13,16 @@ return [
'pick_client' => 'Client auswählen…',
'client_hint' => 'Das Gerät, das die Person repräsentiert — Anwesenheit folgt seiner WLAN-Verbindung.',
'unifi_error' => 'UniFi nicht erreichbar — MAC-Adresse manuell eingeben.',
'edit_title' => 'Person bearbeiten',
'edit' => 'Bearbeiten',
'save' => 'Speichern',
'avatar_upload' => 'Bild wählen',
'avatar_hint' => 'JPG/PNG, bis 4 MB. Optional.',
'delete' => 'Person löschen',
'delete_title' => 'Person löschen?',
'delete_body' => ':name wird gelöscht. Die Zuordnung zum Gerät geht verloren.',
'home_title' => 'Zuhause',
'home_none' => 'Gerade ist niemand zuhause.',
'away_count' => '{0}niemand abwesend|{1}:count abwesend|[2,*]:count abwesend',
'since' => 'seit',
];

View File

@ -13,4 +13,16 @@ return [
'pick_client' => 'Pick a client…',
'client_hint' => 'The device that represents this person — presence follows its WLAN connection.',
'unifi_error' => 'UniFi unreachable — enter the MAC manually.',
'edit_title' => 'Edit person',
'edit' => 'Edit',
'save' => 'Save',
'avatar_upload' => 'Choose image',
'avatar_hint' => 'JPG/PNG, up to 4 MB. Optional.',
'delete' => 'Delete person',
'delete_title' => 'Delete person?',
'delete_body' => ':name will be deleted. The device link is lost.',
'home_title' => 'Home',
'home_none' => 'Nobody is home right now.',
'away_count' => '{0}nobody away|{1}:count away|[2,*]:count away',
'since' => 'since',
];

View File

@ -1,6 +1,29 @@
<form wire:submit="save">
<x-modal :title="__('persons.add_title')" icon="persons">
<x-modal :title="$editing ? __('persons.edit_title') : __('persons.add_title')" icon="persons">
<div class="p-5 flex flex-col gap-4">
{{-- Avatar --}}
<div class="flex items-center gap-4">
<div class="relative shrink-0">
@php $preview = $avatar ? $avatar->temporaryUrl() : $currentAvatarUrl; @endphp
@if ($preview)
<img src="{{ $preview }}" alt="" class="w-16 h-16 rounded-full object-cover border border-line-soft">
@else
<span class="grid place-items-center w-16 h-16 rounded-full bg-accent/15 text-accent font-bold text-[20px] uppercase">{{ mb_substr($name ?: '?', 0, 1) }}</span>
@endif
<div wire:loading wire:target="avatar" class="absolute inset-0 grid place-items-center rounded-full bg-base/70">
<x-icon name="activity" :size="18" class="text-accent pulse-live" />
</div>
</div>
<div class="flex flex-col gap-1.5">
<label class="inline-flex items-center gap-1.5 rounded-lg border border-line px-3 py-2 text-[12.5px] font-semibold text-ink-2 hover:text-ink hover:bg-raised transition-colors cursor-pointer w-max">
<x-icon name="persons" :size="14" /> {{ __('persons.avatar_upload') }}
<input type="file" wire:model="avatar" accept="image/*" class="hidden">
</label>
<span class="text-[11px] text-ink-3">{{ __('persons.avatar_hint') }}</span>
@error('avatar') <p class="text-[12px] text-offline">{{ $message }}</p> @enderror
</div>
</div>
<div class="flex flex-col gap-1.5">
<label for="p-name" class="text-[12px] font-semibold text-ink-2">{{ __('devices.name') }}</label>
<input wire:model="name" id="p-name" type="text" autofocus
@ -29,7 +52,7 @@
<x-slot:footer>
<button type="button" wire:click="closeModal" class="rounded-lg border border-line px-3.5 py-2 text-[13px] font-semibold text-ink-2 hover:text-ink hover:bg-raised transition-colors">{{ __('common.cancel') }}</button>
<button type="submit" class="rounded-lg bg-accent px-3.5 py-2 text-[13px] font-bold text-base hover:brightness-110 transition-[filter]">{{ __('persons.add') }}</button>
<button type="submit" class="rounded-lg bg-accent px-3.5 py-2 text-[13px] font-bold text-base hover:brightness-110 transition-[filter]">{{ $editing ? __('persons.save') : __('persons.add') }}</button>
</x-slot:footer>
</x-modal>
</form>

View File

@ -1,7 +1,7 @@
<div wire:poll.30s>
<x-topbar :title="__('nav.persons')" :subtitle="__('persons.subtitle')">
<x-slot:actions>
<button type="button" wire:click="$dispatch('openModal', { component: 'modals.add-person' })"
<button type="button" wire:click="$dispatch('openModal', { component: 'modals.manage-person' })"
class="inline-flex items-center gap-1.5 rounded-lg bg-accent px-3 py-1.5 text-[12px] font-bold text-base hover:brightness-110 transition-[filter]">
<x-icon name="plus" :size="15" /> <span class="hidden sm:inline">{{ __('persons.add') }}</span>
</button>
@ -20,19 +20,63 @@
</div>
</x-panel>
@else
{{-- Who's home --}}
<section class="relative overflow-hidden rounded-card border border-line-soft {{ $home->isNotEmpty() ? 'bg-online/10' : 'bg-surface' }}">
<span class="absolute inset-y-0 left-0 w-[3px] {{ $home->isNotEmpty() ? 'bg-online' : 'bg-line' }}"></span>
<div class="flex flex-wrap items-center gap-4 p-4 pl-5">
<div class="min-w-0">
<h2 class="text-[15px] font-bold text-ink"><span class="font-mono tabular-nums">{{ $home->count() }}</span> {{ __('persons.home_title') }}</h2>
<p class="text-[12px] text-ink-2">{{ trans_choice('persons.away_count', $awayCount, ['count' => $awayCount]) }}</p>
</div>
<div class="flex items-center -space-x-2 ml-auto">
@forelse ($home as $p)
@if ($p->avatarUrl())
<img src="{{ $p->avatarUrl() }}" alt="{{ $p->name }}" title="{{ $p->name }}" class="w-9 h-9 rounded-full object-cover border-2 border-base">
@else
<span title="{{ $p->name }}" class="grid place-items-center w-9 h-9 rounded-full bg-online/20 text-online font-bold text-[13px] uppercase border-2 border-base">{{ $p->initials() }}</span>
@endif
@empty
<span class="text-[12.5px] text-ink-3">{{ __('persons.home_none') }}</span>
@endforelse
</div>
</div>
</section>
{{-- People --}}
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
@foreach ($persons as $person)
<x-panel>
<div class="flex items-center gap-3">
<span class="grid place-items-center w-11 h-11 rounded-full bg-inset text-ink font-bold text-[15px] uppercase">{{ mb_substr($person->name, 0, 1) }}</span>
@if ($person->avatarUrl())
<img src="{{ $person->avatarUrl() }}" alt="{{ $person->name }}" class="w-11 h-11 rounded-full object-cover shrink-0">
@else
<span @class([
'grid place-items-center w-11 h-11 rounded-full font-bold text-[15px] uppercase shrink-0',
'bg-online/20 text-online' => $person->isHome(),
'bg-inset text-ink-2' => ! $person->isHome(),
])>{{ $person->initials() }}</span>
@endif
<div class="min-w-0">
<div class="text-[14px] font-bold text-ink truncate">{{ $person->name }}</div>
<div class="text-[11.5px] text-ink-3">{{ $person->presence_changed_at?->diffForHumans() ?? '—' }}</div>
<div class="text-[11.5px] text-ink-3">{{ __('persons.since') }} {{ $person->presence_changed_at?->diffForHumans() ?? '—' }}</div>
</div>
<x-status-pill :state="$person->isHome() ? 'online' : ($person->presence === 'away' ? 'neutral' : 'warning')" class="ml-auto">
<div class="ml-auto flex items-center gap-1.5 shrink-0">
<x-status-pill :state="$person->isHome() ? 'online' : ($person->presence === 'away' ? 'neutral' : 'warning')">
{{ __('persons.presence_'.$person->presence) }}
</x-status-pill>
</div>
</div>
<div class="flex items-center gap-2 mt-3 pt-3 border-t border-line-soft">
<button type="button" wire:click="$dispatch('openModal', { component: 'modals.manage-person', arguments: { person: @js($person->uuid) } })"
class="inline-flex items-center gap-1.5 rounded-lg border border-line px-3 py-1.5 text-[12px] font-semibold text-ink-2 hover:text-ink hover:bg-raised transition-colors">
<x-icon name="settings" :size="13" /> {{ __('persons.edit') }}
</button>
<button type="button"
x-on:click="$wire.set('pendingDelete', @js($person->uuid)); $wire.$dispatch('openModal', { component: 'modals.confirm', arguments: { title: @js(__('persons.delete_title')), body: @js(__('persons.delete_body', ['name' => $person->name])), confirmLabel: @js(__('persons.delete')), event: 'delete-person', to: 'persons', danger: true } })"
class="ml-auto inline-flex items-center gap-1.5 rounded-lg border border-line px-3 py-1.5 text-[12px] font-semibold text-ink-3 hover:text-offline hover:border-offline/40 transition-colors">
<x-icon name="close" :size="13" /> {{ __('persons.delete') }}
</button>
</div>
</x-panel>
@endforeach
</div>

View File

@ -0,0 +1,105 @@
<?php
namespace Tests\Feature;
use App\Livewire\Modals\ManagePerson;
use App\Livewire\Persons\Index;
use App\Models\Person;
use App\Models\User;
use App\Services\UnifiClient;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
use Tests\TestCase;
class PresenceTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
// Never touch a real UniFi controller in tests.
$this->app->bind(UnifiClient::class, fn () => new class extends UnifiClient
{
public function __construct() {}
public function clients(): array
{
return [];
}
public function connectedMacs(): array
{
return [];
}
public function isConfigured(): bool
{
return true;
}
});
}
public function test_person_flips_to_away_only_after_the_debounce(): void
{
config()->set('homeos.presence.away_debounce_minutes', 3);
// Seen 1 min ago (within debounce) → still home.
$recent = Person::create(['name' => 'A', 'mac' => 'aa:bb', 'presence' => 'home', 'last_seen_home_at' => now()->subMinute()]);
// Seen 10 min ago (past debounce) and off wifi → away.
$stale = Person::create(['name' => 'B', 'mac' => 'cc:dd', 'presence' => 'home', 'last_seen_home_at' => now()->subMinutes(10)]);
$this->artisan('presence:poll')->assertSuccessful();
$this->assertSame('home', $recent->fresh()->presence);
$this->assertSame('away', $stale->fresh()->presence);
}
public function test_manage_person_creates_with_an_avatar(): void
{
Storage::fake('public');
$this->actingAs(User::factory()->create());
Livewire::test(ManagePerson::class)
->set('name', 'Boban')
->set('avatar', UploadedFile::fake()->create('me.jpg', 200, 'image/jpeg'))
->call('save')
->assertRedirect(route('persons.index'));
$person = Person::where('name', 'Boban')->first();
$this->assertNotNull($person->avatar_path);
Storage::disk('public')->assertExists($person->avatar_path);
}
public function test_manage_person_edits_an_existing_person(): void
{
$this->actingAs(User::factory()->create());
$person = Person::create(['name' => 'Old', 'presence' => 'unknown']);
Livewire::test(ManagePerson::class, ['person' => $person->uuid])
->assertSet('editing', true)
->set('name', 'New')
->call('save');
$this->assertSame('New', $person->fresh()->name);
}
public function test_deleting_a_person_removes_it_and_its_avatar(): void
{
Storage::fake('public');
$this->actingAs(User::factory()->create());
$path = UploadedFile::fake()->create('a.jpg', 200, 'image/jpeg')->store('avatars', 'public');
$person = Person::create(['name' => 'X', 'presence' => 'home', 'avatar_path' => $path]);
Livewire::test(Index::class)
->set('pendingDelete', $person->uuid)
->call('deletePerson');
$this->assertModelMissing($person);
Storage::disk('public')->assertMissing($path);
}
}