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
parent
f50a665735
commit
b85d2f60fe
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -1,5 +1,12 @@
|
|||
<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">
|
||||
@if ($persons->isEmpty())
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue