Files
car/monitor.php
T

3362 lines
132 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' => 120064512, 'meter_adjusted_bytes' => 129148416, 'included_bytes' => 104857600, 'coupon_registered_at' => null],
'2026-07' => ['total_bytes' => 6549504, 'meter_adjusted_bytes' => 7169536, '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,
]);
db_ensure_data_usage_schema($pdo);
return $pdo;
}
function db_ensure_data_usage_schema(PDO $pdo): void
{
static $done = false;
if ($done) {
return;
}
$stmt = $pdo->query("SHOW COLUMNS FROM car_data_usage LIKE 'carrier_estimated_bytes'");
if (!$stmt || !$stmt->fetch()) {
$pdo->exec("ALTER TABLE car_data_usage ADD COLUMN carrier_estimated_bytes INT UNSIGNED NOT NULL DEFAULT 0 AFTER total_bytes");
}
$pdo->exec("CREATE TABLE IF NOT EXISTS car_usage_calibrations (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
month CHAR(7) NOT NULL,
anchor_ts DATETIME NOT NULL,
total_bytes BIGINT UNSIGNED NOT NULL,
meter_adjusted_bytes BIGINT UNSIGNED NOT NULL,
included_bytes BIGINT UNSIGNED NOT NULL DEFAULT 104857600,
source VARCHAR(32) NOT NULL DEFAULT 'manual',
note VARCHAR(255) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uniq_month_anchor (month, anchor_ts),
KEY idx_month_anchor (month, anchor_ts)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
$done = true;
}
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'];
$carrierAdjustedTotalBytes = $usage['carrier_adjusted_total_bytes'];
$remainingBytes = max(DATA_MONTHLY_INCLUDED_BYTES - $carrierAdjustedTotalBytes, 0);
$overBytes = max($carrierAdjustedTotalBytes - 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'), $pdo);
$billingScaleInfo = billing_meter_scale_info($monthStart->format('Y-m'), $currentBilling, $pdo);
$billingScale = $billingScaleInfo['scale'];
$billingCurrentBytes = (int)round($carrierAdjustedTotalBytes * $billingScale);
$projectedBytes = (int)round($carrierAdjustedTotalBytes / $measuredSeconds * $monthSeconds);
$billingProjectedBytes = $carrierAdjustedTotalBytes > 0
? max((int)round($projectedBytes * $billingScale), $billingCurrentBytes)
: 0;
$calibration = billing_calibration($currentBilling, $billingScaleInfo);
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['carrier_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,
'carrier_adjusted_total_bytes' => $carrierAdjustedTotalBytes,
'carrier_adjusted_failure_bytes' => $usage['carrier_adjusted_failure_bytes'],
'billing_sent_bytes' => (int)round($sentBytes * $billingScale),
'billing_received_bytes' => (int)round($receivedBytes * $billingScale),
'billing_adjusted_failure_bytes' => (int)round($usage['carrier_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_carrier_adjusted_total_bytes' => $todayUsage['carrier_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_meter_scale_source' => $billingScaleInfo['source'],
'billing_meter_scale_source_month' => $billingScaleInfo['source_month'],
'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 carrier_billing_bytes(int $payloadBytes): int
{
$payloadBytes = max(0, $payloadBytes);
if ($payloadBytes <= 0) {
return 0;
}
return (int)(ceil($payloadBytes / DATA_BILLING_UNIT_BYTES) * DATA_BILLING_UNIT_BYTES);
}
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;
$carrierAdjustedTotalBytes = 0;
$carrierAdjustedFailureBytes = 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, carrier_estimated_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;
$carrierAdjustedTotalBytes += (int)($row['carrier_estimated_bytes'] ?? 0) > 0
? (int)$row['carrier_estimated_bytes']
: carrier_billing_bytes($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;
$carrierEstimatedBytes = carrier_billing_bytes($estimatedBytes);
$carrierAdjustedTotalBytes += $carrierEstimatedBytes;
$carrierAdjustedFailureBytes += $carrierEstimatedBytes;
}
} 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,
'carrier_adjusted_total_bytes' => $carrierAdjustedTotalBytes,
'carrier_adjusted_failure_bytes' => $carrierAdjustedFailureBytes,
'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 billing_usage_row(array $row): array
{
$totalBytes = (int)($row['total_bytes'] ?? 0);
$includedBytes = (int)($row['included_bytes'] ?? DATA_MONTHLY_INCLUDED_BYTES);
return [
'month' => (string)($row['month'] ?? ''),
'anchor_ts' => $row['anchor_ts'] ?? null,
'total_bytes' => $totalBytes,
'total_mb' => round(bytes_to_mb($totalBytes), 6),
'meter_adjusted_bytes' => (int)($row['meter_adjusted_bytes'] ?? 0),
'included_bytes' => $includedBytes,
'included_mb' => round(bytes_to_mb($includedBytes), 6),
'coupon_registered_at' => $row['coupon_registered_at'] ?? null,
'source' => (string)($row['source'] ?? 'manual'),
'note' => $row['note'] ?? null,
'created_at' => $row['created_at'] ?? null,
];
}
function current_billing_usage(string $month, ?PDO $pdo = null): ?array
{
if ($pdo instanceof PDO) {
try {
$stmt = $pdo->prepare("SELECT month, anchor_ts, total_bytes, meter_adjusted_bytes, included_bytes, source, note, created_at
FROM car_usage_calibrations
WHERE month = ?
ORDER BY anchor_ts DESC, id DESC
LIMIT 1");
$stmt->execute([$month]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (is_array($row)) {
return billing_usage_row($row);
}
} catch (Throwable $e) {
// 테이블 생성 전이거나 DB 권한 문제가 있으면 아래 상수 기준점을 사용한다.
}
}
if (!isset(DATA_CURRENT_BILLING_USAGE_BYTES[$month])) {
return null;
}
$row = DATA_CURRENT_BILLING_USAGE_BYTES[$month];
return billing_usage_row([
'month' => $month,
'anchor_ts' => $row['anchor_ts'] ?? null,
'total_bytes' => (int)($row['total_bytes'] ?? 0),
'meter_adjusted_bytes' => (int)($row['meter_adjusted_bytes'] ?? 0),
'included_bytes' => (int)($row['included_bytes'] ?? DATA_MONTHLY_INCLUDED_BYTES),
'coupon_registered_at' => $row['coupon_registered_at'] ?? null,
'source' => 'constant',
'note' => '코드 기준점',
'created_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 previous_billing_usage(string $month, ?PDO $pdo = null): ?array
{
$dbRow = null;
if ($pdo instanceof PDO) {
try {
$stmt = $pdo->prepare("SELECT month, anchor_ts, total_bytes, meter_adjusted_bytes, included_bytes, source, note, created_at
FROM car_usage_calibrations
WHERE month < ?
ORDER BY month DESC, anchor_ts DESC, id DESC
LIMIT 1");
$stmt->execute([$month]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (is_array($row)) {
$dbRow = billing_usage_row($row);
}
} catch (Throwable $e) {
$dbRow = null;
}
}
$constantMonth = null;
foreach (DATA_CURRENT_BILLING_USAGE_BYTES as $billingMonth => $row) {
if ($billingMonth >= $month || ($row['meter_adjusted_bytes'] ?? 0) <= 0) {
continue;
}
if ($constantMonth === null || $billingMonth > $constantMonth) {
$constantMonth = $billingMonth;
}
}
$constantRow = $constantMonth ? current_billing_usage($constantMonth, null) : null;
if ($dbRow && (!$constantRow || strcmp((string)$dbRow['month'], (string)$constantRow['month']) >= 0)) {
return $dbRow;
}
return $constantRow;
}
function billing_meter_scale_info(string $month, ?array $currentBilling, ?PDO $pdo = null): array
{
$currentScale = billing_meter_scale($currentBilling);
if ($currentBilling && $currentScale > 0) {
return [
'scale' => $currentScale,
'source' => 'current_month_anchor',
'source_month' => $month,
'source_billing' => $currentBilling,
];
}
$fallbackBilling = previous_billing_usage($month, $pdo);
$fallbackScale = billing_meter_scale($fallbackBilling);
if ($fallbackBilling && $fallbackScale > 0) {
return [
'scale' => $fallbackScale,
'source' => 'previous_month_anchor',
'source_month' => $fallbackBilling['month'] ?? null,
'source_billing' => $fallbackBilling,
];
}
return [
'scale' => 1.0,
'source' => 'meter_adjusted_usage',
'source_month' => null,
'source_billing' => null,
];
}
function billing_calibration(?array $currentBilling, array $billingScaleInfo): array
{
$estimatedCurrentBytes = $currentBilling ? (int)$currentBilling['total_bytes'] : 0;
$billingScale = (float)($billingScaleInfo['scale'] ?? 1.0);
$source = $billingScaleInfo['source'] ?? 'meter_adjusted_usage';
$sourceBilling = $billingScaleInfo['source_billing'] ?? null;
$note = match ($source) {
'current_month_anchor' => 'T world 현재월 사용량으로 0.5KB 올림 TCP 세션 누적값의 보정 배율을 역산합니다. TCP payload 계측값도 별도로 유지합니다.',
'previous_month_anchor' => '현재월 T world 기준점이 아직 없어서 가장 최근 월 보정 배율을 임시로 사용합니다. 현재월 기준점이 기록되면 그 배율로 다시 계산합니다.',
default => '통신사형 사용량은 과금 대상 TCP 세션을 0.5KB 단위로 올림 처리합니다. 순수 연결 실패와 긴 수신 공백은 제외합니다.',
};
return [
'mode' => $source,
'note' => $note,
'current_billing' => $currentBilling,
'scale_source_billing' => $sourceBilling,
'billing_meter_scale_source' => $source,
'billing_meter_scale_source_month' => $billingScaleInfo['source_month'] ?? null,
'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 car_monitor_is_admin(): bool
{
$session = car_monitor_launcher_session();
return !empty($session['admin']);
}
function car_monitor_require_admin(): void
{
if (car_monitor_is_admin()) {
return;
}
json_response([
'status' => 'forbidden',
'message' => '관리자 권한이 필요합니다.',
], 403);
}
function request_payload(): array
{
$raw = file_get_contents('php://input');
if (is_string($raw) && trim($raw) !== '') {
$json = json_decode($raw, true);
if (is_array($json)) {
return $json;
}
}
return $_POST;
}
function parse_usage_bytes(mixed $value, string $unit): int
{
$normalized = trim(str_replace(',', '', (string)$value));
if ($normalized === '' || !is_numeric($normalized)) {
throw new InvalidArgumentException('T world 사용량을 숫자로 입력해야 합니다.');
}
$amount = (float)$normalized;
if ($amount < 0) {
throw new InvalidArgumentException('T world 사용량은 0 이상이어야 합니다.');
}
return match (strtolower(trim($unit))) {
'b', 'byte', 'bytes' => (int)round($amount),
'kb', 'kib' => (int)round($amount * 1024),
'mb', 'mib' => (int)round($amount * 1024 * 1024),
default => throw new InvalidArgumentException('사용량 단위는 B, KB, MB 중 하나여야 합니다.'),
};
}
function parse_anchor_datetime(?string $value): DateTimeImmutable
{
$timezone = new DateTimeZone(date_default_timezone_get());
$raw = trim((string)$value);
if ($raw === '') {
return new DateTimeImmutable('now', $timezone);
}
$normalized = str_replace('T', ' ', $raw);
if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $normalized)) {
$normalized .= ':00';
}
$dt = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $normalized, $timezone);
if (!$dt) {
throw new InvalidArgumentException('기준 시각 형식이 올바르지 않습니다.');
}
return $dt;
}
function save_usage_calibration(PDO $pdo, array $payload): array
{
$anchor = parse_anchor_datetime($payload['anchor_ts'] ?? null);
$monthStart = $anchor->modify('first day of this month')->setTime(0, 0, 0);
$nextMonthStart = $monthStart->modify('first day of next month');
if ($anchor < $monthStart || $anchor >= $nextMonthStart) {
throw new InvalidArgumentException('기준 시각이 현재 월 범위를 벗어났습니다.');
}
$carrierBytes = parse_usage_bytes($payload['total_usage'] ?? '', (string)($payload['unit'] ?? 'KB'));
$rangeUsage = calibrated_usage_range($pdo, $monthStart, $anchor);
$meterBytes = (int)$rangeUsage['carrier_adjusted_total_bytes'];
if ($meterBytes <= 0) {
throw new RuntimeException('기준 시각까지의 로컬 통신사형 누적값이 없습니다.');
}
$month = $monthStart->format('Y-m');
$anchorText = $anchor->format('Y-m-d H:i:s');
$note = trim((string)($payload['note'] ?? ''));
if (function_exists('mb_strlen') && mb_strlen($note) > 255) {
$note = mb_substr($note, 0, 255);
} elseif (strlen($note) > 255) {
$note = substr($note, 0, 255);
}
$stmt = $pdo->prepare("INSERT INTO car_usage_calibrations
(month, anchor_ts, total_bytes, meter_adjusted_bytes, included_bytes, source, note)
VALUES (?, ?, ?, ?, ?, 'manual', ?)
ON DUPLICATE KEY UPDATE
total_bytes = VALUES(total_bytes),
meter_adjusted_bytes = VALUES(meter_adjusted_bytes),
included_bytes = VALUES(included_bytes),
source = VALUES(source),
note = VALUES(note),
created_at = CURRENT_TIMESTAMP");
$stmt->execute([
$month,
$anchorText,
$carrierBytes,
$meterBytes,
DATA_MONTHLY_INCLUDED_BYTES,
$note === '' ? null : $note,
]);
return [
'month' => $month,
'anchor_ts' => $anchorText,
'total_bytes' => $carrierBytes,
'meter_adjusted_bytes' => $meterBytes,
'scale' => round($carrierBytes / $meterBytes, 6),
'note' => $note === '' ? null : $note,
];
}
function usage_calibration_rows(PDO $pdo): array
{
$stmt = $pdo->query("SELECT month, anchor_ts, total_bytes, meter_adjusted_bytes, included_bytes, source, note, created_at
FROM car_usage_calibrations
ORDER BY month DESC, anchor_ts DESC, id DESC
LIMIT 12");
$rows = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$billing = billing_usage_row($row);
$meter = max(1, (int)$billing['meter_adjusted_bytes']);
$billing['scale'] = round((int)$billing['total_bytes'] / $meter, 6);
$rows[] = $billing;
}
return $rows;
}
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>
<link rel="stylesheet" href="https://chaegeon.com/custom/common/css/launcher.css">
</head>
<body>
<main class="car-auth-page">
<div id="findMyShortcut"></div>
</main>
<script src="https://chaegeon.com/custom/common/js/auth-pin.js?v=<?php echo (int)(@filemtime('/mnt/synology-web/custom/common/js/auth-pin.js') ?: time()); ?>"></script>
<script>
window.addEventListener('DOMContentLoaded', function () {
window.CustomAuthPin && window.CustomAuthPin.render({
mount: '#findMyShortcut',
apiUrl: '/car/monitor.php?mode=auth-login',
title: '비밀번호',
placeholder: '비밀번호',
backLabel: '채건닷컴',
backHref: 'https://chaegeon.com/',
onSuccess: function () { location.reload(); }
});
});
</script>
</body>
</html>
<?php
exit;
}
function car_monitor_issue_pwa_cookie_if_requested(): void
{
if (($_GET['pwa'] ?? '') !== '1') {
return;
}
$fetchDest = strtolower((string)($_SERVER['HTTP_SEC_FETCH_DEST'] ?? ''));
if ($fetchDest !== '' && $fetchDest !== 'document') {
return;
}
$commonAuth = '/mnt/synology-web/custom/common/auth.php';
if (!is_readable($commonAuth)) {
return;
}
require_once $commonAuth;
if (function_exists('custom_issue_auth_cookie')) {
if (function_exists('custom_has_auth_cookie') && custom_has_auth_cookie()) {
header('Location: /car/monitor.php', true, 302);
exit;
}
$isAdmin = function_exists('custom_is_site_admin') && custom_is_site_admin();
custom_issue_auth_cookie($isAdmin);
}
header('Location: /car/monitor.php', true, 302);
exit;
}
function car_monitor_auth_api(): void
{
$commonAuth = '/mnt/synology-web/custom/common/auth.php';
if (!is_readable($commonAuth)) {
json_response(['result' => 'fail', 'message' => 'auth_module_missing'], 500);
}
require_once $commonAuth;
if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['failMessages'])) {
json_response(['failMessages' => ['비밀번호가 틀립니다.']]);
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
json_response(['error' => 'method_not_allowed'], 405);
}
$data = request_payload();
$password = (string)($data['password'] ?? '');
if (preg_match('/^\d{4}$/', $password) !== 1) {
json_response(['result' => 'fail', 'message' => 'password_digits_only'], 401);
}
if (function_exists('custom_verify_password') && custom_verify_password($password)) {
custom_issue_auth_cookie(false);
json_response(['result' => 'ok']);
}
json_response(['result' => 'fail'], 401);
}
if (isset($_GET['mode']) && $_GET['mode'] === 'auth-login') {
car_monitor_auth_api();
}
car_monitor_issue_pwa_cookie_if_requested();
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);
}
}
if (isset($_GET['mode']) && $_GET['mode'] === 'usage-calibration') {
car_monitor_require_access(true);
car_monitor_require_admin();
try {
$pdo = car_db();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$saved = save_usage_calibration($pdo, request_payload());
json_response([
'status' => 'success',
'saved' => $saved,
'usage' => monthly_data_usage($pdo),
'calibrations' => usage_calibration_rows($pdo),
]);
}
json_response([
'status' => 'success',
'usage' => monthly_data_usage($pdo),
'calibrations' => usage_calibration_rows($pdo),
'server_time' => time(),
]);
} catch (Throwable $e) {
json_response([
'status' => 'error',
'message' => $e->getMessage(),
], 400);
}
}
car_monitor_require_access(false);
$carMonitorSession = car_monitor_launcher_session();
?>
<!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">
<script src="/car/assets/asset-reload.js?v=20260721_nocache1" data-service="car" defer></script>
<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 {
display: inline-flex;
align-items: center;
gap: 4px;
margin-bottom: 6px;
color: var(--accent-blue);
font-size: 13px;
font-weight: 700;
line-height: 1;
text-decoration: none;
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.55);
max-width: min(44vw, 260px);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.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-icon-main {
width: 75px;
height: 75px;
font-size: 1.45rem;
flex: 0 0 75px;
}
.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);
}
.admin-settings-btn.d-none {
display: none !important;
}
.custom-modal-overlay {
position: fixed;
inset: 0;
z-index: 1080;
display: flex;
align-items: center;
justify-content: center;
padding: 18px;
background: rgba(15, 23, 42, 0.36);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.custom-modal-overlay[hidden] {
display: none !important;
}
.custom-modal-panel {
width: min(560px, 100%);
max-height: min(760px, calc(100vh - 36px));
overflow: auto;
border-radius: 20px;
background: var(--card-bg);
border: 1px solid var(--card-border);
box-shadow: 0 24px 80px rgba(15, 23, 42, 0.28);
color: var(--text-primary);
}
.custom-modal-header,
.custom-modal-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 18px 20px;
}
.custom-modal-header {
border-bottom: 1px solid var(--card-border);
}
.custom-modal-footer {
border-top: 1px solid var(--card-border);
justify-content: flex-end;
}
.custom-modal-body {
padding: 18px 20px 20px;
}
.calibration-grid {
display: grid;
grid-template-columns: 1fr 120px;
gap: 12px;
}
.calibration-field {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 14px;
}
.calibration-field label {
font-size: 0.78rem;
font-weight: 700;
color: var(--text-secondary);
}
.calibration-field input,
.calibration-field select {
width: 100%;
border: 1px solid var(--card-border);
border-radius: 12px;
background: rgba(255, 255, 255, 0.78);
color: #0f172a;
padding: 10px 12px;
font: inherit;
outline: none;
}
[data-bs-theme="dark"] .calibration-field input,
[data-bs-theme="dark"] .calibration-field select {
background: rgba(15, 23, 42, 0.86);
color: var(--text-primary);
}
.calibration-summary {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin: 14px 0;
}
.calibration-summary-item {
border-radius: 12px;
background: rgba(148, 163, 184, 0.12);
padding: 10px 12px;
}
.calibration-history {
margin-top: 14px;
border-radius: 12px;
overflow: hidden;
border: 1px solid var(--card-border);
}
.calibration-history-row {
display: grid;
grid-template-columns: 92px 1fr 78px;
gap: 8px;
padding: 9px 10px;
font-size: 0.78rem;
border-bottom: 1px solid var(--card-border);
}
.calibration-history-row:last-child {
border-bottom: 0;
}
.calibration-message {
min-height: 22px;
font-size: 0.82rem;
}
@media (max-width: 575px) {
.custom-modal-overlay {
align-items: stretch;
padding: 10px;
}
.custom-modal-panel {
max-height: calc(100vh - 20px);
}
.calibration-grid,
.calibration-summary {
grid-template-columns: 1fr;
}
.calibration-history-row {
grid-template-columns: 1fr;
}
}
@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>
<div class="container py-3">
<div class="dashboard-header">
<div class="d-flex align-items-center gap-3">
<div class="app-icon-main bg-primary bg-opacity-10 text-primary rounded-circle d-flex align-items-center justify-content-center border border-primary border-opacity-25">
<i class="fas fa-car"></i>
</div>
<div>
<a href="https://chaegeon.com/" id="blogBackLink" class="blog-back-link"><span class="backArrow"></span> 채건닷컴</a>
<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>
<button class="btn-icon-toggle shadow-sm admin-settings-btn d-none" id="settings-btn" type="button" title="설정">
<i class="fas fa-gear"></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 CAR_MONITOR_SESSION = <?php echo json_encode($carMonitorSession, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); ?>;
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;
let latestUsageData = null;
let calibrationModalOpen = false;
let calibrationSaveInFlight = false;
const FALLBACK_BACK_TARGET = 'https://chaegeon.com/';
const BACK_HISTORY_KEY = 'chaegeon.backHistory.v1';
const BACK_LINK_PATH_TITLE = {
'/': '채건닷컴',
'/home': '홈',
'/blog': '블로그',
};
document.addEventListener('DOMContentLoaded', () => {
setupBackLink();
initLanguage();
initTheme();
initWakeLock();
initCalibrationSettings();
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 cleanPageTitle(title) {
return String(title || '')
.replace(/^채건닷컴\s*-\s*/i, '')
.replace(/\s*-\s*채건닷컴$/i, '')
.trim();
}
function sameSiteUrl(url) {
try {
const parsed = new URL(url, location.href);
return parsed.hostname === location.hostname || parsed.hostname.endsWith('.chaegeon.com') || parsed.hostname === 'chaegeon.com'
? parsed
: null;
} catch (e) {
return null;
}
}
function titleFromBackUrl(url) {
const parsed = sameSiteUrl(url);
if (!parsed) return '';
return BACK_LINK_PATH_TITLE[parsed.pathname.replace(/\/$/, '') || '/'] || cleanPageTitle(parsed.pathname.split('/').filter(Boolean).pop() || '');
}
function backPageKey(url) {
const parsed = sameSiteUrl(url);
if (!parsed) return '';
const pathname = parsed.pathname.replace(/\/+$/, '') || '/';
return `${parsed.hostname.replace(/^www\./, '')}${pathname}`;
}
function rememberBackHistory() {
try {
const current = new URL(location.href);
const list = JSON.parse(sessionStorage.getItem(BACK_HISTORY_KEY) || '[]')
.filter(item => item && item.href && item.key);
const entry = { href: current.href, key: backPageKey(current.href), time: Date.now() };
const last = list[list.length - 1];
const next = last && last.href === entry.href ? list : [...list, entry];
sessionStorage.setItem(BACK_HISTORY_KEY, JSON.stringify(next.slice(-20)));
} catch (e) {}
}
function storedBackTarget(currentKey) {
try {
const list = JSON.parse(sessionStorage.getItem(BACK_HISTORY_KEY) || '[]');
for (let i = list.length - 1; i >= 0; i--) {
const item = list[i];
if (item?.href && item.key && item.key !== currentKey && sameSiteUrl(item.href)) {
return item.href;
}
}
} catch (e) {}
return '';
}
function updateBackLinkLabel(label) {
const link = document.getElementById('blogBackLink');
if (!link) return;
link.innerHTML = `<span class="backArrow"></span> ${label}`;
}
async function resolveBackTarget() {
const ref = sameSiteUrl(document.referrer || '');
const current = new URL(location.href);
const currentKey = backPageKey(current.href);
rememberBackHistory();
const refIsDifferentPage = ref && backPageKey(ref.href) !== currentKey;
const target = refIsDifferentPage ? ref.href : (storedBackTarget(currentKey) || FALLBACK_BACK_TARGET);
const knownLabel = titleFromBackUrl(target);
let label = knownLabel;
const targetUrl = sameSiteUrl(target);
if (!knownLabel && targetUrl && targetUrl.origin === location.origin) {
try {
const res = await fetch(targetUrl.href, { credentials: 'same-origin', cache: 'force-cache' });
if (res.ok) {
const html = await res.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
label = cleanPageTitle(doc.querySelector('title')?.textContent) || label;
}
} catch (e) {}
}
return { href: target, label: label || '블로그' };
}
function setupBackLink() {
const link = document.getElementById('blogBackLink');
if (!link) return;
link.href = FALLBACK_BACK_TARGET;
updateBackLinkLabel('채건닷컴');
resolveBackTarget().then(({ href, label }) => {
link.href = href;
updateBackLinkLabel(label);
});
link.addEventListener('click', (event) => {
event.preventDefault();
location.href = link.href || FALLBACK_BACK_TARGET;
});
}
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;
latestUsageData = usage;
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);
if (calibrationModalOpen) {
renderCalibrationSummary(usage);
}
}
function initCalibrationSettings() {
const btn = document.getElementById('settings-btn');
if (!btn || !CAR_MONITOR_SESSION?.admin) return;
btn.classList.remove('d-none');
btn.addEventListener('click', openCalibrationModal);
document.getElementById('calibration-close-btn')?.addEventListener('click', closeCalibrationModal);
document.getElementById('calibration-cancel-btn')?.addEventListener('click', closeCalibrationModal);
document.getElementById('calibration-save-btn')?.addEventListener('click', saveCalibration);
['calibration-total-usage', 'calibration-unit'].forEach(id => {
document.getElementById(id)?.addEventListener('input', updateCalibrationPreview);
document.getElementById(id)?.addEventListener('change', updateCalibrationPreview);
});
}
function openCalibrationModal() {
const overlay = document.getElementById('calibration-overlay');
if (!overlay) return;
calibrationModalOpen = true;
overlay.hidden = false;
document.body.style.overflow = 'hidden';
const anchorInput = document.getElementById('calibration-anchor-ts');
if (anchorInput && !anchorInput.value) {
anchorInput.value = formatDateTimeLocal(new Date());
}
setCalibrationMessage('T world 사용량을 입력하면 현재 월 기준점으로 저장합니다.', 'muted');
renderCalibrationSummary(latestUsageData);
fetchCalibrationState();
setTimeout(() => document.getElementById('calibration-total-usage')?.focus(), 80);
}
function closeCalibrationModal() {
const overlay = document.getElementById('calibration-overlay');
if (!overlay) return;
calibrationModalOpen = false;
overlay.hidden = true;
document.body.style.overflow = '';
}
function fetchCalibrationState() {
fetch('?mode=usage-calibration', { cache: 'no-store' })
.then(res => res.json())
.then(payload => {
if (payload.status !== 'success') {
setCalibrationMessage(payload.message || '기준점 조회 실패', 'danger');
return;
}
if (payload.usage) {
latestUsageData = payload.usage;
updateUsage(payload.usage);
}
renderCalibrationHistory(payload.calibrations || []);
})
.catch(() => setCalibrationMessage('기준점 조회 실패', 'danger'));
}
function saveCalibration() {
if (calibrationSaveInFlight) return;
const totalInput = document.getElementById('calibration-total-usage');
const unitInput = document.getElementById('calibration-unit');
const anchorInput = document.getElementById('calibration-anchor-ts');
const noteInput = document.getElementById('calibration-note');
const totalUsage = totalInput?.value?.trim() || '';
if (!totalUsage) {
setCalibrationMessage('T world 사용량을 입력하세요.', 'danger');
totalInput?.focus();
return;
}
calibrationSaveInFlight = true;
const saveBtn = document.getElementById('calibration-save-btn');
if (saveBtn) saveBtn.disabled = true;
setCalibrationMessage('저장 중입니다.', 'muted');
fetch('?mode=usage-calibration', {
method: 'POST',
cache: 'no-store',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
total_usage: totalUsage,
unit: unitInput?.value || 'KB',
anchor_ts: anchorInput?.value || '',
note: noteInput?.value || '',
}),
})
.then(res => res.json())
.then(payload => {
if (payload.status !== 'success') {
setCalibrationMessage(payload.message || '저장 실패', 'danger');
return;
}
if (payload.usage) {
latestUsageData = payload.usage;
updateUsage(payload.usage);
}
renderCalibrationHistory(payload.calibrations || []);
const saved = payload.saved || {};
setCalibrationMessage(`저장 완료: ${saved.month || '-'} 배율 ${Number(saved.scale || 0).toFixed(6)}`, 'success');
fetchUsage();
})
.catch(() => setCalibrationMessage('저장 실패', 'danger'))
.finally(() => {
calibrationSaveInFlight = false;
if (saveBtn) saveBtn.disabled = false;
});
}
function renderCalibrationSummary(usage) {
const setText = (id, value) => {
const el = document.getElementById(id);
if (el) el.textContent = value;
};
if (!usage) {
setText('calibration-month', '-');
setText('calibration-meter', '-');
setText('calibration-current-scale', '-');
setText('calibration-source', '-');
updateCalibrationPreview();
return;
}
setText('calibration-month', usage.month || '-');
setText('calibration-meter', formatPreciseMb(Number(usage.carrier_adjusted_total_bytes || 0)));
setText('calibration-current-scale', Number(usage.billing_meter_scale || 1).toFixed(6));
const sourceMonth = usage.billing_meter_scale_source_month || '-';
const source = usage.billing_meter_scale_source === 'current_month_anchor'
? '현재월'
: (usage.billing_meter_scale_source === 'previous_month_anchor' ? '직전월' : '로컬');
setText('calibration-source', `${source} ${sourceMonth}`);
updateCalibrationPreview();
}
function renderCalibrationHistory(rows) {
const box = document.getElementById('calibration-history');
if (!box) return;
if (!rows.length) {
box.innerHTML = '<div class="calibration-history-row text-muted"><span>저장된 기준점 없음</span></div>';
return;
}
box.innerHTML = rows.map(row => {
const scale = Number(row.scale || 0);
return `
<div class="calibration-history-row">
<span class="font-mono">${escapeHtml(row.month || '-')}</span>
<span>${escapeHtml(String(row.anchor_ts || '-').replace(' ', ' '))}<br><span class="text-muted">${formatPreciseMb(Number(row.total_bytes || 0))} / ${formatPreciseMb(Number(row.meter_adjusted_bytes || 0))}</span></span>
<span class="font-mono text-end">${scale ? scale.toFixed(6) : '-'}</span>
</div>`;
}).join('');
}
function updateCalibrationPreview() {
const amount = parseFloat(String(document.getElementById('calibration-total-usage')?.value || '').replace(/,/g, ''));
const unit = document.getElementById('calibration-unit')?.value || 'KB';
const meterBytes = Number(latestUsageData?.carrier_adjusted_total_bytes || 0);
let bytes = 0;
if (Number.isFinite(amount) && amount >= 0) {
if (unit === 'MB') bytes = amount * 1024 * 1024;
else if (unit === 'KB') bytes = amount * 1024;
else bytes = amount;
}
const preview = bytes > 0 && meterBytes > 0 ? bytes / meterBytes : 0;
const el = document.getElementById('calibration-preview-scale');
if (el) el.textContent = preview ? preview.toFixed(6) : '-';
}
function setCalibrationMessage(message, type = 'muted') {
const el = document.getElementById('calibration-message');
if (!el) return;
el.textContent = message;
el.className = `calibration-message text-${type}`;
}
function formatDateTimeLocal(date) {
const pad = value => String(value).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}
function updateRemote(data) {
const remoteEl = document.getElementById('val-remote');
if (parseInt(data.remote_start_running) === 1) {
remoteEl.textContent = t('running');
remoteEl.className = 'value-text text-danger status-active-pulse';
} else if (parseInt(data.remote_start_preparing) === 1) {
remoteEl.textContent = t('preparing');
remoteEl.className = 'value-text text-warning';
} else {
remoteEl.textContent = t('standby');
remoteEl.className = 'value-text text-secondary';
}
document.getElementById('val-remote-time').textContent = data.remote_start_remaining || '--:--';
}
function updateDoors(data) {
['fl', 'fr', 'rl', 'rr', 'trunk'].forEach(pos => {
const el = document.getElementById('door-' + pos);
if (!el) return;
if (parseInt(data['door_' + pos]) === 1) el.classList.add('open');
else el.classList.remove('open');
});
}
function parseLocalTimestamp(value) {
const match = String(value || '').match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/);
if (!match) return null;
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 id="calibration-overlay" class="custom-modal-overlay" hidden>
<div class="custom-modal-panel" role="dialog" aria-modal="true" aria-labelledby="calibration-title">
<div class="custom-modal-header">
<div>
<div id="calibration-title" class="fw-bold">데이터 사용량 기준점</div>
<div class="small text-muted">T world 사용량 숫자로 월별 보정 배율을 저장합니다.</div>
</div>
<button type="button" class="btn-icon-toggle" id="calibration-close-btn" title="닫기">
<i class="fas fa-xmark"></i>
</button>
</div>
<div class="custom-modal-body">
<div class="calibration-grid">
<div class="calibration-field">
<label for="calibration-total-usage">T world 사용량</label>
<input id="calibration-total-usage" name="calibration_total_usage" type="number" inputmode="decimal" min="0" step="0.001" placeholder="예: 6396 또는 6.25" autocomplete="off">
</div>
<div class="calibration-field">
<label for="calibration-unit">단위</label>
<select id="calibration-unit" name="calibration_unit">
<option value="KB" selected>KB</option>
<option value="MB">MB</option>
<option value="B">B</option>
</select>
</div>
</div>
<div class="calibration-field">
<label for="calibration-anchor-ts">기준 시각</label>
<input id="calibration-anchor-ts" name="calibration_anchor_ts" type="datetime-local" step="1" autocomplete="off">
</div>
<div class="calibration-field">
<label for="calibration-note">메모</label>
<input id="calibration-note" name="calibration_note" type="text" maxlength="80" placeholder="예: T world 사용량 조회" autocomplete="off">
</div>
<div class="calibration-summary">
<div class="calibration-summary-item">
<div class="label-text mb-1">현재 월</div>
<div id="calibration-month" class="font-mono fw-bold">-</div>
</div>
<div class="calibration-summary-item">
<div class="label-text mb-1">로컬 통신사형 누적</div>
<div id="calibration-meter" class="font-mono fw-bold">-</div>
</div>
<div class="calibration-summary-item">
<div class="label-text mb-1">현재 배율</div>
<div id="calibration-current-scale" class="font-mono fw-bold">-</div>
</div>
<div class="calibration-summary-item">
<div class="label-text mb-1">입력 예상 배율</div>
<div id="calibration-preview-scale" class="font-mono fw-bold">-</div>
</div>
<div class="calibration-summary-item">
<div class="label-text mb-1">기준 출처</div>
<div id="calibration-source" class="font-mono fw-bold">-</div>
</div>
</div>
<div id="calibration-message" class="calibration-message text-muted"></div>
<div id="calibration-history" class="calibration-history"></div>
</div>
<div class="custom-modal-footer">
<button type="button" class="btn btn-light rounded-pill px-4" id="calibration-cancel-btn">닫기</button>
<button type="button" class="btn btn-primary rounded-pill px-4" id="calibration-save-btn">저장</button>
</div>
</div>
</div>
<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?v=20260721_nocache1', {scope: '/car/'})
.then(registration => registration.update())
.catch(() => {});
});
}
</script>
<script src="https://chaegeon.com/log/logger.js"></script>
</body>
</html>