From b85d2f60fedeba65d1e2ec6d623caa4d7894b406 Mon Sep 17 00:00:00 2001 From: HomeOS Bootstrap Date: Sat, 18 Jul 2026 00:31:32 +0200 Subject: [PATCH] =?UTF-8?q?Phase=205:=20UniFi=20presence=20=E2=80=94=20pol?= =?UTF-8?q?l,=20person=E2=86=94client=20mapping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UnifiClient service (art-of-wifi/unifi-api-client) talks to the local UDM with a read-only account (self-signed cert, verify_ssl off). Verified live: logged in and read 32 active clients. - presence:poll command (scheduled every minute, withoutOverlapping): "home" immediately on association, "away" only after an 8-min debounce so iPhone WLAN sleep can't cause false-aways. Broadcasts PresenceChanged on the presence channel. - Persons page: "Person hinzufügen" modal picks the representing device straight from the live UniFi client list (falls back to manual MAC entry if UniFi is down); the sweep then tracks that person's presence. mac + last_seen_home_at on persons. Co-Authored-By: Claude Opus 4.8 --- app/Console/Commands/PresencePollCommand.php | 75 +++++++++++++++++++ app/Events/PresenceChanged.php | 27 +++++++ app/Livewire/Modals/AddPerson.php | 63 ++++++++++++++++ app/Models/Person.php | 9 ++- app/Services/UnifiClient.php | 54 +++++++++++++ composer.json | 1 + composer.lock | 55 +++++++++++++- config/services.php | 9 +++ .../2026_07_18_010001_add_mac_to_persons.php | 24 ++++++ lang/de/persons.php | 6 ++ lang/en/persons.php | 6 ++ .../livewire/modals/add-person.blade.php | 41 ++++++++++ .../views/livewire/persons/index.blade.php | 9 ++- routes/console.php | 4 + 14 files changed, 379 insertions(+), 4 deletions(-) create mode 100644 app/Console/Commands/PresencePollCommand.php create mode 100644 app/Events/PresenceChanged.php create mode 100644 app/Livewire/Modals/AddPerson.php create mode 100644 app/Services/UnifiClient.php create mode 100644 database/migrations/2026_07_18_010001_add_mac_to_persons.php create mode 100644 resources/views/livewire/modals/add-person.blade.php diff --git a/app/Console/Commands/PresencePollCommand.php b/app/Console/Commands/PresencePollCommand.php new file mode 100644 index 0000000..b159003 --- /dev/null +++ b/app/Console/Commands/PresencePollCommand.php @@ -0,0 +1,75 @@ +isConfigured()) { + $this->warn('UniFi is not configured (UNIFI_* env).'); + + return self::SUCCESS; + } + + try { + $connected = array_flip($unifi->connectedMacs()); + } catch (\Throwable $e) { + report($e); + $this->error('UniFi poll failed: '.$e->getMessage()); + + return self::FAILURE; + } + + $this->info('UniFi active clients: '.count($connected)); + $debounce = now()->subMinutes(self::AWAY_DEBOUNCE_MINUTES); + + foreach (Person::whereNotNull('mac')->get() as $person) { + $home = isset($connected[strtolower($person->mac)]); + + if ($home) { + $person->last_seen_home_at = now(); + if ($person->presence !== 'home') { + $this->markPresence($person, 'home'); + } else { + $person->save(); + } + + continue; + } + + // Not associated: flip to away only once the debounce has elapsed. + $seenLongAgo = $person->last_seen_home_at === null || $person->last_seen_home_at->lt($debounce); + if ($person->presence !== 'away' && $seenLongAgo) { + $this->markPresence($person, 'away'); + } + } + + return self::SUCCESS; + } + + private function markPresence(Person $person, string $presence): void + { + $person->presence = $presence; + $person->presence_changed_at = now(); + $person->save(); + + PresenceChanged::dispatch($person->uuid); + } +} diff --git a/app/Events/PresenceChanged.php b/app/Events/PresenceChanged.php new file mode 100644 index 0000000..89a1232 --- /dev/null +++ b/app/Events/PresenceChanged.php @@ -0,0 +1,27 @@ + */ + 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/Models/Person.php b/app/Models/Person.php index 39c71c9..38f6e13 100644 --- a/app/Models/Person.php +++ b/app/Models/Person.php @@ -12,9 +12,14 @@ class Person extends Model protected $table = 'persons'; - protected $fillable = ['uuid', 'name', 'avatar_color', 'user_id', 'presence', 'presence_changed_at']; + protected $fillable = [ + 'uuid', 'name', 'avatar_color', 'user_id', 'mac', 'presence', 'presence_changed_at', 'last_seen_home_at', + ]; - protected $casts = ['presence_changed_at' => 'datetime']; + protected $casts = [ + 'presence_changed_at' => 'datetime', + 'last_seen_home_at' => 'datetime', + ]; public function user(): BelongsTo { diff --git a/app/Services/UnifiClient.php b/app/Services/UnifiClient.php new file mode 100644 index 0000000..a465401 --- /dev/null +++ b/app/Services/UnifiClient.php @@ -0,0 +1,54 @@ + active clients (mac, hostname/name, ip, last_seen) */ + public function clients(): array + { + $api = $this->connect(); + $clients = $api->list_clients(); + + return is_array($clients) ? $clients : []; + } + + /** Lower-cased MACs currently associated. */ + public function connectedMacs(): array + { + return array_values(array_filter(array_map( + fn ($c) => isset($c->mac) ? strtolower($c->mac) : null, + $this->clients(), + ))); + } + + public function isConfigured(): bool + { + return filled(config('services.unifi.host')) && filled(config('services.unifi.username')); + } + + protected function connect(): Client + { + $api = new Client( + config('services.unifi.username'), + config('services.unifi.password'), + 'https://'.config('services.unifi.host').':'.config('services.unifi.port'), + config('services.unifi.site'), + null, + config('services.unifi.verify_ssl'), + ); + + if ($api->login() !== true) { + throw new \RuntimeException('UniFi login failed — check UNIFI_* credentials.'); + } + + return $api; + } +} diff --git a/composer.json b/composer.json index 471bd03..d6fba08 100644 --- a/composer.json +++ b/composer.json @@ -7,6 +7,7 @@ "license": "MIT", "require": { "php": "^8.3", + "art-of-wifi/unifi-api-client": "^2.2", "laravel/framework": "^13.8", "laravel/horizon": "^5.47", "laravel/reverb": "^1.10", diff --git a/composer.lock b/composer.lock index a85782c..62e5ed8 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,61 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ee1777b0e0b13cda0b08354e8ba08aa7", + "content-hash": "4d8a11a464455ac401f254459411449a", "packages": [ + { + "name": "art-of-wifi/unifi-api-client", + "version": "v2.2.0", + "source": { + "type": "git", + "url": "https://github.com/Art-of-WiFi/UniFi-API-client.git", + "reference": "fc0deabd20bef0e9b7725e6412455dcab0386a10" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Art-of-WiFi/UniFi-API-client/zipball/fc0deabd20bef0e9b7725e6412455dcab0386a10", + "reference": "fc0deabd20bef0e9b7725e6412455dcab0386a10", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "php": ">=7.4.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "UniFi_API\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Art of WiFi", + "email": "info@artofowifi.net", + "homepage": "https://artofwifi.net" + } + ], + "description": "API client class for use with Ubiquiti's UniFi controller", + "homepage": "https://github.com/Art-of-WiFi/UniFi-API-client", + "keywords": [ + "UBNT", + "api", + "client", + "controller", + "ubiquiti", + "unifi", + "unifi os" + ], + "support": { + "issues": "https://github.com/Art-of-WiFi/UniFi-API-client/issues", + "source": "https://github.com/Art-of-WiFi/UniFi-API-client/tree/v2.2.0" + }, + "time": "2026-03-22T13:45:58+00:00" + }, { "name": "brick/math", "version": "0.18.0", diff --git a/config/services.php b/config/services.php index 6a90eb8..b234a1a 100644 --- a/config/services.php +++ b/config/services.php @@ -35,4 +35,13 @@ return [ ], ], + 'unifi' => [ + 'host' => env('UNIFI_HOST'), + 'port' => env('UNIFI_PORT', 443), + 'username' => env('UNIFI_USERNAME'), + 'password' => env('UNIFI_PASSWORD'), + 'site' => env('UNIFI_SITE', 'default'), + 'verify_ssl' => filter_var(env('UNIFI_VERIFY_SSL', false), FILTER_VALIDATE_BOOL), + ], + ]; diff --git a/database/migrations/2026_07_18_010001_add_mac_to_persons.php b/database/migrations/2026_07_18_010001_add_mac_to_persons.php new file mode 100644 index 0000000..42b6488 --- /dev/null +++ b/database/migrations/2026_07_18_010001_add_mac_to_persons.php @@ -0,0 +1,24 @@ +string('mac')->nullable()->unique()->after('user_id'); + $table->timestamp('last_seen_home_at')->nullable()->after('presence_changed_at'); + }); + } + + public function down(): void + { + Schema::table('persons', function (Blueprint $table) { + $table->dropColumn(['mac', 'last_seen_home_at']); + }); + } +}; diff --git a/lang/de/persons.php b/lang/de/persons.php index 36fd0b3..4ea7727 100644 --- a/lang/de/persons.php +++ b/lang/de/persons.php @@ -7,4 +7,10 @@ return [ 'presence_home' => 'Zuhause', 'presence_away' => 'Abwesend', 'presence_unknown' => 'Unbekannt', + 'add' => 'Person hinzufügen', + 'add_title' => 'Person hinzufügen', + 'device' => 'Gerät (UniFi-Client)', + '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.', ]; diff --git a/lang/en/persons.php b/lang/en/persons.php index 2b26704..685aae3 100644 --- a/lang/en/persons.php +++ b/lang/en/persons.php @@ -7,4 +7,10 @@ return [ 'presence_home' => 'Home', 'presence_away' => 'Away', 'presence_unknown' => 'Unknown', + 'add' => 'Add person', + 'add_title' => 'Add person', + 'device' => 'Device (UniFi client)', + '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.', ]; diff --git a/resources/views/livewire/modals/add-person.blade.php b/resources/views/livewire/modals/add-person.blade.php new file mode 100644 index 0000000..7ba43d7 --- /dev/null +++ b/resources/views/livewire/modals/add-person.blade.php @@ -0,0 +1,41 @@ +
+
+ +

{{ __('persons.add_title') }}

+ +
+ +
+
+ + + @error('name')

{{ $message }}

@enderror +
+ +
+ + @if ($unifiError) +

{{ __('persons.unifi_error') }}

+ + @else + +

{{ __('persons.client_hint') }}

+ @endif + @error('mac')

{{ $message }}

@enderror +
+ +
+ + +
+
+
diff --git a/resources/views/livewire/persons/index.blade.php b/resources/views/livewire/persons/index.blade.php index 04b7dbb..16910d1 100644 --- a/resources/views/livewire/persons/index.blade.php +++ b/resources/views/livewire/persons/index.blade.php @@ -1,5 +1,12 @@
- + + + + +
@if ($persons->isEmpty()) diff --git a/routes/console.php b/routes/console.php index 3c9adf1..77ccfca 100644 --- a/routes/console.php +++ b/routes/console.php @@ -2,7 +2,11 @@ use Illuminate\Foundation\Inspiring; use Illuminate\Support\Facades\Artisan; +use Illuminate\Support\Facades\Schedule; Artisan::command('inspire', function () { $this->comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); + +// Presence sweep — "home" immediately on association, "away" after a debounce (in the command). +Schedule::command('presence:poll')->everyMinute()->withoutOverlapping();