Use DHCP leases for LAN client wake support
This commit is contained in:
+117
-4
@@ -2732,17 +2732,54 @@ function lan_link_speed_label(string $iface = 'eth1'): string
|
||||
return $mbps . ' Mbps';
|
||||
}
|
||||
|
||||
function lan_client_rows(array $leases, array $aliases, string $iface = 'eth1'): array
|
||||
function observed_lan_macs(array $leases): array
|
||||
{
|
||||
if ($leases === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$stmt = db()->query("
|
||||
SELECT mac
|
||||
FROM wifi_observed_sessions
|
||||
WHERE band = 'LAN'
|
||||
");
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$macs = [];
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
$mac = strtolower((string)($row['mac'] ?? ''));
|
||||
if (wifi_valid_mac($mac) && isset($leases[$mac])) {
|
||||
$macs[$mac] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return array_keys($macs);
|
||||
}
|
||||
|
||||
function lan_client_rows(array $leases, array $aliases, array $wifiMacs = [], string $iface = 'eth1'): array
|
||||
{
|
||||
$rows = [];
|
||||
$linkSpeed = lan_link_speed_label($iface);
|
||||
$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))));
|
||||
|
||||
foreach ($lanMacs as $mac) {
|
||||
$mac = strtolower((string)$mac);
|
||||
if (!wifi_valid_mac($mac) || isset($wifiMacs[$mac])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (lan_fdb_macs($iface) as $mac) {
|
||||
$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[] = [
|
||||
'band' => 'LAN',
|
||||
@@ -2758,8 +2795,10 @@ function lan_client_rows(array $leases, array $aliases, string $iface = 'eth1'):
|
||||
'signal_source' => 'lan',
|
||||
'tx_bitrate_source' => 'lan_link',
|
||||
'rx_bitrate_source' => 'lan',
|
||||
'connected_time' => 'N/A',
|
||||
'connected_time' => $isFdbVisible ? 'N/A' : '-',
|
||||
'inactive_time' => '-',
|
||||
'presence' => $isFdbVisible ? 'fdb' : 'lease',
|
||||
'wol_available' => true,
|
||||
'rx_bytes' => 0,
|
||||
'tx_bytes' => 0,
|
||||
'rx_packets' => 0,
|
||||
@@ -2771,6 +2810,53 @@ function lan_client_rows(array $leases, array $aliases, string $iface = 'eth1'):
|
||||
return $rows;
|
||||
}
|
||||
|
||||
function send_wake_on_lan(string $mac, string $broadcast = '192.168.50.255', int $port = 9): array
|
||||
{
|
||||
$mac = strtolower(trim($mac));
|
||||
if (!wifi_valid_mac($mac)) {
|
||||
throw new InvalidArgumentException('bad_mac');
|
||||
}
|
||||
|
||||
$leases = dnsmasq_leases();
|
||||
if (!isset($leases[$mac])) {
|
||||
throw new InvalidArgumentException('unknown_dhcp_client');
|
||||
}
|
||||
|
||||
$lanMacs = array_fill_keys(array_merge(lan_fdb_macs(), observed_lan_macs($leases)), true);
|
||||
if (!isset($lanMacs[$mac])) {
|
||||
throw new InvalidArgumentException('not_lan_client');
|
||||
}
|
||||
|
||||
$macBytes = hex2bin(str_replace(':', '', $mac));
|
||||
if ($macBytes === false || strlen($macBytes) !== 6) {
|
||||
throw new InvalidArgumentException('bad_mac');
|
||||
}
|
||||
|
||||
$packet = str_repeat("\xFF", 6) . str_repeat($macBytes, 16);
|
||||
$socket = socket_create(AF_INET, SOCK_DGRAM, SOL_UDP);
|
||||
if ($socket === false) {
|
||||
throw new RuntimeException('socket_create_failed');
|
||||
}
|
||||
|
||||
try {
|
||||
socket_set_option($socket, SOL_SOCKET, SO_BROADCAST, 1);
|
||||
$sent = socket_sendto($socket, $packet, strlen($packet), 0, $broadcast, $port);
|
||||
if ($sent === false || $sent !== strlen($packet)) {
|
||||
throw new RuntimeException('wake_packet_failed');
|
||||
}
|
||||
} finally {
|
||||
socket_close($socket);
|
||||
}
|
||||
|
||||
return [
|
||||
'mac' => $mac,
|
||||
'ip' => $leases[$mac]['ip'] ?? null,
|
||||
'hostname' => $leases[$mac]['hostname'] ?? null,
|
||||
'broadcast' => $broadcast,
|
||||
'port' => $port,
|
||||
];
|
||||
}
|
||||
|
||||
function wifi_time_ms(string $value): int
|
||||
{
|
||||
if (preg_match('/(\d+)/', $value, $m)) {
|
||||
@@ -3155,7 +3241,14 @@ function wifi_data(): array
|
||||
parse_live_wifi_rows($iface, $band, iw_station_dump($iface), $leases, $aliases)
|
||||
);
|
||||
}
|
||||
$clients = array_merge($clients, lan_client_rows($leases, $aliases));
|
||||
$wifiMacs = array_values(array_unique(array_filter(
|
||||
array_map(
|
||||
static fn(array $client): string => strtolower((string)($client['mac'] ?? '')),
|
||||
$clients
|
||||
),
|
||||
static fn(string $mac): bool => wifi_valid_mac($mac)
|
||||
)));
|
||||
$clients = array_merge($clients, lan_client_rows($leases, $aliases, $wifiMacs));
|
||||
|
||||
$clients = dedupe_wifi_clients($clients);
|
||||
$clients = apply_wifi_estimates($clients);
|
||||
@@ -3721,6 +3814,26 @@ function control_api_dispatch(): void
|
||||
]);
|
||||
}
|
||||
|
||||
if ($action === 'wake_lan') {
|
||||
$wake = send_wake_on_lan((string)($_POST['mac'] ?? ''));
|
||||
|
||||
add_fan_action(
|
||||
'wake_lan',
|
||||
null,
|
||||
null,
|
||||
'WOL packet sent to ' . $wake['mac'] . ' ' . ($wake['hostname'] ?? ''),
|
||||
true
|
||||
);
|
||||
|
||||
json_out([
|
||||
'ok' => true,
|
||||
'data' => [
|
||||
'wake' => $wake,
|
||||
'status' => collect_snapshot(false),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
if ($action === 'collect') {
|
||||
json_out([
|
||||
'ok' => true,
|
||||
|
||||
+47
-4
@@ -184,6 +184,10 @@
|
||||
wifiAliasTitle: 'WiFi Hostname',
|
||||
wifiAliasPrompt: 'Enter a name to keep for this MAC address.',
|
||||
wifiAliasSaved: 'WiFi hostname saved.',
|
||||
wolConfirmTitle: 'Wake LAN Device',
|
||||
wolConfirm: ({ hostname, mac }) => `Send one Wake-on-LAN request to ${hostname || mac}?`,
|
||||
wolSent: 'Wake-on-LAN request sent.',
|
||||
wolFailed: 'Wake-on-LAN request failed',
|
||||
show: 'Show',
|
||||
hide: 'Hide',
|
||||
translateButton: 'Translate',
|
||||
@@ -332,6 +336,10 @@
|
||||
wifiAliasTitle: 'WiFi 호스트명',
|
||||
wifiAliasPrompt: '이 MAC 주소에 계속 표시할 이름을 입력하세요.',
|
||||
wifiAliasSaved: 'WiFi 호스트명을 저장했습니다.',
|
||||
wolConfirmTitle: 'LAN 기기 켜기',
|
||||
wolConfirm: ({ hostname, mac }) => `${hostname || mac} 기기에 WOL 요청을 1회 보낼까요?`,
|
||||
wolSent: 'WOL 요청을 보냈습니다.',
|
||||
wolFailed: 'WOL 요청 실패',
|
||||
show: '보기',
|
||||
hide: '숨기기',
|
||||
translateButton: '번역',
|
||||
@@ -1215,6 +1223,14 @@
|
||||
return `<td><button type="button" class="wifi-host-edit" data-mac="${attr(row.mac || '')}" data-hostname="${attr(row.hostname || '')}" title="${attr(title)}">${escapeHtml(row.hostname || '-')}</button></td>`;
|
||||
}
|
||||
|
||||
function wifiBandCell(row) {
|
||||
if (row?.band !== 'LAN' || !row?.wol_available) {
|
||||
return td(row?.band || '-');
|
||||
}
|
||||
|
||||
return `<td><button type="button" class="lan-wake-btn" data-mac="${attr(row.mac || '')}" data-hostname="${attr(row.hostname || '')}" title="${attr(t('wolConfirmTitle'))}">LAN</button></td>`;
|
||||
}
|
||||
|
||||
function wifiMetricCell(row, key) {
|
||||
if (key !== 'signal') {
|
||||
return `<td>${escapeHtml(row[key] || '-')}</td>`;
|
||||
@@ -1287,6 +1303,26 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function wakeLanDevice(mac, hostname = '') {
|
||||
if (!mac) return;
|
||||
|
||||
const confirmed = await window.customConfirm(t('wolConfirm', { hostname, mac }), {
|
||||
title: t('wolConfirmTitle'),
|
||||
okText: t('dialogOk'),
|
||||
cancelText: t('dialogCancel'),
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const data = await api('wake_lan', { mac });
|
||||
notice(t('wolSent'), 'success');
|
||||
render(data.status || await api('status'));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
notice(e.message || t('wolFailed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function parseWifiDurationSeconds(value) {
|
||||
const text = String(value ?? '').trim();
|
||||
if (text === '' || text.toUpperCase() === 'N/A') {
|
||||
@@ -1451,7 +1487,7 @@
|
||||
els.wifiTable.innerHTML = rows.length
|
||||
? rows.map(row => `
|
||||
<tr>
|
||||
${td(row.band)}
|
||||
${wifiBandCell(row)}
|
||||
${wifiHostCell(row)}
|
||||
${td(row.ip)}
|
||||
${td(row.mac)}
|
||||
@@ -2273,9 +2309,16 @@
|
||||
els.customServiceList?.addEventListener(eventName, holdCustomServiceScroll, { passive: true });
|
||||
});
|
||||
els.wifiTable?.addEventListener('click', event => {
|
||||
const button = event.target?.closest?.('.wifi-host-edit');
|
||||
if (!button) return;
|
||||
editWifiHostname(button.dataset.mac || '', button.dataset.hostname || '');
|
||||
const hostButton = event.target?.closest?.('.wifi-host-edit');
|
||||
if (hostButton) {
|
||||
editWifiHostname(hostButton.dataset.mac || '', hostButton.dataset.hostname || '');
|
||||
return;
|
||||
}
|
||||
|
||||
const lanButton = event.target?.closest?.('.lan-wake-btn');
|
||||
if (lanButton) {
|
||||
wakeLanDevice(lanButton.dataset.mac || '', lanButton.dataset.hostname || '');
|
||||
}
|
||||
});
|
||||
els.statusBatteryRemaining?.addEventListener('mouseenter', showBatteryTooltip);
|
||||
els.statusBatteryRemaining?.addEventListener('mouseleave', hideBatteryTooltip);
|
||||
|
||||
+1
-1
@@ -128,7 +128,7 @@ html[data-theme="light"] .btn.secondary{background:#d9e2ef;color:#172033}html[da
|
||||
.btn:disabled{opacity:.55;cursor:not-allowed}
|
||||
.status-list{display:grid;gap:10px}.status-row{display:grid;grid-template-columns:112px minmax(0,1fr);gap:12px;align-items:baseline;padding:10px 12px;background:var(--card2);border-radius:14px}.status-key{color:var(--sub);font-size:13px}.status-value{overflow:visible;white-space:normal;word-break:break-word;font-variant-numeric:tabular-nums}
|
||||
.table-wrap{max-width:100%;overflow-x:auto;overflow-y:hidden;-webkit-overflow-scrolling:touch;border-radius:16px;border:1px solid var(--line)}.table-wrap.compact{border-radius:12px}.wifi-table{width:100%;min-width:1080px;table-layout:fixed;border-collapse:collapse}.wifi-table th,.wifi-table td{padding:11px 13px;border-bottom:1px solid rgba(128,145,170,.18);text-align:left;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.wifi-table th{position:sticky;top:0;background:var(--table-head);z-index:1;font-weight:500}.wifi-table tr:hover{background:rgba(128,145,170,.09)}.wifi-table .lan-link-speed{text-align:center}.wifi-table .col-band{width:72px}.wifi-table .col-host{width:240px}.wifi-table .col-ip{width:132px}.wifi-table .col-mac{width:170px}.wifi-table .col-signal{width:100px}.wifi-table .col-rate{width:120px}.wifi-table .col-time{width:130px}
|
||||
.wifi-host-edit{appearance:none;border:0;background:transparent;color:inherit;font:inherit;max-width:100%;padding:0;margin:0;cursor:pointer;text-align:left;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-decoration:underline;text-decoration-style:dotted;text-underline-offset:3px}.wifi-host-edit:hover{color:var(--blue)}
|
||||
.wifi-host-edit,.lan-wake-btn{appearance:none;border:0;background:transparent;color:inherit;font:inherit;max-width:100%;padding:0;margin:0;cursor:pointer;text-align:left;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-decoration:underline;text-decoration-style:dotted;text-underline-offset:3px}.wifi-host-edit:hover,.lan-wake-btn:hover{color:var(--blue)}.lan-wake-btn{font-weight:700}
|
||||
.wifi-signal{font-weight:700;font-variant-numeric:tabular-nums;cursor:help;text-decoration:underline;text-decoration-style:dotted;text-decoration-thickness:1px;text-underline-offset:3px}.wifi-signal.best{color:#16a34a}.wifi-signal.excellent{color:#22c55e}.wifi-signal.good{color:#38bdf8}.wifi-signal.fine{color:#0ea5e9}.wifi-signal.normal{color:#f59e0b}.wifi-signal.weak{color:#fb7185}.wifi-signal.bad{color:#ef4444}.wifi-signal.unknown{color:var(--sub);text-decoration:none;cursor:default}
|
||||
.chart-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px}.chart-box{height:280px;background:var(--input);border:1px solid rgba(84,101,128,.58);border-radius:16px;padding:12px 12px 18px}.chart-box h3{margin:0 0 8px;color:var(--sub);font-size:13px}.chart-canvas{position:relative;height:calc(100% - 27px);min-height:0}.chart-canvas canvas{width:100%!important;height:100%!important}.chart-secondary{margin-top:14px}
|
||||
.details-panel{border:1px solid rgba(84,101,128,.52);border-radius:16px;background:rgba(128,145,170,.06);padding:0;margin-top:14px;overflow:hidden}.details-panel>summary{cursor:pointer;list-style:none;display:flex;align-items:center;justify-content:space-between;gap:12px;min-height:48px;padding:0 14px;color:var(--text);font-weight:500}.details-panel>summary::-webkit-details-marker{display:none}.details-panel>summary::after{content:"+";display:grid;place-items:center;width:24px;height:24px;border:1px solid var(--line);border-radius:999px;color:var(--sub);font-weight:700}.details-panel[open]>summary::after{content:"-"}.details-body{padding:0 14px 14px}.section-note{margin:0;color:var(--sub);font-size:12px}
|
||||
|
||||
Reference in New Issue
Block a user