Files
car/monitor.php
T

2429 lines
97 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
$carSecretFile = '/home/seo/secret/car.php';
if (!is_file($carSecretFile)) {
throw new RuntimeException('Missing car secret config: ' . $carSecretFile);
}
$carSecretConfig = require $carSecretFile;
$carDbConfig = is_array($carSecretConfig['db'] ?? null) ? $carSecretConfig['db'] : [];
define('DB_HOST', (string)($carDbConfig['host'] ?? '127.0.0.1'));
define('DB_NAME', (string)($carDbConfig['name'] ?? 'car'));
define('DB_USER', (string)($carDbConfig['user'] ?? 'car'));
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;
const DATA_MONTHLY_BASE_FEE_KRW = 5500;
const DATA_MONTHLY_ADDON_FEE_KRW = 2200;
const RECEIVE_GAP_LIMIT_SEC = 60;
// SKT T world "잔여기본통화 정보" 기준 현재월 누적값.
// carrier_total_bytes / meter_adjusted_bytes 로 보정 배율을 역산해 로컬 송수신 계측값에 적용한다.
const DATA_CURRENT_BILLING_USAGE_BYTES = [
'2026-06' => ['total_bytes' => 107350016, 'meter_adjusted_bytes' => 54104011, 'included_bytes' => 104857600, 'coupon_registered_at' => null],
];
function car_db(): PDO
{
static $pdo = null;
if ($pdo instanceof PDO) {
return $pdo;
}
$dsn = 'mysql:host=' . DB_HOST . ';dbname=' . DB_NAME . ';charset=' . DB_CHARSET;
$pdo = new PDO($dsn, DB_USER, DB_PASS, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
return $pdo;
}
function seconds_from_ts(?string $ts): ?int
{
if (!$ts) {
return null;
}
$time = strtotime($ts);
if ($time === false) {
return null;
}
return max(0, time() - $time);
}
function current_state_duration(PDO $pdo, ?array $latest): int
{
if (!$latest || empty($latest['ts'])) {
return 0;
}
$driving = (int)($latest['driving'] ?? 0);
$engine = (int)($latest['engine'] ?? 0);
$remoteRunning = (int)($latest['remote_start_running'] ?? 0);
$remotePreparing = (int)($latest['remote_start_preparing'] ?? 0);
$latestTs = strtotime((string)$latest['ts']);
if ($latestTs === false) {
return 0;
}
$stmt = $pdo->prepare(""
. "SELECT ts, driving, engine, remote_start_running, remote_start_preparing "
. "FROM car_status "
. "WHERE id='car' "
. "AND ts <= :latest_ts "
. "ORDER BY ts DESC LIMIT 50000"
);
$stmt->execute([
':latest_ts' => date('Y-m-d H:i:s', $latestTs),
]);
$startTs = $latestTs;
$newerTs = $latestTs;
while ($row = $stmt->fetch()) {
$rowTs = strtotime((string)($row['ts'] ?? ''));
if ($rowTs === false) {
continue;
}
if (($newerTs - $rowTs) > RECEIVE_GAP_LIMIT_SEC) {
break;
}
$sameState =
(int)($row['driving'] ?? 0) === $driving &&
(int)($row['engine'] ?? 0) === $engine &&
(int)($row['remote_start_running'] ?? 0) === $remoteRunning &&
(int)($row['remote_start_preparing'] ?? 0) === $remotePreparing;
if (!$sameState) {
break;
}
$startTs = $rowTs;
$newerTs = $rowTs;
}
$now = time();
$endTs = ($now - $latestTs) > RECEIVE_GAP_LIMIT_SEC ? $latestTs : $now;
return max(0, $endTs - $startTs);
}
function current_engine_state_duration(PDO $pdo, ?array $latest): int
{
if (!$latest || empty($latest['ts'])) {
return 0;
}
$engine = (int)($latest['engine'] ?? 0);
$latestTs = strtotime((string)$latest['ts']);
if ($latestTs === false) {
return 0;
}
$latestTsText = date('Y-m-d H:i:s', $latestTs);
$oppositeEngine = $engine === 1 ? 0 : 1;
$stmt = $pdo->prepare(""
. "SELECT ts "
. "FROM car_status "
. "WHERE id='car' AND engine = :engine AND ts <= :latest_ts "
. "ORDER BY ts DESC LIMIT 1"
);
$stmt->execute([
':engine' => $oppositeEngine,
':latest_ts' => $latestTsText,
]);
$oppositeTs = $stmt->fetchColumn();
if ($oppositeTs) {
$stmt = $pdo->prepare(""
. "SELECT ts "
. "FROM car_status "
. "WHERE id='car' AND engine = :engine AND ts > :opposite_ts AND ts <= :latest_ts "
. "ORDER BY ts ASC LIMIT 1"
);
$stmt->execute([
':engine' => $engine,
':opposite_ts' => (string)$oppositeTs,
':latest_ts' => $latestTsText,
]);
$startTsText = $stmt->fetchColumn() ?: $latestTsText;
} else {
$stmt = $pdo->prepare(""
. "SELECT ts "
. "FROM car_status "
. "WHERE id='car' AND engine = :engine AND ts <= :latest_ts "
. "ORDER BY ts ASC LIMIT 1"
);
$stmt->execute([
':engine' => $engine,
':latest_ts' => $latestTsText,
]);
$startTsText = $stmt->fetchColumn() ?: $latestTsText;
}
$startTs = strtotime((string)$startTsText);
if ($startTs === false) {
return 0;
}
return max(0, time() - $startTs);
}
function latest_tcp_usage(PDO $pdo): ?array
{
$stmt = $pdo->query(""
. "SELECT ts, source, cmd, sent_bytes, received_bytes, total_bytes, tcp_ok, tcp_error "
. "FROM car_data_usage "
. "WHERE id='car' "
. "ORDER BY ts DESC LIMIT 1"
);
$row = $stmt->fetch();
return $row ?: null;
}
function monthly_data_usage(PDO $pdo): array
{
$now = new DateTimeImmutable('now');
$monthStart = $now->modify('first day of this month')->setTime(0, 0, 0);
$nextMonthStart = $monthStart->modify('first day of next month');
$todayStart = $now->setTime(0, 0, 0);
$tomorrowStart = $todayStart->modify('+1 day');
$usage = calibrated_usage_range($pdo, $monthStart, $nextMonthStart);
$todayUsage = calibrated_usage_range($pdo, $todayStart, $tomorrowStart);
$sentBytes = $usage['sent_bytes'];
$receivedBytes = $usage['received_bytes'];
$totalBytes = $usage['total_bytes'];
$adjustedTotalBytes = $usage['adjusted_total_bytes'];
$remainingBytes = max(DATA_MONTHLY_INCLUDED_BYTES - $adjustedTotalBytes, 0);
$overBytes = max($adjustedTotalBytes - DATA_MONTHLY_INCLUDED_BYTES, 0);
$overUnits = $overBytes > 0 ? (int)ceil($overBytes / DATA_BILLING_UNIT_BYTES) : 0;
$overFeeKrw = $overUnits * DATA_BILLING_UNIT_KRW;
$monthSeconds = max(1, $nextMonthStart->getTimestamp() - $monthStart->getTimestamp());
$remainingSeconds = max(0, $nextMonthStart->getTimestamp() - $now->getTimestamp());
$daysInMonth = (int)$monthStart->format('t');
$firstUsageTs = $usage['first_usage_ts'];
$projectionStart = $firstUsageTs ? strtotime((string)$firstUsageTs) : $monthStart->getTimestamp();
if ($projectionStart === false || $projectionStart < $monthStart->getTimestamp()) {
$projectionStart = $monthStart->getTimestamp();
}
$measuredSeconds = max(1, $now->getTimestamp() - $projectionStart);
$currentBilling = current_billing_usage($monthStart->format('Y-m'));
$billingScale = billing_meter_scale($currentBilling);
$billingCurrentBytes = (int)round($adjustedTotalBytes * $billingScale);
$projectedBytes = (int)round($adjustedTotalBytes / $measuredSeconds * $monthSeconds);
$billingProjectedBytes = $adjustedTotalBytes > 0
? max((int)round($projectedBytes * $billingScale), $billingCurrentBytes)
: 0;
$calibration = billing_calibration($currentBilling, $billingScale);
if ($billingProjectedBytes <= 0) {
$billingProjectedBytes = $calibration['projected_total_bytes'];
}
$projectedOverBytes = max($projectedBytes - DATA_MONTHLY_INCLUDED_BYTES, 0);
$projectedOverUnits = $projectedOverBytes > 0 ? (int)ceil($projectedOverBytes / DATA_BILLING_UNIT_BYTES) : 0;
$projectedOverFeeKrw = $projectedOverUnits * DATA_BILLING_UNIT_KRW;
$billingCurrentOverBytes = max($billingCurrentBytes - DATA_MONTHLY_INCLUDED_BYTES, 0);
$billingCurrentOverUnits = $billingCurrentOverBytes > 0 ? (int)ceil($billingCurrentOverBytes / DATA_BILLING_UNIT_BYTES) : 0;
$billingCurrentOverFeeKrw = $billingCurrentOverUnits * DATA_BILLING_UNIT_KRW;
$billingProjectedOverBytes = max($billingProjectedBytes - DATA_MONTHLY_INCLUDED_BYTES, 0);
$billingProjectedOverUnits = $billingProjectedOverBytes > 0 ? (int)ceil($billingProjectedOverBytes / DATA_BILLING_UNIT_BYTES) : 0;
$billingProjectedOverFeeKrw = $billingProjectedOverUnits * DATA_BILLING_UNIT_KRW;
$fixedFeeKrw = DATA_MONTHLY_BASE_FEE_KRW + DATA_MONTHLY_ADDON_FEE_KRW;
$dailyRecommendedBytes = (int)floor(DATA_MONTHLY_INCLUDED_BYTES / max(1, $daysInMonth));
$estimatedTodayBytes = (int)round($todayUsage['adjusted_total_bytes'] * $billingScale);
return [
'month' => $monthStart->format('Y-m'),
'month_start' => $monthStart->format('Y-m-d H:i:s'),
'next_month_start' => $nextMonthStart->format('Y-m-d H:i:s'),
'days_remaining' => (int)floor($remainingSeconds / 86400),
'days_in_month' => $daysInMonth,
'included_bytes' => DATA_MONTHLY_INCLUDED_BYTES,
'daily_recommended_bytes' => $dailyRecommendedBytes,
'estimated_today_bytes' => $estimatedTodayBytes,
'meter_estimated_today_bytes' => $todayUsage['adjusted_total_bytes'],
'sent_bytes' => $sentBytes,
'received_bytes' => $receivedBytes,
'total_bytes' => $totalBytes,
'adjusted_total_bytes' => $adjustedTotalBytes,
'billing_sent_bytes' => (int)round($sentBytes * $billingScale),
'billing_received_bytes' => (int)round($receivedBytes * $billingScale),
'billing_adjusted_failure_bytes' => (int)round($usage['adjusted_failure_bytes'] * $billingScale),
'connected_failure_count' => $usage['connected_failure_count'],
'connect_failure_count' => $usage['connect_failure_count'],
'receive_gap_excluded_count' => $usage['receive_gap_excluded_count'],
'adjusted_failure_bytes' => $usage['adjusted_failure_bytes'],
'today_total_bytes' => $todayUsage['total_bytes'],
'today_adjusted_total_bytes' => $todayUsage['adjusted_total_bytes'],
'today_sample_count' => $todayUsage['sample_count'],
'today_connected_failure_count' => $todayUsage['connected_failure_count'],
'today_connect_failure_count' => $todayUsage['connect_failure_count'],
'today_receive_gap_excluded_count' => $todayUsage['receive_gap_excluded_count'],
'remaining_bytes' => $remainingBytes,
'over_bytes' => $overBytes,
'over_fee_raw_krw' => round($overFeeKrw, 2),
'over_fee_krw' => floor_krw_10($overFeeKrw),
'base_fee_krw' => DATA_MONTHLY_BASE_FEE_KRW,
'addon_fee_krw' => DATA_MONTHLY_ADDON_FEE_KRW,
'fixed_fee_krw' => $fixedFeeKrw,
'estimated_service_fee_raw_krw' => round($fixedFeeKrw + $overFeeKrw, 2),
'estimated_service_fee_krw' => floor_krw_10($fixedFeeKrw + $overFeeKrw),
'projected_total_bytes' => $projectedBytes,
'projected_over_bytes' => $projectedOverBytes,
'projected_over_fee_raw_krw' => round($projectedOverFeeKrw, 2),
'projected_over_fee_krw' => floor_krw_10($projectedOverFeeKrw),
'projected_service_fee_raw_krw' => round($fixedFeeKrw + $projectedOverFeeKrw, 2),
'projected_service_fee_krw' => floor_krw_10($fixedFeeKrw + $projectedOverFeeKrw),
'billing_estimated_current_bytes' => $billingCurrentBytes,
'billing_meter_scale' => round($billingScale, 6),
'billing_remaining_bytes' => max(DATA_MONTHLY_INCLUDED_BYTES - $billingCurrentBytes, 0),
'billing_over_bytes' => $billingCurrentOverBytes,
'billing_over_fee_raw_krw' => round($billingCurrentOverFeeKrw, 2),
'billing_over_fee_krw' => floor_krw_10($billingCurrentOverFeeKrw),
'billing_projected_total_bytes' => $billingProjectedBytes,
'billing_projected_over_bytes' => $billingProjectedOverBytes,
'billing_projected_over_fee_raw_krw' => round($billingProjectedOverFeeKrw, 2),
'billing_projected_over_fee_krw' => floor_krw_10($billingProjectedOverFeeKrw),
'billing_projected_service_fee_raw_krw' => round($fixedFeeKrw + $billingProjectedOverFeeKrw, 2),
'billing_projected_service_fee_krw' => floor_krw_10($fixedFeeKrw + $billingProjectedOverFeeKrw),
'calibration' => $calibration,
'sample_count' => $usage['sample_count'],
'first_usage_ts' => $usage['first_usage_ts'],
'last_usage_ts' => $usage['last_usage_ts'],
'measured_seconds' => $measuredSeconds,
'billing_unit_bytes' => DATA_BILLING_UNIT_BYTES,
'billing_unit_krw' => DATA_BILLING_UNIT_KRW,
];
}
function bytes_to_mb(float $bytes): float
{
return $bytes / 1024 / 1024;
}
function floor_krw_10(float $value): int
{
return (int)floor(max(0.0, $value) / 10) * 10;
}
function usage_success_averages(PDO $pdo, DateTimeInterface $start, DateTimeInterface $end): array
{
$successfulTotalBytes = 0;
$successfulCount = 0;
$avgByCmd = [];
$stmt = $pdo->prepare(""
. "SELECT cmd, SUM(total_bytes) AS total_bytes, COUNT(*) AS sample_count "
. "FROM car_data_usage "
. "WHERE id='car' AND ts >= :start_ts AND ts < :end_ts "
. "AND tcp_ok = 1 AND total_bytes > 0 "
. "GROUP BY cmd"
);
$stmt->execute([
':start_ts' => $start->format('Y-m-d H:i:s'),
':end_ts' => $end->format('Y-m-d H:i:s'),
]);
while ($row = $stmt->fetch()) {
$cmd = (string)($row['cmd'] ?? '');
$total = (int)($row['total_bytes'] ?? 0);
$count = (int)($row['sample_count'] ?? 0);
if ($count <= 0) {
continue;
}
$successfulTotalBytes += $total;
$successfulCount += $count;
$avgByCmd[$cmd] = (int)round($total / $count);
}
$globalAverage = $successfulCount > 0 ? (int)round($successfulTotalBytes / $successfulCount) : 0;
return [
'avg_by_cmd' => $avgByCmd,
'global_average' => $globalAverage,
'success_count' => $successfulCount,
];
}
function calibrated_usage_range(PDO $pdo, DateTimeInterface $start, DateTimeInterface $end): array
{
$averages = usage_success_averages($pdo, $start, $end);
$avgByCmd = $averages['avg_by_cmd'];
$globalAverage = $averages['global_average'];
$sentBytes = 0;
$receivedBytes = 0;
$totalBytes = 0;
$firstUsageTs = null;
$lastUsageTs = null;
$adjustedTotalBytes = 0;
$adjustedFailureBytes = 0;
$connectedFailureCount = 0;
$connectFailureCount = 0;
$receiveGapExcludedCount = 0;
$lastSuccessfulTs = null;
$sampleCount = 0;
$stmt = null;
$previousBuffered = $pdo->getAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY);
$pdo->setAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, false);
try {
$stmt = $pdo->prepare(""
. "SELECT ts, cmd, sent_bytes, received_bytes, total_bytes, tcp_ok "
. "FROM car_data_usage "
. "WHERE id='car' AND ts >= :start_ts AND ts < :end_ts "
. "ORDER BY ts ASC"
);
$stmt->execute([
':start_ts' => $start->format('Y-m-d H:i:s'),
':end_ts' => $end->format('Y-m-d H:i:s'),
]);
while ($row = $stmt->fetch()) {
$sampleCount++;
$cmd = (string)($row['cmd'] ?? '');
$sent = (int)($row['sent_bytes'] ?? 0);
$received = (int)($row['received_bytes'] ?? 0);
$total = (int)($row['total_bytes'] ?? 0);
$tcpOk = (int)($row['tcp_ok'] ?? 0) === 1;
$rowTs = strtotime((string)($row['ts'] ?? ''));
$sentBytes += $sent;
$receivedBytes += $received;
$totalBytes += $total;
if ($firstUsageTs === null && !empty($row['ts'])) {
$firstUsageTs = (string)$row['ts'];
}
if (!empty($row['ts'])) {
$lastUsageTs = (string)$row['ts'];
}
if ($tcpOk) {
$adjustedTotalBytes += $total;
if ($rowTs !== false) {
$lastSuccessfulTs = $rowTs;
}
continue;
}
if ($sent <= 0) {
// TCP 연결 자체가 실패한 건은 모뎀까지 명령이 전달되지 않은 것으로 보고 과금 추정에서 제외한다.
$connectFailureCount++;
continue;
}
$connectedFailureCount++;
if (
$lastSuccessfulTs === null ||
$rowTs === false ||
($rowTs - $lastSuccessfulTs) > RECEIVE_GAP_LIMIT_SEC
) {
$receiveGapExcludedCount++;
continue;
}
$estimatedBytes = $avgByCmd[$cmd] ?? $globalAverage;
if ($estimatedBytes <= 0) {
$estimatedBytes = max($sent, $total);
}
$adjustedTotalBytes += $estimatedBytes;
$adjustedFailureBytes += $estimatedBytes;
}
} finally {
if ($stmt instanceof PDOStatement) {
$stmt->closeCursor();
}
$pdo->setAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, $previousBuffered);
}
return [
'sent_bytes' => $sentBytes,
'received_bytes' => $receivedBytes,
'total_bytes' => $totalBytes,
'adjusted_total_bytes' => $adjustedTotalBytes,
'adjusted_failure_bytes' => $adjustedFailureBytes,
'connected_failure_count' => $connectedFailureCount,
'connect_failure_count' => $connectFailureCount,
'receive_gap_excluded_count' => $receiveGapExcludedCount,
'sample_count' => $sampleCount,
'success_count' => $averages['success_count'],
'success_average_bytes' => $globalAverage,
'first_usage_ts' => $firstUsageTs,
'last_usage_ts' => $lastUsageTs,
];
}
function current_billing_usage(string $month): ?array
{
if (!isset(DATA_CURRENT_BILLING_USAGE_BYTES[$month])) {
return null;
}
$row = DATA_CURRENT_BILLING_USAGE_BYTES[$month];
return [
'month' => $month,
'total_bytes' => (int)($row['total_bytes'] ?? 0),
'total_mb' => round(bytes_to_mb((int)($row['total_bytes'] ?? 0)), 6),
'meter_adjusted_bytes' => (int)($row['meter_adjusted_bytes'] ?? 0),
'included_bytes' => (int)($row['included_bytes'] ?? DATA_MONTHLY_INCLUDED_BYTES),
'included_mb' => round(bytes_to_mb((int)($row['included_bytes'] ?? DATA_MONTHLY_INCLUDED_BYTES)), 6),
'coupon_registered_at' => $row['coupon_registered_at'] ?? null,
];
}
function billing_meter_scale(?array $currentBilling): float
{
if (!$currentBilling) {
return 1.0;
}
$meterAdjustedBytes = (int)($currentBilling['meter_adjusted_bytes'] ?? 0);
if ($meterAdjustedBytes <= 0) {
return 1.0;
}
return (int)($currentBilling['total_bytes'] ?? 0) / $meterAdjustedBytes;
}
function billing_calibration(?array $currentBilling, float $billingScale): array
{
$estimatedCurrentBytes = $currentBilling ? (int)$currentBilling['total_bytes'] : 0;
$source = $currentBilling ? 'carrier_scaled_meter_usage' : 'meter_adjusted_usage';
return [
'mode' => $source,
'note' => $currentBilling
? 'Carrier portal current usage is used to reverse-calculate a billing scale for metered local sent/received bytes. Metered local usage remains available separately.'
: 'Metered usage includes successful requests and connected timeouts within 60 seconds of the last successful receive. Pure connect failures and longer receive gaps are excluded.',
'current_billing' => $currentBilling,
'billing_meter_scale' => round($billingScale, 6),
'projected_total_bytes' => 0,
'projected_total_mb' => 0.0,
'estimated_current_bytes' => $estimatedCurrentBytes,
'estimated_current_mb' => round(bytes_to_mb($estimatedCurrentBytes), 2),
'formula_fee_per_mb_krw' => round(1024 * 1024 / DATA_BILLING_UNIT_BYTES * DATA_BILLING_UNIT_KRW, 3),
];
}
function json_response(array $payload, int $status = 200): void
{
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-cache, no-store, must-revalidate');
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
function car_monitor_launcher_session(): array
{
static $session = null;
if (is_array($session)) {
return $session;
}
$commonAuth = '/mnt/synology-web/custom/common/auth.php';
if (is_readable($commonAuth)) {
require_once $commonAuth;
if (function_exists('custom_has_auth_cookie') && custom_has_auth_cookie()) {
$isAdmin = function_exists('custom_auth_cookie_is_admin') && custom_auth_cookie_is_admin();
$session = ['authenticated' => true, 'admin' => $isAdmin];
return $session;
}
if (function_exists('custom_is_site_admin') && custom_is_site_admin()) {
if (function_exists('custom_issue_auth_cookie')) {
custom_issue_auth_cookie(true);
}
$session = ['authenticated' => true, 'admin' => true];
return $session;
}
}
$cookieHeader = trim((string)($_SERVER['HTTP_COOKIE'] ?? ''));
if ($cookieHeader === '') {
$session = ['authenticated' => false, 'admin' => false];
return $session;
}
$url = 'https://chaegeon.com/custom/launcher/api.php?session=1';
$body = false;
if (function_exists('curl_init')) {
$ch = curl_init($url);
if ($ch !== false) {
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 2,
CURLOPT_TIMEOUT => 4,
CURLOPT_HTTPHEADER => ['Cookie: ' . $cookieHeader],
CURLOPT_USERAGENT => 'car-monitor-auth/1.0',
]);
$body = curl_exec($ch);
curl_close($ch);
}
}
if ($body === false) {
$context = stream_context_create([
'http' => [
'method' => 'GET',
'timeout' => 4,
'header' => "Cookie: {$cookieHeader}\r\nUser-Agent: car-monitor-auth/1.0\r\n",
],
]);
$body = @file_get_contents($url, false, $context);
}
$data = is_string($body) ? json_decode($body, true) : null;
$session = is_array($data) ? $data : ['authenticated' => false, 'admin' => false];
return $session;
}
function car_monitor_has_access(): bool
{
$session = car_monitor_launcher_session();
return !empty($session['authenticated']);
}
function car_monitor_require_access(bool $json = false): void
{
if (car_monitor_has_access()) {
return;
}
if ($json) {
json_response([
'status' => 'unauthorized',
'message' => '런처 인증이 필요합니다.',
], 401);
}
http_response_code(401);
header('Content-Type: text/html; charset=utf-8');
header('Cache-Control: no-cache, no-store, must-revalidate');
?>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>차량 모니터 인증</title>
<style>
body{margin:0;min-height:100vh;display:grid;place-items:center;background:#f1f5f9;color:#334155;font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
.box{width:min(360px,calc(100vw - 40px));padding:28px;border:1px solid #dbe4f0;border-radius:18px;background:#fff;box-shadow:0 18px 50px rgba(15,23,42,.12);text-align:center}
h1{margin:0 0 10px;font-size:22px}
p{margin:0 0 20px;line-height:1.6;color:#64748b}
a{display:inline-flex;align-items:center;justify-content:center;min-height:44px;padding:0 20px;border-radius:999px;background:#2563eb;color:#fff;text-decoration:none;font-weight:700}
</style>
</head>
<body>
<main class="box">
<h1>인증이 필요합니다</h1>
</main>
</body>
</html>
<?php
exit;
}
if (isset($_GET['mode']) && $_GET['mode'] === 'ajax') {
car_monitor_require_access(true);
try {
$pdo = car_db();
$latest = $pdo->query(""
. "SELECT * FROM car_status "
. "WHERE id='car' "
. "ORDER BY ts DESC LIMIT 1"
)->fetch() ?: null;
$stmtLogs = $pdo->prepare(""
. "SELECT ts, cmd, boundary, engine, driving, battery_voltage, "
. "door_fl, door_fr, door_rl, door_rr, door_trunk, "
. "remote_start_preparing, remote_start_running, remote_start_remaining, "
. "hazard, raw_full, raw_trim "
. "FROM car_status "
. "WHERE id='car' "
. "ORDER BY ts DESC LIMIT :limit"
);
$stmtLogs->bindValue(':limit', LOG_LIMIT, PDO::PARAM_INT);
$stmtLogs->execute();
$logs = $stmtLogs->fetchAll();
$serverNow = time();
foreach ($logs as &$logRow) {
$rowTs = strtotime((string)($logRow['ts'] ?? ''));
$logRow['age_seconds'] = $rowTs !== false
? max(0, $serverNow - $rowTs)
: 0;
}
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);
$isFastPollState = $latest ? (
(int)($latest['driving'] ?? 0) === 1 ||
(int)($latest['engine'] ?? 0) === 1 ||
(int)($latest['remote_start_preparing'] ?? 0) === 1 ||
(int)($latest['remote_start_running'] ?? 0) === 1
) : false;
$isStale = $ageSeconds !== null && $ageSeconds > 30;
$staleReason = null;
if ($isStale && $latestTcp && (int)$latestTcp['tcp_ok'] === 0) {
$staleReason = (string)($latestTcp['tcp_error'] ?? 'tcp_failed');
}
json_response([
'status' => 'success',
'data' => $latest,
'logs' => $logs,
'chart' => $chart,
'meta' => [
'age_seconds' => $ageSeconds,
'stale' => $isStale,
'stale_reason' => $staleReason,
'latest_tcp' => $latestTcp,
'server_time' => $serverNow,
'state_duration' => current_engine_state_duration($pdo, $latest),
'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) {
json_response([
'status' => 'error',
'message' => 'DB 조회 실패',
], 500);
}
}
if (isset($_GET['mode']) && $_GET['mode'] === 'usage') {
car_monitor_require_access(true);
try {
json_response([
'status' => 'success',
'usage' => monthly_data_usage(car_db()),
]);
} catch (Throwable $e) {
json_response([
'status' => 'error',
'message' => '데이터 사용량 조회 실패',
], 500);
}
}
car_monitor_require_access(false);
?>
<!DOCTYPE html>
<html lang="ko" data-bs-theme="light">
<head>
<script src="https://chaegeon.com/log/bancheck.min.js?_=<?php echo time(); ?>"></script>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#0f172a">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="Car Monitor">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>Car Monitor</title>
<link rel="icon" href="/car/assets/favicon.svg" type="image/svg+xml">
<link rel="shortcut icon" href="/car/favicon.ico">
<link rel="icon" href="/car/assets/icon-32.png" type="image/png" sizes="32x32">
<link rel="icon" href="/car/assets/icon-192.png" type="image/png" sizes="192x192">
<link rel="apple-touch-icon" href="/car/assets/apple-touch-icon.png">
<link rel="manifest" href="/car/assets/site.webmanifest">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Gowun+Dodum&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
:root {
--font-main: 'Gowun Dodum', system-ui, -apple-system, sans-serif;
/* [Light Theme Variables] */
--bg-body: #f1f5f9;
--bg-gradient: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
--card-bg: rgba(255, 255, 255, 0.65);
--card-border: rgba(255, 255, 255, 0.8);
--card-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
--text-primary: #334155;
--text-secondary: #64748b;
--text-muted: #94a3b8;
--table-head-text: #64748b;
--table-row-bg: rgba(255, 255, 255, 0.5);
--table-row-hover: rgba(255, 255, 255, 0.9);
--car-fill: #e2e8f0;
--car-stroke: #94a3b8;
--car-glass: rgba(0,0,0,0.1);
--accent-blue: #3b82f6;
--accent-green: #10b981;
--accent-red: #ef4444;
--accent-yellow: #f59e0b;
--box-bg-blue: rgba(59, 130, 246, 0.1);
--box-bg-green: rgba(16, 185, 129, 0.1);
--box-bg-red: rgba(239, 68, 68, 0.1);
--box-bg-yellow: rgba(245, 158, 11, 0.1);
--chart-line: #3b82f6;
--chart-grad-start: rgba(59, 130, 246, 0.4);
--chart-grad-end: rgba(59, 130, 246, 0);
/* Badge Colors (Light) */
--badge-bg-parked: rgba(148, 163, 184, 0.15);
--badge-text-parked: #64748b;
--badge-border-parked: rgba(148, 163, 184, 0.3);
--badge-bg-driving: rgba(16, 185, 129, 0.15);
--badge-text-driving: #10b981;
--badge-border-driving: rgba(16, 185, 129, 0.3);
}
[data-bs-theme="dark"] {
/* [Dark Theme Variables] */
--bg-body: #0b0e14;
--bg-gradient: radial-gradient(circle at 50% 0%, #1c2333 0%, #0b0e14 100%);
--card-bg: rgba(23, 28, 38, 0.6);
--card-border: rgba(255, 255, 255, 0.08);
--card-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
--text-primary: #e2e8f0;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--table-head-text: #94a3b8;
--table-row-bg: rgba(30, 41, 59, 0.4);
--table-row-hover: rgba(30, 41, 59, 0.8);
--car-fill: #1e293b;
--car-stroke: #475569;
--car-glass: rgba(0,0,0,0.5);
--box-bg-blue: rgba(59, 130, 246, 0.15);
--box-bg-green: rgba(16, 185, 129, 0.15);
--box-bg-red: rgba(239, 68, 68, 0.15);
--box-bg-yellow: rgba(245, 158, 11, 0.15);
--chart-line: #60a5fa;
--chart-grad-start: rgba(96, 165, 250, 0.5);
--chart-grad-end: rgba(96, 165, 250, 0);
/* Badge Colors (Dark) */
--badge-bg-parked: rgba(148, 163, 184, 0.2);
--badge-text-parked: #94a3b8;
--badge-border-parked: rgba(148, 163, 184, 0.2);
--badge-bg-driving: rgba(16, 185, 129, 0.2);
--badge-text-driving: #34d399;
--badge-border-driving: rgba(16, 185, 129, 0.2);
}
body {
font-family: var(--font-main);
background: var(--bg-body);
background-image: var(--bg-gradient);
background-attachment: fixed;
color: var(--text-primary);
min-height: 100vh;
transition: background 0.3s ease, color 0.3s ease;
}
/* Glassmorphism Cards */
.glass-card {
background: var(--card-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--card-border);
border-radius: 20px;
box-shadow: var(--card-shadow);
transition: all 0.3s ease;
overflow: hidden; /* Fix for header background corner bleeding */
}
.glass-card > * {
min-width: 0;
}
/* Headers & Typography */
.dashboard-header {
display: flex; justify-content: space-between; align-items: center;
padding: 1.5rem 0.5rem; margin-bottom: 0.5rem;
}
.blog-back-link {
position: fixed;
top: 14px;
left: 16px;
z-index: 1040;
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--accent-blue);
font-size: 14px;
font-weight: 700;
line-height: 1;
text-decoration: none;
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.55);
}
.blog-back-link:hover,
.blog-back-link:focus {
color: var(--accent-blue);
text-decoration: none;
}
.blog-back-link .backArrow {
font-size: 22px;
line-height: 1;
transform: translateY(-1px);
}
.app-title {
font-size: 1.25rem; font-weight: 800; letter-spacing: -0.5px; color: var(--text-primary);
}
.label-text { font-size: 0.75rem; font-weight: 700; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 0.5rem; }
.value-text { font-size: 1.8rem; font-weight: 800; letter-spacing: -1px; line-height: 1.1; color: var(--text-primary); }
.unit-text { font-size: 0.9rem; font-weight: 600; color: var(--text-secondary); margin-left: 2px; }
.font-mono { font-family: 'SF Mono', 'Menlo', 'Monaco', monospace; }
.panel-header {
min-height: 55px;
display: flex;
align-items: center;
padding: 1rem;
border-bottom: 1px solid rgba(108, 117, 125, 0.1);
background: rgba(108, 117, 125, 0.1);
}
/* Icon Squares */
.icon-box {
width: 46px; height: 46px; border-radius: 14px;
display: flex; align-items: center; justify-content: center;
font-size: 1.25rem; margin-bottom: 1rem;
border: 1px solid transparent; transition: all 0.3s ease;
}
.c-blue { background: var(--box-bg-blue); color: var(--accent-blue); border-color: rgba(59, 130, 246, 0.2); }
.c-green { background: var(--box-bg-green); color: var(--accent-green); border-color: rgba(16, 185, 129, 0.2); }
.c-red { background: var(--box-bg-red); color: var(--accent-red); border-color: rgba(239, 68, 68, 0.2); }
.c-yellow { background: var(--box-bg-yellow); color: var(--accent-yellow); border-color: rgba(245, 158, 11, 0.2); }
/* Car Visuals */
.car-container {
position: relative;
height: 390px;
display: flex;
justify-content: center;
align-items: center;
}
.car-body {
fill: var(--car-fill); stroke: var(--car-stroke); stroke-width: 2;
transition: all 0.5s ease; filter: drop-shadow(0 10px 15px rgba(0,0,0,0.1));
}
.car-glass { fill: var(--car-glass); }
/* Door Indicators */
.door-line {
position: absolute; background: var(--accent-red);
box-shadow: 0 0 10px var(--accent-red);
opacity: 0; border-radius: 2px;
transform: var(--door-transform, none);
transform-origin: center;
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.door-line.open { opacity: 1; transform: var(--door-transform, none) scale(1.1); }
/* 더뉴 니로 2020 탑뷰 실루엣 기준 */
.dp-fl { top: 132px; left: calc(50% - 86px); width: 4px; height: 72px; }
.dp-fr { top: 132px; right: calc(50% - 86px); width: 4px; height: 72px; }
.dp-rl { top: 225px; left: calc(50% - 86px); width: 4px; height: 78px; }
.dp-rr { top: 225px; right: calc(50% - 86px); width: 4px; height: 78px; }
.dp-tk { --door-transform: translateX(-50%); bottom: 28px; left: 50%; width: 86px; height: 4px; }
/* Headlights */
.headlight-beam {
position: absolute; top: -40px; left: 50%; transform: translateX(-50%);
width: 220px; height: 200px;
background: radial-gradient(ellipse at bottom, rgba(255, 255, 255, 0.8) 0%, rgba(255,255,255,0) 60%);
mix-blend-mode: screen; opacity: 0; transition: opacity 0.5s ease;
pointer-events: none; z-index: 0;
}
[data-bs-theme="light"] .headlight-beam { mix-blend-mode: hard-light; background: radial-gradient(ellipse at bottom, rgba(255, 200, 0, 0.5) 0%, rgba(255,255,255,0) 60%); }
.headlight-beam.on { opacity: 0.6; }
/* Engine Icon */
.engine-indicator {
position: absolute;
top: 46%;
left: 50%;
transform: translate(-50%, -50%);
}
.engine-indicator.active {
color: var(--accent-yellow);
filter: drop-shadow(0 0 15px var(--accent-yellow));
animation: spin-slow 4s linear infinite;
}
@keyframes spin-slow { 100% { transform: translate(-50%, -50%) rotate(360deg); } }
/* Log Table Styling */
.table-scroll {
max-height: 400px;
overflow: auto;
-webkit-overflow-scrolling: touch;
scrollbar-gutter: stable;
}
.table-container { width: 100%; min-width: 720px; border-collapse: separate; border-spacing: 0 5px; }
.table-container th {
font-size: 0.7rem; color: var(--table-head-text); font-weight: 700;
padding: 0 10px 10px 10px; text-align: center; border: none;
}
.table-container td {
background: var(--table-row-bg);
padding: 10px 10px; font-size: 0.85rem; color: var(--text-primary);
border: none; vertical-align: middle; text-align: center;
transition: background 0.2s; letter-spacing: -0.2px;
word-break: keep-all;
}
.table-container td:first-child {
padding-left: 8px;
padding-right: 6px;
}
.table-container td:last-child {
padding-left: 16px;
padding-right: 14px;
}
.table-container td:nth-last-child(2) {
font-size: 0.9rem;
}
.table-container th,
.table-container td:nth-child(1),
.table-container td:nth-child(3),
.table-container td:nth-child(7),
.table-container td:nth-child(8) {
white-space: nowrap;
}
.table-container tr:hover td { background: var(--table-row-hover); }
.table-container tr td:first-child { border-top-left-radius: 10px; border-bottom-left-radius: 10px; }
.table-container tr td:last-child { border-top-right-radius: 10px; border-bottom-right-radius: 10px; }
/* Command Pills */
.cmd-pill {
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
padding: 3px 10px; border-radius: 6px;
font-size: 0.75rem; font-weight: 600; min-width: 90px;
}
.duration-line {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.detail-text {
color: var(--text-secondary);
font-family: 'SF Mono', monospace; font-size: 0.75rem;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 180px; margin: 0 auto;
}
.btn-icon-toggle {
width: 40px; height: 40px; border-radius: 50%; border: 1px solid var(--card-border);
background: var(--card-bg); color: var(--text-primary);
display: flex; align-items: center; justify-content: center; cursor: pointer;
transition: all 0.2s;
}
.btn-icon-toggle:hover { background: var(--bg-body); transform: scale(1.05); }
.btn-icon-toggle.active {
background: var(--box-bg-green);
border-color: rgba(34, 197, 94, 0.45);
color: var(--accent-green);
}
@keyframes pulse-ring {
0% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7); }
70% { box-shadow: 0 0 0 10px rgba(239, 68, 68, 0); }
100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); }
}
.status-active-pulse { animation: pulse-ring 2s infinite; }
/* Status Badge */
.status-badge {
padding: 0.35em 1em;
border-radius: 50rem;
font-weight: 500;
font-size: 0.85rem;
border: 1px solid transparent;
transition: all 0.3s ease;
}
.status-badge.parked {
background-color: var(--badge-bg-parked);
color: var(--badge-text-parked);
border-color: var(--badge-border-parked);
}
.status-badge.driving {
background-color: var(--badge-bg-driving);
color: var(--badge-text-driving);
border-color: var(--badge-border-driving);
}
.trim-link {
cursor: pointer;
}
.trim-link:hover {
color: var(--accent-blue);
}
.usage-card {
padding: 1.5rem 1.7rem;
}
.usage-layout {
display: grid;
grid-template-columns: minmax(240px, 305px) 1fr;
gap: 1.3rem;
align-items: center;
}
.usage-head {
display: flex;
align-items: center;
gap: 1rem;
min-width: 0;
}
.usage-icon {
flex: 0 0 auto;
margin-bottom: 0;
}
.usage-main {
min-width: 0;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
scrollbar-gutter: stable;
}
.usage-meter {
height: 11px;
border-radius: 999px;
background: rgba(148, 163, 184, 0.22);
overflow: hidden;
margin-bottom: 0.9rem;
min-width: 660px;
}
.usage-meter-fill {
height: 100%;
width: 0%;
border-radius: inherit;
background: linear-gradient(90deg, var(--accent-green), var(--accent-blue));
transition: width 0.25s ease, background 0.25s ease;
}
.usage-meter-fill.warning {
background: linear-gradient(90deg, var(--accent-yellow), var(--accent-red));
}
.usage-stat {
min-width: 0;
}
.usage-stat .font-mono {
white-space: nowrap;
}
.usage-metrics {
display: flex;
justify-content: space-between;
gap: 1rem;
min-width: 660px;
align-items: flex-start;
}
.usage-metric {
flex: 0 0 auto;
text-align: center;
}
.usage-metric:first-child {
text-align: left;
}
.usage-metric:last-child {
text-align: right;
}
.usage-metric .label-text {
font-size: 0.7rem;
margin-bottom: 0.2rem;
letter-spacing: 0.45px;
white-space: nowrap;
}
.usage-metric .font-mono {
font-size: 1rem;
line-height: 1.2;
white-space: nowrap;
}
.usage-detail-line {
margin-top: 0.55rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.usage-detail-line:empty {
display: none;
}
@media (max-width: 991.98px) {
.usage-layout {
grid-template-columns: 1fr;
}
.usage-metrics {
gap: 0.55rem;
min-width: 0;
}
.usage-meter {
min-width: 0;
}
}
@media (max-width: 575.98px) {
.container {
max-width: 100%;
padding-left: 0.9rem;
padding-right: 0.9rem;
}
.dashboard-header {
gap: 0.8rem;
align-items: flex-start;
}
.dashboard-header > .d-flex:last-child {
gap: 0.45rem !important;
flex: 0 0 auto;
}
.btn-icon-toggle {
width: 36px;
height: 36px;
}
.glass-card.h-100.p-4 {
padding: 1rem !important;
}
.value-text {
font-size: 1.55rem;
}
.icon-box {
width: 42px;
height: 42px;
border-radius: 13px;
margin-bottom: 0.85rem;
}
.usage-card {
padding: 1rem;
}
.usage-head {
gap: 0.75rem;
}
.usage-stat .d-flex {
flex-wrap: nowrap !important;
}
.usage-main {
padding-bottom: 0.25rem;
overflow-x: visible;
overflow-y: visible;
}
.usage-metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem 0.9rem;
min-width: 0;
}
.usage-meter {
min-width: 0;
}
.usage-metric {
min-width: 0;
text-align: center;
}
.usage-metric:nth-child(3n + 1) {
text-align: left;
}
.usage-metric:nth-child(3n) {
text-align: right;
}
.usage-metric .label-text { font-size: 0.64rem; }
.usage-metric .font-mono { font-size: 0.88rem; }
.usage-detail-line {
white-space: normal;
}
.table-scroll {
max-height: 52vh;
}
.table-container {
min-width: 660px;
}
.table-container th {
padding-left: 8px;
padding-right: 8px;
}
.table-container td {
padding: 9px 8px;
font-size: 0.82rem;
}
.cmd-pill {
min-width: 78px;
padding-left: 8px;
padding-right: 8px;
}
.detail-text {
max-width: 140px;
}
}
</style>
</head>
<body>
<a href="/blog" class="blog-back-link"><span class="backArrow"></span> 블로그</a>
<div class="container py-3">
<div class="dashboard-header">
<div class="d-flex align-items-center gap-3">
<div class="bg-primary bg-opacity-10 text-primary rounded-circle d-flex align-items-center justify-content-center border border-primary border-opacity-25" style="width:48px; height:48px;">
<i class="fas fa-car"></i>
</div>
<div>
<div class="d-flex align-items-center gap-2">
<div class="app-title" data-i18n="appTitle">Car Monitor</div>
<span id="live-badge" class="badge bg-secondary bg-opacity-10 text-secondary border border-secondary border-opacity-20 rounded-pill" style="font-size:0.6rem; vertical-align: top;">WAIT</span>
</div>
<div class="text-secondary" style="font-size: 0.8rem;" data-i18n="appSubtitle">Vehicle Status Dashboard</div>
</div>
</div>
<div class="d-flex align-items-center gap-3">
<div class="text-end d-none d-sm-block">
<div class="label-text mb-0" data-i18n="lastData">LAST DATA</div>
<div id="last-updated" class="font-mono fw-bold text-primary">--:--:--</div>
</div>
<button class="btn-icon-toggle shadow-sm" id="wakelock-btn" onclick="toggleWakeLock()" title="WakeLock">
<i class="fas fa-mobile-screen-button"></i>
</button>
<button class="btn-icon-toggle shadow-sm" id="lang-btn" onclick="toggleLanguage()" title="Language">
<i class="fas fa-globe"></i>
</button>
<button class="btn-icon-toggle shadow-sm" id="theme-btn" onclick="toggleTheme()">
<i class="fas fa-moon"></i>
</button>
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-6 col-md-3">
<div class="glass-card h-100 p-4 position-relative">
<div class="icon-box c-blue"><i class="fas fa-car-battery"></i></div>
<div class="label-text" data-i18n="battery">Battery</div>
<div class="d-flex align-items-baseline">
<span id="val-battery" class="value-text">-</span><span id="val-battery-unit" class="unit-text">V</span>
</div>
<div id="val-battery-state" class="font-mono small mt-1 text-muted" style="font-size: 0.75rem; opacity: 0.8;">-</div>
<div style="height: 60px; position: absolute; bottom: 0; left: 0; right: 0; opacity: 0.5;">
<canvas id="chartBattery"></canvas>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="glass-card h-100 p-4">
<div class="icon-box c-yellow"><i class="fas fa-power-off"></i></div>
<div class="label-text" data-i18n="engine">Engine</div>
<div class="d-flex align-items-center gap-2 mb-2">
<span id="val-engine" class="value-text text-secondary">OFF</span>
</div>
<div id="val-state-timer" class="font-mono text-secondary mb-2 duration-line" style="font-size: 0.85rem;">-</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="glass-card h-100 p-4">
<div class="icon-box c-red"><i class="fas fa-mobile-screen"></i></div>
<div class="label-text" data-i18n="remoteStart">Remote Start</div>
<div class="mb-1">
<span id="val-remote" class="value-text text-secondary">Standby</span>
</div>
<div class="d-flex align-items-center text-secondary font-mono small">
<i class="far fa-clock me-1"></i><span id="val-remote-time">--:--</span>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="glass-card h-100 p-4">
<div class="d-flex justify-content-between align-items-start">
<div class="icon-box c-green"><i class="fas fa-signal"></i></div>
<div class="text-end">
<div class="label-text mb-0" style="font-size:0.65rem;" data-i18n="age">AGE</div>
<div id="age-seconds" class="font-mono fw-bold text-muted duration-line">-</div>
</div>
</div>
<div class="label-text mt-1" data-i18n="pollTarget">Poll Target</div>
<div>
<span id="val-poll-interval" class="value-text">-</span><span id="val-poll-unit" class="unit-text">s</span>
</div>
<div id="val-freshness" class="font-mono small mt-1 text-muted" style="font-size: 0.75rem;">-</div>
</div>
</div>
</div>
<div class="glass-card usage-card mb-4">
<div class="usage-layout">
<div class="usage-head">
<div class="icon-box c-green usage-icon"><i class="fas fa-database"></i></div>
<div class="usage-stat">
<div id="usage-title" class="label-text mb-1">데이터 사용량</div>
<div class="d-flex align-items-baseline gap-2 flex-wrap">
<span id="usage-current" class="value-text">-</span>
<span id="usage-limit" class="unit-text">/ 100MB</span>
</div>
</div>
</div>
<div class="usage-main">
<div class="usage-meter">
<div id="usage-meter-fill" class="usage-meter-fill"></div>
</div>
<div class="usage-metrics small">
<div class="usage-metric">
<div class="label-text" data-i18n="dailyRecommended">하루 권장</div>
<div id="usage-daily-rec" class="font-mono fw-bold">-</div>
</div>
<div class="usage-metric">
<div class="label-text" data-i18n="todayUsage">오늘 사용</div>
<div id="usage-today" class="font-mono fw-bold">-</div>
</div>
<div class="usage-metric">
<div class="label-text" data-i18n="remainingUsage">잔여 사용량</div>
<div id="usage-remaining" class="font-mono fw-bold">-</div>
</div>
<div class="usage-metric">
<div class="label-text" data-i18n="estimatedUsage">예상 사용량</div>
<div id="usage-projected-total" class="font-mono fw-bold">-</div>
</div>
<div class="usage-metric">
<div class="label-text" data-i18n="estimatedOverFee">예상 초과</div>
<div id="usage-projected-over-fee" class="font-mono fw-bold">-</div>
</div>
<div class="usage-metric">
<div class="label-text" data-i18n="estimatedFee">예상 요금</div>
<div id="usage-projected-fee" class="font-mono fw-bold">-</div>
</div>
</div>
<div id="usage-detail" class="font-mono small text-muted usage-detail-line"></div>
</div>
</div>
</div>
<div class="row g-4">
<div class="col-lg-4">
<div class="glass-card h-100 d-flex flex-column">
<div class="panel-header">
<h6 class="mb-0 fw-bold small text-uppercase text-secondary"><i class="fas fa-car-side me-2"></i><span data-i18n="realtimeVisual">Real-time Visual</span></h6>
</div>
<div class="flex-grow-1 car-container">
<div id="visual-headlight" class="headlight-beam"></div>
<svg width="210" height="340" viewBox="0 0 320 640" xmlns="http://www.w3.org/2000/svg" style="z-index: 5;">
<defs>
<linearGradient id="bodyGrad" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stop-color="rgba(255,255,255,0.22)"/>
<stop offset="100%" stop-color="rgba(0,0,0,0.05)"/>
</linearGradient>
<linearGradient id="glassGrad" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stop-color="rgba(255,255,255,0.18)"/>
<stop offset="100%" stop-color="rgba(0,0,0,0.18)"/>
</linearGradient>
<filter id="softShadow" x="-30%" y="-30%" width="160%" height="160%">
<feDropShadow dx="0" dy="10" stdDeviation="10" flood-color="rgba(0,0,0,0.18)"/>
</filter>
</defs>
<!-- outer body : compact SUV / Niro-like top view -->
<path class="car-body" filter="url(#softShadow)" d="
M160,36
C205,38 244,54 266,92
C278,114 283,144 285,179
L287,250
L287,408
L285,480
C283,518 276,548 262,571
C239,608 200,622 160,624
C120,622 81,608 58,571
C44,548 37,518 35,480
L33,408
L33,250
L35,179
C37,144 42,114 54,92
C76,54 115,38 160,36
Z" />
<!-- body highlight -->
<path fill="url(#bodyGrad)" d="
M160,52
C199,54 232,68 251,100
C262,119 266,147 268,179
L270,250
L270,404
L268,474
C266,509 260,534 248,554
C228,586 194,600 160,602
C126,600 92,586 72,554
C60,534 54,509 52,474
L50,404
L50,250
L52,179
C54,147 58,119 69,100
C88,68 121,54 160,52
Z" opacity="0.38"/>
<!-- front windshield -->
<path class="car-glass" fill="url(#glassGrad)" d="
M92,122
C106,102 214,102 228,122
L238,192
C239,203 81,203 82,192
Z" />
<!-- roof / cabin -->
<path fill="rgba(0,0,0,0.10)" d="
M90,205
C96,194 224,194 230,205
L240,420
C242,444 78,444 80,420
Z" />
<!-- rear glass -->
<path class="car-glass" fill="url(#glassGrad)" d="
M96,440
C110,430 210,430 224,440
L216,512
C198,524 122,524 104,512
Z" />
<!-- bonnet hint -->
<path fill="rgba(255,255,255,0.10)" d="
M110,82
C126,74 194,74 210,82
C224,89 230,102 232,114
C212,108 108,108 88,114
C90,102 96,89 110,82
Z"/>
<!-- side rails / shoulder -->
<path fill="rgba(0,0,0,0.12)" d="M53,190 L64,188 L70,510 L58,505 Z" />
<path fill="rgba(0,0,0,0.12)" d="M267,190 L256,188 L250,510 L262,505 Z" />
<!-- mirrors -->
<path class="car-body" d="M50,205 L30,196 L30,228 L50,236 Z" />
<path class="car-body" d="M270,205 L290,196 L290,228 L270,236 Z" />
<!-- front lamp housings -->
<path fill="rgba(255,255,255,0.22)" d="M108,58 C126,50 194,50 212,58 L206,72 C190,66 130,66 114,72 Z"/>
<path fill="rgba(0,0,0,0.08)" d="M82,96 L238,96 L242,108 L78,108 Z"/>
<!-- rear bumper / hatch hint -->
<path fill="rgba(0,0,0,0.10)" d="
M92,548
C112,558 208,558 228,548
L220,578
C200,590 120,590 100,578
Z"/>
<!-- door seams center hints -->
<path stroke="rgba(0,0,0,0.12)" stroke-width="2" fill="none" d="M102,214 L218,214"/>
<path stroke="rgba(0,0,0,0.12)" stroke-width="2" fill="none" d="M98,396 L222,396"/>
<!-- wheel arch hints -->
<path fill="rgba(0,0,0,0.08)" d="M72,250 C66,278 66,372 72,400 L86,400 C80,372 80,278 86,250 Z"/>
<path fill="rgba(0,0,0,0.08)" d="M248,250 C254,278 254,372 248,400 L234,400 C240,372 240,278 234,250 Z"/>
</svg>
<i id="visual-engine-icon" class="fas fa-fan engine-indicator"></i>
<div id="door-fl" class="door-line dp-fl"></div>
<div id="door-fr" class="door-line dp-fr"></div>
<div id="door-rl" class="door-line dp-rl"></div>
<div id="door-rr" class="door-line dp-rr"></div>
<div id="door-trunk" class="door-line dp-tk"></div>
</div>
</div>
</div>
<div class="col-lg-8">
<div class="glass-card h-100 d-flex flex-column">
<div class="panel-header">
<h6 class="mb-0 fw-bold small text-uppercase text-secondary"><i class="fas fa-list-ul me-2"></i><span data-i18n="statusLog">Status Log</span></h6>
</div>
<div class="p-3 flex-grow-1">
<div class="table-responsive table-scroll">
<table class="table-container">
<thead>
<tr>
<th width="10%" data-i18n="time">TIME</th>
<th width="9%" data-i18n="cmd">CMD</th>
<th width="7%" data-i18n="engShort">ENG</th>
<th width="8%" data-i18n="batShort">BAT</th>
<th width="11%" data-i18n="door">DOOR</th>
<th width="9%" data-i18n="remoteShort">REMOTE</th>
<th width="7%" data-i18n="hazShort">HAZ</th>
<th width="7%" data-i18n="age">AGE</th>
<th data-i18n="trim">TRIM</th>
</tr>
</thead>
<tbody id="log-table-body"></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
const I18N = {
ko: {
appTitle: '차량 모니터',
appSubtitle: '차량 상태 대시보드',
lastData: '마지막 데이터',
battery: '배터리',
engine: '시동',
remoteStart: '원격 시동',
age: '경과',
pollTarget: '조회 주기',
realtimeVisual: '실시간 차량',
statusLog: '상태 기록',
time: '시간',
cmd: '명령',
engShort: '시동',
batShort: '전압',
door: '도어',
remoteShort: '원격시동',
hazShort: '비상등',
trim: '요약',
dailyRecommended: '권장 사용량',
todayUsage: '오늘 사용량',
remainingUsage: '잔여 사용량',
estimatedUsage: '예상 사용량',
estimatedOverFee: '예상 초과',
estimatedFee: '예상 요금',
rawFullData: '원본 데이터',
wait: '대기',
online: '정상',
stale: '지연',
error: '오류',
dataDelayed: '데이터 지연',
targetInterval: '정상 수신',
on: '켜짐',
off: '꺼짐',
yes: '주행',
no: '정차',
parked: '주차',
driving: '주행',
running: '실행 중',
preparing: '준비 중',
standby: '대기',
charging: '충전 중',
normal: '정상',
watch: '주의',
low: '낮음',
engineStateOnSentence: '시동이 {duration} 켜져있음',
engineStateOffSentence: '시동이 {duration} 꺼져있음',
dataUsageTitle: '{month} 데이터 사용량 / {days}일 남음',
wakeLockTitle: '화면 켜짐 유지',
wakeLockUnsupported: '화면 켜짐 유지 미지원',
languageTitle: '언어 변경',
rawView: '원본 보기',
cmdStatus: '상태',
cmdEngOff: '시동 끔',
cmdEngOn: '시동 켬',
cmdHazOn: '비상등 켬',
cmdHazOff: '비상등 끔',
cmdLock: '잠금',
cmdUnlock: '잠금 해제',
cmdTrunk: '트렁크',
secShort: '초',
dayShort: '일',
hourShort: '시간',
minShort: '분',
},
en: {
appTitle: 'Car Monitor',
appSubtitle: 'Vehicle Status Dashboard',
lastData: 'LAST DATA',
battery: 'Battery',
engine: 'Engine',
remoteStart: 'Remote Start',
age: 'Age',
pollTarget: 'Poll Target',
realtimeVisual: 'Real-time Visual',
statusLog: 'Status Log',
time: 'TIME',
cmd: 'CMD',
engShort: 'ENG',
batShort: 'BAT',
door: 'DOOR',
remoteShort: 'REMOTE',
hazShort: 'HAZ',
trim: 'TRIM',
dailyRecommended: 'Daily Rec.',
todayUsage: 'Today',
remainingUsage: 'Remain',
estimatedUsage: 'Est. Use',
estimatedOverFee: 'Est. Over',
estimatedFee: 'Est. Fee',
rawFullData: 'RAW FULL DATA',
wait: 'WAIT',
online: 'ONLINE',
stale: 'STALE',
error: 'ERROR',
dataDelayed: 'Data delayed',
targetInterval: 'Target interval',
on: 'ON',
off: 'OFF',
yes: 'YES',
no: 'NO',
parked: 'Parked',
driving: 'Driving',
running: 'Running',
preparing: 'Preparing',
standby: 'Standby',
charging: 'Charging',
normal: 'Normal',
watch: 'Watch',
low: 'Low',
engineStateOnSentence: 'Engine has been ON for {duration}',
engineStateOffSentence: 'Engine has been OFF for {duration}',
dataUsageTitle: '{month} Data Usage / {days}d left',
wakeLockTitle: 'Keep screen awake',
wakeLockUnsupported: 'WakeLock unavailable',
languageTitle: 'Change language',
rawView: 'View RAW',
cmdStatus: 'STATUS',
cmdEngOff: 'ENG OFF',
cmdEngOn: 'ENG ON',
cmdHazOn: 'HAZ ON',
cmdHazOff: 'HAZ OFF',
cmdLock: 'LOCK',
cmdUnlock: 'UNLOCK',
cmdTrunk: 'TRUNK',
secShort: 's',
dayShort: 'd',
hourShort: 'h',
minShort: 'm',
}
};
let currentLang = 'ko';
const DEFAULT_USAGE_REFRESH_INTERVAL_SEC = 10;
const CMD_MAP = {
se: { label: 'cmdStatus', icon: 'fa-search', bg: 'var(--box-bg-blue)', color: 'var(--accent-blue)' },
ef: { label: 'cmdEngOff', icon: 'fa-power-off', bg: 'rgba(148, 163, 184, 0.2)', color: 'var(--text-secondary)' },
en: { label: 'cmdEngOn', icon: 'fa-bolt', bg: 'var(--box-bg-red)', color: 'var(--accent-red)' },
hn: { label: 'cmdHazOn', icon: 'fa-triangle-exclamation', bg: 'var(--box-bg-yellow)', color: 'var(--accent-yellow)' },
hf: { label: 'cmdHazOff', icon: 'fa-minus', bg: 'rgba(148, 163, 184, 0.2)', color: 'var(--text-secondary)' },
dl: { label: 'cmdLock', icon: 'fa-lock', bg: 'rgba(139, 92, 246, 0.2)', color: '#a78bfa' },
du: { label: 'cmdUnlock', icon: 'fa-lock-open', bg: 'var(--box-bg-green)', color: 'var(--accent-green)' },
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 usageRefreshIntervalSec = DEFAULT_USAGE_REFRESH_INTERVAL_SEC;
let usageInFlight = false;
let wakeLockSentinel = null;
let wakeLockWanted = false;
let latestDashboardData = null;
let latestDashboardMeta = null;
document.addEventListener('DOMContentLoaded', () => {
initLanguage();
initTheme();
initWakeLock();
initChart();
fetchData();
refreshTimer = setInterval(fetchData, 1000);
fetchUsage();
window.addEventListener('resize', debounce(updateResponsiveDurations, 150));
});
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && wakeLockWanted) {
requestWakeLock();
}
});
function initLanguage() {
const saved = localStorage.getItem('lang');
const browser = (navigator.language || navigator.userLanguage || 'ko').toLowerCase();
currentLang = saved || (browser.startsWith('ko') ? 'ko' : 'en');
applyLanguage();
}
function toggleLanguage() {
currentLang = currentLang === 'ko' ? 'en' : 'ko';
localStorage.setItem('lang', currentLang);
applyLanguage();
fetchData();
}
function t(key) {
return I18N[currentLang]?.[key] || I18N.en[key] || key;
}
function applyLanguage() {
document.documentElement.setAttribute('lang', currentLang);
document.title = t('appTitle');
document.querySelectorAll('[data-i18n]').forEach(el => {
el.textContent = t(el.dataset.i18n);
});
const badge = document.getElementById('live-badge');
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();
}
function initTheme() {
const savedTheme = localStorage.getItem('theme') || 'light';
document.documentElement.setAttribute('data-bs-theme', savedTheme);
updateThemeIcon(savedTheme);
}
function toggleTheme() {
const html = document.documentElement;
const current = html.getAttribute('data-bs-theme');
const next = current === 'light' ? 'dark' : 'light';
html.setAttribute('data-bs-theme', next);
localStorage.setItem('theme', next);
updateThemeIcon(next);
updateChartTheme();
}
function updateThemeIcon(theme) {
const icon = document.querySelector('#theme-btn i');
icon.className = theme === 'dark' ? 'fas fa-sun text-warning' : 'fas fa-moon text-secondary';
}
function initWakeLock() {
wakeLockWanted = localStorage.getItem('wakeLock') === 'on';
updateWakeLockButton();
if (wakeLockWanted) {
requestWakeLock();
}
}
function wakeLockSupported() {
return 'wakeLock' in navigator;
}
async function requestWakeLock() {
if (!wakeLockSupported()) {
wakeLockWanted = false;
localStorage.setItem('wakeLock', 'off');
updateWakeLockButton();
return;
}
try {
wakeLockSentinel = await navigator.wakeLock.request('screen');
wakeLockSentinel.addEventListener('release', () => {
wakeLockSentinel = null;
updateWakeLockButton();
});
updateWakeLockButton();
} catch (error) {
wakeLockSentinel = null;
updateWakeLockButton();
}
}
async function releaseWakeLock() {
if (wakeLockSentinel) {
try {
await wakeLockSentinel.release();
} catch (error) {
// Browser may already have released it on visibility or power policy changes.
}
}
wakeLockSentinel = null;
updateWakeLockButton();
}
function toggleWakeLock() {
wakeLockWanted = !wakeLockWanted;
localStorage.setItem('wakeLock', wakeLockWanted ? 'on' : 'off');
if (wakeLockWanted) {
requestWakeLock();
} else {
releaseWakeLock();
}
updateWakeLockButton();
}
function updateWakeLockButton() {
const btn = document.getElementById('wakelock-btn');
if (!btn) return;
const supported = wakeLockSupported();
const active = Boolean(wakeLockSentinel);
btn.classList.toggle('active', active);
btn.disabled = !supported;
btn.title = supported ? t('wakeLockTitle') : t('wakeLockUnsupported');
btn.setAttribute('aria-label', btn.title);
}
function getChartColors() {
const style = getComputedStyle(document.body);
return {
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));
}
function fetchUsage() {
if (usageInFlight) {
return;
}
usageInFlight = true;
if (usageTimer) {
clearTimeout(usageTimer);
usageTimer = null;
}
fetch('?mode=usage', { cache: 'no-store' })
.then(res => res.json())
.then(payload => {
if (payload.status !== 'success') return;
updateUsage(payload.usage || null);
})
.catch(() => {})
.finally(() => {
usageInFlight = false;
scheduleUsageFetch();
});
}
function scheduleUsageFetch() {
if (usageTimer) {
clearTimeout(usageTimer);
}
usageTimer = setTimeout(fetchUsage, usageRefreshIntervalSec * 1000);
}
function normalizedUsageInterval(value) {
const seconds = Number(value);
return seconds > 0 ? seconds : DEFAULT_USAGE_REFRESH_INTERVAL_SEC;
}
function updateLiveBadge(ok, stale = false) {
const badge = document.getElementById('live-badge');
if (!ok) {
badge.textContent = t('error');
badge.className = 'badge bg-danger bg-opacity-10 text-danger border border-danger border-opacity-20 rounded-pill';
return;
}
if (stale) {
badge.textContent = t('stale');
badge.className = 'badge bg-warning bg-opacity-10 text-warning border border-warning border-opacity-20 rounded-pill';
return;
}
badge.textContent = t('online');
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}', `<span class="fw-bold">${duration}</span>`);
document.getElementById('val-state-timer').innerHTML = sentence;
}
function updateUsage(usage) {
if (!usage) return;
const total = Number(usage.total_bytes || 0);
const billingTotal = Number(usage.billing_estimated_current_bytes ?? total);
const included = Number(usage.included_bytes || 0);
const percent = included > 0 ? Math.min((billingTotal / included) * 100, 100) : 0;
const fill = document.getElementById('usage-meter-fill');
const overBytes = Number(usage.billing_over_bytes ?? usage.over_bytes ?? 0);
const projectedOverBytes = Number(usage.billing_projected_over_bytes ?? usage.projected_over_bytes ?? 0);
const projectedTotal = Number(usage.billing_projected_total_bytes ?? usage.projected_total_bytes ?? billingTotal);
const daysRemaining = Number(usage.days_remaining || 0);
const estimatedOverFee = Number(usage.billing_projected_over_fee_krw ?? usage.projected_over_fee_krw ?? 0);
const estimatedFee = Number(usage.billing_projected_service_fee_krw ?? usage.projected_service_fee_krw ?? usage.estimated_service_fee_krw ?? 0);
document.getElementById('usage-title').textContent =
t('dataUsageTitle')
.replace('{month}', formatUsageMonth(usage.month))
.replace('{days}', String(daysRemaining));
document.getElementById('usage-current').textContent = formatPreciseMb(billingTotal);
document.getElementById('usage-limit').textContent = '/ ' + formatBytes(included);
document.getElementById('usage-daily-rec').textContent = formatPreciseMb(Number(usage.daily_recommended_bytes || 0));
document.getElementById('usage-today').textContent = formatPreciseMb(Number(usage.estimated_today_bytes || 0));
document.getElementById('usage-remaining').textContent = formatPreciseMb(Number(usage.billing_remaining_bytes ?? usage.remaining_bytes ?? 0));
document.getElementById('usage-projected-total').textContent = formatPreciseMb(projectedTotal);
document.getElementById('usage-projected-over-fee').textContent = formatKrw(estimatedOverFee);
document.getElementById('usage-projected-fee').textContent = formatKrw(estimatedFee);
document.getElementById('usage-detail').textContent = '';
fill.style.width = percent.toFixed(2) + '%';
fill.classList.toggle('warning', overBytes > 0 || projectedOverBytes > 0 || percent >= 85);
}
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;
return new Date(
Number(match[1]),
Number(match[2]) - 1,
Number(match[3]),
Number(match[4]),
Number(match[5]),
Number(match[6])
).getTime();
}
function updateLogs(logs, meta = {}) {
const tbody = document.getElementById('log-table-body');
const serverNowMs = Number(meta.server_time || 0) > 0 ? Number(meta.server_time) * 1000 : Date.now();
const html = logs.map(log => {
const cmdCode = String(log.cmd || '').toLowerCase();
const cmd = CMD_MAP[cmdCode] || { label: '', icon: 'fa-terminal', bg: 'rgba(128,128,128,0.1)', color: 'var(--text-secondary)' };
const cmdText = cmd.label ? t(cmd.label) : (cmdCode.toUpperCase() || '-');
const logMs = parseLocalTimestamp(log.ts);
const ageSec = Number.isFinite(Number(log.age_seconds))
? Math.max(0, Number(log.age_seconds))
: (logMs === null ? 0 : Math.max(0, Math.floor((serverNowMs - logMs) / 1000)));
const doors = doorSummary(log);
const remote = remoteSummary(log);
const trim = escapeHtml(log.raw_trim || '-');
const raw = escapeHtml(log.raw_full || '');
return `
<tr>
<td class="font-mono text-muted">${escapeHtml(String(log.ts || '').substring(11))}</td>
<td><div class="cmd-pill" style="background:${cmd.bg}; color:${cmd.color}"><i class="fas ${cmd.icon}"></i> ${cmdText}</div></td>
<td class="font-mono ${parseInt(log.engine) === 1 ? 'text-warning' : 'text-secondary'}">${parseInt(log.engine) === 1 ? t('on') : t('off')}</td>
<td class="font-mono">${formatVoltage(log.battery_voltage)}</td>
<td class="font-mono text-secondary">${doors}</td>
<td class="font-mono text-secondary">${remote}</td>
<td class="font-mono ${parseInt(log.hazard) === 1 ? 'text-warning' : 'text-muted'}">${parseInt(log.hazard) === 1 ? t('on') : '-'}</td>
<td class="font-mono text-muted">${formatAge(ageSec)}</td>
<td><div class="detail-text trim-link" data-raw="${raw}" title="${t('rawView')}">${trim}</div></td>
</tr>`;
}).join('');
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');
if (parseInt(row.door_fr) === 1) open.push('FR');
if (parseInt(row.door_rl) === 1) open.push('RL');
if (parseInt(row.door_rr) === 1) open.push('RR');
if (parseInt(row.door_trunk) === 1) open.push('TR');
return open.length ? open.join(',') : '-';
}
function remoteSummary(row) {
if (parseInt(row.remote_start_running) === 1) return t('running') + ' ' + (row.remote_start_remaining || '');
if (parseInt(row.remote_start_preparing) === 1) return t('preparing');
return '-';
}
function formatVoltage(v) {
const n = parseFloat(v || 0);
return n ? n.toFixed(1) + 'V' : '-';
}
function formatBytes(bytes) {
const n = Math.max(0, Number(bytes || 0));
if (n >= 1024 * 1024) return (n / 1024 / 1024).toFixed(2) + 'MB';
if (n >= 1024) return (n / 1024).toFixed(1) + 'KB';
return Math.round(n) + 'B';
}
function formatPreciseMb(bytes) {
const n = Math.max(0, Number(bytes || 0));
return (n / 1024 / 1024).toFixed(3) + 'MB';
}
function formatKrw(value) {
const n = Math.max(0, Number(value || 0));
const billed = Math.floor(n / 10) * 10;
if (currentLang === 'ko') return billed.toLocaleString('ko-KR') + '원';
return '₩' + billed.toLocaleString('en-US');
}
function formatUsageMonth(value) {
const parts = String(value || '').split('-');
if (parts.length !== 2) return value || '-';
const month = Number(parts[1]);
if (!month) return value;
return currentLang === 'ko' ? month + '월' : new Date(2000, month - 1, 1).toLocaleString('en-US', { month: 'short' });
}
function formatAge(sec) {
if (sec < 60) return '+' + formatSecondsShort(sec);
if (sec < 3600) return '+' + Math.floor(sec / 60) + t('minShort');
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
document.addEventListener('click', e => {
const el = e.target.closest('.trim-link');
if (!el) return;
document.getElementById('rawModalContent').textContent = el.dataset.raw || '(empty)';
new bootstrap.Modal(document.getElementById('rawModal')).show();
});
</script>
<div class="modal fade" id="rawModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content glass-card">
<div class="modal-header">
<h6 class="modal-title fw-bold" data-i18n="rawFullData">RAW FULL DATA</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<pre id="rawModalContent" class="font-mono small mb-0"></pre>
</div>
</div>
</div>
</div>
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/car/sw.js', {scope: '/car/'}).catch(() => {});
});
}
</script>
<script src="https://chaegeon.com/log/logger.js"></script>
</body>
</html>