From 4fb9af695c88f7328974b8bc48b14606bce836d1 Mon Sep 17 00:00:00 2001 From: seo Date: Sat, 29 Aug 2026 02:29:52 +0900 Subject: [PATCH] =?UTF-8?q?LAN=20WOL=20=EB=8C=80=EC=83=81=20=ED=8C=90?= =?UTF-8?q?=EC=A0=95=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- config/config.php | 18 ++++ public/api.php | 201 ++++++++++++++++++++++++++++++++++--------- touch_panel/panel.py | 42 ++++++++- 4 files changed, 218 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 9dc6ab0..9a72873 100644 --- a/README.md +++ b/README.md @@ -165,7 +165,7 @@ cd /var/www/control 각 타일에는 글자 아래에 세 개의 작은 표시등을 두며 읽기/업로드는 초록, 쓰기/다운로드는 주황, 오류/드롭 증가는 빨강으로 표시합니다. 활동 표시등은 이벤트 감지 후 120ms 동안 유지하고, 오류 표시등은 2초 동안 유지해 너무 짧은 이벤트도 눈에 보이게 합니다. 네트워크 카드 오른쪽 값은 항상 `D n`으로 드롭 누적을 표시합니다. `LOCAL`은 `lo`, `UPLINK`는 기본 라우트 인터페이스 기준입니다. 라즈베리파이 `ETH1`은 컴퓨터와 연결된 물리 포트 단독 카운터이며 `br0`는 합산하지 않습니다. `SSD I/O`의 값은 `/run/control-rpi-disk-temp` 캐시 파일의 `/dev/sda` Samsung T7 디스크 자체 온도입니다. 캐시 파일은 root로 실행되는 `control-rpi-disk-temp.service`가 몇 초마다 `smartctl -d sntasmedia`로 갱신하므로 GUI 패널은 sudo를 반복 실행하지 않습니다. -하단 `PC` 버튼은 `192.168.50.73`에 1초마다 ping을 보내 켜짐 여부를 표시하고, 꺼져 있을 때 누르면 `74:56:3C:CD:CD:79`로 WOL magic packet을 보냅니다. 주황은 WOL 후 최대 1분 동안 컴퓨터 응답 대기 표시, 빨강은 magic packet 전송 실패 시 5초 또는 1분 대기 후 응답 실패 시 1분 표시입니다. +하단 `PC` 버튼은 `192.168.50.73`에 1초마다 ping을 보내 켜짐 여부를 표시하고, 꺼져 있을 때 누르면 DHCP lease에서 해당 IP의 현재 MAC을 찾아 WOL magic packet을 보냅니다. 주황은 WOL 후 최대 1분 동안 컴퓨터 응답 대기 표시, 빨강은 magic packet 전송 실패 시 5초 또는 1분 대기 후 응답 실패 시 1분 표시입니다. GUI 자동 실행은 데스크톱 autostart가 1차 기준입니다. diff --git a/config/config.php b/config/config.php index cbfaec1..9266cc2 100644 --- a/config/config.php +++ b/config/config.php @@ -595,6 +595,24 @@ function bootstrap_db(): void COLLATE=utf8mb4_unicode_ci "); + $pdo->exec(" + CREATE TABLE IF NOT EXISTS lan_wol_clients ( + mac VARCHAR(17) NOT NULL PRIMARY KEY, + iface VARCHAR(32) NOT NULL, + last_ip VARCHAR(64) NULL, + hostname VARCHAR(255) NULL, + first_seen_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + last_seen_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) + ON UPDATE CURRENT_TIMESTAMP(3), + + INDEX idx_iface (iface), + INDEX idx_last_seen_at (last_seen_at) + ) ENGINE=InnoDB + DEFAULT CHARSET=utf8mb4 + COLLATE=utf8mb4_unicode_ci + "); + $pdo->exec(" CREATE TABLE IF NOT EXISTS network_usage_logs ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, diff --git a/public/api.php b/public/api.php index f6cf8d5..e587b0f 100644 --- a/public/api.php +++ b/public/api.php @@ -3346,18 +3346,154 @@ function lan_link_speed_label(string $iface = 'eth1'): string return $mbps . ' Mbps'; } -function observed_lan_macs(array $leases): array +function lan_client_display_row( + string $mac, + array $lease, + array $aliases, + string $iface, + string $linkSpeed, + bool $isOnline, + string $presence +): array { + $leaseHostname = (string)($lease['hostname'] ?? 'N/A'); + $aliasHostname = $aliases[$mac] ?? null; + $hostname = $aliasHostname ?: $leaseHostname; + $hostnameSource = $aliasHostname ? 'alias' : ($leaseHostname === 'N/A' ? 'generated' : 'lease'); + + return [ + 'band' => 'LAN', + 'iface' => $iface, + 'mac' => $mac, + 'ip' => $lease['ip'] ?? 'N/A', + 'hostname' => $hostname === 'N/A' ? '기기-' . strtoupper(substr(str_replace(':', '', $mac), -6)) : $hostname, + 'hostname_source' => $hostnameSource, + 'name' => $hostname === 'N/A' ? $mac : $hostname, + 'signal' => '-', + 'tx_bitrate' => $linkSpeed, + 'rx_bitrate' => '-', + 'signal_source' => 'lan', + 'tx_bitrate_source' => 'lan_link', + 'rx_bitrate_source' => 'lan', + 'connected_time' => $isOnline ? 'N/A' : '-', + 'inactive_time' => '-', + 'presence' => $presence, + 'wol_available' => !$isOnline, + 'rx_bytes' => 0, + 'tx_bytes' => 0, + 'rx_packets' => 0, + 'tx_packets' => 0, + 'tx_failed' => 0, + ]; +} + +function remember_lan_wol_clients(array $rows): void +{ + if ($rows === []) { + return; + } + + try { + $pdo = db(); + $stmt = $pdo->prepare(" + INSERT INTO lan_wol_clients + (mac, iface, last_ip, hostname, first_seen_at, last_seen_at) + VALUES + (:mac, :iface, :last_ip, :hostname, NOW(3), NOW(3)) + ON DUPLICATE KEY UPDATE + iface = VALUES(iface), + last_ip = VALUES(last_ip), + hostname = VALUES(hostname), + last_seen_at = NOW(3) + "); + + foreach ($rows as $row) { + $mac = strtolower((string)($row['mac'] ?? '')); + if (!wifi_valid_mac($mac) || (string)($row['presence'] ?? '') !== 'fdb') { + continue; + } + + $stmt->execute([ + ':mac' => $mac, + ':iface' => (string)($row['iface'] ?? 'eth1'), + ':last_ip' => ($row['ip'] ?? 'N/A') === 'N/A' ? null : (string)$row['ip'], + ':hostname' => ($row['hostname'] ?? 'N/A') === 'N/A' ? null : (string)$row['hostname'], + ]); + } + } catch (Throwable) { + } +} + +function seed_lan_wol_clients_from_observed(array $leases, array $aliases = [], string $iface = 'eth1'): void +{ + if ($leases === []) { + return; + } + + try { + $pdo = db(); + $rows = $pdo->query(" + SELECT mac, last_ip, hostname, last_seen_at + FROM wifi_observed_sessions + WHERE band = 'LAN' + ")->fetchAll(); + if ($rows === []) { + return; + } + + $upsert = $pdo->prepare(" + INSERT INTO lan_wol_clients + (mac, iface, last_ip, hostname, first_seen_at, last_seen_at) + VALUES + (:mac, :iface, :last_ip, :hostname, :last_seen_at, :last_seen_at) + ON DUPLICATE KEY UPDATE + iface = VALUES(iface), + last_ip = COALESCE(VALUES(last_ip), last_ip), + hostname = COALESCE(VALUES(hostname), hostname), + last_seen_at = GREATEST(last_seen_at, VALUES(last_seen_at)) + "); + + foreach ($rows as $row) { + $mac = strtolower((string)($row['mac'] ?? '')); + if (!wifi_valid_mac($mac) || !isset($leases[$mac])) { + continue; + } + + $lease = $leases[$mac]; + $leaseHostname = (string)($lease['hostname'] ?? 'N/A'); + $hostname = $aliases[$mac] ?? ($leaseHostname === 'N/A' ? (string)($row['hostname'] ?? '') : $leaseHostname); + $lastSeenAt = (string)($row['last_seen_at'] ?? ''); + if ($lastSeenAt === '') { + $lastSeenAt = date('Y-m-d H:i:s.v'); + } + + $upsert->execute([ + ':mac' => $mac, + ':iface' => $iface, + ':last_ip' => (string)($lease['ip'] ?? ($row['last_ip'] ?? '')), + ':hostname' => $hostname === '' || $hostname === 'N/A' ? null : $hostname, + ':last_seen_at' => $lastSeenAt, + ]); + } + } catch (Throwable) { + } +} + +function lan_wol_candidate_macs(array $leases, string $iface = 'eth1'): array { if ($leases === []) { return []; } + seed_lan_wol_clients_from_observed($leases, [], $iface); + try { - $stmt = db()->query(" + $stmt = db()->prepare(" SELECT mac - FROM wifi_observed_sessions - WHERE band = 'LAN' + FROM lan_wol_clients + WHERE iface = :iface + ORDER BY last_seen_at DESC "); + $stmt->execute([':iface' => $iface]); } catch (Throwable) { return []; } @@ -3380,7 +3516,7 @@ function lan_client_rows(array $leases, array $aliases, array $wifiMacs = [], st $lanFdbList = lan_fdb_macs($iface); $lanFdbMacs = array_fill_keys($lanFdbList, true); $wifiMacs = array_fill_keys(array_map('strtolower', $wifiMacs), true); - $lanMacs = array_values(array_unique(array_merge($lanFdbList, observed_lan_macs($leases)))); + $lanMacs = array_values(array_unique($lanFdbList)); foreach ($lanMacs as $mac) { $mac = strtolower((string)$mac); @@ -3388,37 +3524,18 @@ function lan_client_rows(array $leases, array $aliases, array $wifiMacs = [], st continue; } - $lease = $leases[$mac] ?? []; - $leaseHostname = (string)($lease['hostname'] ?? 'N/A'); - $aliasHostname = $aliases[$mac] ?? null; - $hostname = $aliasHostname ?: $leaseHostname; - $hostnameSource = $aliasHostname ? 'alias' : ($leaseHostname === 'N/A' ? 'generated' : 'lease'); - $isFdbVisible = isset($lanFdbMacs[$mac]); + $rows[] = lan_client_display_row($mac, $leases[$mac] ?? [], $aliases, $iface, $linkSpeed, true, 'fdb'); + } - $rows[] = [ - 'band' => 'LAN', - 'iface' => $iface, - 'mac' => $mac, - 'ip' => $lease['ip'] ?? 'N/A', - 'hostname' => $hostname === 'N/A' ? '기기-' . strtoupper(substr(str_replace(':', '', $mac), -6)) : $hostname, - 'hostname_source' => $hostnameSource, - 'name' => $hostname === 'N/A' ? $mac : $hostname, - 'signal' => '-', - 'tx_bitrate' => $linkSpeed, - 'rx_bitrate' => '-', - 'signal_source' => 'lan', - 'tx_bitrate_source' => 'lan_link', - 'rx_bitrate_source' => 'lan', - 'connected_time' => $isFdbVisible ? 'N/A' : '-', - 'inactive_time' => '-', - 'presence' => $isFdbVisible ? 'fdb' : 'lease', - 'wol_available' => !$isFdbVisible, - 'rx_bytes' => 0, - 'tx_bytes' => 0, - 'rx_packets' => 0, - 'tx_packets' => 0, - 'tx_failed' => 0, - ]; + remember_lan_wol_clients($rows); + $wolMacs = array_values(array_unique(lan_wol_candidate_macs($leases, $iface))); + foreach ($wolMacs as $mac) { + $mac = strtolower((string)$mac); + if (!wifi_valid_mac($mac) || isset($wifiMacs[$mac]) || isset($lanFdbMacs[$mac])) { + continue; + } + + $rows[] = lan_client_display_row($mac, $leases[$mac] ?? [], $aliases, $iface, $linkSpeed, false, 'wol'); } return $rows; @@ -3441,8 +3558,8 @@ function send_wake_on_lan(string $mac, string $broadcast = '192.168.50.255', int throw new InvalidArgumentException('device_already_online'); } - $lanMacs = array_fill_keys(array_merge(array_keys($activeLanMacs), observed_lan_macs($leases)), true); - if (!isset($lanMacs[$mac])) { + $lanMacs = array_fill_keys(lan_wol_candidate_macs($leases), true); + if (!isset($lanMacs[$mac]) && !isset($leases[$mac])) { throw new InvalidArgumentException('not_lan_client'); } @@ -3485,8 +3602,8 @@ function wake_lan_status(string $mac): array $leases = dnsmasq_leases(); $activeLanMacs = array_fill_keys(lan_fdb_macs(), true); - $observedLanMacs = array_fill_keys(observed_lan_macs($leases), true); - $known = isset($leases[$mac]) || isset($activeLanMacs[$mac]) || isset($observedLanMacs[$mac]); + $wolCandidateMacs = array_fill_keys(lan_wol_candidate_macs($leases), true); + $known = isset($activeLanMacs[$mac]) || isset($wolCandidateMacs[$mac]) || isset($leases[$mac]); if (!$known) { throw new InvalidArgumentException('unknown_dhcp_client'); @@ -3500,7 +3617,7 @@ function wake_lan_status(string $mac): array 'ip' => $lease['ip'] ?? null, 'hostname' => $lease['hostname'] ?? null, 'state' => $isOnline ? 'on' : 'off', - 'presence' => $isOnline ? 'fdb' : (isset($observedLanMacs[$mac]) ? 'observed' : 'lease'), + 'presence' => $isOnline ? 'fdb' : (isset($wolCandidateMacs[$mac]) ? 'wol' : 'lease'), 'wol_available' => !$isOnline, ]; } @@ -3743,6 +3860,9 @@ function observed_wifi_duration_seconds(array $clients): array if (!in_array($band, ['5G', 'LAN'], true) || !wifi_valid_mac($mac)) { continue; } + if ($band === 'LAN' && (string)($client['presence'] ?? '') !== 'fdb') { + continue; + } $visibleMacsByBand[$band][] = $mac; @@ -3833,6 +3953,7 @@ function apply_observed_wifi_connected_time(array $clients): array $mac = strtolower((string)($client['mac'] ?? '')); if (!in_array((string)($client['band'] ?? ''), ['5G', 'LAN'], true) + || ((string)($client['band'] ?? '') === 'LAN' && (string)($client['presence'] ?? '') !== 'fdb') || !wifi_connected_time_missing($client['connected_time'] ?? null) || !array_key_exists($mac, $observedSeconds) ) { diff --git a/touch_panel/panel.py b/touch_panel/panel.py index e4d1119..721c9fd 100755 --- a/touch_panel/panel.py +++ b/touch_panel/panel.py @@ -28,7 +28,7 @@ NET_GROUPS = { } PC_IP = "192.168.50.73" -PC_MAC = "74:56:3C:CD:CD:79" +PC_DHCP_LEASE_FILE = "/var/lib/misc/dnsmasq.leases" SYNOLOGY_PING_HOST = "chaegeon.com" SYNOLOGY_SSH = [ "/usr/bin/ssh", @@ -339,15 +339,46 @@ def compact_count(value): return str(value) -def send_magic_packet(mac): +def normalize_mac(mac): clean = mac.replace(":", "").replace("-", "") if len(clean) != 12: + return "" + try: + bytes.fromhex(clean) + except ValueError: + return "" + return ":".join(clean[i:i + 2] for i in range(0, 12, 2)).lower() + + +def dhcp_mac_for_ip(ip): + for line in read_text(PC_DHCP_LEASE_FILE).splitlines(): + parts = line.split() + if len(parts) >= 3 and parts[2] == ip: + mac = normalize_mac(parts[1]) + if mac: + return mac + return "" + + +def broadcast_targets_for_ip(ip): + parts = ip.split(".") + targets = [] + if len(parts) == 4 and all(part.isdigit() and 0 <= int(part) <= 255 for part in parts): + targets.append(".".join(parts[:3] + ["255"])) + targets.append("255.255.255.255") + return list(dict.fromkeys(targets)) + + +def send_magic_packet(mac, ip=PC_IP): + mac = normalize_mac(mac) + if not mac: return False + clean = mac.replace(":", "") packet = bytes.fromhex("ff" * 6 + clean * 16) try: with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) - for target in ["192.168.50.255", "255.255.255.255"]: + for target in broadcast_targets_for_ip(ip): sock.sendto(packet, (target, 9)) return True except OSError: @@ -532,10 +563,13 @@ class PcWakeButton(StatusTile): self.error_until = 0.0 self.error_dot.set_steady(False) self.detail.setText("깨우는 중") - if not send_magic_packet(PC_MAC): + mac = dhcp_mac_for_ip(PC_IP) + if not mac or not send_magic_packet(mac, PC_IP): self.wait_until = 0.0 self.error_until = now + 5 self.error_dot.set_steady(True) + self.write_dot.set_steady(False) + self.detail.setText("MAC 없음" if not mac else "실패") super().mousePressEvent(event) def tick(self):