diff --git a/.env.example b/.env.example index 41ebd19..567e093 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/app/Console/Commands/PresencePollCommand.php b/app/Console/Commands/PresencePollCommand.php index b159003..86dcadd 100644 --- a/app/Console/Commands/PresencePollCommand.php +++ b/app/Console/Commands/PresencePollCommand.php @@ -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)]); diff --git a/app/Livewire/Modals/AddPerson.php b/app/Livewire/Modals/AddPerson.php deleted file mode 100644 index 136123d..0000000 --- a/app/Livewire/Modals/AddPerson.php +++ /dev/null @@ -1,63 +0,0 @@ - */ - 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'); - } -} diff --git a/app/Livewire/Modals/ManagePerson.php b/app/Livewire/Modals/ManagePerson.php new file mode 100644 index 0000000..aba106e --- /dev/null +++ b/app/Livewire/Modals/ManagePerson.php @@ -0,0 +1,94 @@ + */ + 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'); + } +} diff --git a/app/Livewire/Persons/Index.php b/app/Livewire/Persons/Index.php index b18d35f..64fcd91 100644 --- a/app/Livewire/Persons/Index.php +++ b/app/Livewire/Persons/Index.php @@ -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(), ]); } } diff --git a/app/Models/Person.php b/app/Models/Person.php index 38f6e13..0577a9c 100644 --- a/app/Models/Person.php +++ b/app/Models/Person.php @@ -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(''); + } } diff --git a/config/homeos.php b/config/homeos.php index 184b0a4..44b8eec 100644 --- a/config/homeos.php +++ b/config/homeos.php @@ -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), + ], ]; diff --git a/database/migrations/2026_07_18_080001_add_avatar_path_to_persons.php b/database/migrations/2026_07_18_080001_add_avatar_path_to_persons.php new file mode 100644 index 0000000..4c5375d --- /dev/null +++ b/database/migrations/2026_07_18_080001_add_avatar_path_to_persons.php @@ -0,0 +1,22 @@ +string('avatar_path')->nullable()->after('avatar_color'); + }); + } + + public function down(): void + { + Schema::table('persons', function (Blueprint $table) { + $table->dropColumn('avatar_path'); + }); + } +}; diff --git a/lang/de/persons.php b/lang/de/persons.php index 4ea7727..709a399 100644 --- a/lang/de/persons.php +++ b/lang/de/persons.php @@ -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', ]; diff --git a/lang/en/persons.php b/lang/en/persons.php index 685aae3..5fdd041 100644 --- a/lang/en/persons.php +++ b/lang/en/persons.php @@ -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', ]; diff --git a/resources/views/livewire/modals/add-person.blade.php b/resources/views/livewire/modals/manage-person.blade.php similarity index 56% rename from resources/views/livewire/modals/add-person.blade.php rename to resources/views/livewire/modals/manage-person.blade.php index 2ff8f63..f995ddb 100644 --- a/resources/views/livewire/modals/add-person.blade.php +++ b/resources/views/livewire/modals/manage-person.blade.php @@ -1,6 +1,29 @@
- +
+ {{-- Avatar --}} +
+
+ @php $preview = $avatar ? $avatar->temporaryUrl() : $currentAvatarUrl; @endphp + @if ($preview) + + @else + {{ mb_substr($name ?: '?', 0, 1) }} + @endif +
+ +
+
+
+ + {{ __('persons.avatar_hint') }} + @error('avatar')

{{ $message }}

@enderror +
+
+
- + diff --git a/resources/views/livewire/persons/index.blade.php b/resources/views/livewire/persons/index.blade.php index 16910d1..1455e95 100644 --- a/resources/views/livewire/persons/index.blade.php +++ b/resources/views/livewire/persons/index.blade.php @@ -1,7 +1,7 @@
- @@ -20,18 +20,62 @@
@else + {{-- Who's home --}} +
+ +
+
+

{{ $home->count() }} {{ __('persons.home_title') }}

+

{{ trans_choice('persons.away_count', $awayCount, ['count' => $awayCount]) }}

+
+
+ @forelse ($home as $p) + @if ($p->avatarUrl()) + {{ $p->name }} + @else + {{ $p->initials() }} + @endif + @empty + {{ __('persons.home_none') }} + @endforelse +
+
+
+ + {{-- People --}}
@foreach ($persons as $person)
- {{ mb_substr($person->name, 0, 1) }} + @if ($person->avatarUrl()) + {{ $person->name }} + @else + $person->isHome(), + 'bg-inset text-ink-2' => ! $person->isHome(), + ])>{{ $person->initials() }} + @endif
{{ $person->name }}
-
{{ $person->presence_changed_at?->diffForHumans() ?? '—' }}
+
{{ __('persons.since') }} {{ $person->presence_changed_at?->diffForHumans() ?? '—' }}
- - {{ __('persons.presence_'.$person->presence) }} - +
+ + {{ __('persons.presence_'.$person->presence) }} + +
+
+
+ +
@endforeach diff --git a/tests/Feature/PresenceTest.php b/tests/Feature/PresenceTest.php new file mode 100644 index 0000000..eaa8620 --- /dev/null +++ b/tests/Feature/PresenceTest.php @@ -0,0 +1,105 @@ +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); + } +}