Track LAN client connection time

This commit is contained in:
seo
2026-07-20 23:29:37 +09:00
parent 7425148af8
commit 2261d3ebe4
4 changed files with 62 additions and 34 deletions
+8 -8
View File
@@ -71,7 +71,7 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를
- `/tmp/control-throttling-state.json`: `vcgencmd get_throttled` 기반 스로틀링 감지 상태, 최근 지속시간, 최근 episode 이력 - `/tmp/control-throttling-state.json`: `vcgencmd get_throttled` 기반 스로틀링 감지 상태, 최근 지속시간, 최근 episode 이력
- `system_notice_state`: notice 활성 상태와 기준값 - `system_notice_state`: notice 활성 상태와 기준값
- `system_notice_logs`: notice 발생 이력 - `system_notice_logs`: notice 발생 이력
- `wifi_observed_sessions`: 5G WiFi client의 최초/마지막 감지 시간 - `wifi_observed_sessions`: 5G WiFi/LAN client의 최초/마지막 감지 시간
- `wifi_client_aliases`: MAC별 수동 WiFi 호스트명 - `wifi_client_aliases`: MAC별 수동 WiFi 호스트명
- `app_settings`: 설정 모달에서 저장한 Control 동작값 오버라이드 - `app_settings`: 설정 모달에서 저장한 Control 동작값 오버라이드
- `/home/seo/secret/control.php`: 앱 비밀번호, DB 설정, 선택적 배터리 설정 - `/home/seo/secret/control.php`: 앱 비밀번호, DB 설정, 선택적 배터리 설정
@@ -128,7 +128,7 @@ System Notice는 다음 조건으로 동작합니다.
## WiFi 관측 자동화 ## WiFi 관측 자동화
5G 외부 WiFi 모듈`iw station dump``connected time` 제공지 않는 경우가 있어, 서버가 MAC별 최초 감지 시각을 DB에 저장하고 경과 시간을 계산합니다. 5G 외부 WiFi 모듈과 LAN 클라이언트는 장치별 연결 시간이 직접 제공지 않는 경우가 있어, 서버가 MAC별 최초 감지 시각을 DB에 저장하고 경과 시간을 계산합니다.
기존 대시보드 조회만으로도 보정은 가능하지만, 사용자가 접속하지 않은 시간에는 최초 감지가 늦어질 수 있습니다. 이를 막기 위해 다음 systemd timer가 10초마다 관측 CLI를 실행합니다. 기존 대시보드 조회만으로도 보정은 가능하지만, 사용자가 접속하지 않은 시간에는 최초 감지가 늦어질 수 있습니다. 이를 막기 위해 다음 systemd timer가 10초마다 관측 CLI를 실행합니다.
@@ -143,7 +143,7 @@ control-wifi-observe.service
/usr/bin/php /var/www/control/bin/wifi_observe.php /usr/bin/php /var/www/control/bin/wifi_observe.php
``` ```
이 작업은 팬 정책, 차트 로그 저장, System Notice 기록과 분리되어 있으며 WiFi client 목록 확인과 `wifi_observed_sessions` 갱신만 수행합니다. 이 작업은 팬 정책, 차트 로그 저장, System Notice 기록과 분리되어 있으며 WiFi/LAN client 목록 확인과 `wifi_observed_sessions` 갱신만 수행합니다.
호스트명은 dnsmasq lease 값을 우선 사용합니다. lease hostname이 `N/A`이면 MAC 주소 끝 6자리 기반 임시명 `기기-XXXXXX`을 표시하고, 표의 호스트명을 클릭해 저장한 수동 이름은 `wifi_client_aliases`에 보관해 이후에도 우선 표시합니다. 호스트명은 dnsmasq lease 값을 우선 사용합니다. lease hostname이 `N/A`이면 MAC 주소 끝 6자리 기반 임시명 `기기-XXXXXX`을 표시하고, 표의 호스트명을 클릭해 저장한 수동 이름은 `wifi_client_aliases`에 보관해 이후에도 우선 표시합니다.
@@ -156,10 +156,10 @@ control-wifi-observe.service
3. 대시보드는 `status` snapshot을 렌더링합니다. 3. 대시보드는 `status` snapshot을 렌더링합니다.
4. WebSocket 연결이 성공하면 `status` 메시지로 갱신하고, 실패하면 HTTP fallback을 사용합니다. 4. WebSocket 연결이 성공하면 `status` 메시지로 갱신하고, 실패하면 HTTP fallback을 사용합니다.
5. 팬 조작은 상태 저장, 정책 적용, 로그 저장 순서로 처리합니다. 5. 팬 조작은 상태 저장, 정책 적용, 로그 저장 순서로 처리합니다.
6. WiFi client 목록은 `iw station dump`와 dnsmasq lease를 조합하고, LAN client는 `bridge fdb show br br0`의 USB LAN 포트 MAC을 dnsmasq lease와 매칭합니다. 5G 연결 시간이 `N/A`면 MAC 기준 최초 감지 시간을 DB에 저장해 경과 시간을 계산합니다. 6. WiFi client 목록은 `iw station dump`와 dnsmasq lease를 조합하고, LAN client는 `bridge fdb show br br0`의 USB LAN 포트 MAC을 dnsmasq lease와 매칭합니다. 5G 또는 LAN 연결 시간이 비어 있으면 MAC 기준 최초 감지 시간을 DB에 저장해 경과 시간을 계산합니다.
7. WiFi hostname이 비어 있으면 MAC 기반 임시명을 표시하고, 수동 저장한 MAC별 이름은 DB에서 우선 적용합니다. 7. WiFi hostname이 비어 있으면 MAC 기반 임시명을 표시하고, 수동 저장한 MAC별 이름은 DB에서 우선 적용합니다.
8. WiFi 신호/송신 속도/수신 속도 값이 비어 있으면 실제로 존재하는 다른 무선 지표와 대역별 기준으로 값을 채웁니다. 8. WiFi 신호/송신 속도/수신 속도 값이 비어 있으면 실제로 존재하는 다른 무선 지표와 대역별 기준으로 값을 채웁니다.
9. `control-wifi-observe.timer`는 사용자 접속과 무관하게 10초마다 5G 관측 세션을 갱신합니다. 9. `control-wifi-observe.timer`는 사용자 접속과 무관하게 10초마다 5G/LAN 관측 세션을 갱신합니다.
10. WakeLock 버튼은 활성 상태를 초록색 버튼으로 표시합니다. 10. WakeLock 버튼은 활성 상태를 초록색 버튼으로 표시합니다.
11. Reboot 버튼은 설정된 확인 문구와 관리자 암호 재입력을 모두 통과한 뒤 서버 API로 재부팅을 요청합니다. 11. Reboot 버튼은 설정된 확인 문구와 관리자 암호 재입력을 모두 통과한 뒤 서버 API로 재부팅을 요청합니다.
12. 시스템 상태는 `vcgencmd get_throttled`를 읽어 현재 저전압/스로틀링 여부와 부팅 후 이력, 최근 감지 시각, 현재/최근 지속시간을 표시합니다. 12. 시스템 상태는 `vcgencmd get_throttled`를 읽어 현재 저전압/스로틀링 여부와 부팅 후 이력, 최근 감지 시각, 현재/최근 지속시간을 표시합니다.
@@ -187,7 +187,7 @@ control-wifi-observe.service
- `apply_fan_policy()`: 팬 목표값 계산과 적용 - `apply_fan_policy()`: 팬 목표값 계산과 적용
- `fan_control_temp()`: CPU/RP1 혼합 팬 판단 온도 계산 - `fan_control_temp()`: CPU/RP1 혼합 팬 판단 온도 계산
- `json_out()`: API JSON 응답 표준화 - `json_out()`: API JSON 응답 표준화
- `apply_observed_wifi_connected_time()`: 5G WiFi 연결 시간이 없는 client에 서버 관측 경과 시간 적용 - `apply_observed_wifi_connected_time()`: 5G WiFi와 LAN 연결 시간이 없는 client에 서버 관측 경과 시간 적용
- `wifi_client_aliases()`, `save_wifi_client_alias()`: MAC별 WiFi 호스트명 조회와 저장 - `wifi_client_aliases()`, `save_wifi_client_alias()`: MAC별 WiFi 호스트명 조회와 저장
- `apply_wifi_estimates()`: WiFi 신호/송신 속도/수신 속도 결측값 보정 - `apply_wifi_estimates()`: WiFi 신호/송신 속도/수신 속도 결측값 보정
- `battery_status()`: `CONTROL_BATTERY_ENABLED`가 true일 때만 배터리 센서를 읽고, 현재 기본 구성에서는 제거 상태를 반환 - `battery_status()`: `CONTROL_BATTERY_ENABLED`가 true일 때만 배터리 센서를 읽고, 현재 기본 구성에서는 제거 상태를 반환
@@ -206,7 +206,7 @@ control-wifi-observe.service
- `sync_current_battery_remaining_history()`: 잔여 시간 차트 마지막 지점을 현재 snapshot 잔여 시간과 동기화 - `sync_current_battery_remaining_history()`: 잔여 시간 차트 마지막 지점을 현재 snapshot 잔여 시간과 동기화
- `batteryRemainingTitle()`, `showBatteryTooltip()`, `updateBatteryTooltip()`: 잔여 시간 계산 근거 커스텀 팝오버 표시와 실시간 갱신 - `batteryRemainingTitle()`, `showBatteryTooltip()`, `updateBatteryTooltip()`: 잔여 시간 계산 근거 커스텀 팝오버 표시와 실시간 갱신
- `setting_definitions()`, `settings_payload()`, `save_settings()`, `reset_settings()`: 설정 모달 항목 정의, 조회, 저장, 초기화 - `setting_definitions()`, `settings_payload()`, `save_settings()`, `reset_settings()`: 설정 모달 항목 정의, 조회, 저장, 초기화
- `bin/wifi_observe.php`: 화면 접속 없이 `wifi_data()`를 호출해 5G 관측 세션을 선제 갱신 - `bin/wifi_observe.php`: 화면 접속 없이 `wifi_data()`를 호출해 5G/LAN 관측 세션을 선제 갱신
- `assets/wakelock.js`: WakeLock 버튼 상태와 Screen Wake Lock API 제어 - `assets/wakelock.js`: WakeLock 버튼 상태와 Screen Wake Lock API 제어
- `customAlert()`, `customConfirm()`, `customPrompt()`: 대시보드 공통 확인/입력 dialog - `customAlert()`, `customConfirm()`, `customPrompt()`: 대시보드 공통 확인/입력 dialog
- `controlLang`, `controlTheme`: 재접속 후에도 유지되는 언어/테마 localStorage key - `controlLang`, `controlTheme`: 재접속 후에도 유지되는 언어/테마 localStorage key
@@ -238,7 +238,7 @@ control-wifi-observe.service
- 2.4G 내장 WiFi는 `hostapd-24g.service` 개별 restart 직후 일부 IoT 단말이 WPA/EAPOL 재협상 루프에 들어갈 수 있으므로, 안정화된 상태에서는 개별 restart를 피하고 필요 시 전체 reboot 또는 채널 변경으로 재초기화합니다. - 2.4G 내장 WiFi는 `hostapd-24g.service` 개별 restart 직후 일부 IoT 단말이 WPA/EAPOL 재협상 루프에 들어갈 수 있으므로, 안정화된 상태에서는 개별 restart를 피하고 필요 시 전체 reboot 또는 채널 변경으로 재초기화합니다.
- `hostapd-24g.service`, `hostapd-5g.service`는 종료 후 해당 `wlan` 인터페이스를 `br0`에서 떼도록 두어, 모듈이 빠졌다가 돌아온 뒤 hostapd가 다시 붙이는 흐름으로 정리합니다. - `hostapd-24g.service`, `hostapd-5g.service`는 종료 후 해당 `wlan` 인터페이스를 `br0`에서 떼도록 두어, 모듈이 빠졌다가 돌아온 뒤 hostapd가 다시 붙이는 흐름으로 정리합니다.
- 5G WiFi 연결 시간은 외부 모듈이 값을 제공하지 않을 때 서버가 처음 감지한 시각 기준으로 계산합니다. 현재 5G 목록에서 MAC이 사라지면 관측 세션을 즉시 종료하므로 재연결 시 0부터 다시 누적됩니다. - 5G WiFi 연결 시간은 외부 모듈이 값을 제공하지 않을 때 서버가 처음 감지한 시각 기준으로 계산합니다. 현재 5G 목록에서 MAC이 사라지면 관측 세션을 즉시 종료하므로 재연결 시 0부터 다시 누적됩니다.
- `control-wifi-observe.timer`는 10초마다 `/var/www/control/bin/wifi_observe.php`를 실행해 사용자가 대시보드에 접속하지 않아도 5G 연결 시간 카운터를 시작하고 유지합니다. - `control-wifi-observe.timer`는 10초마다 `/var/www/control/bin/wifi_observe.php`를 실행해 사용자가 대시보드에 접속하지 않아도 5G/LAN 연결 시간 카운터를 시작하고 유지합니다.
- WebSocket은 장기 실행 프로세스이므로 `public/api.php`, `config/config.php`, `bin/control_ws.php` 변경을 감지하면 15초 안에 종료되고 `control-websocket.service`가 새 프로세스로 재시작합니다. - WebSocket은 장기 실행 프로세스이므로 `public/api.php`, `config/config.php`, `bin/control_ws.php` 변경을 감지하면 15초 안에 종료되고 `control-websocket.service`가 새 프로세스로 재시작합니다.
- WebSocket 장기 실행 중 DB 연결이 끊길 수 있으므로 reconnect 로그를 확인합니다. - WebSocket 장기 실행 중 DB 연결이 끊길 수 있으므로 reconnect 로그를 확인합니다.
- SmartThings 재로드 자동화는 서울 전력분전반과 목포 누전차단기 전력 센서의 값이 10초 이상 변하지 않으면 통합을 재로드합니다. - SmartThings 재로드 자동화는 서울 전력분전반과 목포 누전차단기 전력 센서의 값이 10초 이상 변하지 않으면 통합을 재로드합니다.
+5
View File
@@ -9,6 +9,7 @@ $data = wifi_data();
$clients = is_array($data['clients'] ?? null) ? $data['clients'] : []; $clients = is_array($data['clients'] ?? null) ? $data['clients'] : [];
$missing5g = 0; $missing5g = 0;
$missing24 = 0; $missing24 = 0;
$missingLan = 0;
foreach ($clients as $client) { foreach ($clients as $client) {
$band = (string)($client['band'] ?? ''); $band = (string)($client['band'] ?? '');
@@ -20,6 +21,8 @@ foreach ($clients as $client) {
$missing5g++; $missing5g++;
} elseif ($band === '2.4G') { } elseif ($band === '2.4G') {
$missing24++; $missing24++;
} elseif ($band === 'LAN') {
$missingLan++;
} }
} }
@@ -28,6 +31,8 @@ echo json_encode([
'generated_at' => date('Y-m-d H:i:s'), 'generated_at' => date('Y-m-d H:i:s'),
'count24' => (int)($data['count24'] ?? 0), 'count24' => (int)($data['count24'] ?? 0),
'count5' => (int)($data['count5'] ?? 0), 'count5' => (int)($data['count5'] ?? 0),
'countLan' => (int)($data['countLan'] ?? 0),
'missing_connected_time_24g' => $missing24, 'missing_connected_time_24g' => $missing24,
'missing_connected_time_5g' => $missing5g, 'missing_connected_time_5g' => $missing5g,
'missing_connected_time_lan' => $missingLan,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL; ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL;
+30 -24
View File
@@ -2739,7 +2739,7 @@ function lan_client_rows(array $leases, array $aliases, string $iface = 'eth1'):
'tx_bitrate_source' => 'lan', 'tx_bitrate_source' => 'lan',
'rx_bitrate_source' => 'lan', 'rx_bitrate_source' => 'lan',
'connected_time' => 'N/A', 'connected_time' => 'N/A',
'inactive_time' => 'N/A', 'inactive_time' => '-',
'rx_bytes' => 0, 'rx_bytes' => 0,
'tx_bytes' => 0, 'tx_bytes' => 0,
'rx_packets' => 0, 'rx_packets' => 0,
@@ -2908,47 +2908,53 @@ function wifi_valid_mac(string $mac): bool
return preg_match('/^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/', strtolower($mac)) === 1; return preg_match('/^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/', strtolower($mac)) === 1;
} }
function prune_observed_wifi_sessions(PDO $pdo, array $visibleMacs): void function prune_observed_wifi_sessions(PDO $pdo, array $visibleMacsByBand): void
{ {
$visibleMacs = array_values(array_unique(array_filter( foreach (['5G', 'LAN'] as $band) {
array_map(static fn($mac) => strtolower((string)$mac), $visibleMacs), $visibleMacs = array_values(array_unique(array_filter(
static fn($mac) => wifi_valid_mac($mac) array_map(static fn($mac) => strtolower((string)$mac), $visibleMacsByBand[$band] ?? []),
))); static fn($mac) => wifi_valid_mac($mac)
)));
if ($visibleMacs === []) { if ($visibleMacs === []) {
$pdo->exec("DELETE FROM wifi_observed_sessions WHERE band = '5G'"); $stmt = $pdo->prepare("DELETE FROM wifi_observed_sessions WHERE band = ?");
return; $stmt->execute([$band]);
continue;
}
$placeholders = implode(',', array_fill(0, count($visibleMacs), '?'));
$stmt = $pdo->prepare("
DELETE FROM wifi_observed_sessions
WHERE band = ?
AND mac NOT IN ($placeholders)
");
$stmt->execute(array_merge([$band], $visibleMacs));
} }
$placeholders = implode(',', array_fill(0, count($visibleMacs), '?'));
$stmt = $pdo->prepare("
DELETE FROM wifi_observed_sessions
WHERE band = '5G'
AND mac NOT IN ($placeholders)
");
$stmt->execute($visibleMacs);
} }
function observed_wifi_duration_seconds(array $clients): array function observed_wifi_duration_seconds(array $clients): array
{ {
$targets = []; $targets = [];
$visible5gMacs = []; $visibleMacsByBand = [
'5G' => [],
'LAN' => [],
];
foreach ($clients as $client) { foreach ($clients as $client) {
$band = (string)($client['band'] ?? ''); $band = (string)($client['band'] ?? '');
$mac = strtolower((string)($client['mac'] ?? '')); $mac = strtolower((string)($client['mac'] ?? ''));
if ($band !== '5G' || !wifi_valid_mac($mac)) { if (!in_array($band, ['5G', 'LAN'], true) || !wifi_valid_mac($mac)) {
continue; continue;
} }
$visible5gMacs[] = $mac; $visibleMacsByBand[$band][] = $mac;
if (!wifi_connected_time_missing($client['connected_time'] ?? null)) { if (!wifi_connected_time_missing($client['connected_time'] ?? null)) {
continue; continue;
} }
$targets[$mac] = [ $targets[$band . ':' . $mac] = [
'mac' => $mac, 'mac' => $mac,
'band' => $band, 'band' => $band,
'iface' => (string)($client['iface'] ?? ''), 'iface' => (string)($client['iface'] ?? ''),
@@ -2960,7 +2966,7 @@ function observed_wifi_duration_seconds(array $clients): array
try { try {
$pdo = db(); $pdo = db();
$pdo->beginTransaction(); $pdo->beginTransaction();
prune_observed_wifi_sessions($pdo, $visible5gMacs); prune_observed_wifi_sessions($pdo, $visibleMacsByBand);
if ($targets === []) { if ($targets === []) {
$pdo->commit(); $pdo->commit();
@@ -2998,7 +3004,7 @@ function observed_wifi_duration_seconds(array $clients): array
FROM wifi_observed_sessions FROM wifi_observed_sessions
WHERE mac IN ($placeholders) WHERE mac IN ($placeholders)
"); ");
$stmt->execute(array_keys($targets)); $stmt->execute(array_values(array_unique(array_column($targets, 'mac'))));
$rows = $stmt->fetchAll(); $rows = $stmt->fetchAll();
$pdo->commit(); $pdo->commit();
@@ -3029,7 +3035,7 @@ function apply_observed_wifi_connected_time(array $clients): array
foreach ($clients as &$client) { foreach ($clients as &$client) {
$mac = strtolower((string)($client['mac'] ?? '')); $mac = strtolower((string)($client['mac'] ?? ''));
if (($client['band'] ?? '') !== '5G' if (!in_array((string)($client['band'] ?? ''), ['5G', 'LAN'], true)
|| !wifi_connected_time_missing($client['connected_time'] ?? null) || !wifi_connected_time_missing($client['connected_time'] ?? null)
|| !array_key_exists($mac, $observedSeconds) || !array_key_exists($mac, $observedSeconds)
) { ) {
+19 -2
View File
@@ -1346,6 +1346,23 @@
return formatDhms(parseWifiDurationSeconds(value)) || value; return formatDhms(parseWifiDurationSeconds(value)) || value;
} }
function wifiCellValue(row, key) {
const value = row?.[key];
const text = String(value ?? '').trim();
if (row?.band === 'LAN' && (text === '' || text.toUpperCase() === 'N/A')) {
return '-';
}
return text || '-';
}
function wifiConnectedCellValue(row) {
if (row?.band === 'LAN' && wifiCellValue(row, 'connected_time') === '-') {
return '-';
}
return wifiConnectedTime(row?.connected_time);
}
function eventStateText(eventState, activeKey, normalKey, recoveringKey, repeatedKey, seenKey, notSeenKey) { function eventStateText(eventState, activeKey, normalKey, recoveringKey, repeatedKey, seenKey, notSeenKey) {
if (!eventState || eventState.available === false) { if (!eventState || eventState.available === false) {
return t('na'); return t('na');
@@ -1415,8 +1432,8 @@
${wifiMetricCell(row, 'signal')} ${wifiMetricCell(row, 'signal')}
${wifiMetricCell(row, 'tx_bitrate')} ${wifiMetricCell(row, 'tx_bitrate')}
${wifiMetricCell(row, 'rx_bitrate')} ${wifiMetricCell(row, 'rx_bitrate')}
${td(wifiConnectedTime(row.connected_time))} ${td(wifiConnectedCellValue(row))}
${td(row.inactive_time)} ${td(wifiCellValue(row, 'inactive_time'))}
</tr> </tr>
`).join('') `).join('')
: `<tr><td colspan="9">${escapeHtml(t('noWifiClients'))}</td></tr>`; : `<tr><td colspan="9">${escapeHtml(t('noWifiClients'))}</td></tr>`;