Control WiFi 진단과 배터리 예측 보강

This commit is contained in:
seo
2026-06-26 18:09:29 +09:00
parent ebef879634
commit 6665b37975
5 changed files with 557 additions and 26 deletions
+132 -6
View File
@@ -15,6 +15,7 @@
wifi24: $('#wifi24'),
wifi5: $('#wifi5'),
wifiTable: $('#wifiTable'),
wifiScan: $('#wifiScan'),
statusHost: $('#statusHost'),
statusLoad: $('#statusLoad'),
statusUsers: $('#statusUsers'),
@@ -145,6 +146,15 @@
remaining: 'Remaining',
batteryVoltage: 'Battery Voltage',
wifiClients: 'WiFi Clients',
wifiScan: 'Nearby WiFi Scan',
wifiScanDisabled: 'Nearby WiFi scan is disabled.',
wifiScanEmpty: 'No nearby WiFi scan results.',
wifiScanCached: 'cached',
wifiScanFresh: 'fresh',
ssid: 'SSID',
channel: 'Channel',
security: 'Security',
lastSeen: 'Last Seen',
band: 'Band',
hostname: 'Hostname',
signal: 'Signal',
@@ -286,6 +296,15 @@
remaining: '잔여 시간',
batteryVoltage: '배터리 전압',
wifiClients: 'WiFi 클라이언트',
wifiScan: '주변 WiFi 스캔',
wifiScanDisabled: '주변 WiFi 스캔이 꺼져 있습니다.',
wifiScanEmpty: '주변 WiFi 스캔 결과가 없습니다.',
wifiScanCached: '캐시',
wifiScanFresh: '실시간',
ssid: 'SSID',
channel: '채널',
security: '보안',
lastSeen: '마지막 감지',
band: '대역',
hostname: '호스트명',
signal: '신호',
@@ -909,6 +928,14 @@
if (remaining.seconds !== undefined && remaining.seconds !== null) {
lines.push(`계산 잔여: ${formatDurationSeconds(remaining.seconds)}`);
}
if (remaining.runtime_cap_seconds !== undefined && remaining.runtime_cap_seconds !== null) {
const capText = formatDurationSeconds(remaining.runtime_cap_seconds);
const cappedText = remaining.runtime_capped ? ' / 현실 상한 적용' : '';
lines.push(`현실 상한: ${capText}${cappedText}`);
}
if (remaining.original_seconds !== undefined && remaining.original_seconds !== null) {
lines.push(`상한 전 계산값: ${formatDurationSeconds(remaining.original_seconds)}`);
}
if (remaining.drop_per_hour !== undefined && remaining.drop_per_hour !== null) {
lines.push(`시간당 SOC 감소: ${Number(remaining.drop_per_hour).toFixed(3)}%`);
}
@@ -960,28 +987,33 @@
lines.push('세부 후보');
remaining.windows.forEach(row => {
if (row.source === 'learned_profile') {
lines.push(`- 장기 학습: ${row.intervals || 0}구간 / SOC대 ${row.soc_buckets || 0}개 / 누적감소 ${Number(row.drop_percent || 0).toFixed(3)}% / 신뢰도 ${(Number(row.confidence || 0) * 100).toFixed(0)}%`);
const cap = row.runtime_capped ? ` / 상한 전 ${formatDurationSeconds(row.original_seconds)}` : '';
lines.push(`- 장기 학습: ${row.intervals || 0}구간 / SOC대 ${row.soc_buckets || 0}개 / 누적감소 ${Number(row.drop_percent || 0).toFixed(3)}% / 신뢰도 ${(Number(row.confidence || 0) * 100).toFixed(0)}%${cap}`);
} else if (row.source === 'energy_profile') {
const wh = row.wh_per_percent === null || row.wh_per_percent === undefined ? '-' : `${Number(row.wh_per_percent).toFixed(4)}Wh/%`;
const stability = row.stability === null || row.stability === undefined ? '' : ` / 안정도 ${(Number(row.stability) * 100).toFixed(0)}%`;
lines.push(`- 에너지 학습: ${row.intervals || 0}구간 / SOC대 ${row.soc_buckets || 0}개 / ${wh} / 신뢰도 ${(Number(row.confidence || 0) * 100).toFixed(0)}%${stability}`);
const cap = row.runtime_capped ? ` / 상한 전 ${formatDurationSeconds(row.original_seconds)}` : '';
lines.push(`- 에너지 학습: ${row.intervals || 0}구간 / SOC대 ${row.soc_buckets || 0}개 / ${wh} / 신뢰도 ${(Number(row.confidence || 0) * 100).toFixed(0)}%${stability}${cap}`);
} else if (row.source === 'capacity_power_model') {
const capacity = row.capacity_wh === null || row.capacity_wh === undefined ? '-' : `${Number(row.capacity_wh).toFixed(2)}Wh`;
const stability = row.stability === null || row.stability === undefined ? '' : ` / 안정도 ${(Number(row.stability) * 100).toFixed(0)}%`;
lines.push(`- 용량 전력 모델: ${capacity} / 신뢰도 ${(Number(row.confidence || 0) * 100).toFixed(0)}%${stability}`);
const cap = row.runtime_capped ? ` / 상한 전 ${formatDurationSeconds(row.original_seconds)}` : '';
lines.push(`- 용량 전력 모델: ${capacity} / 신뢰도 ${(Number(row.confidence || 0) * 100).toFixed(0)}%${stability}${cap}`);
} else if (row.source === 'voltage_slope') {
const load = row.load_factor === null || row.load_factor === undefined ? '-' : `${Number(row.load_factor).toFixed(3)}x`;
const kept = row.kept_ratio === null || row.kept_ratio === undefined ? '' : ` / 유효점 ${(Number(row.kept_ratio) * 100).toFixed(0)}%`;
const stability = row.stability === null || row.stability === undefined ? '' : ` / 안정도 ${(Number(row.stability) * 100).toFixed(0)}%`;
const floor = row.voltage_floor === null || row.voltage_floor === undefined ? '' : ` / 하한 ${Number(row.voltage_floor).toFixed(3)}V`;
const volts = row.volts_per_hour === null || row.volts_per_hour === undefined ? '' : ` / ${Number(row.volts_per_hour).toFixed(4)}V/h`;
lines.push(`- 전압 ${row.minutes}분: 신뢰도 ${(Number(row.confidence || 0) * 100).toFixed(0)}% / 부하보정 ${load}${kept}${stability}${floor}${volts}`);
const cap = row.runtime_capped ? ` / 상한 전 ${formatDurationSeconds(row.original_seconds)}` : '';
lines.push(`- 전압 ${row.minutes}분: 신뢰도 ${(Number(row.confidence || 0) * 100).toFixed(0)}% / 부하보정 ${load}${kept}${stability}${floor}${volts}${cap}`);
} else {
const load = row.load_factor === null || row.load_factor === undefined ? '-' : `${Number(row.load_factor).toFixed(3)}x`;
const prefix = row.source === 'robust_regression' ? '강건 회귀' : '최근 추세';
const kept = row.kept_ratio === null || row.kept_ratio === undefined ? '' : ` / 유효점 ${(Number(row.kept_ratio) * 100).toFixed(0)}%`;
const stability = row.stability === null || row.stability === undefined ? '' : ` / 안정도 ${(Number(row.stability) * 100).toFixed(0)}%`;
lines.push(`- ${prefix} ${row.minutes}분: 감소 ${Number(row.drop_percent || 0).toFixed(3)}% / 신뢰도 ${(Number(row.confidence || 0) * 100).toFixed(0)}% / 부하보정 ${load}${kept}${stability}`);
const cap = row.runtime_capped ? ` / 상한 전 ${formatDurationSeconds(row.original_seconds)}` : '';
lines.push(`- ${prefix} ${row.minutes}분: 감소 ${Number(row.drop_percent || 0).toFixed(3)}% / 신뢰도 ${(Number(row.confidence || 0) * 100).toFixed(0)}% / 부하보정 ${load}${kept}${stability}${cap}`);
}
});
}
@@ -1091,7 +1123,36 @@
}
function wifiMetricCell(row, key) {
return `<td>${escapeHtml(row[key] || '-')}</td>`;
if (key !== 'signal') {
return `<td>${escapeHtml(row[key] || '-')}</td>`;
}
const dbm = wifiSignalDbm(row[key]);
const cls = wifiSignalClass(dbm);
const title = dbm === null ? '' : `title="${attr(signalQualityText(dbm))}"`;
return `<td><span class="wifi-signal ${cls}" ${title}>${escapeHtml(row[key] || '-')}</span></td>`;
}
function wifiSignalDbm(value) {
const match = String(value ?? '').match(/-?\d+(?:\.\d+)?/);
return match ? Number(match[0]) : null;
}
function wifiSignalClass(dbm) {
if (dbm === null || !Number.isFinite(dbm)) return 'unknown';
if (dbm >= -55) return 'excellent';
if (dbm >= -67) return 'good';
if (dbm >= -76) return 'fair';
if (dbm >= -84) return 'weak';
return 'bad';
}
function signalQualityText(dbm) {
if (dbm >= -55) return '매우 강함';
if (dbm >= -67) return '강함';
if (dbm >= -76) return '보통';
if (dbm >= -84) return '약함';
return '매우 약함';
}
async function editWifiHostname(mac, currentName) {
@@ -1255,6 +1316,71 @@
</tr>
`).join('')
: `<tr><td colspan="9">${escapeHtml(t('noWifiClients'))}</td></tr>`;
renderWifiScan(data.wifi?.scan || null);
}
function renderWifiScan(scan) {
if (!els.wifiScan) return;
if (!scan || scan.enabled === false) {
els.wifiScan.innerHTML = `<div class="wifi-scan-empty">${escapeHtml(t('wifiScanDisabled'))}</div>`;
return;
}
const networks = Array.isArray(scan.networks) ? scan.networks : [];
const errors = Array.isArray(scan.errors) ? scan.errors : [];
const status = `${scan.cached ? t('wifiScanCached') : t('wifiScanFresh')} · ${escapeHtml(scan.generated_at || '-')}`;
const groups = ['2.4G', '5G'].map(band => {
const bandRows = networks.filter(row => row.band === band);
return `
<section class="wifi-scan-band">
<h3>${escapeHtml(band)} <span>${bandRows.length}${scan.total_found ? ` / ${scan.total_found}` : ''}</span></h3>
<div class="table-wrap compact">
<table class="wifi-scan-table">
<thead><tr><th>${escapeHtml(t('ssid'))}</th><th>BSSID</th><th>${escapeHtml(t('channel'))}</th><th>${escapeHtml(t('signal'))}</th><th>${escapeHtml(t('security'))}</th><th>${escapeHtml(t('lastSeen'))}</th><th>WiFi</th><th>폭</th><th>속도</th><th>기능</th></tr></thead>
<tbody>
${bandRows.length ? bandRows.map(row => {
const dbm = wifiSignalDbm(row.signal);
return `<tr>
<td>${escapeHtml(row.ssid || '-')}</td>
<td>${escapeHtml(row.bssid || '-')}</td>
<td>${escapeHtml(row.channel ?? '-')} ${row.freq ? `(${escapeHtml(row.freq)}MHz)` : ''}</td>
<td><span class="wifi-signal ${wifiSignalClass(dbm)}">${escapeHtml(row.signal || '-')}</span></td>
<td>${escapeHtml(row.security || '-')}</td>
<td>${row.last_seen_ms === null || row.last_seen_ms === undefined ? '-' : `${escapeHtml(row.last_seen_ms)}ms`}</td>
<td>${escapeHtml(wifiStandardText(row))}</td>
<td>${escapeHtml(row.channel_width || '-')}</td>
<td>${escapeHtml(row.rates || '-')}</td>
<td>${escapeHtml(row.capability || '-')}</td>
</tr>`;
}).join('') : `<tr><td colspan="10">${escapeHtml(t('wifiScanEmpty'))}</td></tr>`}
</tbody>
</table>
</div>
</section>
`;
}).join('');
const errorHtml = errors.length
? `<div class="wifi-scan-errors">${errors.map(row => `<div><strong>${escapeHtml(row.band || row.iface || '-')}</strong> ${escapeHtml(row.reason || '-')}</div>`).join('')}</div>`
: '';
els.wifiScan.innerHTML = `
<div class="wifi-scan-head">
<h3>${escapeHtml(t('wifiScan'))}</h3>
<span>${status}</span>
</div>
${errorHtml}
<div class="wifi-scan-grid">${groups}</div>
`;
}
function wifiStandardText(row) {
const parts = [];
if (row?.he) parts.push('WiFi 6');
if (row?.vht) parts.push('WiFi 5');
if (row?.ht) parts.push('WiFi 4');
return parts.join(' / ') || '-';
}
function renderSystemStatus(data) {