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
-
-
-
--:--
+
+
고전압 배터리
+
+ -%
+
-
-
-
-
+
+
현재 속도
+
+ -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();
@@ -2154,11 +2069,9 @@ $carMonitorSession = car_monitor_launcher_session();
+
-
-
-
-
+
@@ -2166,35 +2079,12 @@ $carMonitorSession = car_monitor_launcher_session();
공조
-
-
-
-
-
@@ -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([