feat: add live Gen5W telemetry dashboard

This commit is contained in:
seo-amugae committed 2026-10-18 16:20:34 +09:00
1 parent 46d95fd395
commit d006fed6db
4 files changed
+1083

No files matched your search

+506
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
date_default_timezone_set('Asia/Seoul');
require_once __DIR__ . '/../common/auth.php';
require_once __DIR__ . '/NiroTelemetrySnapshot.php';
$carSecretFile = __DIR__ . '/../../config/car.php';
if (!is_file($carSecretFile)) {
@@ -1094,6 +1095,15 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'ajax') {
], 500);
}
}
if (isset($_GET['mode']) && $_GET['mode'] === 'telemetry') {
car_monitor_require_access(true);
json_response([
'status' => 'success',
'telemetry' => NiroTelemetrySnapshot::dashboard(),
'server_time' => time(),
]);
}
if (isset($_GET['mode']) && $_GET['mode'] === 'usage') {
car_monitor_require_access(true);
@@ -1832,6 +1842,155 @@ $carMonitorSession = car_monitor_launcher_session();
max-width: 140px;
}
}
.telemetry-card {
position: relative;
}
.telemetry-card::before {
content: '';
position: absolute;
inset: 0 0 auto 0;
height: 3px;
background: linear-gradient(90deg, #22c55e, #3b82f6 48%, #8b5cf6);
}
.telemetry-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1.15rem 1.25rem 0.95rem;
border-bottom: 1px solid rgba(108, 117, 125, 0.12);
}
.telemetry-source {
display: flex;
gap: 0.55rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.telemetry-chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
border: 1px solid rgba(100, 116, 139, 0.18);
background: rgba(100, 116, 139, 0.08);
color: var(--text-secondary);
border-radius: 999px;
padding: 0.32rem 0.62rem;
font: 700 0.72rem/1 var(--font-main);
white-space: nowrap;
}
.telemetry-chip.live {
color: var(--accent-green);
border-color: rgba(16, 185, 129, 0.28);
background: rgba(16, 185, 129, 0.11);
}
.telemetry-chip.warn {
color: var(--accent-yellow);
border-color: rgba(245, 158, 11, 0.28);
background: rgba(245, 158, 11, 0.11);
}
.telemetry-chip.down {
color: var(--accent-red);
border-color: rgba(239, 68, 68, 0.28);
background: rgba(239, 68, 68, 0.11);
}
.telemetry-hero-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.75rem;
padding: 1rem 1.25rem;
}
.telemetry-hero {
min-width: 0;
border-radius: 16px;
padding: 0.9rem 1rem;
border: 1px solid rgba(100, 116, 139, 0.12);
background: rgba(100, 116, 139, 0.055);
}
.telemetry-hero-value {
margin-top: 0.2rem;
color: var(--text-primary);
font-size: 1.35rem;
font-weight: 800;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.telemetry-groups {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.8rem;
padding: 0 1.25rem 1.25rem;
}
.telemetry-group {
min-width: 0;
border-radius: 16px;
border: 1px solid rgba(100, 116, 139, 0.12);
background: rgba(100, 116, 139, 0.045);
overflow: hidden;
}
.telemetry-group-title {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.78rem 0.9rem;
color: var(--text-secondary);
border-bottom: 1px solid rgba(100, 116, 139, 0.1);
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.02em;
}
.telemetry-metrics {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0;
}
.telemetry-metric {
min-width: 0;
padding: 0.68rem 0.85rem;
border-bottom: 1px solid rgba(100, 116, 139, 0.075);
}
.telemetry-metric:nth-last-child(-n + 2) {
border-bottom: 0;
}
.telemetry-metric-label {
color: var(--text-muted);
font-size: 0.66rem;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.telemetry-metric-value {
margin-top: 0.18rem;
color: var(--text-primary);
font: 800 0.88rem/1.25 var(--font-main);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.telemetry-metric-value.unsupported {
color: var(--text-muted);
font-weight: 600;
}
@media (max-width: 1199.98px) {
.telemetry-groups { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 767.98px) {
.telemetry-title-row { align-items: flex-start; }
.telemetry-source { gap: 0.35rem; }
.telemetry-hero-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.telemetry-groups { grid-template-columns: 1fr; }
}
@media (max-width: 575.98px) {
.telemetry-title-row { display: block; }
.telemetry-source { justify-content: flex-start; margin-top: 0.75rem; }
.telemetry-title-row,
.telemetry-hero-grid,
.telemetry-groups { padding-left: 1rem; padding-right: 1rem; }
.telemetry-hero-value { font-size: 1.15rem; }
}
</style>
</head>
<body>
@@ -1928,6 +2087,119 @@ $carMonitorSession = car_monitor_launcher_session();
</div>
</div>
<section class="glass-card telemetry-card mb-4" aria-labelledby="telemetry-heading">
<div class="telemetry-title-row">
<div>
<div class="d-flex align-items-center gap-2">
<i class="fas fa-wave-square text-primary"></i>
<h2 id="telemetry-heading" class="h6 fw-bold mb-0" data-i18n="telemetryTitle">Gen5W 실시간 텔레메트리</h2>
</div>
<div class="small text-secondary mt-1" data-i18n="telemetrySubtitle">헤드유닛 MICOM 서비스 전체 snapshot에서 선별한 차량 상태</div>
</div>
<div class="telemetry-source" aria-live="polite">
<span id="telemetry-live" class="telemetry-chip"><i class="fas fa-circle"></i><span>연결 중</span></span>
<span id="telemetry-age" class="telemetry-chip"><i class="far fa-clock"></i><span>-</span></span>
<span id="telemetry-count" class="telemetry-chip"><i class="fas fa-layer-group"></i><span>-</span></span>
<span id="telemetry-services" class="telemetry-chip"><i class="fas fa-microchip"></i><span>-</span></span>
</div>
</div>
<div class="telemetry-hero-grid">
<div class="telemetry-hero">
<div class="label-text mb-0" data-i18n="vehicleMotion">주행 상태</div>
<div id="telemetry-motion" class="telemetry-hero-value">-</div>
</div>
<div class="telemetry-hero">
<div class="label-text mb-0" data-i18n="gearSpeed">기어 · 속도</div>
<div id="telemetry-gear-speed" class="telemetry-hero-value">-</div>
</div>
<div class="telemetry-hero">
<div class="label-text mb-0" data-i18n="odometer">누적 주행거리</div>
<div id="telemetry-odometer" class="telemetry-hero-value">-</div>
</div>
<div class="telemetry-hero">
<div class="label-text mb-0" data-i18n="outsideTemperature">외기 온도</div>
<div id="telemetry-outside-temp" class="telemetry-hero-value">-</div>
</div>
</div>
<div class="telemetry-groups">
<div class="telemetry-group">
<div class="telemetry-group-title"><i class="fas fa-gauge-high text-primary"></i><span data-i18n="drivingPower">주행 · 전원</span></div>
<div class="telemetry-metrics">
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="ignition">IGN</div><div id="tm-ignition" class="telemetry-metric-value">-</div></div>
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="parkingBrake">주차 브레이크</div><div id="tm-parking-brake" class="telemetry-metric-value">-</div></div>
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="driveMode">주행 모드</div><div id="tm-drive-mode" class="telemetry-metric-value">-</div></div>
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="cameraState">카메라</div><div id="tm-camera" class="telemetry-metric-value">-</div></div>
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="range">주행 가능 거리</div><div id="tm-range" class="telemetry-metric-value">-</div></div>
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="fuelWarning">연료 경고</div><div id="tm-fuel-warning" class="telemetry-metric-value">-</div></div>
</div>
</div>
<div class="telemetry-group">
<div class="telemetry-group-title"><i class="fas fa-bolt text-warning"></i><span data-i18n="hevEnergy">HEV · 배터리</span></div>
<div class="telemetry-metrics">
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="batterySoc12v">12V SOC</div><div id="tm-battery-soc" class="telemetry-metric-value">-</div></div>
<div class="telemetry-metric"><div class="telemetry-metric-label">LDC 12V</div><div id="tm-ldc-charge" class="telemetry-metric-value">-</div></div>
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="hevMode">HEV 모드</div><div id="tm-hev-mode" class="telemetry-metric-value">-</div></div>
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="chargeState">충전 상태</div><div id="tm-charge-state" class="telemetry-metric-value">-</div></div>
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="motorUsage">모터 사용</div><div id="tm-motor-usage" class="telemetry-metric-value">-</div></div>
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="wirelessCharging">무선 충전</div><div id="tm-wireless-charge" class="telemetry-metric-value">-</div></div>
</div>
</div>
<div class="telemetry-group">
<div class="telemetry-group-title"><i class="fas fa-shield-halved text-success"></i><span data-i18n="accessSafety">도어 · 안전</span></div>
<div class="telemetry-metrics">
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="doors">도어</div><div id="tm-doors" class="telemetry-metric-value">-</div></div>
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="doorLocks">잠금</div><div id="tm-locks" class="telemetry-metric-value">-</div></div>
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="trunk">트렁크</div><div id="tm-trunk" class="telemetry-metric-value">-</div></div>
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="hazardLights">비상등</div><div id="tm-hazard" class="telemetry-metric-value">-</div></div>
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="headLights">전조등</div><div id="tm-head-lamp" class="telemetry-metric-value">-</div></div>
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="dtcWarning">DTC 경고</div><div id="tm-dtc" class="telemetry-metric-value">-</div></div>
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="driverSeatbelt">운전석 벨트</div><div id="tm-driver-belt" class="telemetry-metric-value">-</div></div>
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="passengerSeatbelt">동승석 벨트</div><div id="tm-passenger-belt" class="telemetry-metric-value">-</div></div>
</div>
</div>
<div class="telemetry-group">
<div class="telemetry-group-title"><i class="fas fa-temperature-half text-danger"></i><span data-i18n="climate">공조</span></div>
<div class="telemetry-metrics">
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="climatePower">공조 전원</div><div id="tm-climate" class="telemetry-metric-value">-</div></div>
<div class="telemetry-metric"><div class="telemetry-metric-label">A/C</div><div id="tm-ac" class="telemetry-metric-value">-</div></div>
<div class="telemetry-metric"><div class="telemetry-metric-label">AUTO · DUAL</div><div id="tm-auto-dual" class="telemetry-metric-value">-</div></div>
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="fanLevel">풍량</div><div id="tm-fan" class="telemetry-metric-value">-</div></div>
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="driverTemperature">운전석 설정</div><div id="tm-driver-temp" class="telemetry-metric-value">-</div></div>
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="passengerTemperature">동승석 설정</div><div id="tm-passenger-temp" class="telemetry-metric-value">-</div></div>
</div>
</div>
<div class="telemetry-group">
<div class="telemetry-group-title"><i class="fas fa-route text-primary"></i><span data-i18n="navigation">내비 · 안내</span></div>
<div class="telemetry-metrics">
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="routeGuide">경로 안내</div><div id="tm-route" class="telemetry-metric-value">-</div></div>
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="nextTurn">다음 회전</div><div id="tm-next-turn" class="telemetry-metric-value">-</div></div>
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="speedLimit">제한 속도</div><div id="tm-speed-limit" class="telemetry-metric-value">-</div></div>
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="offRoute">경로 이탈</div><div id="tm-off-route" class="telemetry-metric-value">-</div></div>
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="safetyCameraDistance">단속 지점 거리</div><div id="tm-camera-distance" class="telemetry-metric-value">-</div></div>
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="safetyCameraLimit">단속 제한 속도</div><div id="tm-camera-limit" class="telemetry-metric-value">-</div></div>
</div>
</div>
<div class="telemetry-group">
<div class="telemetry-group-title"><i class="fas fa-display text-info"></i><span data-i18n="headUnitMedia">헤드유닛 · 미디어</span></div>
<div class="telemetry-metrics">
<div class="telemetry-metric"><div class="telemetry-metric-label">MICOM</div><div id="tm-micom" class="telemetry-metric-value">-</div></div>
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="bootState">부팅 상태</div><div id="tm-boot" class="telemetry-metric-value">-</div></div>
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="powerState">전원 상태</div><div id="tm-power" class="telemetry-metric-value">-</div></div>
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="avPower">AV 전원</div><div id="tm-av-power" class="telemetry-metric-value">-</div></div>
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="mediaSource">미디어 소스</div><div id="tm-audio-mode" class="telemetry-metric-value">-</div></div>
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="volumeMute">음량 · 음소거</div><div id="tm-volume" class="telemetry-metric-value">-</div></div>
</div>
</div>
</div>
</section>
<div class="glass-card usage-card mb-4">
<div class="usage-layout">
<div class="usage-head">
@@ -2208,6 +2480,52 @@ $carMonitorSession = car_monitor_launcher_session();
dayShort: '일',
hourShort: '시간',
minShort: '분',
telemetryTitle: 'Gen5W 실시간 텔레메트리',
telemetrySubtitle: '헤드유닛 MICOM 서비스 전체 snapshot에서 선별한 차량 상태',
vehicleMotion: '주행 상태',
gearSpeed: '기어 · 속도',
odometer: '누적 주행거리',
outsideTemperature: '외기 온도',
drivingPower: '주행 · 전원',
ignition: 'IGN',
parkingBrake: '주차 브레이크',
driveMode: '주행 모드',
cameraState: '카메라',
range: '주행 가능 거리',
fuelWarning: '연료 경고',
hevEnergy: 'HEV · 배터리',
batterySoc12v: '12V SOC',
hevMode: 'HEV 모드',
chargeState: '충전 상태',
motorUsage: '모터 사용',
wirelessCharging: '무선 충전',
accessSafety: '도어 · 안전',
doors: '도어',
doorLocks: '잠금',
trunk: '트렁크',
hazardLights: '비상등',
headLights: '전조등',
dtcWarning: 'DTC 경고',
driverSeatbelt: '운전석 벨트',
passengerSeatbelt: '동승석 벨트',
climate: '공조',
climatePower: '공조 전원',
fanLevel: '풍량',
driverTemperature: '운전석 설정',
passengerTemperature: '동승석 설정',
navigation: '내비 · 안내',
routeGuide: '경로 안내',
nextTurn: '다음 회전',
speedLimit: '제한 속도',
offRoute: '경로 이탈',
safetyCameraDistance: '단속 지점 거리',
safetyCameraLimit: '단속 제한 속도',
headUnitMedia: '헤드유닛 · 미디어',
bootState: '부팅 상태',
powerState: '전원 상태',
avPower: 'AV 전원',
mediaSource: '미디어 소스',
volumeMute: '음량 · 음소거',
},
en: {
appTitle: 'Car Monitor',
@@ -2273,6 +2591,52 @@ $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',
odometer: 'Odometer',
outsideTemperature: 'Outside Temp.',
drivingPower: 'Driving · Power',
ignition: 'Ignition',
parkingBrake: 'Parking Brake',
driveMode: 'Drive Mode',
cameraState: 'Camera',
range: 'Range',
fuelWarning: 'Fuel Warning',
hevEnergy: 'HEV · Battery',
batterySoc12v: '12V SOC',
hevMode: 'HEV Mode',
chargeState: 'Charge State',
motorUsage: 'Motor Usage',
wirelessCharging: 'Wireless Charge',
accessSafety: 'Access · Safety',
doors: 'Doors',
doorLocks: 'Locks',
trunk: 'Trunk',
hazardLights: 'Hazards',
headLights: 'Headlights',
dtcWarning: 'DTC Warning',
driverSeatbelt: 'Driver Belt',
passengerSeatbelt: 'Passenger Belt',
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',
}
};
@@ -2294,6 +2658,7 @@ $carMonitorSession = car_monitor_launcher_session();
let batteryChart = null;
let refreshTimer = null;
let usageTimer = null;
let telemetryTimer = null;
let usageRefreshIntervalSec = DEFAULT_USAGE_REFRESH_INTERVAL_SEC;
let usageInFlight = false;
let wakeLockSentinel = null;
@@ -2301,6 +2666,7 @@ $carMonitorSession = car_monitor_launcher_session();
let latestDashboardData = null;
let latestDashboardMeta = null;
let latestUsageData = null;
let latestTelemetryData = null;
let calibrationModalOpen = false;
let calibrationSaveInFlight = false;
const FALLBACK_BACK_TARGET = 'https://chaegeon.com/';
@@ -2320,6 +2686,8 @@ $carMonitorSession = car_monitor_launcher_session();
initChart();
fetchData();
refreshTimer = setInterval(fetchData, 1000);
fetchTelemetry();
telemetryTimer = setInterval(fetchTelemetry, 5000);
fetchUsage();
window.addEventListener('resize', debounce(updateResponsiveDurations, 150));
});
@@ -2472,6 +2840,7 @@ $carMonitorSession = car_monitor_launcher_session();
}
updateWakeLockButton();
updateResponsiveDurations();
if (latestTelemetryData) updateTelemetry(latestTelemetryData);
}
function initTheme() {
@@ -2630,6 +2999,143 @@ $carMonitorSession = car_monitor_launcher_session();
})
.catch(() => updateLiveBadge(false));
}
function fetchTelemetry() {
fetch('?mode=telemetry', { cache: 'no-store' })
.then(res => res.json())
.then(payload => {
if (payload.status !== 'success') return;
updateTelemetry(payload.telemetry || null);
})
.catch(() => updateTelemetry(null));
}
function updateTelemetry(telemetry) {
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';
if (!telemetry || !telemetry.available) {
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 = '-';
return;
}
const isHealthy = telemetry.online && !telemetry.stale;
live.className = `telemetry-chip ${isHealthy ? 'live' : (telemetry.online ? 'warn' : 'down')}`;
live.querySelector('span').textContent = isHealthy
? (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'}`;
const metrics = telemetry.metrics || {};
const value = name => metrics[name]?.supported ? String(metrics[name].value) : null;
const numeric = name => {
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 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.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);
set('telemetry-gear-speed', `${rawState('gear') ?? '-'} · ${speed ?? '-'} km/h`, value('gear') !== null || speed !== null);
const odometer = numeric('odometer_raw');
set('telemetry-odometer', odometer === null ? null : `${(odometer / 10).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-ignition', onOff('ignition'));
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'));
const soc = numeric('battery_soc_12v');
set('tm-battery-soc', soc === null ? null : `${soc}%`);
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'));
const doorNames = ['driver_door', 'passenger_door', 'rear_left_door', 'rear_right_door'];
const supportedDoors = doorNames.filter(name => value(name) !== null);
const openDoors = supportedDoors.filter(name => yes(name)).length;
set('tm-doors', supportedDoors.length ? (openDoors ? `${openDoors}${ko ? '개 열림' : ' open'}` : (ko ? '모두 닫힘' : 'All closed')) : null);
const lockNames = ['driver_unlock', 'passenger_unlock', 'rear_left_unlock', 'rear_right_unlock'];
const supportedLocks = lockNames.filter(name => value(name) !== null);
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-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')));
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 fan = numeric('blower_level');
set('tm-fan', fan === null ? null : `${fan}${ko ? '단' : ''}`);
const driverTemp = numeric('driver_temperature');
const passengerTemp = numeric('passenger_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-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', rawState('off_route'));
const cameraDistance = numeric('camera_distance');
set('tm-camera-distance', cameraDistance === null ? null : `${cameraDistance.toLocaleString()} m`);
set('tm-camera-limit', rawState('camera_speed_limit'));
set('tm-micom', onOff('micom_connected'));
const boot = onOff('boot_sequence_finished') ?? 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')}`);
}
function fetchUsage() {
if (usageInFlight) {