Phase 5: UniFi presence — poll, person↔client mapping

- 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 <noreply@anthropic.com>
feat/phase-1-bootstrap
HomeOS Bootstrap 2026-07-18 00:31:32 +02:00
parent f50a665735
commit b85d2f60fe
14 changed files with 379 additions and 4 deletions

View File

@ -0,0 +1,75 @@
<?php
namespace App\Console\Commands;
use App\Events\PresenceChanged;
use App\Models\Person;
use App\Services\UnifiClient;
use Illuminate\Console\Command;
/**
* Presence sweep. "home" immediately on association; "away" only after a debounce
* (iPhone WLAN sleep causes false-aways otherwise). Never hangs a security automation
* on a single raw signal.
*/
class PresencePollCommand extends Command
{
protected $signature = 'presence:poll';
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()) {
$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);
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/** A person's presence (home/away) changed. */
class PresenceChanged implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(public string $personUuid) {}
public function broadcastOn(): array
{
return [new PrivateChannel('presence')];
}
public function broadcastAs(): string
{
return 'PresenceChanged';
}
}

View File

@ -0,0 +1,63 @@
<?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

@ -12,9 +12,14 @@ class Person extends Model
protected $table = 'persons'; 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 public function user(): BelongsTo
{ {

View File

@ -0,0 +1,54 @@
<?php
namespace App\Services;
use UniFi_API\Client;
/**
* Thin wrapper over art-of-wifi/unifi-api-client. Presence source of truth is UniFi
* (phones associate with the UniFi WLAN, not the Fritz!Box). Uses a read-only local
* account on the UDM; talks to the LOCAL host with a self-signed cert (verify_ssl off).
*/
class UnifiClient
{
/** @return array<int, object> 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;
}
}

View File

@ -7,6 +7,7 @@
"license": "MIT", "license": "MIT",
"require": { "require": {
"php": "^8.3", "php": "^8.3",
"art-of-wifi/unifi-api-client": "^2.2",
"laravel/framework": "^13.8", "laravel/framework": "^13.8",
"laravel/horizon": "^5.47", "laravel/horizon": "^5.47",
"laravel/reverb": "^1.10", "laravel/reverb": "^1.10",

55
composer.lock generated
View File

@ -4,8 +4,61 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "ee1777b0e0b13cda0b08354e8ba08aa7", "content-hash": "4d8a11a464455ac401f254459411449a",
"packages": [ "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", "name": "brick/math",
"version": "0.18.0", "version": "0.18.0",

View File

@ -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),
],
]; ];

View File

@ -0,0 +1,24 @@
<?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) {
// The device (UniFi client) MAC that represents this person's presence.
$table->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']);
});
}
};

View File

@ -7,4 +7,10 @@ return [
'presence_home' => 'Zuhause', 'presence_home' => 'Zuhause',
'presence_away' => 'Abwesend', 'presence_away' => 'Abwesend',
'presence_unknown' => 'Unbekannt', '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.',
]; ];

View File

@ -7,4 +7,10 @@ return [
'presence_home' => 'Home', 'presence_home' => 'Home',
'presence_away' => 'Away', 'presence_away' => 'Away',
'presence_unknown' => 'Unknown', '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.',
]; ];

View File

@ -0,0 +1,41 @@
<div class="flex flex-col">
<header class="flex items-center gap-3 px-5 py-4 border-b border-line-soft">
<span class="grid place-items-center w-9 h-9 rounded-lg bg-accent/10 text-accent shrink-0"><x-icon name="persons" :size="18" /></span>
<h2 class="text-[15px] font-bold text-ink">{{ __('persons.add_title') }}</h2>
<button type="button" wire:click="closeModal" class="ml-auto grid place-items-center w-9 h-9 rounded-lg text-ink-3 hover:bg-raised hover:text-ink transition-colors" aria-label="{{ __('common.close') }}">
<x-icon name="close" :size="18" />
</button>
</header>
<form wire:submit="save" class="p-5 flex flex-col gap-4">
<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
class="w-full rounded-lg bg-raised border border-line px-3 py-2.5 text-sm text-ink outline-none focus:border-accent focus:ring-1 focus:ring-accent">
@error('name') <p class="text-[12px] text-offline">{{ $message }}</p> @enderror
</div>
<div class="flex flex-col gap-1.5">
<label for="p-mac" class="text-[12px] font-semibold text-ink-2">{{ __('persons.device') }}</label>
@if ($unifiError)
<p class="text-[12px] text-warning">{{ __('persons.unifi_error') }}</p>
<input wire:model="mac" id="p-mac" type="text" placeholder="aa:bb:cc:dd:ee:ff"
class="w-full rounded-lg bg-raised border border-line px-3 py-2.5 text-sm font-mono text-ink outline-none focus:border-accent focus:ring-1 focus:ring-accent">
@else
<select wire:model="mac" id="p-mac" class="w-full rounded-lg bg-raised border border-line px-3 py-2.5 text-sm text-ink outline-none focus:border-accent">
<option value="">{{ __('persons.pick_client') }}</option>
@foreach ($clientOptions as $o)
<option value="{{ $o['mac'] }}">{{ $o['label'] }} · {{ $o['mac'] }}</option>
@endforeach
</select>
<p class="text-[11.5px] text-ink-3">{{ __('persons.client_hint') }}</p>
@endif
@error('mac') <p class="text-[12px] text-offline">{{ $message }}</p> @enderror
</div>
<div class="flex items-center justify-end gap-2 pt-1">
<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>
</div>
</form>
</div>

View File

@ -1,5 +1,12 @@
<div wire:poll.30s> <div wire:poll.30s>
<x-topbar :title="__('nav.persons')" :subtitle="__('persons.subtitle')" /> <x-topbar :title="__('nav.persons')" :subtitle="__('persons.subtitle')">
<x-slot:actions>
<button type="button" wire:click="$dispatch('openModal', { component: 'modals.add-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>
</x-slot:actions>
</x-topbar>
<div class="px-5 lg:px-[26px] py-6 flex flex-col gap-5 max-w-[1560px] mx-auto w-full"> <div class="px-5 lg:px-[26px] py-6 flex flex-col gap-5 max-w-[1560px] mx-auto w-full">
@if ($persons->isEmpty()) @if ($persons->isEmpty())

View File

@ -2,7 +2,11 @@
use Illuminate\Foundation\Inspiring; use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;
Artisan::command('inspire', function () { Artisan::command('inspire', function () {
$this->comment(Inspiring::quote()); $this->comment(Inspiring::quote());
})->purpose('Display an 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();