['total_bytes' => 26937856, 'meter_adjusted_bytes' => 13240579, 'included_bytes' => 104857600, 'coupon_registered_at' => null], ]; function car_db(): PDO { static $pdo = null; if ($pdo instanceof PDO) { return $pdo; } $dsn = 'mysql:host=' . DB_HOST . ';dbname=' . DB_NAME . ';charset=' . DB_CHARSET; $pdo = new PDO($dsn, DB_USER, DB_PASS, [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false, ]); return $pdo; } function seconds_from_ts(?string $ts): ?int { if (!$ts) { return null; } $time = strtotime($ts); if ($time === false) { return null; } return max(0, time() - $time); } function current_state_duration(PDO $pdo, ?array $latest): int { if (!$latest || empty($latest['ts'])) { return 0; } $driving = (int)($latest['driving'] ?? 0); $engine = (int)($latest['engine'] ?? 0); $remoteRunning = (int)($latest['remote_start_running'] ?? 0); $remotePreparing = (int)($latest['remote_start_preparing'] ?? 0); $latestTs = strtotime((string)$latest['ts']); if ($latestTs === false) { return 0; } $stmt = $pdo->prepare("" . "SELECT ts, driving, engine, remote_start_running, remote_start_preparing " . "FROM car_status " . "WHERE id='car' " . "AND ts <= :latest_ts " . "ORDER BY ts DESC LIMIT 50000" ); $stmt->execute([ ':latest_ts' => date('Y-m-d H:i:s', $latestTs), ]); $startTs = $latestTs; $newerTs = $latestTs; while ($row = $stmt->fetch()) { $rowTs = strtotime((string)($row['ts'] ?? '')); if ($rowTs === false) { continue; } if (($newerTs - $rowTs) > RECEIVE_GAP_LIMIT_SEC) { break; } $sameState = (int)($row['driving'] ?? 0) === $driving && (int)($row['engine'] ?? 0) === $engine && (int)($row['remote_start_running'] ?? 0) === $remoteRunning && (int)($row['remote_start_preparing'] ?? 0) === $remotePreparing; if (!$sameState) { break; } $startTs = $rowTs; $newerTs = $rowTs; } $now = time(); $endTs = ($now - $latestTs) > RECEIVE_GAP_LIMIT_SEC ? $latestTs : $now; return max(0, $endTs - $startTs); } function current_engine_state_duration(PDO $pdo, ?array $latest): int { if (!$latest || empty($latest['ts'])) { return 0; } $engine = (int)($latest['engine'] ?? 0); $latestTs = strtotime((string)$latest['ts']); if ($latestTs === false) { return 0; } $latestTsText = date('Y-m-d H:i:s', $latestTs); $oppositeEngine = $engine === 1 ? 0 : 1; $stmt = $pdo->prepare("" . "SELECT ts " . "FROM car_status " . "WHERE id='car' AND engine = :engine AND ts <= :latest_ts " . "ORDER BY ts DESC LIMIT 1" ); $stmt->execute([ ':engine' => $oppositeEngine, ':latest_ts' => $latestTsText, ]); $oppositeTs = $stmt->fetchColumn(); if ($oppositeTs) { $stmt = $pdo->prepare("" . "SELECT ts " . "FROM car_status " . "WHERE id='car' AND engine = :engine AND ts > :opposite_ts AND ts <= :latest_ts " . "ORDER BY ts ASC LIMIT 1" ); $stmt->execute([ ':engine' => $engine, ':opposite_ts' => (string)$oppositeTs, ':latest_ts' => $latestTsText, ]); $startTsText = $stmt->fetchColumn() ?: $latestTsText; } else { $stmt = $pdo->prepare("" . "SELECT ts " . "FROM car_status " . "WHERE id='car' AND engine = :engine AND ts <= :latest_ts " . "ORDER BY ts ASC LIMIT 1" ); $stmt->execute([ ':engine' => $engine, ':latest_ts' => $latestTsText, ]); $startTsText = $stmt->fetchColumn() ?: $latestTsText; } $startTs = strtotime((string)$startTsText); if ($startTs === false) { return 0; } return max(0, time() - $startTs); } function latest_tcp_usage(PDO $pdo): ?array { $stmt = $pdo->query("" . "SELECT ts, source, cmd, sent_bytes, received_bytes, total_bytes, tcp_ok, tcp_error " . "FROM car_data_usage " . "WHERE id='car' " . "ORDER BY ts DESC LIMIT 1" ); $row = $stmt->fetch(); return $row ?: null; } function monthly_data_usage(PDO $pdo): array { $now = new DateTimeImmutable('now'); $monthStart = $now->modify('first day of this month')->setTime(0, 0, 0); $nextMonthStart = $monthStart->modify('first day of next month'); $stmt = $pdo->prepare("" . "SELECT ts, source, cmd, sent_bytes, received_bytes, total_bytes, tcp_ok, tcp_error " . "FROM car_data_usage " . "WHERE id='car' AND ts >= :month_start AND ts < :next_month_start " . "ORDER BY ts ASC" ); $stmt->execute([ ':month_start' => $monthStart->format('Y-m-d H:i:s'), ':next_month_start' => $nextMonthStart->format('Y-m-d H:i:s'), ]); $usageRows = $stmt->fetchAll(); $usage = calibrated_usage_rows($usageRows); $todayStart = $now->setTime(0, 0, 0); $tomorrowStart = $todayStart->modify('+1 day'); $todayUsage = calibrated_usage_rows(array_values(array_filter($usageRows, function (array $row) use ($todayStart, $tomorrowStart): bool { $ts = strtotime((string)($row['ts'] ?? '')); return $ts !== false && $ts >= $todayStart->getTimestamp() && $ts < $tomorrowStart->getTimestamp(); }))); $sentBytes = $usage['sent_bytes']; $receivedBytes = $usage['received_bytes']; $totalBytes = $usage['total_bytes']; $adjustedTotalBytes = $usage['adjusted_total_bytes']; $remainingBytes = max(DATA_MONTHLY_INCLUDED_BYTES - $adjustedTotalBytes, 0); $overBytes = max($adjustedTotalBytes - DATA_MONTHLY_INCLUDED_BYTES, 0); $overUnits = $overBytes > 0 ? (int)ceil($overBytes / DATA_BILLING_UNIT_BYTES) : 0; $overFeeKrw = $overUnits * DATA_BILLING_UNIT_KRW; $monthSeconds = max(1, $nextMonthStart->getTimestamp() - $monthStart->getTimestamp()); $remainingSeconds = max(0, $nextMonthStart->getTimestamp() - $now->getTimestamp()); $daysInMonth = (int)$monthStart->format('t'); $firstUsageTs = $usage['first_usage_ts']; $projectionStart = $firstUsageTs ? strtotime((string)$firstUsageTs) : $monthStart->getTimestamp(); if ($projectionStart === false || $projectionStart < $monthStart->getTimestamp()) { $projectionStart = $monthStart->getTimestamp(); } $measuredSeconds = max(1, $now->getTimestamp() - $projectionStart); $currentBilling = current_billing_usage($monthStart->format('Y-m')); $billingScale = billing_meter_scale($currentBilling); $billingCurrentBytes = (int)round($adjustedTotalBytes * $billingScale); $projectedBytes = (int)round($adjustedTotalBytes / $measuredSeconds * $monthSeconds); $billingProjectedBytes = $adjustedTotalBytes > 0 ? max((int)round($projectedBytes * $billingScale), $billingCurrentBytes) : 0; $calibration = billing_calibration($currentBilling, $billingScale); if ($billingProjectedBytes <= 0) { $billingProjectedBytes = $calibration['projected_total_bytes']; } $projectedOverBytes = max($projectedBytes - DATA_MONTHLY_INCLUDED_BYTES, 0); $projectedOverUnits = $projectedOverBytes > 0 ? (int)ceil($projectedOverBytes / DATA_BILLING_UNIT_BYTES) : 0; $projectedOverFeeKrw = $projectedOverUnits * DATA_BILLING_UNIT_KRW; $billingCurrentOverBytes = max($billingCurrentBytes - DATA_MONTHLY_INCLUDED_BYTES, 0); $billingCurrentOverUnits = $billingCurrentOverBytes > 0 ? (int)ceil($billingCurrentOverBytes / DATA_BILLING_UNIT_BYTES) : 0; $billingCurrentOverFeeKrw = $billingCurrentOverUnits * DATA_BILLING_UNIT_KRW; $billingProjectedOverBytes = max($billingProjectedBytes - DATA_MONTHLY_INCLUDED_BYTES, 0); $billingProjectedOverUnits = $billingProjectedOverBytes > 0 ? (int)ceil($billingProjectedOverBytes / DATA_BILLING_UNIT_BYTES) : 0; $billingProjectedOverFeeKrw = $billingProjectedOverUnits * DATA_BILLING_UNIT_KRW; $fixedFeeKrw = DATA_MONTHLY_BASE_FEE_KRW + DATA_MONTHLY_ADDON_FEE_KRW; $dailyRecommendedBytes = (int)floor(DATA_MONTHLY_INCLUDED_BYTES / max(1, $daysInMonth)); $estimatedTodayBytes = (int)round($todayUsage['adjusted_total_bytes'] * $billingScale); return [ 'month' => $monthStart->format('Y-m'), 'month_start' => $monthStart->format('Y-m-d H:i:s'), 'next_month_start' => $nextMonthStart->format('Y-m-d H:i:s'), 'days_remaining' => (int)floor($remainingSeconds / 86400), 'days_in_month' => $daysInMonth, 'included_bytes' => DATA_MONTHLY_INCLUDED_BYTES, 'daily_recommended_bytes' => $dailyRecommendedBytes, 'estimated_today_bytes' => $estimatedTodayBytes, 'meter_estimated_today_bytes' => $todayUsage['adjusted_total_bytes'], 'sent_bytes' => $sentBytes, 'received_bytes' => $receivedBytes, 'total_bytes' => $totalBytes, 'adjusted_total_bytes' => $adjustedTotalBytes, 'billing_sent_bytes' => (int)round($sentBytes * $billingScale), 'billing_received_bytes' => (int)round($receivedBytes * $billingScale), 'billing_adjusted_failure_bytes' => (int)round($usage['adjusted_failure_bytes'] * $billingScale), 'connected_failure_count' => $usage['connected_failure_count'], 'connect_failure_count' => $usage['connect_failure_count'], 'receive_gap_excluded_count' => $usage['receive_gap_excluded_count'], 'adjusted_failure_bytes' => $usage['adjusted_failure_bytes'], 'today_total_bytes' => $todayUsage['total_bytes'], 'today_adjusted_total_bytes' => $todayUsage['adjusted_total_bytes'], 'today_sample_count' => $todayUsage['sample_count'], 'today_connected_failure_count' => $todayUsage['connected_failure_count'], 'today_connect_failure_count' => $todayUsage['connect_failure_count'], 'today_receive_gap_excluded_count' => $todayUsage['receive_gap_excluded_count'], 'remaining_bytes' => $remainingBytes, 'over_bytes' => $overBytes, 'over_fee_raw_krw' => round($overFeeKrw, 2), 'over_fee_krw' => floor_krw_10($overFeeKrw), 'base_fee_krw' => DATA_MONTHLY_BASE_FEE_KRW, 'addon_fee_krw' => DATA_MONTHLY_ADDON_FEE_KRW, 'fixed_fee_krw' => $fixedFeeKrw, 'estimated_service_fee_raw_krw' => round($fixedFeeKrw + $overFeeKrw, 2), 'estimated_service_fee_krw' => floor_krw_10($fixedFeeKrw + $overFeeKrw), 'projected_total_bytes' => $projectedBytes, 'projected_over_bytes' => $projectedOverBytes, 'projected_over_fee_raw_krw' => round($projectedOverFeeKrw, 2), 'projected_over_fee_krw' => floor_krw_10($projectedOverFeeKrw), 'projected_service_fee_raw_krw' => round($fixedFeeKrw + $projectedOverFeeKrw, 2), 'projected_service_fee_krw' => floor_krw_10($fixedFeeKrw + $projectedOverFeeKrw), 'billing_estimated_current_bytes' => $billingCurrentBytes, 'billing_meter_scale' => round($billingScale, 6), 'billing_remaining_bytes' => max(DATA_MONTHLY_INCLUDED_BYTES - $billingCurrentBytes, 0), 'billing_over_bytes' => $billingCurrentOverBytes, 'billing_over_fee_raw_krw' => round($billingCurrentOverFeeKrw, 2), 'billing_over_fee_krw' => floor_krw_10($billingCurrentOverFeeKrw), 'billing_projected_total_bytes' => $billingProjectedBytes, 'billing_projected_over_bytes' => $billingProjectedOverBytes, 'billing_projected_over_fee_raw_krw' => round($billingProjectedOverFeeKrw, 2), 'billing_projected_over_fee_krw' => floor_krw_10($billingProjectedOverFeeKrw), 'billing_projected_service_fee_raw_krw' => round($fixedFeeKrw + $billingProjectedOverFeeKrw, 2), 'billing_projected_service_fee_krw' => floor_krw_10($fixedFeeKrw + $billingProjectedOverFeeKrw), 'calibration' => $calibration, 'sample_count' => $usage['sample_count'], 'first_usage_ts' => $usage['first_usage_ts'], 'last_usage_ts' => $usage['last_usage_ts'], 'measured_seconds' => $measuredSeconds, 'billing_unit_bytes' => DATA_BILLING_UNIT_BYTES, 'billing_unit_krw' => DATA_BILLING_UNIT_KRW, ]; } function bytes_to_mb(float $bytes): float { return $bytes / 1024 / 1024; } function floor_krw_10(float $value): int { return (int)floor(max(0.0, $value) / 10) * 10; } function calibrated_usage_rows(array $rows): array { $sentBytes = 0; $receivedBytes = 0; $totalBytes = 0; $successfulTotalBytes = 0; $successfulCount = 0; $successByCmd = []; $firstUsageTs = null; $lastUsageTs = null; foreach ($rows as $row) { $cmd = (string)($row['cmd'] ?? ''); $sent = (int)($row['sent_bytes'] ?? 0); $received = (int)($row['received_bytes'] ?? 0); $total = (int)($row['total_bytes'] ?? 0); $tcpOk = (int)($row['tcp_ok'] ?? 0) === 1; $sentBytes += $sent; $receivedBytes += $received; $totalBytes += $total; if ($firstUsageTs === null && !empty($row['ts'])) { $firstUsageTs = (string)$row['ts']; } if (!empty($row['ts'])) { $lastUsageTs = (string)$row['ts']; } if ($tcpOk && $total > 0) { $successfulTotalBytes += $total; $successfulCount++; if (!isset($successByCmd[$cmd])) { $successByCmd[$cmd] = ['total' => 0, 'count' => 0]; } $successByCmd[$cmd]['total'] += $total; $successByCmd[$cmd]['count']++; } } $globalAverage = $successfulCount > 0 ? (int)round($successfulTotalBytes / $successfulCount) : 0; $avgByCmd = []; foreach ($successByCmd as $cmd => $stat) { $avgByCmd[$cmd] = $stat['count'] > 0 ? (int)round($stat['total'] / $stat['count']) : $globalAverage; } $adjustedTotalBytes = 0; $adjustedFailureBytes = 0; $connectedFailureCount = 0; $connectFailureCount = 0; $receiveGapExcludedCount = 0; $lastSuccessfulTs = null; foreach ($rows as $row) { $cmd = (string)($row['cmd'] ?? ''); $sent = (int)($row['sent_bytes'] ?? 0); $total = (int)($row['total_bytes'] ?? 0); $tcpOk = (int)($row['tcp_ok'] ?? 0) === 1; $rowTs = strtotime((string)($row['ts'] ?? '')); if ($tcpOk) { $adjustedTotalBytes += $total; if ($rowTs !== false) { $lastSuccessfulTs = $rowTs; } continue; } if ($sent <= 0) { // TCP 연결 자체가 실패한 건은 모뎀까지 명령이 전달되지 않은 것으로 보고 과금 추정에서 제외한다. $connectFailureCount++; continue; } $connectedFailureCount++; if ( $lastSuccessfulTs === null || $rowTs === false || ($rowTs - $lastSuccessfulTs) > RECEIVE_GAP_LIMIT_SEC ) { $receiveGapExcludedCount++; continue; } $estimatedBytes = $avgByCmd[$cmd] ?? $globalAverage; if ($estimatedBytes <= 0) { $estimatedBytes = max($sent, $total); } $adjustedTotalBytes += $estimatedBytes; $adjustedFailureBytes += $estimatedBytes; } return [ 'sent_bytes' => $sentBytes, 'received_bytes' => $receivedBytes, 'total_bytes' => $totalBytes, 'adjusted_total_bytes' => $adjustedTotalBytes, 'adjusted_failure_bytes' => $adjustedFailureBytes, 'connected_failure_count' => $connectedFailureCount, 'connect_failure_count' => $connectFailureCount, 'receive_gap_excluded_count' => $receiveGapExcludedCount, 'sample_count' => count($rows), 'success_count' => $successfulCount, 'success_average_bytes' => $globalAverage, 'first_usage_ts' => $firstUsageTs, 'last_usage_ts' => $lastUsageTs, ]; } function current_billing_usage(string $month): ?array { if (!isset(DATA_CURRENT_BILLING_USAGE_BYTES[$month])) { return null; } $row = DATA_CURRENT_BILLING_USAGE_BYTES[$month]; return [ 'month' => $month, 'total_bytes' => (int)($row['total_bytes'] ?? 0), 'total_mb' => round(bytes_to_mb((int)($row['total_bytes'] ?? 0)), 6), 'meter_adjusted_bytes' => (int)($row['meter_adjusted_bytes'] ?? 0), 'included_bytes' => (int)($row['included_bytes'] ?? DATA_MONTHLY_INCLUDED_BYTES), 'included_mb' => round(bytes_to_mb((int)($row['included_bytes'] ?? DATA_MONTHLY_INCLUDED_BYTES)), 6), 'coupon_registered_at' => $row['coupon_registered_at'] ?? null, ]; } function billing_meter_scale(?array $currentBilling): float { if (!$currentBilling) { return 1.0; } $meterAdjustedBytes = (int)($currentBilling['meter_adjusted_bytes'] ?? 0); if ($meterAdjustedBytes <= 0) { return 1.0; } return (int)($currentBilling['total_bytes'] ?? 0) / $meterAdjustedBytes; } function billing_calibration(?array $currentBilling, float $billingScale): array { $estimatedCurrentBytes = $currentBilling ? (int)$currentBilling['total_bytes'] : 0; $source = $currentBilling ? 'carrier_scaled_meter_usage' : 'meter_adjusted_usage'; return [ 'mode' => $source, 'note' => $currentBilling ? 'Carrier portal current usage is used to reverse-calculate a billing scale for metered local sent/received bytes. Metered local usage remains available separately.' : 'Metered usage includes successful requests and connected timeouts within 60 seconds of the last successful receive. Pure connect failures and longer receive gaps are excluded.', 'current_billing' => $currentBilling, 'billing_meter_scale' => round($billingScale, 6), 'projected_total_bytes' => 0, 'projected_total_mb' => 0.0, 'estimated_current_bytes' => $estimatedCurrentBytes, 'estimated_current_mb' => round(bytes_to_mb($estimatedCurrentBytes), 2), 'formula_fee_per_mb_krw' => round(1024 * 1024 / DATA_BILLING_UNIT_BYTES * DATA_BILLING_UNIT_KRW, 3), ]; } function json_response(array $payload, int $status = 200): void { http_response_code($status); header('Content-Type: application/json; charset=utf-8'); header('Cache-Control: no-cache, no-store, must-revalidate'); echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); exit; } if (isset($_GET['mode']) && $_GET['mode'] === 'ajax') { try { $pdo = car_db(); $latest = $pdo->query("" . "SELECT * FROM car_status " . "WHERE id='car' " . "ORDER BY ts DESC LIMIT 1" )->fetch() ?: null; $stmtLogs = $pdo->prepare("" . "SELECT ts, cmd, boundary, engine, driving, battery_voltage, " . "door_fl, door_fr, door_rl, door_rr, door_trunk, " . "remote_start_preparing, remote_start_running, remote_start_remaining, " . "hazard, raw_full, raw_trim " . "FROM car_status " . "WHERE id='car' " . "ORDER BY ts DESC LIMIT :limit" ); $stmtLogs->bindValue(':limit', LOG_LIMIT, PDO::PARAM_INT); $stmtLogs->execute(); $logs = $stmtLogs->fetchAll(); $stmtChart = $pdo->prepare("" . "SELECT ts, battery_voltage FROM (" . " SELECT ts, battery_voltage " . " FROM car_status " . " WHERE id='car' AND battery_voltage > 0 " . " ORDER BY ts DESC LIMIT :limit" . ") AS recent ORDER BY ts ASC" ); $stmtChart->bindValue(':limit', CHART_LIMIT, PDO::PARAM_INT); $stmtChart->execute(); $chart = $stmtChart->fetchAll(); $ageSeconds = seconds_from_ts($latest['ts'] ?? null); $latestTcp = latest_tcp_usage($pdo); $isFastPollState = $latest ? ( (int)($latest['driving'] ?? 0) === 1 || (int)($latest['engine'] ?? 0) === 1 || (int)($latest['remote_start_preparing'] ?? 0) === 1 || (int)($latest['remote_start_running'] ?? 0) === 1 ) : false; $isStale = $ageSeconds !== null && $ageSeconds > 30; $staleReason = null; if ($isStale && $latestTcp && (int)$latestTcp['tcp_ok'] === 0) { $staleReason = (string)($latestTcp['tcp_error'] ?? 'tcp_failed'); } json_response([ 'status' => 'success', 'data' => $latest, 'logs' => $logs, 'chart' => $chart, 'meta' => [ 'age_seconds' => $ageSeconds, 'stale' => $isStale, 'stale_reason' => $staleReason, 'latest_tcp' => $latestTcp, 'state_duration' => current_engine_state_duration($pdo, $latest), 'poll_interval' => $isFastPollState ? 5 : 10, 'log_count' => count($logs), 'chart_count' => count($chart), ], ]); } catch (Throwable $e) { json_response([ 'status' => 'error', 'message' => 'DB 조회 실패', ], 500); } } if (isset($_GET['mode']) && $_GET['mode'] === 'usage') { try { json_response([ 'status' => 'success', 'usage' => monthly_data_usage(car_db()), ]); } catch (Throwable $e) { json_response([ 'status' => 'error', 'message' => '데이터 사용량 조회 실패', ], 500); } } ?>
| TIME | CMD | ENG | BAT | DOOR | REMOTE | HAZ | AGE | TRIM |
|---|