Add monitor wake lock toggle

This commit is contained in:
seo
2026-06-15 19:37:16 +09:00
parent 2eb396cdfe
commit 05376742cd
2 changed files with 98 additions and 10 deletions
+5
View File
@@ -14,6 +14,7 @@ Car는 차량 모뎀/게이트웨이에서 받은 상태를 수집해 DB에 저
- 차량 상태 API - 차량 상태 API
- 허용된 차량 명령 TCP 전송 - 허용된 차량 명령 TCP 전송
- monitor 화면과 AJAX 상태 갱신 - monitor 화면과 AJAX 상태 갱신
- monitor 화면 WakeLock, 언어, 테마 토글
- 사용량/요금 표시와 보정 metadata - 사용량/요금 표시와 보정 metadata
- 4.95초 UI timeout과 60초 사용량 정산 분리 - 4.95초 UI timeout과 60초 사용량 정산 분리
- TCP 실패 reason과 마지막 수신 지연 표시 - TCP 실패 reason과 마지막 수신 지연 표시
@@ -36,6 +37,10 @@ Car는 차량 모뎀/게이트웨이에서 받은 상태를 수집해 DB에 저
로그 테이블의 경과 계산은 `YYYY-MM-DD HH:MM:SS` 값을 브라우저 기본 파서에 맡기지 않고 로컬 시간으로 직접 파싱합니다. 브라우저별 UTC/로컬 해석 차이로 로그 경과가 흔들리는 문제를 줄입니다. 로그 테이블의 경과 계산은 `YYYY-MM-DD HH:MM:SS` 값을 브라우저 기본 파서에 맡기지 않고 로컬 시간으로 직접 파싱합니다. 브라우저별 UTC/로컬 해석 차이로 로그 경과가 흔들리는 문제를 줄입니다.
## Monitor 화면 제어
헤더 우측 컨트롤은 WakeLock, 언어, 테마 순서입니다. WakeLock은 Screen Wake Lock API를 지원하는 브라우저에서 화면 꺼짐을 방지하며, 활성 상태는 초록색 아이콘 버튼으로 표시합니다. 사용자의 선택은 `localStorage`에 저장하고, 새로고침이나 탭 복귀 후에는 브라우저 정책에 맞춰 다시 획득을 시도합니다.
## 구성 ## 구성
- `api.php`: 차량 상태/제어 API - `api.php`: 차량 상태/제어 API
+93 -10
View File
@@ -875,20 +875,18 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') {
font-family: 'SF Mono', monospace; font-size: 0.75rem; font-family: 'SF Mono', monospace; font-size: 0.75rem;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 180px; margin: 0 auto; 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); width: 40px; height: 40px; border-radius: 50%; border: 1px solid var(--card-border);
background: var(--card-bg); color: var(--text-primary); background: var(--card-bg); color: var(--text-primary);
display: flex; align-items: center; justify-content: center; cursor: pointer; display: flex; align-items: center; justify-content: center; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
} }
.btn-theme-toggle:hover { background: var(--bg-body); transform: scale(1.05); } .btn-icon-toggle:hover { background: var(--bg-body); transform: scale(1.05); }
.btn-lang-toggle { .btn-icon-toggle.active {
width: 40px; height: 40px; border-radius: 50%; border: 1px solid var(--card-border); background: var(--box-bg-green);
background: var(--card-bg); color: var(--text-primary); border-color: rgba(34, 197, 94, 0.45);
display: flex; align-items: center; justify-content: center; cursor: pointer; color: var(--accent-green);
transition: all 0.2s;
} }
.btn-lang-toggle:hover { background: var(--bg-body); transform: scale(1.05); }
@keyframes pulse-ring { @keyframes pulse-ring {
0% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7); } 0% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7); }
@@ -1041,10 +1039,13 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') {
<div class="label-text mb-0" data-i18n="lastData">LAST DATA</div> <div class="label-text mb-0" data-i18n="lastData">LAST DATA</div>
<div id="last-updated" class="font-mono fw-bold text-primary">--:--:--</div> <div id="last-updated" class="font-mono fw-bold text-primary">--:--:--</div>
</div> </div>
<button class="btn-lang-toggle shadow-sm" id="lang-btn" onclick="toggleLanguage()" title="Language"> <button class="btn-icon-toggle shadow-sm" id="wakelock-btn" onclick="toggleWakeLock()" title="WakeLock">
<i class="fas fa-mobile-screen-button"></i>
</button>
<button class="btn-icon-toggle shadow-sm" id="lang-btn" onclick="toggleLanguage()" title="Language">
<i class="fas fa-globe"></i> <i class="fas fa-globe"></i>
</button> </button>
<button class="btn-theme-toggle shadow-sm" id="theme-btn" onclick="toggleTheme()"> <button class="btn-icon-toggle shadow-sm" id="theme-btn" onclick="toggleTheme()">
<i class="fas fa-moon"></i> <i class="fas fa-moon"></i>
</button> </button>
</div> </div>
@@ -1371,6 +1372,8 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') {
engineStateOnSentence: '시동이 {duration} 켜져있음', engineStateOnSentence: '시동이 {duration} 켜져있음',
engineStateOffSentence: '시동이 {duration} 꺼져있음', engineStateOffSentence: '시동이 {duration} 꺼져있음',
dataUsageTitle: '{month} 데이터 사용량 / {days}일 남음', dataUsageTitle: '{month} 데이터 사용량 / {days}일 남음',
wakeLockTitle: '화면 켜짐 유지',
wakeLockUnsupported: '화면 켜짐 유지 미지원',
languageTitle: '언어 변경', languageTitle: '언어 변경',
rawView: '원본 보기', rawView: '원본 보기',
cmdStatus: '상태', cmdStatus: '상태',
@@ -1434,6 +1437,8 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') {
engineStateOnSentence: 'Engine has been ON for {duration}', engineStateOnSentence: 'Engine has been ON for {duration}',
engineStateOffSentence: 'Engine has been OFF for {duration}', engineStateOffSentence: 'Engine has been OFF for {duration}',
dataUsageTitle: '{month} Data Usage / {days}d left', dataUsageTitle: '{month} Data Usage / {days}d left',
wakeLockTitle: 'Keep screen awake',
wakeLockUnsupported: 'WakeLock unavailable',
languageTitle: 'Change language', languageTitle: 'Change language',
rawView: 'View RAW', rawView: 'View RAW',
cmdStatus: 'STATUS', cmdStatus: 'STATUS',
@@ -1470,16 +1475,25 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') {
let usageTimer = null; let usageTimer = null;
let usageRefreshIntervalSec = DEFAULT_USAGE_REFRESH_INTERVAL_SEC; let usageRefreshIntervalSec = DEFAULT_USAGE_REFRESH_INTERVAL_SEC;
let usageInFlight = false; let usageInFlight = false;
let wakeLockSentinel = null;
let wakeLockWanted = false;
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
initLanguage(); initLanguage();
initTheme(); initTheme();
initWakeLock();
initChart(); initChart();
fetchData(); fetchData();
refreshTimer = setInterval(fetchData, 1000); refreshTimer = setInterval(fetchData, 1000);
fetchUsage(); fetchUsage();
}); });
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && wakeLockWanted) {
requestWakeLock();
}
});
function initLanguage() { function initLanguage() {
const saved = localStorage.getItem('lang'); const saved = localStorage.getItem('lang');
const browser = (navigator.language || navigator.userLanguage || 'ko').toLowerCase(); const browser = (navigator.language || navigator.userLanguage || 'ko').toLowerCase();
@@ -1515,6 +1529,7 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') {
langBtn.title = t('languageTitle'); langBtn.title = t('languageTitle');
langBtn.setAttribute('aria-label', t('languageTitle')); langBtn.setAttribute('aria-label', t('languageTitle'));
} }
updateWakeLockButton();
} }
function initTheme() { 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'; 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() { function getChartColors() {
const style = getComputedStyle(document.body); const style = getComputedStyle(document.body);
return { return {