'A UniFi device goes offline', 'device_online' => 'A UniFi device comes back online', // ── Client tracking ────────────────────────────────────────────────────── 'client_offline' => 'A tracked client (by MAC) disconnects', 'client_online' => 'A tracked client (by MAC) connects', // ── WAN ────────────────────────────────────────────────────────────────── 'wan_down' => 'Internet / WAN goes down', 'wan_up' => 'Internet / WAN comes back up', // ── Threshold alerts (fire on entry into alert state) ──────────────────── 'client_count_high' => 'AP client count exceeds threshold', 'cu_high' => 'Channel utilization exceeds threshold', 'satisfaction_low' => 'WiFi experience drops below threshold', 'high_error_rate' => 'High retry/drop rate on an AP', // ── Threshold resolved (fire on exit from alert state) ─────────────────── 'client_count_normal' => 'AP client count returns to normal', 'cu_normal' => 'Channel utilization returns to normal', 'satisfaction_normal' => 'WiFi experience returns to normal', 'error_rate_normal' => 'Error rate returns to normal', // ── Informational ──────────────────────────────────────────────────────── 'firmware_available' => 'AP firmware update available', 'ap_unexpected_reboot' => 'AP rebooted unexpectedly (uptime reset)', ]; public const TEMPLATE_VARS = [ 'device_offline' => ['{{device}}', '{{mac}}', '{{timestamp}}'], 'device_online' => ['{{device}}', '{{mac}}', '{{timestamp}}'], 'client_offline' => ['{{client_name}}', '{{mac}}', '{{last_ap}}', '{{last_ssid}}', '{{timestamp}}'], 'client_online' => ['{{client_name}}', '{{mac}}', '{{ap}}', '{{ssid}}', '{{ip}}', '{{os}}', '{{timestamp}}'], 'wan_down' => ['{{timestamp}}'], 'wan_up' => ['{{timestamp}}'], 'client_count_high' => ['{{device}}', '{{mac}}', '{{clients}}', '{{threshold}}', '{{timestamp}}'], 'client_count_normal' => ['{{device}}', '{{mac}}', '{{clients}}', '{{threshold}}', '{{timestamp}}'], 'cu_high' => ['{{device}}', '{{mac}}', '{{radio}}', '{{cu}}', '{{threshold}}', '{{timestamp}}'], 'cu_normal' => ['{{device}}', '{{mac}}', '{{radio}}', '{{cu}}', '{{threshold}}', '{{timestamp}}'], 'satisfaction_low' => ['{{device}}', '{{mac}}', '{{satisfaction}}', '{{threshold}}', '{{timestamp}}'], 'satisfaction_normal' => ['{{device}}', '{{mac}}', '{{satisfaction}}', '{{threshold}}', '{{timestamp}}'], 'high_error_rate' => ['{{device}}', '{{mac}}', '{{retries}}', '{{timestamp}}'], 'error_rate_normal' => ['{{device}}', '{{mac}}', '{{timestamp}}'], 'firmware_available' => ['{{device}}', '{{mac}}', '{{version}}', '{{timestamp}}'], 'ap_unexpected_reboot' => ['{{device}}', '{{mac}}', '{{uptime}}', '{{timestamp}}'], ]; public const DEFAULT_TEMPLATES = [ 'device_offline' => '🔴 {{device}} ({{mac}}) has gone offline', 'device_online' => '🟢 {{device}} ({{mac}}) is back online', 'client_offline' => '📴 Client {{client_name}} ({{mac}}) disconnected from {{last_ap}} / {{last_ssid}}', 'client_online' => '📱 Client {{client_name}} ({{mac}}) connected to {{ap}} / {{ssid}} — IP: {{ip}}, OS: {{os}}', 'wan_down' => '🔴 Internet connection is DOWN', 'wan_up' => '🟢 Internet connection restored', 'client_count_high' => '⚠️ {{device}}: {{clients}} clients connected (threshold: {{threshold}})', 'client_count_normal' => '✅ {{device}}: client count returned to normal ({{clients}} clients)', 'cu_high' => '⚠️ {{device}} ({{radio}}): {{cu}}% channel utilization (threshold: {{threshold}}%)', 'cu_normal' => '✅ {{device}} ({{radio}}): channel utilization returned to normal ({{cu}}%)', 'satisfaction_low' => '⚠️ {{device}}: WiFi experience {{satisfaction}}% (threshold: {{threshold}}%)', 'satisfaction_normal' => '✅ {{device}}: WiFi experience returned to normal ({{satisfaction}}%)', 'high_error_rate' => '⚠️ {{device}}: high retry count ({{retries}})', 'error_rate_normal' => '✅ {{device}}: error rate returned to normal', 'firmware_available' => '🔄 {{device}}: firmware update available (current: {{version}})', 'ap_unexpected_reboot' => '🔄 {{device}}: unexpected reboot (uptime: {{uptime}}s)', ]; public function checkAll(UnifiApiClient $unifi): int { $configs = WebhookConfig::where('is_active', true)->get(); if ($configs->isEmpty()) return 0; try { $devices = $unifi->getDevices(); $health = $unifi->getSiteHealth(); } catch (\Throwable $e) { Log::warning('unifi.webhook_check_failed', ['error' => $e->getMessage()]); return 0; } $activeClients = null; $needsClients = $configs->contains(fn ($c) => array_intersect($c->events ?? [], ['client_offline', 'client_online'])); if ($needsClients) { try { $activeClients = $unifi->getActiveClients(); } catch (\Throwable) { $activeClients = []; } } $wan = collect($health)->firstWhere('subsystem', 'wan'); $aps = collect($devices)->where('type', 'uap'); $fired = 0; foreach ($configs as $config) { $events = $config->events ?? []; $thresholds = $config->thresholds ?? []; $filter = $config->device_filter ?? []; $templates = $config->templates ?? []; $clientMacs = $config->tracked_clients ?? []; $cid = $config->id; foreach ($events as $event) { $alerts = match ($event) { 'device_offline' => $this->checkDeviceTransition($devices, $filter, false), 'device_online' => $this->checkDeviceTransition($devices, $filter, true), 'client_offline' => $this->checkClientTransition($activeClients ?? [], $clientMacs, false), 'client_online' => $this->checkClientTransition($activeClients ?? [], $clientMacs, true), 'wan_down' => $this->checkWan($wan, false), 'wan_up' => $this->checkWan($wan, true), 'client_count_high' => $this->checkClientCount($aps, $thresholds['client_count'] ?? 50, $filter, $cid), 'client_count_normal' => $this->checkClientCountResolved($aps, $thresholds['client_count'] ?? 50, $filter, $cid), 'cu_high' => $this->checkCu($aps, $thresholds['cu_threshold'] ?? 80, $thresholds['cu_band'] ?? null, $filter, $cid, (int) ($thresholds['cu_sustain'] ?? 3)), 'cu_normal' => $this->checkCuResolved($aps, $thresholds['cu_threshold'] ?? 80, $thresholds['cu_band'] ?? null, $filter, $cid), 'satisfaction_low' => $this->checkSatisfaction($aps, $thresholds['satisfaction_min'] ?? 50, $filter, $cid), 'satisfaction_normal' => $this->checkSatisfactionResolved($aps, $thresholds['satisfaction_min'] ?? 50, $filter, $cid), 'high_error_rate' => $this->checkErrorRate($aps, $filter, $cid), 'error_rate_normal' => $this->checkErrorRateResolved($aps, $filter, $cid), 'firmware_available' => $this->checkFirmware($devices, $filter), 'ap_unexpected_reboot'=> $this->checkReboot($aps, $filter), default => [], }; foreach ($alerts as $alert) { // Extract internal metadata before cooldown/fire $deviceStateUpdate = $alert['_device_state_update'] ?? null; unset($alert['_device_state_update']); if ($this->isInCooldown($config, $event, $alert['key'] ?? '')) continue; $alert['message'] = $this->applyTemplate($event, $alert, $templates[$event] ?? null); $this->fire($config, $event, $alert); $fired++; // Update device in_alert state — only AFTER confirmed firing (not if suppressed by cooldown) if ($deviceStateUpdate !== null) { [$stateModel, $inAlert] = $deviceStateUpdate; $stateModel->update(['in_alert' => $inAlert]); } } } } $this->syncDeviceStates($devices); if ($activeClients !== null) $this->syncClientStates($activeClients); return $fired; } // ── Device transitions ──────────────────────────────────────────────────── /** * Checks device online/offline transitions. * * Offline (2-poll grace): fires when a device has been offline for 2 consecutive polls * AND no active offline alert is already outstanding (in_alert = false). * Setting in_alert=true is deferred until after the alert is confirmed not suppressed by cooldown. * * Online ("resolved"): fires when a device has been online for 2 consecutive polls * AND an active offline alert was previously sent (in_alert = true). * This prevents orphan "back online" notifications with no preceding "offline". */ private function checkDeviceTransition(array $devices, array $filter, bool $comingOnline): array { $alerts = []; foreach ($devices as $dev) { $mac = $dev['mac']; if (! empty($filter) && ! in_array($mac, $filter)) continue; $name = $dev['name'] ?? $dev['model'] ?? $mac; $isOnline = ($dev['state'] ?? 0) == 1; $prev = DeviceState::where('device_mac', $mac)->first(); if (! $prev) continue; // Skip planned reboots — these are intentional, not alerts if (Cache::has('unifi:planned_reboot:' . strtolower($mac))) continue; if ($comingOnline) { // Online: 2nd consecutive online poll, and we previously sent an offline alert if ($prev->was_online === false && $isOnline && $prev->consecutive_count >= 1 && $prev->in_alert) { $alerts[] = [ 'key' => $mac, 'device' => $name, 'mac' => $mac, 'message' => "{$name} is back online", '_device_state_update' => [$prev, false], // clear in_alert after fire ]; } } else { // Offline: 2nd consecutive offline poll, no active alert already outstanding if ($prev->was_online === true && ! $isOnline && $prev->consecutive_count >= 1 && ! $prev->in_alert) { $alerts[] = [ 'key' => $mac, 'device' => $name, 'mac' => $mac, 'message' => "{$name} has gone offline", '_device_state_update' => [$prev, true], // set in_alert after fire ]; } } } return $alerts; } // ── Client tracking ─────────────────────────────────────────────────────── private function checkClientTransition(?array $clients, array $trackedMacs, bool $comingOnline): array { if (empty($trackedMacs) || $clients === null) return []; $connectedMacs = collect($clients)->pluck('mac')->map(fn ($m) => strtolower($m))->all(); $alerts = []; foreach ($trackedMacs as $entry) { $mac = strtolower(is_array($entry) ? ($entry['mac'] ?? '') : $entry); $name = is_array($entry) ? ($entry['name'] ?? $mac) : $mac; if (! $mac) continue; $prev = DeviceState::where('device_mac', $mac)->first(); $isOnline = in_array($mac, $connectedMacs); if (! $prev) continue; $clientInfo = collect($clients)->firstWhere('mac', $mac); if ($comingOnline && $prev->was_online === false && $isOnline && $prev->consecutive_count >= 1) { $alerts[] = [ 'key' => $mac, 'client_name' => $name, 'mac' => $mac, 'ap' => $clientInfo['ap_mac'] ?? '', 'ssid' => $clientInfo['essid'] ?? '', 'ip' => $clientInfo['ip'] ?? '', 'os' => $clientInfo['os_name'] ?? '', 'message' => "{$name} ({$mac}) connected", ]; } if (! $comingOnline && $prev->was_online === true && ! $isOnline && $prev->consecutive_count >= 2) { $alerts[] = [ 'key' => $mac, 'client_name' => $name, 'mac' => $mac, 'last_ap' => $prev->device_name ?? '', 'last_ssid' => '', 'message' => "{$name} ({$mac}) disconnected", ]; } } return $alerts; } // ── WAN ────────────────────────────────────────────────────────────────── private function checkWan(?array $wan, bool $comingUp): array { $status = $wan['status'] ?? 'unknown'; $prevKey = 'unifi:wan_was_ok'; $wasOk = cache()->get($prevKey, true); $isOk = $status === 'ok'; cache()->put($prevKey, $isOk, 3600); if ($comingUp && ! $wasOk && $isOk) return [['key' => 'wan', 'message' => 'Internet connection restored']]; if (! $comingUp && $wasOk && ! $isOk) return [['key' => 'wan', 'message' => 'Internet connection is down']]; return []; } // ── Threshold checks (alert on entry, resolved on exit) ────────────────── /** * Client count — alert when exceeding threshold (only once per alert state entry). */ private function checkClientCount($aps, int $threshold, array $filter, int $configId): array { $alerts = []; foreach ($aps as $ap) { if (! empty($filter) && ! in_array($ap['mac'], $filter)) continue; $count = $ap['num_sta'] ?? 0; $cacheKey = "unifi:alert:{$configId}:client_count:{$ap['mac']}"; if ($count >= $threshold) { if (! Cache::has($cacheKey)) { Cache::put($cacheKey, $threshold, now()->addHours(4)); $name = $ap['name'] ?? $ap['mac']; $alerts[] = ['key' => $ap['mac'], 'device' => $name, 'mac' => $ap['mac'], 'clients' => $count, 'threshold' => $threshold, 'message' => "{$name}: {$count} clients"]; } else { Cache::put($cacheKey, $threshold, now()->addHours(4)); // refresh TTL } } } return $alerts; } /** * Client count — resolved when dropping back below threshold. * Only fires if the cached threshold matches the current config (prevents spurious * resolved alerts after the threshold is raised). */ private function checkClientCountResolved($aps, int $threshold, array $filter, int $configId): array { $alerts = []; foreach ($aps as $ap) { if (! empty($filter) && ! in_array($ap['mac'], $filter)) continue; $count = $ap['num_sta'] ?? 0; $cacheKey = "unifi:alert:{$configId}:client_count:{$ap['mac']}"; $cached = Cache::get($cacheKey); if ($count < $threshold && $cached !== null) { Cache::forget($cacheKey); if ((int) $cached === $threshold) { // Only alert if this is the same threshold that originally triggered $name = $ap['name'] ?? $ap['mac']; $alerts[] = ['key' => $ap['mac'], 'device' => $name, 'mac' => $ap['mac'], 'clients' => $count, 'threshold' => $threshold, 'message' => "{$name}: client count returned to normal ({$count})"]; } } } return $alerts; } /** * Channel utilization — alert on entry, with sustain guard. * * CU must stay above threshold for $sustainPolls consecutive poll cycles before * the alert fires. A pending counter cache key tracks progress toward that threshold. * The counter is cleared as soon as CU drops back below threshold. */ private function checkCu($aps, int $threshold, ?string $band, array $filter, int $configId, int $sustainPolls = 3): array { $alerts = []; foreach ($aps as $ap) { if (! empty($filter) && ! in_array($ap['mac'], $filter)) continue; foreach ($ap['radio_table_stats'] ?? [] as $radio) { if ($band && ($radio['radio'] ?? '') !== $band) continue; $cu = $radio['cu_total'] ?? 0; $rName = $radio['radio'] ?? '?'; $alertKey = "unifi:alert:{$configId}:cu:{$ap['mac']}:{$rName}"; $pendingKey = "unifi:alert:{$configId}:cu_pending:{$ap['mac']}:{$rName}"; if ($cu >= $threshold) { if (Cache::has($alertKey)) { Cache::put($alertKey, $threshold, now()->addHours(4)); // refresh TTL while still in alert } else { $count = (int) Cache::get($pendingKey, 0) + 1; Cache::put($pendingKey, $count, now()->addHours(1)); if ($count >= $sustainPolls) { Cache::put($alertKey, $threshold, now()->addHours(4)); Cache::forget($pendingKey); $name = $ap['name'] ?? $ap['mac']; $alerts[] = ['key' => $ap['mac'].':'.$rName, 'device' => $name, 'mac' => $ap['mac'], 'radio' => $rName, 'cu' => $cu, 'threshold' => $threshold, 'message' => "{$name} ({$rName}): {$cu}% CU"]; } } } else { Cache::forget($pendingKey); // reset sustain counter when CU drops below threshold } } } return $alerts; } /** * Channel utilization — resolved on exit. * Also clears the pending sustain counter so it doesn't carry over to the next alert cycle. * Only fires the resolved alert when the cached threshold matches the current config — * this prevents a spurious "back to normal" after the threshold has been raised. */ private function checkCuResolved($aps, int $threshold, ?string $band, array $filter, int $configId): array { $alerts = []; foreach ($aps as $ap) { if (! empty($filter) && ! in_array($ap['mac'], $filter)) continue; foreach ($ap['radio_table_stats'] ?? [] as $radio) { if ($band && ($radio['radio'] ?? '') !== $band) continue; $cu = $radio['cu_total'] ?? 0; $rName = $radio['radio'] ?? '?'; $alertKey = "unifi:alert:{$configId}:cu:{$ap['mac']}:{$rName}"; $pendingKey = "unifi:alert:{$configId}:cu_pending:{$ap['mac']}:{$rName}"; if ($cu < $threshold) { Cache::forget($pendingKey); // always reset sustain counter when below threshold $cached = Cache::get($alertKey); if ($cached !== null) { Cache::forget($alertKey); if ((int) $cached === $threshold) { // Only alert if this is the same threshold that originally triggered $name = $ap['name'] ?? $ap['mac']; $alerts[] = ['key' => $ap['mac'].':'.$rName, 'device' => $name, 'mac' => $ap['mac'], 'radio' => $rName, 'cu' => $cu, 'threshold' => $threshold, 'message' => "{$name} ({$rName}): CU returned to normal ({$cu}%)"]; } } } } } return $alerts; } /** * WiFi satisfaction — alert on entry. */ private function checkSatisfaction($aps, int $minSat, array $filter, int $configId): array { $alerts = []; foreach ($aps as $ap) { if (! empty($filter) && ! in_array($ap['mac'], $filter)) continue; $sat = $ap['satisfaction'] ?? null; $cacheKey = "unifi:alert:{$configId}:satisfaction:{$ap['mac']}"; if ($sat !== null && $sat >= 0 && $sat < $minSat) { if (! Cache::has($cacheKey)) { Cache::put($cacheKey, $minSat, now()->addHours(4)); $name = $ap['name'] ?? $ap['mac']; $alerts[] = ['key' => $ap['mac'], 'device' => $name, 'mac' => $ap['mac'], 'satisfaction' => $sat, 'threshold' => $minSat, 'message' => "{$name}: {$sat}% experience"]; } else { Cache::put($cacheKey, $minSat, now()->addHours(4)); // refresh TTL } } } return $alerts; } /** * WiFi satisfaction — resolved on exit. * Only fires if the cached threshold matches current config. */ private function checkSatisfactionResolved($aps, int $minSat, array $filter, int $configId): array { $alerts = []; foreach ($aps as $ap) { if (! empty($filter) && ! in_array($ap['mac'], $filter)) continue; $sat = $ap['satisfaction'] ?? null; $cacheKey = "unifi:alert:{$configId}:satisfaction:{$ap['mac']}"; $cached = Cache::get($cacheKey); if ($sat !== null && $sat >= $minSat && $cached !== null) { Cache::forget($cacheKey); if ((int) $cached === $minSat) { $name = $ap['name'] ?? $ap['mac']; $alerts[] = ['key' => $ap['mac'], 'device' => $name, 'mac' => $ap['mac'], 'satisfaction' => $sat, 'threshold' => $minSat, 'message' => "{$name}: experience returned to normal ({$sat}%)"]; } } } return $alerts; } /** * Error rate — alert on entry. */ private function checkErrorRate($aps, array $filter, int $configId): array { $alerts = []; foreach ($aps as $ap) { if (! empty($filter) && ! in_array($ap['mac'], $filter)) continue; $retries = $ap['stat']['ap']['tx_retries'] ?? 0; $cacheKey = "unifi:alert:{$configId}:error_rate:{$ap['mac']}"; if ($retries > 1000) { if (! Cache::has($cacheKey)) { Cache::put($cacheKey, 1000, now()->addHours(4)); $name = $ap['name'] ?? $ap['mac']; $alerts[] = ['key' => $ap['mac'], 'device' => $name, 'mac' => $ap['mac'], 'retries' => $retries, 'message' => "{$name}: high retries ({$retries})"]; } else { Cache::put($cacheKey, 1000, now()->addHours(4)); // refresh TTL } } } return $alerts; } /** * Error rate — resolved on exit. */ private function checkErrorRateResolved($aps, array $filter, int $configId): array { $alerts = []; foreach ($aps as $ap) { if (! empty($filter) && ! in_array($ap['mac'], $filter)) continue; $retries = $ap['stat']['ap']['tx_retries'] ?? 0; $cacheKey = "unifi:alert:{$configId}:error_rate:{$ap['mac']}"; if ($retries <= 1000 && Cache::get($cacheKey) !== null) { Cache::forget($cacheKey); $name = $ap['name'] ?? $ap['mac']; $alerts[] = ['key' => $ap['mac'], 'device' => $name, 'mac' => $ap['mac'], 'message' => "{$name}: error rate returned to normal"]; } } return $alerts; } private function checkFirmware(array $devices, array $filter): array { $alerts = []; foreach ($devices as $dev) { if (! empty($filter) && ! in_array($dev['mac'], $filter)) continue; if ($dev['upgradable'] ?? false) { $name = $dev['name'] ?? $dev['mac']; $alerts[] = ['key' => $dev['mac'], 'device' => $name, 'mac' => $dev['mac'], 'version' => $dev['version'] ?? '', 'message' => "{$name}: firmware update available"]; } } return $alerts; } private function checkReboot($aps, array $filter): array { $alerts = []; foreach ($aps as $ap) { if (! empty($filter) && ! in_array($ap['mac'], $filter)) continue; if (Cache::has('unifi:planned_reboot:' . strtolower($ap['mac']))) continue; $uptime = $ap['uptime'] ?? 0; if ($uptime > 0 && $uptime < 300) { $prev = DeviceState::where('device_mac', $ap['mac'])->first(); if ($prev && $prev->was_online) { $name = $ap['name'] ?? $ap['mac']; $alerts[] = ['key' => $ap['mac'], 'device' => $name, 'mac' => $ap['mac'], 'uptime' => $uptime, 'message' => "{$name}: unexpected reboot"]; } } } return $alerts; } // ── Template + Cooldown + Firing ────────────────────────────────────────── private function applyTemplate(string $event, array $data, ?string $customTemplate): string { $template = $customTemplate ?: (self::DEFAULT_TEMPLATES[$event] ?? '{{message}}'); $data['timestamp'] = now()->toIso8601String(); return preg_replace_callback('/\{\{(\w+)\}\}/', function ($matches) use ($data) { return $data[$matches[1]] ?? $matches[0]; }, $template); } private function isInCooldown(WebhookConfig $config, string $event, string $key): bool { return WebhookLog::where('webhook_config_id', $config->id) ->where('event_type', $event) ->where('fired_at', '>=', now()->subMinutes($config->cooldown_minutes)) ->whereJsonContains('payload->data->key', $key) ->exists(); } private function fire(WebhookConfig $config, string $event, array $data): void { $message = $data['message'] ?? $event; $internalPayload = [ 'event' => $event, 'timestamp' => now()->toIso8601String(), 'message' => $message, 'data' => $data, ]; $url = $config->url; $payload = $this->formatPayloadForPlatform($url, $message, $internalPayload); $headers = ['Content-Type' => 'application/json']; if ($config->secret) { $headers['X-Webhook-Signature'] = hash_hmac('sha256', json_encode($internalPayload), $config->secret); } $log = ['webhook_config_id' => $config->id, 'event_type' => $event, 'payload' => $internalPayload, 'fired_at' => now()]; try { $response = Http::withHeaders($headers)->timeout(10)->post($url, $payload); $log['response_code'] = $response->status(); $log['response_body'] = substr($response->body(), 0, 1000); } catch (\Throwable $e) { $log['response_code'] = 0; $log['response_body'] = $e->getMessage(); } WebhookLog::create($log); } private function formatPayloadForPlatform(string $url, string $message, array $fullPayload): array { return self::buildPlatformPayload($url, $message, $fullPayload); } /** * Public/static helper so the test-webhook endpoint produces the * same per-platform payload shape that real events do. */ public static function buildPlatformPayload(string $url, string $message, array $fullPayload): array { if (str_contains($url, 'chat.googleapis.com')) return ['text' => $message]; if (str_contains($url, 'hooks.slack.com')) return ['text' => $message]; if (str_contains($url, 'discord.com/api/webhooks')) return ['content' => $message]; if (str_contains($url, 'webhook.office.com') || str_contains($url, 'workflows.office.com')) return ['text' => $message]; return $fullPayload; } // ── State sync ──────────────────────────────────────────────────────────── /** * Sync device states. Tracks consecutive_count and was_online. * in_alert is managed separately in checkDeviceTransition (updated after confirmed fire). * * Offline grace: 2 consecutive offline polls (count reaches 2 → flip was_online=false) * Online grace: 2 consecutive online polls (count reaches 2 → flip was_online=true) * * checkDeviceTransition fires one poll before the flip (at count >= 1), which is correct: * the alert fires on the 2nd consecutive poll, state flips on the same run's sync call. */ private function syncDeviceStates(array $devices): void { foreach ($devices as $dev) { $mac = $dev['mac']; $isOnline = ($dev['state'] ?? 0) == 1; $prev = DeviceState::where('device_mac', $mac)->first(); if (! $prev) { DeviceState::create([ 'device_mac' => $mac, 'device_name' => $dev['name'] ?? $dev['model'] ?? null, 'was_online' => $isOnline, 'in_alert' => false, 'consecutive_count' => 0, 'last_seen_at' => now(), 'updated_at' => now(), ]); continue; } $prevState = $prev->was_online; if ($prevState === $isOnline) { // Stable — reset counter $prev->update([ 'consecutive_count' => 0, 'last_seen_at' => now(), 'updated_at' => now(), 'device_name' => $dev['name'] ?? $dev['model'] ?? $prev->device_name, ]); } else { // Approaching a state change $count = $prev->consecutive_count + 1; $grace = 2; // 2 consecutive polls for both directions if ($count >= $grace) { // Confirmed — flip was_online, reset counter $prev->update([ 'was_online' => $isOnline, 'consecutive_count' => 0, 'last_seen_at' => now(), 'updated_at' => now(), 'device_name' => $dev['name'] ?? $dev['model'] ?? $prev->device_name, ]); } else { $prev->update([ 'consecutive_count' => $count, 'last_seen_at' => now(), 'updated_at' => now(), ]); } } } } private function syncClientStates(array $clients): void { $connectedMacs = collect($clients)->pluck('mac')->map(fn ($m) => strtolower($m))->all(); $allTracked = WebhookConfig::where('is_active', true) ->whereJsonLength('tracked_clients', '>', 0) ->get() ->flatMap(fn ($c) => collect($c->tracked_clients)->map(fn ($e) => strtolower(is_array($e) ? ($e['mac'] ?? '') : $e))) ->unique() ->filter(); foreach ($allTracked as $mac) { $isOnline = in_array($mac, $connectedMacs); $prev = DeviceState::where('device_mac', $mac)->first(); if (! $prev) { DeviceState::create(['device_mac' => $mac, 'was_online' => $isOnline, 'in_alert' => false, 'consecutive_count' => 1, 'last_seen_at' => now(), 'updated_at' => now()]); continue; } if ($prev->was_online === $isOnline) { $prev->update(['consecutive_count' => 0, 'last_seen_at' => now(), 'updated_at' => now()]); } else { $count = $prev->consecutive_count + 1; $grace = $isOnline ? 2 : 3; if ($count >= $grace) { $prev->update(['was_online' => $isOnline, 'consecutive_count' => 0, 'last_seen_at' => now(), 'updated_at' => now()]); } else { $prev->update(['consecutive_count' => $count, 'last_seen_at' => now(), 'updated_at' => now()]); } } } } }