['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'); ?> 차량 모니터 인증
'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(); } 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(); ?> Car Monitor
채건닷컴
Car Monitor
WAIT
Vehicle Status Dashboard
LAST DATA
--:--:--
Battery
-V
-
Engine
OFF
-
Remote Start
Standby
--:--
AGE
-
Poll Target
-s
-
데이터 사용량
- / 100MB
하루 권장
-
오늘 사용
-
잔여 사용량
-
예상 사용량
-
예상 초과
-
예상 요금
-
Real-time Visual
Status Log
TIME CMD ENG BAT DOOR REMOTE HAZ AGE TRIM