From 5aac4bc208f90c5ce7204d2c73b1ad82d1879531 Mon Sep 17 00:00:00 2001 From: seo Date: Thu, 22 Oct 2026 14:00:43 +0900 Subject: [PATCH] feat: align car monitor with Gen5W entities --- NiroTelemetrySnapshot.php | 50 +- monitor.php | 707 +++++++--------------------- tests/NiroTelemetrySnapshotTest.php | 25 +- 3 files changed, 194 insertions(+), 588 deletions(-) diff --git a/NiroTelemetrySnapshot.php b/NiroTelemetrySnapshot.php index 84dda87..3f8b6c1 100644 --- a/NiroTelemetrySnapshot.php +++ b/NiroTelemetrySnapshot.php @@ -40,11 +40,9 @@ final class NiroTelemetrySnapshot $serviceAvailable = 0; $serviceGroups = [ - ['audio_mode', 'rear_view', 'screen_on'], - ['speed', 'gear_code', 'gear_p_code'], - ['ignition', 'driver_door', 'engine_running'], - ['hev_mode_code', 'total_distance_to_empty', 'odometer'], - ['head_unit_power', 'av_disabled', 'display_off'], + ['speed', 'gear_code', 'parked'], + ['hev_ready', 'hev_battery_soc', 'total_distance_to_empty'], + ['driver_door', 'driver_unlocked', 'trunk'], ['climate_on', 'driver_temperature', 'outside_temperature'], ]; foreach ($serviceGroups as $keys) { @@ -59,22 +57,20 @@ final class NiroTelemetrySnapshot $keyMap = [ 'ignition' => ['ignition'], - 'reverse' => ['rear_view'], + 'hev_ready' => ['hev_ready'], + 'engine_running' => ['engine_running'], 'parking' => ['parked', 'gear_p_code'], 'gear' => ['gear_code', 'gear_p_code'], 'speed' => ['speed'], - 'previous_speed' => [], 'parking_brake' => ['parking_brake'], - 'camera' => ['rear_view'], 'drive_mode' => ['drive_mode_code'], 'fuel_warning' => ['fuel_warning'], + 'discharge_alert' => ['discharge_alert'], 'distance_to_empty' => ['total_distance_to_empty'], + 'electric_distance_to_empty' => ['electric_distance_to_empty'], + 'gas_distance_to_empty' => ['gas_distance_to_empty'], + 'hev_battery_soc' => ['hev_battery_soc'], 'battery_soc_12v' => ['battery_soc_12v'], - 'battery_above_60' => [], - 'ldc_battery' => ['ldc_12v_charge'], - 'hev_mode' => ['hev_mode_code'], - 'hev_charge_state' => ['hev_charge_state_code'], - 'motor_usage' => ['motor_usage_code'], 'ldc_12v_charge' => ['ldc_12v_charge'], 'odometer' => ['odometer'], 'driver_unlock' => ['driver_unlocked'], @@ -86,44 +82,20 @@ final class NiroTelemetrySnapshot 'rear_left_door' => ['rear_left_door'], 'rear_right_door' => ['rear_right_door'], 'trunk' => ['trunk'], - 'lock_state' => [], + 'hood' => ['hood'], 'hazard' => ['hazard'], 'head_lamp' => ['head_lamp'], 'tail_lamp' => ['tail_lamp'], 'fog_lamp' => ['fog_lamp'], - 'driver_seatbelt' => ['driver_seatbelt_code'], - 'passenger_seatbelt' => ['passenger_seatbelt_code'], - 'airbag' => ['airbag_code'], 'climate_on' => ['climate_on'], 'air_conditioner' => ['air_conditioner'], 'climate_auto' => ['climate_auto'], 'climate_dual' => ['climate_dual'], 'driver_temperature' => ['driver_temperature'], 'passenger_temperature' => ['passenger_temperature'], + 'rear_temperature' => ['rear_temperature'], 'outside_temperature' => ['outside_temperature'], 'blower_level' => ['blower_level'], - 'intake_mode' => ['intake_code'], - 'co2_warning' => ['co2_warning_code'], - 'route_guide' => [], - 'next_turn_distance' => [], - 'speed_limit' => [], - 'camera_distance' => [], - 'camera_speed_limit' => [], - 'av_power' => ['av_power'], - 'head_unit_power' => ['head_unit_power'], - 'audio_mode' => ['audio_mode'], - 'audio_focus' => [], - 'audio_volume' => [], - 'mute' => [], - 'micom_connected' => [], - 'boot_completed' => ['head_unit_powered'], - 'boot_sequence_finished' => [], - 'wireless_charging' => ['wireless_charging_code'], - 'dtc_warning' => [], - 'pdw_front_left' => [], - 'pdw_front_right' => [], - 'pdw_rear_left' => [], - 'pdw_rear_right' => [], ]; $metrics = []; diff --git a/monitor.php b/monitor.php index 661230a..80ad277 100644 --- a/monitor.php +++ b/monitor.php @@ -21,7 +21,6 @@ define('DB_PASS', (string)($carDbConfig['pass'] ?? '')); define('DB_CHARSET', (string)($carDbConfig['charset'] ?? 'utf8mb4')); const LOG_LIMIT = 300; -const CHART_LIMIT = 500; const DATA_MONTHLY_INCLUDED_BYTES = 104857600; // 100MB const DATA_BILLING_UNIT_BYTES = 512; // 0.5KB const DATA_BILLING_UNIT_KRW = 0.011; @@ -1043,18 +1042,6 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'ajax') { } unset($logRow); - $stmtChart = $pdo->prepare("" - . "SELECT ts, battery_voltage FROM (" - . " SELECT ts, battery_voltage " - . " FROM car_status " - . " WHERE id='car' AND battery_voltage > 0 " - . " ORDER BY ts DESC LIMIT :limit" - . ") AS recent ORDER BY ts ASC" - ); - $stmtChart->bindValue(':limit', CHART_LIMIT, PDO::PARAM_INT); - $stmtChart->execute(); - $chart = $stmtChart->fetchAll(); - $ageSeconds = seconds_from_ts($latest['ts'] ?? null); $latestTcp = latest_tcp_usage($pdo); @@ -1074,7 +1061,6 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'ajax') { 'status' => 'success', 'data' => $latest, 'logs' => $logs, - 'chart' => $chart, 'meta' => [ 'age_seconds' => $ageSeconds, 'stale' => $isStale, @@ -1085,7 +1071,6 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'ajax') { 'engine_state_duration' => current_engine_state_duration($pdo, $latest), 'poll_interval' => $isFastPollState ? 5 : 10, 'log_count' => count($logs), - 'chart_count' => count($chart), ], ]); } catch (Throwable $e) { @@ -1179,7 +1164,6 @@ $carMonitorSession = car_monitor_launcher_session(); - @@ -2030,59 +1978,48 @@ $carMonitorSession = car_monitor_launcher_session(); -
+
-
-
-
Battery
+
+
+
총 주행 가능 거리
- -V -
-
-
-
- + -km
+
-
-
Engine
+
HEV 상태
- OFF + -
-
-
+
-
-
-
Remote Start
-
- Standby -
-
- --:-- +
+
고전압 배터리
+
+ -%
+
-
-
-
-
-
AGE
-
-
-
+
+
현재 속도
+
+ -km/h
-
Poll Target
-
- -s -
-
-
+
-
@@ -2092,34 +2029,12 @@ $carMonitorSession = car_monitor_launcher_session();
-

Gen5W 실시간 텔레메트리

+

Gen5W 차량 상태

-
헤드유닛 MICOM 서비스 전체 snapshot에서 선별한 차량 상태
연결 중 - - - - - -
-
- -
-
-
주행 상태
-
-
-
-
-
기어 · 속도
-
-
-
-
-
누적 주행거리
-
-
-
-
-
외기 온도
-
-
@@ -2127,24 +2042,24 @@ $carMonitorSession = car_monitor_launcher_session();
주행 · 전원
+
누적 주행거리
-
+
기어
-
IGN
-
+
엔진
-
주차 브레이크
-
-
주행 모드
-
-
카메라
-
-
주행 가능 거리
-
-
연료 경고
-
+
주차 상태
-
HEV · 배터리
+
전기 주행거리
-
+
엔진 주행거리
-
12V SOC
-
LDC 12V
-
-
HEV 모드
-
-
충전 상태
-
-
모터 사용
-
-
무선 충전
-
+
연료 경고
-
+
방전 경고
-
@@ -2154,11 +2069,9 @@ $carMonitorSession = car_monitor_launcher_session();
도어
-
잠금
-
트렁크
-
+
후드
-
비상등
-
-
전조등
-
-
DTC 경고
-
-
운전석 벨트
-
-
동승석 벨트
-
+
등화
-
@@ -2166,35 +2079,12 @@ $carMonitorSession = car_monitor_launcher_session();
공조
공조 전원
-
-
A/C
-
-
AUTO · DUAL
-
+
A/C · AUTO
-
풍량
-
운전석 설정
-
동승석 설정
-
-
-
- -
-
내비 · 안내
-
-
경로 안내
-
-
다음 회전
-
-
제한 속도
-
-
경로 이탈
-
-
단속 지점 거리
-
-
단속 제한 속도
-
-
-
- -
-
헤드유닛 · 미디어
-
-
MICOM
-
-
부팅 상태
-
-
전원 상태
-
-
AV 전원
-
-
미디어 소스
-
-
음량 · 음소거
-
+
후석 설정
-
+
외기 온도
-
@@ -2480,52 +2370,37 @@ $carMonitorSession = car_monitor_launcher_session(); dayShort: '일', hourShort: '시간', minShort: '분', - telemetryTitle: 'Gen5W 실시간 텔레메트리', - telemetrySubtitle: '헤드유닛 MICOM 서비스 전체 snapshot에서 선별한 차량 상태', - vehicleMotion: '주행 상태', - gearSpeed: '기어 · 속도', + totalRange: '총 주행 가능 거리', + hevReady: 'HEV 상태', + hevBattery: '고전압 배터리', + speed: '현재 속도', + telemetryTitle: 'Gen5W 차량 상태', odometer: '누적 주행거리', + gear: '기어', outsideTemperature: '외기 온도', drivingPower: '주행 · 전원', ignition: 'IGN', parkingBrake: '주차 브레이크', - driveMode: '주행 모드', - cameraState: '카메라', - range: '주행 가능 거리', + parking: '주차 상태', fuelWarning: '연료 경고', + dischargeWarning: '방전 경고', hevEnergy: 'HEV · 배터리', + electricRange: '전기 주행거리', + gasRange: '엔진 주행거리', batterySoc12v: '12V SOC', - hevMode: 'HEV 모드', - chargeState: '충전 상태', - motorUsage: '모터 사용', - wirelessCharging: '무선 충전', accessSafety: '도어 · 안전', doors: '도어', doorLocks: '잠금', trunk: '트렁크', + hood: '후드', hazardLights: '비상등', - headLights: '전조등', - dtcWarning: 'DTC 경고', - driverSeatbelt: '운전석 벨트', - passengerSeatbelt: '동승석 벨트', + lights: '등화', climate: '공조', climatePower: '공조 전원', fanLevel: '풍량', driverTemperature: '운전석 설정', passengerTemperature: '동승석 설정', - navigation: '내비 · 안내', - routeGuide: '경로 안내', - nextTurn: '다음 회전', - speedLimit: '제한 속도', - offRoute: '경로 이탈', - safetyCameraDistance: '단속 지점 거리', - safetyCameraLimit: '단속 제한 속도', - headUnitMedia: '헤드유닛 · 미디어', - bootState: '부팅 상태', - powerState: '전원 상태', - avPower: 'AV 전원', - mediaSource: '미디어 소스', - volumeMute: '음량 · 음소거', + rearTemperature: '후석 설정', }, en: { appTitle: 'Car Monitor', @@ -2591,52 +2466,37 @@ $carMonitorSession = car_monitor_launcher_session(); dayShort: 'd', hourShort: 'h', minShort: 'm', - telemetryTitle: 'Gen5W Live Telemetry', - telemetrySubtitle: 'Curated vehicle state from the full head-unit MICOM snapshot', - vehicleMotion: 'Motion', - gearSpeed: 'Gear · Speed', + totalRange: 'Total range', + hevReady: 'HEV status', + hevBattery: 'HEV battery', + speed: 'Current speed', + telemetryTitle: 'Gen5W vehicle status', odometer: 'Odometer', + gear: 'Gear', outsideTemperature: 'Outside Temp.', drivingPower: 'Driving · Power', ignition: 'Ignition', parkingBrake: 'Parking Brake', - driveMode: 'Drive Mode', - cameraState: 'Camera', - range: 'Range', + parking: 'Parking status', fuelWarning: 'Fuel Warning', + dischargeWarning: 'Discharge warning', hevEnergy: 'HEV · Battery', + electricRange: 'Electric range', + gasRange: 'Engine range', batterySoc12v: '12V SOC', - hevMode: 'HEV Mode', - chargeState: 'Charge State', - motorUsage: 'Motor Usage', - wirelessCharging: 'Wireless Charge', accessSafety: 'Access · Safety', doors: 'Doors', doorLocks: 'Locks', trunk: 'Trunk', + hood: 'Hood', hazardLights: 'Hazards', - headLights: 'Headlights', - dtcWarning: 'DTC Warning', - driverSeatbelt: 'Driver Belt', - passengerSeatbelt: 'Passenger Belt', + lights: 'Lights', climate: 'Climate', climatePower: 'Climate Power', fanLevel: 'Fan Level', driverTemperature: 'Driver Setpoint', passengerTemperature: 'Passenger Setpoint', - navigation: 'Navigation · Guidance', - routeGuide: 'Route Guidance', - nextTurn: 'Next Turn', - speedLimit: 'Speed Limit', - offRoute: 'Off Route', - safetyCameraDistance: 'Camera Distance', - safetyCameraLimit: 'Camera Limit', - headUnitMedia: 'Head Unit · Media', - bootState: 'Boot State', - powerState: 'Power State', - avPower: 'AV Power', - mediaSource: 'Media Source', - volumeMute: 'Volume · Mute', + rearTemperature: 'Rear Setpoint', } }; @@ -2655,7 +2515,6 @@ $carMonitorSession = car_monitor_launcher_session(); tu: { label: 'cmdTrunk', icon: 'fa-box-open', bg: 'var(--box-bg-yellow)', color: 'var(--accent-yellow)' } }; - let batteryChart = null; let refreshTimer = null; let usageTimer = null; let telemetryTimer = null; @@ -2663,8 +2522,6 @@ $carMonitorSession = car_monitor_launcher_session(); let usageInFlight = false; let wakeLockSentinel = null; let wakeLockWanted = false; - let latestDashboardData = null; - let latestDashboardMeta = null; let latestUsageData = null; let latestTelemetryData = null; let calibrationModalOpen = false; @@ -2683,13 +2540,11 @@ $carMonitorSession = car_monitor_launcher_session(); initTheme(); initWakeLock(); initCalibrationSettings(); - initChart(); fetchData(); refreshTimer = setInterval(fetchData, 1000); fetchTelemetry(); telemetryTimer = setInterval(fetchTelemetry, 5000); fetchUsage(); - window.addEventListener('resize', debounce(updateResponsiveDurations, 150)); }); document.addEventListener('visibilitychange', () => { @@ -2831,15 +2686,12 @@ $carMonitorSession = car_monitor_launcher_session(); if (badge && ['WAIT', '대기'].includes(badge.textContent.trim())) { badge.textContent = t('wait'); } - const pollUnit = document.getElementById('val-poll-unit'); - if (pollUnit) pollUnit.textContent = t('secShort'); const langBtn = document.getElementById('lang-btn'); if (langBtn) { langBtn.title = t('languageTitle'); langBtn.setAttribute('aria-label', t('languageTitle')); } updateWakeLockButton(); - updateResponsiveDurations(); if (latestTelemetryData) updateTelemetry(latestTelemetryData); } @@ -2857,7 +2709,6 @@ $carMonitorSession = car_monitor_launcher_session(); html.setAttribute('data-bs-theme', next); localStorage.setItem('theme', next); updateThemeIcon(next); - updateChartTheme(); } function updateThemeIcon(theme) { @@ -2933,71 +2784,15 @@ $carMonitorSession = car_monitor_launcher_session(); btn.setAttribute('aria-label', btn.title); } - function getChartColors() { - const style = getComputedStyle(document.body); - return { - line: style.getPropertyValue('--chart-line').trim(), - start: style.getPropertyValue('--chart-grad-start').trim(), - end: style.getPropertyValue('--chart-grad-end').trim() - }; - } - - function initChart() { - const ctx = document.getElementById('chartBattery').getContext('2d'); - const colors = getChartColors(); - const gradient = ctx.createLinearGradient(0, 0, 0, 60); - gradient.addColorStop(0, colors.start); - gradient.addColorStop(1, colors.end); - - batteryChart = new Chart(ctx, { - type: 'line', - data: { - labels: [], - datasets: [{ - data: [], - borderColor: colors.line, - borderWidth: 2, - pointRadius: 0, - tension: 0.3, - fill: true, - backgroundColor: gradient - }] - }, - options: { - responsive: true, - maintainAspectRatio: false, - animation: false, - plugins: { legend: { display: false }, tooltip: { enabled: false } }, - scales: { x: { display: false }, y: { display: false, min: 11.5, max: 15.0 } } - } - }); - } - - function updateChartTheme() { - if (!batteryChart) return; - - const ctx = document.getElementById('chartBattery').getContext('2d'); - const colors = getChartColors(); - const gradient = ctx.createLinearGradient(0, 0, 0, 60); - gradient.addColorStop(0, colors.start); - gradient.addColorStop(1, colors.end); - - batteryChart.data.datasets[0].borderColor = colors.line; - batteryChart.data.datasets[0].backgroundColor = gradient; - batteryChart.update(); - } - function fetchData() { fetch('?mode=ajax', { cache: 'no-store' }) .then(res => res.json()) .then(payload => { if (payload.status !== 'success') return; - updateDashboard(payload.data, payload.meta || {}); updateLogs(payload.logs || [], payload.meta || {}); - updateChart(payload.chart || []); usageRefreshIntervalSec = normalizedUsageInterval(payload.meta?.poll_interval); }) - .catch(() => updateLiveBadge(false)); + .catch(() => {}); } function fetchTelemetry() { @@ -3014,18 +2809,43 @@ $carMonitorSession = car_monitor_launcher_session(); latestTelemetryData = telemetry; const live = document.getElementById('telemetry-live'); const age = document.getElementById('telemetry-age'); - const count = document.getElementById('telemetry-count'); - const services = document.getElementById('telemetry-services'); const ko = currentLang === 'ko'; + const elapsed = telemetry?.age_seconds === null || telemetry?.age_seconds === undefined + ? null + : formatAge(Number(telemetry.age_seconds)).replace(/^\+/, ''); + const sampleTime = Number(telemetry?.sample_epoch || 0) * 1000; - if (!telemetry || !telemetry.available) { + const clearIds = [ + 'val-range', 'val-range-detail', 'val-hev', 'val-hev-detail', 'val-hev-soc', + 'val-hev-soc-detail', 'val-speed', 'val-telemetry-freshness', 'tm-odometer', + 'tm-gear', 'tm-ignition', 'tm-engine', 'tm-parking-brake', 'tm-parked', + 'tm-electric-range', 'tm-gas-range', 'tm-battery-soc', 'tm-ldc-charge', + 'tm-fuel-warning', 'tm-discharge-alert', 'tm-doors', 'tm-locks', 'tm-trunk', + 'tm-hood', 'tm-hazard', 'tm-lights', 'tm-climate', 'tm-ac-auto', 'tm-fan', + 'tm-driver-temp', 'tm-passenger-temp', 'tm-rear-temp', 'tm-outside-temp', + ]; + + if (!telemetry || !telemetry.available || !telemetry.online) { live.className = 'telemetry-chip down'; live.querySelector('span').textContent = telemetry?.configured === false ? (ko ? '설정 필요' : 'Not configured') - : (ko ? '연결 안 됨' : 'Unavailable'); - age.querySelector('span').textContent = '-'; - count.querySelector('span').textContent = '-'; - services.querySelector('span').textContent = '-'; + : (telemetry?.online === false ? (ko ? '오프라인' : 'Offline') : (ko ? '연결 안 됨' : 'Unavailable')); + age.querySelector('span').textContent = elapsed === null ? '-' : `${elapsed} ${ko ? '전' : 'ago'}`; + clearIds.forEach(id => { + const element = document.getElementById(id); + if (element) element.textContent = '-'; + }); + document.getElementById('visual-engine-icon')?.classList.remove('active'); + document.getElementById('visual-headlight')?.classList.remove('on'); + ['fl', 'fr', 'rl', 'rr', 'trunk'].forEach(position => { + const element = document.getElementById(`door-${position}`); + element?.classList.remove('open'); + element?.classList.add('unknown'); + }); + document.getElementById('last-updated').textContent = sampleTime > 0 + ? new Date(sampleTime).toLocaleTimeString(ko ? 'ko-KR' : 'en-US', {hour12: false}) + : '--:--:--'; + updateLiveBadge(Boolean(telemetry?.available), Boolean(telemetry?.available)); return; } @@ -3035,11 +2855,11 @@ $carMonitorSession = car_monitor_launcher_session(); ? (ko ? '5초 실시간' : 'Live · 5s') : (telemetry.online ? (ko ? '지연' : 'Delayed') : (ko ? '오프라인' : 'Offline')); age.className = `telemetry-chip ${telemetry.stale ? 'warn' : ''}`.trim(); - age.querySelector('span').textContent = telemetry.age_seconds === null - ? '-' - : `${formatAge(Number(telemetry.age_seconds))} ${ko ? '전' : 'ago'}`; - count.querySelector('span').textContent = `${Number(telemetry.metric_count || 0).toLocaleString()} ${ko ? '지표' : 'metrics'}`; - services.querySelector('span').textContent = `${telemetry.services?.available ?? 0}/${telemetry.services?.total ?? 0} ${ko ? '서비스' : 'services'}`; + age.querySelector('span').textContent = elapsed === null ? '-' : `${elapsed} ${ko ? '전' : 'ago'}`; + updateLiveBadge(true, !isHealthy); + document.getElementById('last-updated').textContent = sampleTime > 0 + ? new Date(sampleTime).toLocaleTimeString(ko ? 'ko-KR' : 'en-US', {hour12: false}) + : '--:--:--'; const metrics = telemetry.metrics || {}; const value = name => metrics[name]?.supported ? String(metrics[name].value) : null; @@ -3047,51 +2867,59 @@ $carMonitorSession = car_monitor_launcher_session(); const raw = value(name); return raw !== null && raw.trim() !== '' && Number.isFinite(Number(raw)) ? Number(raw) : null; }; - const yes = name => ['true', '1', 'on'].includes(String(value(name)).toLowerCase()); - const onOff = name => value(name) === null ? null : (yes(name) ? t('on') : t('off')); + const boolean = name => { + const raw = value(name); + if (raw === null) return null; + return ['true', '1', 'on'].includes(raw.toLowerCase()); + }; + const yes = name => boolean(name) === true; + const onOff = name => boolean(name) === null ? null : (yes(name) ? t('on') : t('off')); const set = (id, text, supported = text !== null && text !== undefined && text !== '') => { const el = document.getElementById(id); if (!el) return; - el.textContent = supported ? String(text) : (ko ? '미지원' : 'N/A'); + el.textContent = supported ? String(text) : '-'; el.classList.toggle('unsupported', !supported); el.title = supported ? String(text) : ''; }; - const rawState = name => { - const raw = value(name); - return raw === null ? null : raw.replace(/\s*\(0x[0-9a-f]+\)\s*$/i, ''); - }; const speed = numeric('speed'); const parking = yes('parking'); - const reverse = yes('reverse'); - const motion = reverse - ? (ko ? '후진' : 'Reverse') - : (speed !== null && speed > 0 ? (ko ? '주행 중' : 'Driving') : (parking ? t('parked') : (ko ? '정차' : 'Stopped'))); - set('telemetry-motion', motion, true); - const gear = reverse - ? 'R' - : (parking ? 'P' : (speed !== null && speed > 0 ? 'D' : (ko ? '정차' : 'Stopped'))); - set('telemetry-gear-speed', `${gear} · ${speed ?? '-'} km/h`, speed !== null || value('parking') !== null || value('reverse') !== null); + const gear = parking ? 'P' : (speed !== null && speed > 0 ? 'D' : (ko ? '정차' : 'Stopped')); + const totalRange = numeric('distance_to_empty'); + const electricRange = numeric('electric_distance_to_empty'); + const gasRange = numeric('gas_distance_to_empty'); + const hevSoc = numeric('hev_battery_soc'); + const soc12v = numeric('battery_soc_12v'); + const hevReady = boolean('hev_ready'); + const engineRunning = boolean('engine_running'); + + set('val-range', totalRange === null ? null : totalRange.toLocaleString()); + set('val-range-detail', electricRange === null && gasRange === null + ? null + : `${ko ? '전기' : 'EV'} ${electricRange ?? '-'} · ${ko ? '엔진' : 'engine'} ${gasRange ?? '-'}`); + set('val-speed', speed === null ? null : speed.toLocaleString()); + set('val-hev-soc', hevSoc === null ? null : hevSoc.toLocaleString()); + set('val-hev-soc-detail', soc12v === null ? null : `12V ${soc12v}%`); + set('val-hev', hevReady === null ? null : (hevReady ? 'READY' : 'OFF')); + set('val-hev-detail', engineRunning === null + ? null + : (engineRunning ? (ko ? '엔진 작동' : 'Engine running') : (ko ? '엔진 정지' : 'Engine stopped'))); + set('val-telemetry-freshness', elapsed === null ? null : `${elapsed} ${ko ? '전' : 'ago'}`); + const odometer = numeric('odometer'); - set('telemetry-odometer', odometer === null ? null : `${odometer.toLocaleString(undefined, { minimumFractionDigits: 1, maximumFractionDigits: 1 })} km`); - const outside = numeric('outside_temperature'); - set('telemetry-outside-temp', outside === null ? null : `${outside.toFixed(1)} °C`); - + set('tm-odometer', odometer === null ? null : `${odometer.toLocaleString(undefined, { minimumFractionDigits: 1, maximumFractionDigits: 1 })} km`); + set('tm-gear', gear, value('parking') !== null || speed !== null); set('tm-ignition', onOff('ignition')); + set('tm-engine', onOff('engine_running')); set('tm-parking-brake', value('parking_brake') === null ? null : (yes('parking_brake') ? (ko ? '체결' : 'Applied') : (ko ? '해제' : 'Released'))); - set('tm-drive-mode', rawState('drive_mode')); - set('tm-camera', rawState('camera')); - const range = numeric('distance_to_empty'); - set('tm-range', range === null ? null : `${range.toLocaleString()} km`); - set('tm-fuel-warning', onOff('fuel_warning')); + set('tm-parked', value('parking') === null ? null : (parking ? t('parked') : (ko ? '주행 가능' : 'Ready to drive'))); - const soc = numeric('battery_soc_12v'); - set('tm-battery-soc', soc === null ? null : `${soc}%`); + set('tm-electric-range', electricRange === null ? null : `${electricRange.toLocaleString()} km`); + set('tm-gas-range', gasRange === null ? null : `${gasRange.toLocaleString()} km`); + set('tm-battery-soc', soc12v === null ? null : `${soc12v}%`); set('tm-ldc-charge', onOff('ldc_12v_charge')); - set('tm-hev-mode', rawState('hev_mode')); - set('tm-charge-state', rawState('hev_charge_state')); - set('tm-motor-usage', rawState('motor_usage')); - set('tm-wireless-charge', rawState('wireless_charging')); + set('tm-fuel-warning', onOff('fuel_warning')); + set('tm-discharge-alert', onOff('discharge_alert')); const doorNames = ['driver_door', 'passenger_door', 'rear_left_door', 'rear_right_door']; const supportedDoors = doorNames.filter(name => value(name) !== null); @@ -3102,42 +2930,42 @@ $carMonitorSession = car_monitor_launcher_session(); const unlocked = supportedLocks.filter(name => yes(name)).length; set('tm-locks', supportedLocks.length ? (unlocked ? `${unlocked}${ko ? '개 해제' : ' unlocked'}` : (ko ? '모두 잠김' : 'All locked')) : null); set('tm-trunk', value('trunk') === null ? null : (yes('trunk') ? (ko ? '열림' : 'Open') : (ko ? '닫힘' : 'Closed'))); + set('tm-hood', value('hood') === null ? null : (yes('hood') ? (ko ? '열림' : 'Open') : (ko ? '닫힘' : 'Closed'))); set('tm-hazard', onOff('hazard')); - set('tm-head-lamp', onOff('head_lamp')); - set('tm-dtc', onOff('dtc_warning')); - set('tm-driver-belt', value('driver_seatbelt') === null ? null : (yes('driver_seatbelt') ? (ko ? '미착용' : 'Unbuckled') : (ko ? '착용/비활성' : 'Buckled/idle'))); - set('tm-passenger-belt', value('passenger_seatbelt') === null ? null : (yes('passenger_seatbelt') ? (ko ? '미착용' : 'Unbuckled') : (ko ? '착용/비활성' : 'Buckled/idle'))); + const lightNames = ['head_lamp', 'tail_lamp', 'fog_lamp']; + const supportedLights = lightNames.filter(name => value(name) !== null); + const activeLights = supportedLights.filter(name => yes(name)).length; + set('tm-lights', supportedLights.length + ? (activeLights ? `${activeLights}${ko ? '개 켜짐' : ' on'}` : (ko ? '꺼짐' : 'Off')) + : null); set('tm-climate', onOff('climate_on')); - set('tm-ac', onOff('air_conditioner')); const auto = onOff('climate_auto'); - const dual = onOff('climate_dual'); - set('tm-auto-dual', auto === null && dual === null ? null : `AUTO ${auto ?? '-'} · DUAL ${dual ?? '-'}`); + const ac = onOff('air_conditioner'); + set('tm-ac-auto', auto === null && ac === null ? null : `A/C ${ac ?? '-'} · AUTO ${auto ?? '-'}`); const fan = numeric('blower_level'); set('tm-fan', fan === null ? null : `${fan}${ko ? '단' : ''}`); const driverTemp = numeric('driver_temperature'); const passengerTemp = numeric('passenger_temperature'); + const rearTemp = numeric('rear_temperature'); + const outsideTemp = numeric('outside_temperature'); set('tm-driver-temp', driverTemp === null ? null : `${driverTemp.toFixed(1)} °C`); set('tm-passenger-temp', passengerTemp === null ? null : `${passengerTemp.toFixed(1)} °C`); + set('tm-rear-temp', rearTemp === null ? null : `${rearTemp.toFixed(1)} °C`); + set('tm-outside-temp', outsideTemp === null ? null : `${outsideTemp.toFixed(1)} °C`); - set('tm-route', value('route_guide') === null ? null : (yes('route_guide') ? (ko ? '안내 중' : 'Guiding') : (ko ? '안내 없음' : 'Inactive'))); - set('tm-next-turn', rawState('next_turn_distance')); - const speedLimit = numeric('speed_limit'); - set('tm-speed-limit', speedLimit === null ? null : `${speedLimit} km/h`); - set('tm-off-route', null); - const cameraDistance = numeric('camera_distance'); - set('tm-camera-distance', cameraDistance === null ? null : `${cameraDistance.toLocaleString()} m`); - set('tm-camera-limit', cameraDistance === null ? null : rawState('camera_speed_limit')); - - set('tm-micom', onOff('micom_connected')); - const boot = onOff('boot_completed'); - set('tm-boot', boot); - set('tm-power', rawState('head_unit_power')); - set('tm-av-power', rawState('av_power')); - set('tm-audio-mode', rawState('audio_mode')); - const volume = numeric('audio_volume'); - const muted = yes('mute'); - set('tm-volume', volume === null ? null : `${volume} · ${muted ? (ko ? '음소거' : 'Muted') : (ko ? '재생' : 'Audible')}`); + document.getElementById('visual-engine-icon')?.classList.toggle('active', engineRunning === true); + document.getElementById('visual-headlight')?.classList.toggle('on', yes('head_lamp')); + const doorEntities = { + fl: 'driver_door', fr: 'passenger_door', rl: 'rear_left_door', + rr: 'rear_right_door', trunk: 'trunk', + }; + Object.entries(doorEntities).forEach(([position, name]) => { + const element = document.getElementById(`door-${position}`); + if (!element) return; + element.classList.toggle('open', yes(name)); + element.classList.toggle('unknown', boolean(name) === null); + }); } function fetchUsage() { @@ -3191,82 +3019,6 @@ $carMonitorSession = car_monitor_launcher_session(); badge.className = 'badge bg-success bg-opacity-10 text-success border border-success border-opacity-20 rounded-pill'; } - function updateDashboard(data, meta) { - if (!data) { - updateLiveBadge(false); - return; - } - - const age = Number(meta.age_seconds ?? 0); - const isStale = Boolean(meta.stale ?? age > 30); - const isReceiveBad = age >= 60; - latestDashboardData = data; - latestDashboardMeta = meta; - updateLiveBadge(true, isStale); - - document.getElementById('last-updated').textContent = data.ts ? String(data.ts).substring(11) : '--:--:--'; - document.getElementById('age-seconds').textContent = formatResponsiveDuration(age, 'age'); - document.getElementById('val-poll-interval').textContent = meta.poll_interval ?? '-'; - const freshnessEl = document.getElementById('val-freshness'); - freshnessEl.textContent = isReceiveBad ? t('dataDelayed') : t('targetInterval'); - freshnessEl.className = isReceiveBad || isStale ? 'font-mono small mt-1 text-warning' : 'font-mono small mt-1 text-muted'; - - const ageEl = document.getElementById('age-seconds'); - if (age > 60) ageEl.className = 'font-mono fw-bold text-danger duration-line'; - else if (age > 30) ageEl.className = 'font-mono fw-bold text-warning duration-line'; - else ageEl.className = 'font-mono fw-bold text-muted duration-line'; - - const voltage = parseFloat(data.battery_voltage || 0); - const batteryEl = document.getElementById('val-battery'); - const batteryUnitEl = document.getElementById('val-battery-unit'); - const batteryStateEl = document.getElementById('val-battery-state'); - const batteryWarn = voltage > 0 && voltage <= 12.7; - batteryEl.textContent = voltage ? voltage.toFixed(1) : '-'; - batteryEl.className = 'value-text'; - batteryUnitEl.className = 'unit-text'; - batteryStateEl.textContent = batteryLabel(voltage, parseInt(data.engine) === 1); - batteryStateEl.className = batteryWarn ? 'font-mono small mt-1 text-danger' : 'font-mono small mt-1 text-muted'; - - const isEngineOn = parseInt(data.engine) === 1; - const engineEl = document.getElementById('val-engine'); - const visualEngine = document.getElementById('visual-engine-icon'); - const visualHeadlight = document.getElementById('visual-headlight'); - - if (isEngineOn) { - engineEl.textContent = t('on'); - engineEl.className = 'value-text text-warning'; - visualEngine.classList.add('active'); - visualHeadlight.classList.add('on'); - } else { - engineEl.textContent = t('off'); - engineEl.className = 'value-text text-secondary'; - visualEngine.classList.remove('active'); - visualHeadlight.classList.remove('on'); - } - - updateEngineDuration(data, meta); - - updateRemote(data); - updateDoors(data); - } - - function updateResponsiveDurations() { - if (!latestDashboardData || !latestDashboardMeta) return; - - document.getElementById('age-seconds').textContent = - formatResponsiveDuration(Number(latestDashboardMeta.age_seconds ?? 0), 'age'); - updateEngineDuration(latestDashboardData, latestDashboardMeta); - } - - function updateEngineDuration(data, meta) { - const duration = formatResponsiveDuration(Number(meta.state_duration || 0), 'state'); - const isEngineOnForLabel = parseInt(data.engine) === 1; - const sentenceKey = isEngineOnForLabel ? 'engineStateOnSentence' : 'engineStateOffSentence'; - const sentence = t(sentenceKey).replace('{duration}', `${duration}`); - - document.getElementById('val-state-timer').innerHTML = sentence; - } - function updateUsage(usage) { if (!usage) return; latestUsageData = usage; @@ -3500,30 +3252,6 @@ $carMonitorSession = car_monitor_launcher_session(); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; } - function updateRemote(data) { - const remoteEl = document.getElementById('val-remote'); - if (parseInt(data.remote_start_running) === 1) { - remoteEl.textContent = t('running'); - remoteEl.className = 'value-text text-danger status-active-pulse'; - } else if (parseInt(data.remote_start_preparing) === 1) { - remoteEl.textContent = t('preparing'); - remoteEl.className = 'value-text text-warning'; - } else { - remoteEl.textContent = t('standby'); - remoteEl.className = 'value-text text-secondary'; - } - document.getElementById('val-remote-time').textContent = data.remote_start_remaining || '--:--'; - } - - function updateDoors(data) { - ['fl', 'fr', 'rl', 'rr', 'trunk'].forEach(pos => { - const el = document.getElementById('door-' + pos); - if (!el) return; - if (parseInt(data['door_' + pos]) === 1) el.classList.add('open'); - else el.classList.remove('open'); - }); - } - function parseLocalTimestamp(value) { const match = String(value || '').match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/); if (!match) return null; @@ -3570,21 +3298,6 @@ $carMonitorSession = car_monitor_launcher_session(); if (tbody.innerHTML !== html) tbody.innerHTML = html; } - function updateChart(data) { - if (!batteryChart) return; - batteryChart.data.labels = data.map(row => String(row.ts || '').substring(11, 16)); - batteryChart.data.datasets[0].data = data.map(row => parseFloat(row.battery_voltage || 0)); - batteryChart.update(); - } - - function batteryLabel(v, engineOn) { - if (!v) return '-'; - if (engineOn && v >= 13.5) return t('charging'); - if (v > 12.7) return t('normal'); - if (v >= 12.0) return t('watch'); - return t('low'); - } - function doorSummary(row) { const open = []; if (parseInt(row.door_fl) === 1) open.push('FL'); @@ -3639,102 +3352,10 @@ $carMonitorSession = car_monitor_launcher_session(); return '+' + Math.floor(sec / 3600) + t('hourShort'); } - function compactDurationMode(context = 'state') { - const width = window.innerWidth || document.documentElement.clientWidth || 1024; - const target = document.getElementById(context === 'age' ? 'age-seconds' : 'val-state-timer'); - const card = target ? target.closest('.glass-card') : null; - const boxWidth = card - ? card.getBoundingClientRect().width - : (target ? target.getBoundingClientRect().width : 0); - const limitWidth = boxWidth || width; - - if (limitWidth <= 160 || width <= 380) return 'tight'; - if (limitWidth <= 230 || width <= 575) return 'compact'; - return 'full'; - } - - function formatResponsiveDuration(sec, context = 'state') { - const mode = compactDurationMode(context); - if (mode === 'full') { - return context === 'age' ? formatAgeUnits(sec) : formatDuration(sec); - } - - sec = Math.max(0, Number(sec || 0)); - const d = Math.floor(sec / 86400); - const h = Math.floor((sec % 86400) / 3600); - const m = Math.floor((sec % 3600) / 60); - const s = Math.floor(sec % 60); - - if (mode === 'tight') { - if (d > 0) return d + t('dayShort'); - if (h > 0) return h + t('hourShort'); - if (m > 0) return m + t('minShort'); - return s + t('secShort'); - } - - if (d > 0) return d + t('dayShort') + (h > 0 ? ' ' + h + t('hourShort') : ''); - if (h > 0) return h + t('hourShort'); - if (m > 0) return m + t('minShort'); - return s + t('secShort'); - } - - function formatDuration(sec) { - sec = Math.max(0, Number(sec || 0)); - - const d = Math.floor(sec / 86400); - const h = Math.floor((sec % 86400) / 3600); - const m = Math.floor((sec % 3600) / 60); - const s = Math.floor(sec % 60); - - if (d > 0) return d + t('dayShort') + ' ' + h + t('hourShort'); - if (h > 0) return h + t('hourShort') + ' ' + m + t('minShort'); - if (m > 0) return m + t('minShort') + ' ' + s + t('secShort'); - return formatSecondsShort(s); - } - - function formatAgoDuration(sec) { - sec = Math.max(0, Number(sec || 0)); - - const d = Math.floor(sec / 86400); - const h = Math.floor((sec % 86400) / 3600); - const m = Math.floor((sec % 3600) / 60); - const s = Math.floor(sec % 60); - - if (d > 0) return d + t('dayShort') + ' ' + h + t('hourShort') + ' ' + m + t('minShort'); - if (h > 0) return h + t('hourShort') + ' ' + m + t('minShort'); - if (m > 0) return m + t('minShort'); - return s + t('secShort'); - } - - function formatAgeUnits(sec) { - sec = Math.max(0, Number(sec || 0)); - - const d = Math.floor(sec / 86400); - const h = Math.floor((sec % 86400) / 3600); - const m = Math.floor((sec % 3600) / 60); - const s = Math.floor(sec % 60); - const parts = []; - - if (d > 0) parts.push(d + t('dayShort')); - if (h > 0 || parts.length > 0) parts.push(h + t('hourShort')); - if (m > 0 || parts.length > 0) parts.push(m + t('minShort')); - parts.push(s + t('secShort')); - - return parts.join(' '); - } - function formatSecondsShort(sec) { return Math.max(0, Number(sec || 0)) + t('secShort'); } - function debounce(fn, wait) { - let timer = null; - return function (...args) { - clearTimeout(timer); - timer = setTimeout(() => fn.apply(this, args), wait); - }; - } - function escapeHtml(value) { return String(value) .replace(/&/g, '&') diff --git a/tests/NiroTelemetrySnapshotTest.php b/tests/NiroTelemetrySnapshotTest.php index 86bd5f4..9ec175e 100644 --- a/tests/NiroTelemetrySnapshotTest.php +++ b/tests/NiroTelemetrySnapshotTest.php @@ -23,31 +23,44 @@ $summary = NiroTelemetrySnapshot::summarize([ 'state' => [ '_sample_epoch' => $now - 3, 'ignition' => 'true', + 'hev_ready' => 'true', + 'engine_running' => 'false', + 'parked' => 'false', 'speed' => '27', 'gear_code' => '5', + 'parking_brake' => 'false', 'battery_soc_12v' => '93', + 'hev_battery_soc' => '61', 'total_distance_to_empty' => '761', + 'electric_distance_to_empty' => '18', + 'gas_distance_to_empty' => '743', 'odometer' => '259653.7', 'driver_door' => 'false', + 'hood' => 'false', + 'discharge_alert' => 'false', 'outside_temperature' => '25.0', - 'audio_mode' => '54', - 'hev_mode_code' => '2', - 'head_unit_power' => '0', + 'rear_temperature' => '22.0', ], ], ['stale_after' => 20]); check($summary['available'] === true, 'snapshot is available'); check($summary['online'] === true, 'availability topic is respected'); check($summary['stale'] === false, 'fresh sample is not stale'); -check($summary['metric_count'] === 11, 'only mapped supported metrics are counted'); -check($summary['services'] === ['available' => 6, 'total' => 6], 'source families are counted'); +check($summary['metric_count'] === 18, 'only mapped supported metrics are counted'); +check($summary['services'] === ['available' => 4, 'total' => 4], 'source families are counted'); check($summary['metrics']['speed']['value'] === '27', 'speed mapping'); check($summary['metrics']['gear']['value'] === '5', 'live root gear mapping'); check($summary['metrics']['outside_temperature']['value'] === '25.0', 'climate mapping'); check($summary['metrics']['driver_door']['supported'] === true, 'zero is supported'); check($summary['metrics']['distance_to_empty']['value'] === '761', 'live HEV range mapping'); +check($summary['metrics']['electric_distance_to_empty']['value'] === '18', 'electric range mapping'); +check($summary['metrics']['gas_distance_to_empty']['value'] === '743', 'engine range mapping'); +check($summary['metrics']['hev_battery_soc']['value'] === '61', 'HEV battery mapping'); +check($summary['metrics']['hev_ready']['value'] === 'true', 'HEV ready mapping'); +check($summary['metrics']['hood']['value'] === 'false', 'hood mapping'); +check($summary['metrics']['discharge_alert']['value'] === 'false', 'discharge alert mapping'); +check($summary['metrics']['rear_temperature']['value'] === '22.0', 'rear temperature mapping'); check($summary['metrics']['odometer']['value'] === '259653.7', 'odometer is already km'); -check($summary['metrics']['route_guide']['supported'] === false, 'removed raw route value stays unsupported'); check(!array_key_exists('state', $summary), 'raw snapshot is not exposed'); $stale = NiroTelemetrySnapshot::summarize([