1965 lines
82 KiB
PHP
1965 lines
82 KiB
PHP
<?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' => 26937856, 'meter_adjusted_bytes' => 13240579, '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');
|
|
|
|
$stmt = $pdo->prepare(""
|
|
. "SELECT ts, source, cmd, sent_bytes, received_bytes, total_bytes, tcp_ok, tcp_error "
|
|
. "FROM car_data_usage "
|
|
. "WHERE id='car' AND ts >= :month_start AND ts < :next_month_start "
|
|
. "ORDER BY ts ASC"
|
|
);
|
|
$stmt->execute([
|
|
':month_start' => $monthStart->format('Y-m-d H:i:s'),
|
|
':next_month_start' => $nextMonthStart->format('Y-m-d H:i:s'),
|
|
]);
|
|
$usageRows = $stmt->fetchAll();
|
|
$usage = calibrated_usage_rows($usageRows);
|
|
$todayStart = $now->setTime(0, 0, 0);
|
|
$tomorrowStart = $todayStart->modify('+1 day');
|
|
$todayUsage = calibrated_usage_rows(array_values(array_filter($usageRows, function (array $row) use ($todayStart, $tomorrowStart): bool {
|
|
$ts = strtotime((string)($row['ts'] ?? ''));
|
|
return $ts !== false && $ts >= $todayStart->getTimestamp() && $ts < $tomorrowStart->getTimestamp();
|
|
})));
|
|
|
|
$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 calibrated_usage_rows(array $rows): array
|
|
{
|
|
$sentBytes = 0;
|
|
$receivedBytes = 0;
|
|
$totalBytes = 0;
|
|
$successfulTotalBytes = 0;
|
|
$successfulCount = 0;
|
|
$successByCmd = [];
|
|
$firstUsageTs = null;
|
|
$lastUsageTs = null;
|
|
|
|
foreach ($rows as $row) {
|
|
$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;
|
|
|
|
$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 && $total > 0) {
|
|
$successfulTotalBytes += $total;
|
|
$successfulCount++;
|
|
if (!isset($successByCmd[$cmd])) {
|
|
$successByCmd[$cmd] = ['total' => 0, 'count' => 0];
|
|
}
|
|
$successByCmd[$cmd]['total'] += $total;
|
|
$successByCmd[$cmd]['count']++;
|
|
}
|
|
}
|
|
|
|
$globalAverage = $successfulCount > 0 ? (int)round($successfulTotalBytes / $successfulCount) : 0;
|
|
$avgByCmd = [];
|
|
foreach ($successByCmd as $cmd => $stat) {
|
|
$avgByCmd[$cmd] = $stat['count'] > 0 ? (int)round($stat['total'] / $stat['count']) : $globalAverage;
|
|
}
|
|
|
|
$adjustedTotalBytes = 0;
|
|
$adjustedFailureBytes = 0;
|
|
$connectedFailureCount = 0;
|
|
$connectFailureCount = 0;
|
|
$receiveGapExcludedCount = 0;
|
|
$lastSuccessfulTs = null;
|
|
|
|
foreach ($rows as $row) {
|
|
$cmd = (string)($row['cmd'] ?? '');
|
|
$sent = (int)($row['sent_bytes'] ?? 0);
|
|
$total = (int)($row['total_bytes'] ?? 0);
|
|
$tcpOk = (int)($row['tcp_ok'] ?? 0) === 1;
|
|
$rowTs = strtotime((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;
|
|
}
|
|
|
|
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' => count($rows),
|
|
'success_count' => $successfulCount,
|
|
'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;
|
|
}
|
|
|
|
if (isset($_GET['mode']) && $_GET['mode'] === 'ajax') {
|
|
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();
|
|
|
|
$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,
|
|
'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') {
|
|
try {
|
|
json_response([
|
|
'status' => 'success',
|
|
'usage' => monthly_data_usage(car_db()),
|
|
]);
|
|
} catch (Throwable $e) {
|
|
json_response([
|
|
'status' => 'error',
|
|
'message' => '데이터 사용량 조회 실패',
|
|
], 500);
|
|
}
|
|
}
|
|
?>
|
|
<!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 */
|
|
}
|
|
|
|
/* Headers & Typography */
|
|
.dashboard-header {
|
|
display: flex; justify-content: space-between; align-items: center;
|
|
padding: 1.5rem 0.5rem; margin-bottom: 0.5rem;
|
|
}
|
|
.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;
|
|
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
|
}
|
|
.door-line.open { opacity: 1; transform: 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 { bottom: 28px; left: 50%; transform: translateX(-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-container { width: 100%; 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;
|
|
}
|
|
|
|
.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-theme-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-theme-toggle:hover { background: var(--bg-body); transform: scale(1.05); }
|
|
.btn-lang-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-lang-toggle:hover { background: var(--bg-body); transform: scale(1.05); }
|
|
|
|
@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;
|
|
}
|
|
.usage-meter {
|
|
height: 11px;
|
|
border-radius: 999px;
|
|
background: rgba(148, 163, 184, 0.22);
|
|
overflow: hidden;
|
|
margin-bottom: 0.9rem;
|
|
}
|
|
.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: grid;
|
|
grid-template-columns: repeat(6, minmax(100px, 1fr));
|
|
gap: 0.8rem;
|
|
}
|
|
.usage-metric {
|
|
min-width: 0;
|
|
}
|
|
.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 {
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
gap: 0.55rem;
|
|
}
|
|
}
|
|
@media (max-width: 575.98px) {
|
|
.usage-card {
|
|
padding: 1rem;
|
|
}
|
|
.usage-metrics {
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
gap: 0.45rem;
|
|
}
|
|
.usage-detail-line {
|
|
white-space: normal;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<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-lang-toggle shadow-sm" id="lang-btn" onclick="toggleLanguage()" title="Language">
|
|
<i class="fas fa-globe"></i>
|
|
</button>
|
|
<button class="btn-theme-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" 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">-</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" style="max-height: 400px; overflow-y: auto;">
|
|
<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}일 남음',
|
|
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',
|
|
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;
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
initLanguage();
|
|
initTheme();
|
|
initChart();
|
|
fetchData();
|
|
refreshTimer = setInterval(fetchData, 1000);
|
|
fetchUsage();
|
|
});
|
|
|
|
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'));
|
|
}
|
|
}
|
|
|
|
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 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 || []);
|
|
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;
|
|
updateLiveBadge(true, isStale);
|
|
|
|
document.getElementById('last-updated').textContent = data.ts ? String(data.ts).substring(11) : '--:--:--';
|
|
document.getElementById('age-seconds').textContent = formatAgeUnits(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';
|
|
else if (age > 30) ageEl.className = 'font-mono fw-bold text-warning';
|
|
else ageEl.className = 'font-mono fw-bold text-muted';
|
|
|
|
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');
|
|
}
|
|
|
|
const duration = formatDuration(Number(meta.state_duration || 0));
|
|
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;
|
|
|
|
updateRemote(data);
|
|
updateDoors(data);
|
|
}
|
|
|
|
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 updateLogs(logs) {
|
|
const tbody = document.getElementById('log-table-body');
|
|
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 ageSec = Math.max(0, Math.floor((Date.now() - new Date(log.ts).getTime()) / 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 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 escapeHtml(value) {
|
|
return String(value)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
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>
|