UniFi's /rest/apgroup endpoints (and per-SSID ap_group_ids writes via
/rest/wlanconf) require session-cookie auth — they don't accept the
X-API-Key header. The Integration API doesn't expose AP groups at all.
So with the current deployment running on API-key auth, every AP-group
operation returned 400 api.err.InvalidObject. Removing the dead code
rather than carrying a feature that can't function.
* Deleted ApGroupController, ApGroups.vue, the /ap-groups/* routes,
and getApGroups/createApGroup/updateApGroup/deleteApGroup from
UnifiApiClient.
* Removed the per-SSID AP-group assignment from Wifi.vue + the
updateApGroups action + /wifi/{wlanId}/ap-groups route + the
ap_group_ids field from the mapWlan output.
* Removed the AP Groups nav entry from composer.json.
If a future deploy adds local-admin username+password auth, AP groups
can be reintroduced — the UnifiApiClient::buildRequest() session-cookie
path is intact.
v1.3.1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
665 lines
26 KiB
PHP
665 lines
26 KiB
PHP
<?php
|
||
|
||
namespace Dashboard\Unifi\Services;
|
||
|
||
use App\Models\Setting;
|
||
use Illuminate\Support\Facades\Cache;
|
||
use Illuminate\Support\Facades\Http;
|
||
use Illuminate\Support\Facades\Log;
|
||
|
||
class UnifiApiClient
|
||
{
|
||
private ?string $baseUrl = null;
|
||
private ?string $site = null;
|
||
private ?string $username = null;
|
||
private ?string $password = null;
|
||
private ?string $apiKey = null;
|
||
|
||
// Cached session cookies for cookie-based auth
|
||
private ?array $cookies = null;
|
||
|
||
// ── Connection ────────────────────────────────────────────────────────────
|
||
|
||
private function init(): void
|
||
{
|
||
if ($this->baseUrl) return;
|
||
|
||
$this->baseUrl = rtrim(Setting::get('unifi.controller_url', ''), '/');
|
||
$this->site = Setting::get('unifi.site', 'default');
|
||
$this->username = Setting::get('unifi.username', '');
|
||
$this->password = Setting::get('unifi.password', '');
|
||
$this->apiKey = Setting::get('unifi.api_key', '');
|
||
|
||
if (! $this->baseUrl) {
|
||
throw new \RuntimeException('UniFi controller not configured. Go to Unifi Network → Settings.');
|
||
}
|
||
if (! $this->username && ! $this->apiKey) {
|
||
throw new \RuntimeException('No credentials configured. Set either local account credentials or an API key in Unifi Network → Settings.');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get session cookies, logging in if needed. Cached for 30 minutes.
|
||
*/
|
||
private function getSessionCookies(?string $overrideUrl = null, ?string $overrideUser = null, ?string $overridePass = null): array
|
||
{
|
||
$base = $overrideUrl ?? $this->baseUrl;
|
||
$user = $overrideUser ?? $this->username;
|
||
$pass = $overridePass ?? $this->password;
|
||
|
||
$cacheKey = 'unifi:session:' . md5($base . $user);
|
||
|
||
if (! $overrideUrl) {
|
||
$cached = Cache::get($cacheKey);
|
||
if ($cached) return $cached;
|
||
}
|
||
|
||
$loginUrl = "{$base}/api/auth/login";
|
||
|
||
$response = Http::withHeaders([
|
||
'Content-Type' => 'application/json',
|
||
'Accept' => 'application/json',
|
||
])->withoutVerifying()->post($loginUrl, [
|
||
'username' => $user,
|
||
'password' => $pass,
|
||
]);
|
||
|
||
if (! $response->successful()) {
|
||
$msg = $response->json('message') ?? "HTTP {$response->status()}";
|
||
throw new \RuntimeException("UniFi login failed: {$msg}");
|
||
}
|
||
|
||
// Extract cookies from Set-Cookie headers
|
||
$cookies = [];
|
||
foreach ($response->cookies() as $cookie) {
|
||
$cookies[$cookie->getName()] = $cookie->getValue();
|
||
}
|
||
|
||
// Also capture the CSRF token
|
||
$csrf = $cookies['csrf_token'] ?? $response->header('X-CSRF-Token') ?? '';
|
||
$cookieData = ['cookies' => $cookies, 'csrf' => $csrf];
|
||
|
||
if (! $overrideUrl) {
|
||
Cache::put($cacheKey, $cookieData, 1800); // 30 minutes
|
||
}
|
||
|
||
return $cookieData;
|
||
}
|
||
|
||
private function buildRequest()
|
||
{
|
||
// Prefer cookie-based auth (local account) for full access
|
||
if ($this->username && $this->password) {
|
||
$session = $this->getSessionCookies();
|
||
$cookieStr = collect($session['cookies'])->map(fn ($v, $k) => "{$k}={$v}")->implode('; ');
|
||
|
||
return Http::withHeaders([
|
||
'Accept' => 'application/json',
|
||
'Cookie' => $cookieStr,
|
||
'X-CSRF-Token' => $session['csrf'],
|
||
])->withoutVerifying();
|
||
}
|
||
|
||
// Fallback to API key (read-only stats)
|
||
if ($this->apiKey) {
|
||
return Http::withHeaders([
|
||
'X-API-Key' => $this->apiKey,
|
||
'Accept' => 'application/json',
|
||
])->withoutVerifying();
|
||
}
|
||
|
||
throw new \RuntimeException('No credentials configured.');
|
||
}
|
||
|
||
/**
|
||
* Determine the API base path. UniFi OS consoles use /proxy/network/api/s/{site},
|
||
* standalone controllers use /api/s/{site}.
|
||
*/
|
||
private function apiPath(): string
|
||
{
|
||
$cacheKey = 'unifi:api_prefix:' . md5($this->baseUrl);
|
||
|
||
// Only cache if detection actually succeeds — don't cache a guess
|
||
$cached = Cache::get($cacheKey);
|
||
if ($cached) return $cached;
|
||
|
||
$paths = ['/proxy/network/api', '/api'];
|
||
|
||
foreach ($paths as $prefix) {
|
||
try {
|
||
$testUrl = "{$this->baseUrl}{$prefix}/s/{$this->site}/stat/sysinfo";
|
||
$response = $this->buildRequest()->timeout(10)->get($testUrl);
|
||
$ct = $response->header('Content-Type', '');
|
||
|
||
if ($response->successful() && str_contains($ct, 'json')) {
|
||
Log::debug('unifi.api_prefix_detected', ['prefix' => $prefix]);
|
||
Cache::put($cacheKey, $prefix, 3600);
|
||
return $prefix;
|
||
}
|
||
} catch (\Throwable $e) {
|
||
Log::debug('unifi.api_prefix_probe', ['prefix' => $prefix, 'error' => $e->getMessage()]);
|
||
}
|
||
}
|
||
|
||
// Default for modern UniFi OS hardware — but don't cache it so we retry next time
|
||
return '/proxy/network/api';
|
||
}
|
||
|
||
private function siteUrl(string $path): string
|
||
{
|
||
$this->init();
|
||
return "{$this->baseUrl}{$this->apiPath()}/s/{$this->site}{$path}";
|
||
}
|
||
|
||
private function get(string $path): array
|
||
{
|
||
$this->init();
|
||
$url = $this->siteUrl($path);
|
||
|
||
$response = $this->buildRequest()->get($url);
|
||
|
||
if ($response->status() === 401) {
|
||
// Session expired — clear cache and retry once
|
||
Cache::forget('unifi:session:' . md5($this->baseUrl . $this->username));
|
||
$response = $this->buildRequest()->get($url);
|
||
}
|
||
|
||
if (! $response->successful()) {
|
||
Log::warning('unifi.api_error', ['url' => $url, 'status' => $response->status()]);
|
||
throw new \RuntimeException("UniFi API error: HTTP {$response->status()}");
|
||
}
|
||
|
||
return $response->json('data', []);
|
||
}
|
||
|
||
private function post(string $path, array $body = []): array
|
||
{
|
||
$this->init();
|
||
$url = $this->siteUrl($path);
|
||
|
||
$response = $this->buildRequest()->asJson()->post($url, $body);
|
||
|
||
if ($response->status() === 401) {
|
||
Cache::forget('unifi:session:' . md5($this->baseUrl . $this->username));
|
||
$response = $this->buildRequest()->asJson()->post($url, $body);
|
||
}
|
||
|
||
if (! $response->successful()) {
|
||
Log::warning('unifi.api_error', ['url' => $url, 'status' => $response->status(), 'body' => $body]);
|
||
throw new \RuntimeException("UniFi API error: HTTP {$response->status()}");
|
||
}
|
||
|
||
return $response->json('data', []);
|
||
}
|
||
|
||
private function put(string $path, array $body = []): array
|
||
{
|
||
$this->init();
|
||
$url = $this->siteUrl($path);
|
||
|
||
$response = $this->buildRequest()->asJson()->put($url, $body);
|
||
|
||
if ($response->status() === 401) {
|
||
Cache::forget('unifi:session:' . md5($this->baseUrl . $this->username));
|
||
$response = $this->buildRequest()->asJson()->put($url, $body);
|
||
}
|
||
|
||
if (! $response->successful()) {
|
||
throw new \RuntimeException("UniFi API error: HTTP {$response->status()}");
|
||
}
|
||
|
||
return $response->json('data', []);
|
||
}
|
||
|
||
// ── Devices / APs ─────────────────────────────────────────────────────────
|
||
|
||
public function getDevices(): array
|
||
{
|
||
return Cache::remember('unifi:devices', (int) \App\Models\Setting::get('unifi.cache_ttl', 30), fn () =>
|
||
$this->get('/stat/device')
|
||
);
|
||
}
|
||
|
||
public function getAccessPoints(): array
|
||
{
|
||
return collect($this->getDevices())->where('type', 'uap')->values()->all();
|
||
}
|
||
|
||
public function getGateway(): ?array
|
||
{
|
||
return collect($this->getDevices())->firstWhere('type', 'ugw')
|
||
?? collect($this->getDevices())->firstWhere('type', 'udm')
|
||
?? null;
|
||
}
|
||
|
||
public function rebootDevice(string $mac): array
|
||
{
|
||
Cache::forget('unifi:devices');
|
||
return $this->post('/cmd/devmgr', ['cmd' => 'restart', 'mac' => strtolower($mac)]);
|
||
}
|
||
|
||
// ── Clients ───────────────────────────────────────────────────────────────
|
||
|
||
public function getActiveClients(): array
|
||
{
|
||
return Cache::remember('unifi:clients', (int) \App\Models\Setting::get('unifi.cache_ttl', 30), fn () =>
|
||
$this->get('/stat/sta')
|
||
);
|
||
}
|
||
|
||
public function isClientConnected(string $mac): bool
|
||
{
|
||
$data = $this->get('/stat/sta/' . strtolower($mac));
|
||
return ! empty($data);
|
||
}
|
||
|
||
public function kickClient(string $mac): array
|
||
{
|
||
Cache::forget('unifi:clients');
|
||
return $this->post('/cmd/stamgr', ['cmd' => 'kick-sta', 'mac' => strtolower($mac)]);
|
||
}
|
||
|
||
public function blockClient(string $mac): array
|
||
{
|
||
return $this->post('/cmd/stamgr', ['cmd' => 'block-sta', 'mac' => strtolower($mac)]);
|
||
}
|
||
|
||
public function unblockClient(string $mac): array
|
||
{
|
||
return $this->post('/cmd/stamgr', ['cmd' => 'unblock-sta', 'mac' => strtolower($mac)]);
|
||
}
|
||
|
||
// ── Guest Portal ──────────────────────────────────────────────────────────
|
||
|
||
public function authorizeGuest(string $mac, int $minutes = 720, ?int $upKbps = null, ?int $downKbps = null): array
|
||
{
|
||
$body = ['cmd' => 'authorize-guest', 'mac' => strtolower($mac), 'minutes' => $minutes];
|
||
if ($upKbps) $body['up'] = $upKbps;
|
||
if ($downKbps) $body['down'] = $downKbps;
|
||
|
||
Cache::forget('unifi:clients');
|
||
return $this->post('/cmd/stamgr', $body);
|
||
}
|
||
|
||
public function unauthorizeGuest(string $mac): array
|
||
{
|
||
return $this->post('/cmd/stamgr', ['cmd' => 'unauthorize-guest', 'mac' => strtolower($mac)]);
|
||
}
|
||
|
||
private function delete(string $path): void
|
||
{
|
||
$this->init();
|
||
$url = $this->siteUrl($path);
|
||
$response = $this->buildRequest()->delete($url);
|
||
if ($response->status() === 401) {
|
||
Cache::forget('unifi:session:' . md5($this->baseUrl . $this->username));
|
||
$response = $this->buildRequest()->delete($url);
|
||
}
|
||
if (! $response->successful()) {
|
||
throw new \RuntimeException("UniFi API error: HTTP {$response->status()}");
|
||
}
|
||
}
|
||
|
||
// ── WiFi Networks / WLANs ─────────────────────────────────────────────────
|
||
|
||
public function getWlans(): array
|
||
{
|
||
return $this->get('/rest/wlanconf');
|
||
}
|
||
|
||
public function updateWlan(string $wlanId, array $data): array
|
||
{
|
||
return $this->put("/rest/wlanconf/{$wlanId}", $data);
|
||
}
|
||
|
||
// ── PPSK ─────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Build a v2 API URL. UniFi OS consoles expose /proxy/network/v2/api/site/{site}/...
|
||
* Standalone controllers may not have this path.
|
||
*/
|
||
private function v2SiteUrl(string $path): string
|
||
{
|
||
$this->init();
|
||
$v2Base = str_contains($this->apiPath(), 'proxy') ? '/proxy/network' : '';
|
||
return "{$this->baseUrl}{$v2Base}/v2/api/site/{$this->site}{$path}";
|
||
}
|
||
|
||
/**
|
||
* Check whether an HTTP response is a usable JSON response (not a 404 HTML page).
|
||
*/
|
||
private function isJsonResponse($response): bool
|
||
{
|
||
if (! $response->successful()) return false;
|
||
$ct = $response->header('Content-Type', '');
|
||
return str_contains($ct, 'json') || (! str_contains($ct, 'html'));
|
||
}
|
||
|
||
/**
|
||
* Normalize PPSK entries to a consistent shape regardless of API version.
|
||
*
|
||
* We merge the original raw entry with our normalized aliases so:
|
||
* - All original fields survive (frontend can inspect them)
|
||
* - Standard field names (_id, name, x_passphrase, wlan_id, vlan_id) are
|
||
* always present, mapped from whatever variant the API used
|
||
*/
|
||
private function normalizePpsk(array $entries): array
|
||
{
|
||
return array_values(array_map(function ($e) {
|
||
$normalized = [
|
||
'_id' => $e['_id'] ?? $e['id'] ?? null,
|
||
'name' => $e['name'] ?? $e['label'] ?? $e['username'] ?? $e['privatePskName'] ?? null,
|
||
'x_passphrase' => $e['x_passphrase'] ?? $e['password'] ?? $e['passphrase'] ?? $e['psk'] ?? null,
|
||
'wlan_id' => $e['wlan_id'] ?? $e['wlanId'] ?? null,
|
||
'networkconf_id'=> $e['networkconf_id'] ?? $e['network_conf_id'] ?? $e['networkId'] ?? null,
|
||
'vlan_id' => $e['vlan_id'] ?? $e['vlanId'] ?? $e['vlan'] ?? null,
|
||
];
|
||
return array_merge($e, array_filter($normalized, fn ($v) => $v !== null));
|
||
}, $entries));
|
||
}
|
||
|
||
public function getNetworkConfs(): array
|
||
{
|
||
return $this->get('/rest/networkconf');
|
||
}
|
||
|
||
/**
|
||
* Fetch PPSK entries for a WLAN.
|
||
*
|
||
* Tries multiple endpoints in order of likelihood, because the correct path
|
||
* varies significantly by controller version and firmware:
|
||
*
|
||
* 1. Classic REST with wlan_id filter (works on most standalone controllers)
|
||
* 2. Classic REST fetch-all + local filter (when query param is ignored)
|
||
* 3. v2 hotspot endpoint (UniFi Network App 7.x+)
|
||
* 4. v2 wlan password endpoint (some UDM firmware variants)
|
||
* 5. Embedded in the WLAN object itself (some controller versions)
|
||
*/
|
||
public function getPpskEntries(string $wlanId): array
|
||
{
|
||
// 1. Classic path with filter
|
||
try {
|
||
$results = $this->get("/rest/ppsk?wlan_id={$wlanId}");
|
||
if (! empty($results)) {
|
||
Log::info('unifi.ppsk_found', ['path' => 'classic_filter', 'count' => count($results)]);
|
||
return $this->normalizePpsk($results);
|
||
}
|
||
} catch (\Throwable $e) {
|
||
Log::debug('unifi.ppsk_classic_filter_failed', ['error' => $e->getMessage()]);
|
||
}
|
||
|
||
// 2. Classic path without filter — fetch all and filter locally
|
||
try {
|
||
$all = $this->get('/rest/ppsk');
|
||
Log::debug('unifi.ppsk_classic_all', ['total' => count($all)]);
|
||
if (! empty($all)) {
|
||
$filtered = array_filter($all, fn ($e) => ($e['wlan_id'] ?? '') === $wlanId);
|
||
if (! empty($filtered)) {
|
||
Log::info('unifi.ppsk_found', ['path' => 'classic_all_filtered', 'count' => count($filtered)]);
|
||
return $this->normalizePpsk(array_values($filtered));
|
||
}
|
||
}
|
||
} catch (\Throwable $e) {
|
||
Log::debug('unifi.ppsk_classic_all_failed', ['error' => $e->getMessage()]);
|
||
}
|
||
|
||
// 3–4. v2 API paths (try each, skip HTML error pages)
|
||
$v2Paths = [
|
||
"/hotspot/op/private-preshared-key?wlanId={$wlanId}",
|
||
"/wlan/{$wlanId}/password",
|
||
"/wlan/{$wlanId}/private-preshared-key",
|
||
];
|
||
|
||
foreach ($v2Paths as $path) {
|
||
try {
|
||
$url = $this->v2SiteUrl($path);
|
||
$response = $this->buildRequest()->get($url);
|
||
Log::debug('unifi.ppsk_v2_probe', ['path' => $path, 'status' => $response->status(), 'ct' => $response->header('Content-Type')]);
|
||
if (! $this->isJsonResponse($response)) continue;
|
||
|
||
$data = $response->json('data') ?? $response->json() ?? [];
|
||
if (is_array($data) && ! empty($data)) {
|
||
Log::info('unifi.ppsk_found', ['path' => $path, 'count' => count($data)]);
|
||
return $this->normalizePpsk(array_values($data));
|
||
}
|
||
} catch (\Throwable $e) {
|
||
Log::debug('unifi.ppsk_v2_path_failed', ['path' => $path, 'error' => $e->getMessage()]);
|
||
}
|
||
}
|
||
|
||
// 5. Check if PPSKs are embedded in the WLAN object
|
||
try {
|
||
$wlan = $this->get("/rest/wlanconf/{$wlanId}");
|
||
$w = $wlan[0] ?? $wlan;
|
||
$embedded = $w['private_preshared_keys'] ?? [];
|
||
if (! empty($embedded)) {
|
||
Log::info('unifi.ppsk_found', ['path' => 'wlan_embedded', 'count' => count($embedded)]);
|
||
return $this->normalizePpsk($embedded);
|
||
}
|
||
} catch (\Throwable $e) {
|
||
Log::debug('unifi.ppsk_wlan_embedded_failed', ['error' => $e->getMessage()]);
|
||
}
|
||
|
||
Log::warning('unifi.ppsk_all_paths_empty', ['wlan_id' => $wlanId]);
|
||
// All paths exhausted — return empty rather than erroring (network may just have no PPSKs yet)
|
||
return [];
|
||
}
|
||
|
||
public function createPpsk(array $data): array
|
||
{
|
||
$wlanId = $data['wlan_id'] ?? null;
|
||
|
||
// Try v2 hotspot endpoint first
|
||
if ($wlanId) {
|
||
try {
|
||
$url = $this->v2SiteUrl('/hotspot/op/private-preshared-key');
|
||
$response = $this->buildRequest()->asJson()->post($url, $data);
|
||
if ($this->isJsonResponse($response)) {
|
||
return $this->normalizePpsk([$response->json('data') ?? $response->json()]);
|
||
}
|
||
} catch (\Throwable $e) {
|
||
Log::debug('unifi.ppsk_v2_hotspot_create_failed', ['error' => $e->getMessage()]);
|
||
}
|
||
|
||
// Try v2 wlan password endpoint
|
||
try {
|
||
$url = $this->v2SiteUrl("/wlan/{$wlanId}/password");
|
||
$response = $this->buildRequest()->asJson()->post($url, $data);
|
||
if ($this->isJsonResponse($response)) {
|
||
$result = $response->json('data') ?? $response->json();
|
||
return $this->normalizePpsk(is_array($result) && isset($result[0]) ? $result : [$result]);
|
||
}
|
||
} catch (\Throwable $e) {
|
||
Log::debug('unifi.ppsk_v2_wlan_create_failed', ['error' => $e->getMessage()]);
|
||
}
|
||
}
|
||
|
||
// Fall back to classic REST
|
||
$result = $this->post('/rest/ppsk', $data);
|
||
return $this->normalizePpsk(is_array($result) && isset($result[0]) ? $result : [$result]);
|
||
}
|
||
|
||
public function updatePpsk(string $ppskId, array $data): array
|
||
{
|
||
// Try v2 hotspot endpoint first
|
||
try {
|
||
$url = $this->v2SiteUrl("/hotspot/op/private-preshared-key/{$ppskId}");
|
||
$response = $this->buildRequest()->asJson()->put($url, $data);
|
||
if ($this->isJsonResponse($response)) {
|
||
return $this->normalizePpsk([$response->json('data') ?? $response->json()]);
|
||
}
|
||
} catch (\Throwable $e) {
|
||
Log::debug('unifi.ppsk_v2_hotspot_update_failed', ['error' => $e->getMessage()]);
|
||
}
|
||
|
||
$result = $this->put("/rest/ppsk/{$ppskId}", $data);
|
||
return $this->normalizePpsk(is_array($result) && isset($result[0]) ? $result : [$result]);
|
||
}
|
||
|
||
public function deletePpsk(string $ppskId): void
|
||
{
|
||
// Try v2 hotspot endpoint first
|
||
try {
|
||
$url = $this->v2SiteUrl("/hotspot/op/private-preshared-key/{$ppskId}");
|
||
$response = $this->buildRequest()->delete($url);
|
||
if ($response->successful()) return;
|
||
} catch (\Throwable $e) {
|
||
Log::debug('unifi.ppsk_v2_hotspot_delete_failed', ['error' => $e->getMessage()]);
|
||
}
|
||
|
||
$this->delete("/rest/ppsk/{$ppskId}");
|
||
}
|
||
|
||
/**
|
||
* Kick (deauth) every client currently connected via the given PPSK.
|
||
* UniFi station records include a _psk_id field matching the PPSK's _id.
|
||
* Returns the number of clients kicked.
|
||
*/
|
||
public function kickClientsForPpsk(string $ppskUnifiId): int
|
||
{
|
||
$kicked = 0;
|
||
try {
|
||
$clients = $this->get('/rest/sta');
|
||
foreach ($clients as $client) {
|
||
if (($client['_psk_id'] ?? null) !== $ppskUnifiId) continue;
|
||
try {
|
||
$this->post('/cmd/stamgr', ['cmd' => 'kick-sta', 'mac' => $client['mac']]);
|
||
$kicked++;
|
||
} catch (\Throwable $e) {
|
||
// continue kicking remaining clients even if one fails
|
||
}
|
||
}
|
||
} catch (\Throwable $e) {
|
||
// non-fatal: if we can't list clients, skip kicking
|
||
Log::debug('unifi.ppsk_kick_clients_failed', ['ppsk_id' => $ppskUnifiId, 'error' => $e->getMessage()]);
|
||
}
|
||
return $kicked;
|
||
}
|
||
|
||
// ── Health / Stats ────────────────────────────────────────────────────────
|
||
|
||
public function getSiteHealth(): array
|
||
{
|
||
return Cache::remember('unifi:health', (int) \App\Models\Setting::get('unifi.cache_ttl', 30), fn () =>
|
||
$this->get('/stat/health')
|
||
);
|
||
}
|
||
|
||
public function getEvents(int $limit = 100): array
|
||
{
|
||
return Cache::remember('unifi:events', (int) \App\Models\Setting::get('unifi.cache_ttl', 30), fn () =>
|
||
$this->get("/stat/event?start=0&limit={$limit}")
|
||
);
|
||
}
|
||
|
||
public function getAlarms(): array
|
||
{
|
||
return $this->get('/stat/alarm');
|
||
}
|
||
|
||
public function getHistoricalStats(string $type, int $startEpochMs, int $endEpochMs, array $attrs, ?string $mac = null): array
|
||
{
|
||
$body = ['attrs' => $attrs, 'start' => $startEpochMs, 'end' => $endEpochMs];
|
||
if ($mac) $body['mac'] = strtolower($mac);
|
||
|
||
return $this->post("/stat/report/{$type}", $body);
|
||
}
|
||
|
||
// ── Connection Test ───────────────────────────────────────────────────────
|
||
|
||
public function testConnection(): array
|
||
{
|
||
return $this->get('/stat/sysinfo');
|
||
}
|
||
|
||
/**
|
||
* List all sites. Supports both cookie-based and API-key auth.
|
||
* Accepts override credentials for the settings page (before they're saved).
|
||
*/
|
||
public function getSites(?string $overrideUrl = null, ?string $overrideUser = null, ?string $overridePass = null, ?string $overrideKey = null): array
|
||
{
|
||
$base = $overrideUrl ? rtrim($overrideUrl, '/') : null;
|
||
|
||
if (! $base) {
|
||
$this->init();
|
||
$base = $this->baseUrl;
|
||
}
|
||
|
||
// Build the HTTP client with appropriate auth
|
||
if ($overrideUser && $overridePass) {
|
||
$session = $this->getSessionCookies($base, $overrideUser, $overridePass);
|
||
$cookieStr = collect($session['cookies'])->map(fn ($v, $k) => "{$k}={$v}")->implode('; ');
|
||
$http = Http::withHeaders([
|
||
'Accept' => 'application/json',
|
||
'Cookie' => $cookieStr,
|
||
'X-CSRF-Token' => $session['csrf'],
|
||
])->withoutVerifying();
|
||
} elseif ($overrideKey) {
|
||
$http = Http::withHeaders([
|
||
'X-API-Key' => $overrideKey,
|
||
'Accept' => 'application/json',
|
||
])->withoutVerifying();
|
||
} else {
|
||
$http = $this->buildRequest();
|
||
}
|
||
|
||
// Endpoints, in preferred order:
|
||
// 1. UniFi OS Integration API — the one X-API-Key keys are issued for
|
||
// (response shape: [{ id, internalReference, name }])
|
||
// 2. Legacy /proxy/network/api/self/sites — session-cookie API on
|
||
// UniFi OS consoles (response: [{ name, desc, ... }])
|
||
// 3. Legacy /api/self/sites — standalone controller (no /proxy prefix)
|
||
$paths = [
|
||
'/proxy/network/integration/v1/sites',
|
||
'/proxy/network/api/self/sites',
|
||
'/api/self/sites',
|
||
];
|
||
|
||
$lastError = '';
|
||
foreach ($paths as $path) {
|
||
$url = "{$base}{$path}";
|
||
try {
|
||
$response = $http->timeout(10)->get($url);
|
||
|
||
if ($response->successful()) {
|
||
$ct = $response->header('Content-Type', '');
|
||
if (str_contains($ct, 'text/html')) {
|
||
$lastError = "Got HTML on {$path} — not an API endpoint";
|
||
continue;
|
||
}
|
||
|
||
$data = $response->json('data', $response->json());
|
||
if (! is_array($data) || empty($data)) {
|
||
$lastError = "No sites in response from {$path}";
|
||
continue;
|
||
}
|
||
|
||
// Integration API rows have `internalReference` (== legacy
|
||
// site slug) and `name` (human-readable). Normalize to
|
||
// the legacy {name, desc} shape so downstream code that
|
||
// builds URLs with the site slug keeps working.
|
||
if (isset($data[0]['internalReference'])) {
|
||
$data = array_map(fn ($s) => [
|
||
'name' => $s['internalReference'] ?? 'default',
|
||
'desc' => $s['name'] ?? $s['internalReference'] ?? 'Default',
|
||
], $data);
|
||
}
|
||
|
||
if (isset($data[0]['name'])) {
|
||
Log::debug('unifi.sites_found', ['path' => $path, 'count' => count($data)]);
|
||
return $data;
|
||
}
|
||
|
||
$lastError = "Unexpected response shape from {$path}";
|
||
} else {
|
||
$lastError = "HTTP {$response->status()} on {$path}";
|
||
}
|
||
} catch (\Throwable $e) {
|
||
$lastError = "{$path}: {$e->getMessage()}";
|
||
}
|
||
}
|
||
|
||
throw new \RuntimeException("Could not list sites. {$lastError}");
|
||
}
|
||
}
|