diff --git a/README.md b/README.md index 249574f..8381c0f 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Car는 차량 모뎀/게이트웨이에서 받은 상태를 수집해 DB에 저 - 차량 상태 API - 허용된 차량 명령 TCP 전송 - monitor 화면과 AJAX 상태 갱신 +- monitor 화면 WakeLock, 언어, 테마 토글 - 사용량/요금 표시와 보정 metadata - 4.95초 UI timeout과 60초 사용량 정산 분리 - TCP 실패 reason과 마지막 수신 지연 표시 @@ -36,6 +37,10 @@ Car는 차량 모뎀/게이트웨이에서 받은 상태를 수집해 DB에 저 로그 테이블의 경과 계산은 `YYYY-MM-DD HH:MM:SS` 값을 브라우저 기본 파서에 맡기지 않고 로컬 시간으로 직접 파싱합니다. 브라우저별 UTC/로컬 해석 차이로 로그 경과가 흔들리는 문제를 줄입니다. +## Monitor 화면 제어 + +헤더 우측 컨트롤은 WakeLock, 언어, 테마 순서입니다. WakeLock은 Screen Wake Lock API를 지원하는 브라우저에서 화면 꺼짐을 방지하며, 활성 상태는 초록색 아이콘 버튼으로 표시합니다. 사용자의 선택은 `localStorage`에 저장하고, 새로고침이나 탭 복귀 후에는 브라우저 정책에 맞춰 다시 획득을 시도합니다. + ## 구성 - `api.php`: 차량 상태/제어 API diff --git a/monitor.php b/monitor.php index e294f60..bcbea2d 100644 --- a/monitor.php +++ b/monitor.php @@ -875,20 +875,18 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') { font-family: 'SF Mono', monospace; font-size: 0.75rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 180px; margin: 0 auto; } - .btn-theme-toggle { + .btn-icon-toggle { width: 40px; height: 40px; border-radius: 50%; border: 1px solid var(--card-border); background: var(--card-bg); color: var(--text-primary); display: flex; align-items: center; justify-content: center; cursor: pointer; transition: all 0.2s; } - .btn-theme-toggle:hover { background: var(--bg-body); transform: scale(1.05); } - .btn-lang-toggle { - width: 40px; height: 40px; border-radius: 50%; border: 1px solid var(--card-border); - background: var(--card-bg); color: var(--text-primary); - display: flex; align-items: center; justify-content: center; cursor: pointer; - transition: all 0.2s; + .btn-icon-toggle:hover { background: var(--bg-body); transform: scale(1.05); } + .btn-icon-toggle.active { + background: var(--box-bg-green); + border-color: rgba(34, 197, 94, 0.45); + color: var(--accent-green); } - .btn-lang-toggle:hover { background: var(--bg-body); transform: scale(1.05); } @keyframes pulse-ring { 0% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7); } @@ -1041,10 +1039,13 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') {
LAST DATA
--:--:--
- + - @@ -1371,6 +1372,8 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') { engineStateOnSentence: '시동이 {duration} 켜져있음', engineStateOffSentence: '시동이 {duration} 꺼져있음', dataUsageTitle: '{month} 데이터 사용량 / {days}일 남음', + wakeLockTitle: '화면 켜짐 유지', + wakeLockUnsupported: '화면 켜짐 유지 미지원', languageTitle: '언어 변경', rawView: '원본 보기', cmdStatus: '상태', @@ -1434,6 +1437,8 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') { engineStateOnSentence: 'Engine has been ON for {duration}', engineStateOffSentence: 'Engine has been OFF for {duration}', dataUsageTitle: '{month} Data Usage / {days}d left', + wakeLockTitle: 'Keep screen awake', + wakeLockUnsupported: 'WakeLock unavailable', languageTitle: 'Change language', rawView: 'View RAW', cmdStatus: 'STATUS', @@ -1470,16 +1475,25 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') { let usageTimer = null; let usageRefreshIntervalSec = DEFAULT_USAGE_REFRESH_INTERVAL_SEC; let usageInFlight = false; + let wakeLockSentinel = null; + let wakeLockWanted = false; document.addEventListener('DOMContentLoaded', () => { initLanguage(); initTheme(); + initWakeLock(); initChart(); fetchData(); refreshTimer = setInterval(fetchData, 1000); fetchUsage(); }); + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible' && wakeLockWanted) { + requestWakeLock(); + } + }); + function initLanguage() { const saved = localStorage.getItem('lang'); const browser = (navigator.language || navigator.userLanguage || 'ko').toLowerCase(); @@ -1515,6 +1529,7 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') { langBtn.title = t('languageTitle'); langBtn.setAttribute('aria-label', t('languageTitle')); } + updateWakeLockButton(); } function initTheme() { @@ -1539,6 +1554,74 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') { icon.className = theme === 'dark' ? 'fas fa-sun text-warning' : 'fas fa-moon text-secondary'; } + function initWakeLock() { + wakeLockWanted = localStorage.getItem('wakeLock') === 'on'; + updateWakeLockButton(); + if (wakeLockWanted) { + requestWakeLock(); + } + } + + function wakeLockSupported() { + return 'wakeLock' in navigator; + } + + async function requestWakeLock() { + if (!wakeLockSupported()) { + wakeLockWanted = false; + localStorage.setItem('wakeLock', 'off'); + updateWakeLockButton(); + return; + } + + try { + wakeLockSentinel = await navigator.wakeLock.request('screen'); + wakeLockSentinel.addEventListener('release', () => { + wakeLockSentinel = null; + updateWakeLockButton(); + }); + updateWakeLockButton(); + } catch (error) { + wakeLockSentinel = null; + updateWakeLockButton(); + } + } + + async function releaseWakeLock() { + if (wakeLockSentinel) { + try { + await wakeLockSentinel.release(); + } catch (error) { + // Browser may already have released it on visibility or power policy changes. + } + } + wakeLockSentinel = null; + updateWakeLockButton(); + } + + function toggleWakeLock() { + wakeLockWanted = !wakeLockWanted; + localStorage.setItem('wakeLock', wakeLockWanted ? 'on' : 'off'); + if (wakeLockWanted) { + requestWakeLock(); + } else { + releaseWakeLock(); + } + updateWakeLockButton(); + } + + function updateWakeLockButton() { + const btn = document.getElementById('wakelock-btn'); + if (!btn) return; + + const supported = wakeLockSupported(); + const active = Boolean(wakeLockSentinel); + btn.classList.toggle('active', active); + btn.disabled = !supported; + btn.title = supported ? t('wakeLockTitle') : t('wakeLockUnsupported'); + btn.setAttribute('aria-label', btn.title); + } + function getChartColors() { const style = getComputedStyle(document.body); return {