diff --git a/api.php b/api.php index 07e18a0..390d1cf 100644 --- a/api.php +++ b/api.php @@ -44,37 +44,37 @@ if (($params['log'] ?? '') == '1') { $cmdRequested = $params['cmd'] ?? 'se'; $cmd = normalize_cmd($cmdRequested); -if ($cmd === 'se') { - $latest = db_latest($pdo); - - if (!$latest) { +if ($cmd === 'se') { + $latest = db_latest($pdo); + + if (!$latest) { json_exit([ 'request_id' => $requestId, 'error' => 'no_latest_data' - ], 500); - } - - $ageSeconds = max(0, time() - strtotime((string)$latest['ts'])); - $latestUsage = db_latest_usage($pdo); - $staleReason = null; - if ($ageSeconds > 30 && $latestUsage && (int)$latestUsage['tcp_ok'] === 0) { - $staleReason = (string)($latestUsage['tcp_error'] ?? 'tcp_failed'); - } - - json_exit([ - 'request_id' => $requestId, - 'cmd_requested' => 'se', - 'cmd' => $latest['cmd'], - 'ts' => $latest['ts'], - 'meta' => [ - 'age_seconds' => $ageSeconds, - 'stale' => $ageSeconds > 30, - 'stale_reason' => $staleReason, - 'latest_tcp' => $latestUsage, - ], - - 'raw_full' => $latest['raw_full'], - 'raw_trim' => $latest['raw_trim'], + ], 500); + } + + $ageSeconds = max(0, time() - strtotime((string)$latest['ts'])); + $latestUsage = db_latest_usage($pdo); + $staleReason = null; + if ($ageSeconds > 30 && $latestUsage && (int)$latestUsage['tcp_ok'] === 0) { + $staleReason = (string)($latestUsage['tcp_error'] ?? 'tcp_failed'); + } + + json_exit([ + 'request_id' => $requestId, + 'cmd_requested' => 'se', + 'cmd' => $latest['cmd'], + 'ts' => $latest['ts'], + 'meta' => [ + 'age_seconds' => $ageSeconds, + 'stale' => $ageSeconds > 30, + 'stale_reason' => $staleReason, + 'latest_tcp' => $latestUsage, + ], + + 'raw_full' => $latest['raw_full'], + 'raw_trim' => $latest['raw_trim'], 'data' => [ 'boundary' => (int)$latest['boundary'], @@ -110,26 +110,26 @@ if ($requestMethod !== 'POST') { ], 405); } -$tcpMs = 0; -$connectMs = 0; -$readMs = 0; -$tcpError = ''; -$trimError = ''; -$sentBytes = 0; -$receivedBytes = 0; - -$rawFull = tcp_request( - $cmd, - $tcpMs, - $connectMs, - $readMs, - $tcpError, - 'api_control', - $requestId, - $sentBytes, - $receivedBytes -); -$execMs = (int)round((microtime(true) - $tsStart) * 1000); +$tcpMs = 0; +$connectMs = 0; +$readMs = 0; +$tcpError = ''; +$trimError = ''; +$sentBytes = 0; +$receivedBytes = 0; + +$rawFull = tcp_request( + $cmd, + $tcpMs, + $connectMs, + $readMs, + $tcpError, + 'api_control', + $requestId, + $sentBytes, + $receivedBytes +); +$execMs = (int)round((microtime(true) - $tsStart) * 1000); if ($rawFull === '') { json_exit([ @@ -141,16 +141,16 @@ if ($rawFull === '') { 'client_ip' => $clientIp, 'ua' => $userAgent, 'tcp_ok' => 0, - 'tcp_ms' => $tcpMs, - 'connect_ms' => $connectMs, - 'read_ms' => $readMs, - 'sent_bytes' => $sentBytes, - 'received_bytes' => $receivedBytes, - 'total_bytes' => $sentBytes + $receivedBytes, - 'tcp_error' => ($tcpError !== '' ? $tcpError : 'timeout_or_empty'), - 'error' => 'CONTROL_CMD_SEND_FAILED' - ], 502); -} + 'tcp_ms' => $tcpMs, + 'connect_ms' => $connectMs, + 'read_ms' => $readMs, + 'sent_bytes' => $sentBytes, + 'received_bytes' => $receivedBytes, + 'total_bytes' => $sentBytes + $receivedBytes, + 'tcp_error' => ($tcpError !== '' ? $tcpError : 'timeout_or_empty'), + 'error' => 'CONTROL_CMD_SEND_FAILED' + ], 502); +} $rawTrim = make_trim($rawFull, $trimError); $data = ($rawTrim !== '') ? parse_trim($rawTrim) : null; @@ -174,16 +174,16 @@ json_exit([ 'client_ip' => $clientIp, 'ua' => $userAgent, 'tcp_ok' => 1, - 'tcp_ms' => $tcpMs, - 'connect_ms' => $connectMs, - 'read_ms' => $readMs, - 'sent_bytes' => $sentBytes, - 'received_bytes' => $receivedBytes, - 'total_bytes' => $sentBytes + $receivedBytes, - 'tcp_error' => $tcpError, + 'tcp_ms' => $tcpMs, + 'connect_ms' => $connectMs, + 'read_ms' => $readMs, + 'sent_bytes' => $sentBytes, + 'received_bytes' => $receivedBytes, + 'total_bytes' => $sentBytes + $receivedBytes, + 'tcp_error' => $tcpError, 'accepted' => true, 'ack_only' => ($rawTrim === ''), 'raw_full' => $rawFull, 'raw_trim' => $rawTrim, 'data' => $data -]); +]); diff --git a/asset-version.php b/asset-version.php index 0e9d489..3d84290 100644 --- a/asset-version.php +++ b/asset-version.php @@ -59,12 +59,11 @@ $assets = []; foreach (collect_asset_files($root, $scanRoots) as $fullPath) { $relativePath = ltrim(str_replace($rootPath ?: $root, '', $fullPath), DIRECTORY_SEPARATOR); $fileHash = is_readable($fullPath) ? (hash_file('sha256', $fullPath) ?: 'hash-failed') : 'unreadable'; - $fileVersion = (string)filemtime($fullPath) . '-' . substr($fileHash, 0, 10); - $publicPath = '/car/' . str_replace(DIRECTORY_SEPARATOR, '/', $relativePath); + $fileVersion = substr($fileHash, 0, 16); + $publicPath = '/custom/car/' . str_replace(DIRECTORY_SEPARATOR, '/', $relativePath); $fingerprints[] = implode('|', [ ltrim($publicPath, '/'), (string)filesize($fullPath), - (string)filemtime($fullPath), $fileHash, ]); $assets[$publicPath] = $fileVersion; diff --git a/assets/asset-reload.js b/assets/asset-reload.js index 2c74cd7..0967116 100644 --- a/assets/asset-reload.js +++ b/assets/asset-reload.js @@ -214,20 +214,23 @@ }).then(function (data) { if (!data || data.result !== 'ok' || !data.version) return; - var token = data.version.slice(0, 12); var previous = storeGet(storageKey); - var mismatch = loadedAssetMismatch(data.assets); - var forceReload = bfcacheReturn; + var versionChanged = previous && previous !== data.version; + var latestToken = String(data.version).slice(0, 12); + var hasLatestToken = assetTokenFromLocation() === latestToken; + var mismatch = !hasLatestToken && loadedAssetMismatch(data.assets); + var wasBfcacheReturn = bfcacheReturn; bfcacheReturn = false; storeSet(storageKey, data.version); - if (assetTokenFromLocation() !== token || mismatch || forceReload) { - reloadWith(data.version, mismatch || forceReload); + if ((mismatch || wasBfcacheReturn) && !hasLatestToken) { + reloadWith(data.version, true); return; } - if (previous && previous !== data.version) { + if (versionChanged) { reloadWith(data.version, false); + return; } }).then(function () { running = false; diff --git a/assets/site.webmanifest b/assets/site.webmanifest index a3caf87..6c8f76c 100644 --- a/assets/site.webmanifest +++ b/assets/site.webmanifest @@ -2,21 +2,21 @@ "name": "Car Monitor", "short_name": "Car Monitor", "description": "Vehicle status monitor", - "start_url": "/car/monitor.php?pwa=1", - "scope": "/car/", + "start_url": "/custom/car/monitor.php?pwa=1", + "scope": "/custom/car/", "display": "standalone", "orientation": "portrait", "background_color": "#0f172a", "theme_color": "#0f172a", "icons": [ { - "src": "/car/assets/icon-192.png", + "src": "/custom/car/assets/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" }, { - "src": "/car/assets/icon-512.png", + "src": "/custom/car/assets/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" diff --git a/collector_se.php b/collector_se.php index a867a49..983cfb3 100644 --- a/collector_se.php +++ b/collector_se.php @@ -62,19 +62,19 @@ function collect_once(): bool $tcpError = ''; $trimError = ''; - $sentBytes = 0; - $receivedBytes = 0; - $rawFull = tcp_request( - 'se', - $tcpMs, - $connectMs, - $readMs, - $tcpError, - 'collector_se', - null, - $sentBytes, - $receivedBytes - ); + $sentBytes = 0; + $receivedBytes = 0; + $rawFull = tcp_request( + 'se', + $tcpMs, + $connectMs, + $readMs, + $tcpError, + 'collector_se', + null, + $sentBytes, + $receivedBytes + ); if ($rawFull === '') { return false; } @@ -105,4 +105,4 @@ if (!is_fast_poll_state() && should_skip_non_driving()) { } mark_attempt(); -collect_once(); +collect_once(); diff --git a/common.php b/common.php index 819022a..b246c7c 100644 --- a/common.php +++ b/common.php @@ -3,67 +3,68 @@ declare(strict_types=1); date_default_timezone_set('Asia/Seoul'); -$carSecretFile = '/home/seo/secret/car.php'; -if (!is_file($carSecretFile)) { - throw new RuntimeException('Missing car secret config: ' . $carSecretFile); -} - -$carSecretConfig = require $carSecretFile; -$carTcpConfig = is_array($carSecretConfig['tcp'] ?? null) ? $carSecretConfig['tcp'] : []; -$carIdentityConfig = is_array($carSecretConfig['identity'] ?? null) ? $carSecretConfig['identity'] : []; -$carDbConfig = is_array($carSecretConfig['db'] ?? null) ? $carSecretConfig['db'] : []; - -define('TCP_HOST', (string)($carTcpConfig['host'] ?? '')); -define('TCP_PORT', (int)($carTcpConfig['port'] ?? 3400)); - -define('TCP_TOTAL_TIMEOUT', (float)($carTcpConfig['total_timeout'] ?? 4.95)); -define('TCP_USAGE_SETTLEMENT_TIMEOUT', (float)($carTcpConfig['usage_settlement_timeout'] ?? 60.0)); - -define('MODEM', (string)($carIdentityConfig['modem'] ?? '')); -define('USER', (string)($carIdentityConfig['user'] ?? '')); -define('UID', (string)($carIdentityConfig['uid'] ?? '')); -define('TYPE', (string)($carIdentityConfig['type'] ?? '')); - -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')); - -define('AUTH_TOKEN', (string)($carSecretConfig['auth_token'] ?? '')); - -define('ALLOWED_IPS', array_values(array_filter( - (array)($carSecretConfig['allowed_ips'] ?? []), - static fn($ip): bool => is_string($ip) && $ip !== '' -))); - +$carSecretFile = __DIR__ . '/../../config/car.php'; +if (!is_file($carSecretFile)) { + throw new RuntimeException('Missing car secret config: ' . $carSecretFile); +} + +$carSecretConfig = require $carSecretFile; +$carTcpConfig = is_array($carSecretConfig['tcp'] ?? null) ? $carSecretConfig['tcp'] : []; +$carIdentityConfig = is_array($carSecretConfig['identity'] ?? null) ? $carSecretConfig['identity'] : []; +$carDbConfig = is_array($carSecretConfig['db'] ?? null) ? $carSecretConfig['db'] : []; + +define('TCP_HOST', (string)($carTcpConfig['host'] ?? '')); +define('TCP_PORT', (int)($carTcpConfig['port'] ?? 3400)); + +define('TCP_TOTAL_TIMEOUT', (float)($carTcpConfig['total_timeout'] ?? 4.95)); +define('TCP_USAGE_SETTLEMENT_TIMEOUT', (float)($carTcpConfig['usage_settlement_timeout'] ?? 60.0)); + +define('MODEM', (string)($carIdentityConfig['modem'] ?? '')); +define('USER', (string)($carIdentityConfig['user'] ?? '')); +define('UID', (string)($carIdentityConfig['uid'] ?? '')); +define('TYPE', (string)($carIdentityConfig['type'] ?? '')); + +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')); + +define('AUTH_TOKEN', (string)($carSecretConfig['auth_token'] ?? '')); + +define('ALLOWED_IPS', array_values(array_filter( + (array)($carSecretConfig['allowed_ips'] ?? []), + static fn($ip): bool => is_string($ip) && $ip !== '' +))); + const ALLOWED_CMD = ['se', 'ef', 'en', 'hn', 'hf', 'dl', 'du', 'tu']; const CONTROL_CMD = ['ef', 'en', 'hn', 'hf', 'dl', 'du', 'tu']; const CARRIER_BILLING_UNIT_BYTES = 512; +const TCP_STALE_QUEUE_BATCH_LIMIT = 200; const RAW_FULL_REGEX = '/(?\d{11})\/R:(?[a-z])\/E:(?[io]{5}\d{3}[io])\/D:(?[oi]{7})\/L:(?o{5})\/F:(?[ots][oi]\d{4}[oi]{4})\/S:(?[^\/]+)/'; -function db(bool $fresh = false): PDO -{ - static $pdo = null; - - if (!$fresh && $pdo instanceof PDO) { - return $pdo; - } - - $dsn = "mysql:host=" . DB_HOST . ";dbname=" . DB_NAME . ";charset=" . DB_CHARSET; - $conn = new PDO($dsn, DB_USER, DB_PASS, [ - PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, - PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, - ]); - - if ($fresh) { - return $conn; - } - - $pdo = $conn; - return $pdo; -} +function db(bool $fresh = false): PDO +{ + static $pdo = null; + + if (!$fresh && $pdo instanceof PDO) { + return $pdo; + } + + $dsn = "mysql:host=" . DB_HOST . ";dbname=" . DB_NAME . ";charset=" . DB_CHARSET; + $conn = new PDO($dsn, DB_USER, DB_PASS, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); + + if ($fresh) { + return $conn; + } + + $pdo = $conn; + return $pdo; +} function b(string $ch): int { @@ -96,226 +97,245 @@ function normalize_cmd(?string $cmd): string return in_array($cmd, ALLOWED_CMD, true) ? $cmd : 'se'; } -function json_exit(array $payload, int $status = 200): void -{ +function json_exit(array $payload, int $status = 200): void +{ http_response_code($status); header('Content-Type: application/json; charset=utf-8'); header('Cache-Control: no-store'); echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); - exit; -} - -function db_insert_data_usage( - PDO $pdo, - string $source, - string $cmd, - int $sentBytes, - int $receivedBytes, - bool $tcpOk, - string $tcpError = '', - ?string $requestId = null -): void { - try { - db_ensure_data_usage_schema($pdo); - $carrierEstimatedBytes = carrier_billing_bytes($sentBytes + $receivedBytes); - $stmt = $pdo->prepare("INSERT INTO car_data_usage ( - id, ts, source, cmd, sent_bytes, received_bytes, total_bytes, - carrier_estimated_bytes, tcp_ok, tcp_error, request_id - ) VALUES ( - 'car', :ts, :source, :cmd, :sent_bytes, :received_bytes, :total_bytes, - :carrier_estimated_bytes, :tcp_ok, :tcp_error, :request_id - )"); - - $stmt->execute([ - ':ts' => date('Y-m-d H:i:s'), - ':source' => substr($source, 0, 20), - ':cmd' => substr($cmd, 0, 10), - ':sent_bytes' => max(0, $sentBytes), - ':received_bytes' => max(0, $receivedBytes), - ':total_bytes' => max(0, $sentBytes + $receivedBytes), - ':carrier_estimated_bytes' => $carrierEstimatedBytes, - ':tcp_ok' => $tcpOk ? 1 : 0, - ':tcp_error' => $tcpError !== '' ? substr($tcpError, 0, 100) : null, - ':request_id' => $requestId, - ]); - } catch (Throwable $e) { - // 사용량 기록 실패가 차량 제어/조회 자체를 막으면 안 된다. - } -} - -function carrier_billing_bytes(int $payloadBytes): int -{ - $payloadBytes = max(0, $payloadBytes); - if ($payloadBytes <= 0) { - return 0; - } - - return (int)(ceil($payloadBytes / CARRIER_BILLING_UNIT_BYTES) * CARRIER_BILLING_UNIT_BYTES); -} - -function db_ensure_data_usage_schema(PDO $pdo): void -{ - static $done = false; - if ($done) { - return; - } - - try { - $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"); - } - } catch (Throwable $e) { - // 스키마 보강 실패가 차량 제어/조회 자체를 막으면 안 된다. - } - - $done = true; -} - -function record_tcp_usage( - string $source, - string $cmd, - int $sentBytes, - int $receivedBytes, - bool $tcpOk, - string $tcpError = '', - ?string $requestId = null -): void { - db_insert_data_usage( - db(), - $source, - $cmd, - $sentBytes, - $receivedBytes, - $tcpOk, - $tcpError, - $requestId - ); -} - -function tcp_request_payload(string $cmd): string -{ - return json_encode([ - 'type' => 'R', - 'type_sub' => 'car_controll', - 'data' => [ - 'command' => '+SCMD=' . MODEM . '/C:' . $cmd, - 'modem' => MODEM, - 'user' => USER, - 'uid' => UID, - 'type' => TYPE, - ], - ], JSON_UNESCAPED_SLASHES); -} - -function db_ensure_tcp_queue_schema(PDO $pdo): void -{ - static $done = false; - if ($done) { - return; - } - - $pdo->exec("CREATE TABLE IF NOT EXISTS car_tcp_request_queue ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, - request_id VARCHAR(64) NOT NULL UNIQUE, - car_id VARCHAR(20) NOT NULL DEFAULT 'car', - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL, - source VARCHAR(20) NOT NULL, - cmd VARCHAR(10) NOT NULL, - status VARCHAR(32) NOT NULL, - request_payload MEDIUMTEXT NOT NULL, - sent_bytes INT NOT NULL DEFAULT 0, - received_bytes INT NOT NULL DEFAULT 0, - total_bytes INT NOT NULL DEFAULT 0, - tcp_ok TINYINT NULL, - tcp_error VARCHAR(100) NULL, - raw_full MEDIUMTEXT NULL, - connect_ms INT NOT NULL DEFAULT 0, - read_ms INT NOT NULL DEFAULT 0, - tcp_ms INT NOT NULL DEFAULT 0, - ui_deadline_ts DOUBLE NOT NULL DEFAULT 0, - usage_deadline_ts DOUBLE NOT NULL DEFAULT 0, - worker_pid INT NULL, - KEY idx_status_id (status, id), - KEY idx_request_id (request_id) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); - - $done = true; -} - -function tcp_queue_now(): string -{ - return date('Y-m-d H:i:s'); -} - -function tcp_queue_update(PDO $pdo, string $requestId, array $values): void -{ - if (!$values) { - return; - } - - $values['updated_at'] = tcp_queue_now(); - $sets = []; - $params = [':request_id' => $requestId]; - - foreach ($values as $key => $value) { - $param = ':' . $key; - $sets[] = "$key = $param"; - $params[$param] = $value; - } - - $stmt = $pdo->prepare("UPDATE car_tcp_request_queue SET " . implode(', ', $sets) . " WHERE request_id = :request_id"); - $stmt->execute($params); -} - + exit; +} + +function db_insert_data_usage( + PDO $pdo, + string $source, + string $cmd, + int $sentBytes, + int $receivedBytes, + bool $tcpOk, + string $tcpError = '', + ?string $requestId = null +): void { + try { + db_ensure_data_usage_schema($pdo); + $carrierEstimatedBytes = carrier_billing_bytes($sentBytes + $receivedBytes); + $stmt = $pdo->prepare("INSERT INTO car_data_usage ( + id, ts, source, cmd, sent_bytes, received_bytes, total_bytes, + carrier_estimated_bytes, tcp_ok, tcp_error, request_id + ) VALUES ( + 'car', :ts, :source, :cmd, :sent_bytes, :received_bytes, :total_bytes, + :carrier_estimated_bytes, :tcp_ok, :tcp_error, :request_id + )"); + + $stmt->execute([ + ':ts' => date('Y-m-d H:i:s'), + ':source' => substr($source, 0, 20), + ':cmd' => substr($cmd, 0, 10), + ':sent_bytes' => max(0, $sentBytes), + ':received_bytes' => max(0, $receivedBytes), + ':total_bytes' => max(0, $sentBytes + $receivedBytes), + ':carrier_estimated_bytes' => $carrierEstimatedBytes, + ':tcp_ok' => $tcpOk ? 1 : 0, + ':tcp_error' => $tcpError !== '' ? substr($tcpError, 0, 100) : null, + ':request_id' => $requestId, + ]); + } catch (Throwable $e) { + // 사용량 기록 실패가 차량 제어/조회 자체를 막으면 안 된다. + } +} + +function carrier_billing_bytes(int $payloadBytes): int +{ + $payloadBytes = max(0, $payloadBytes); + if ($payloadBytes <= 0) { + return 0; + } + + return (int)(ceil($payloadBytes / CARRIER_BILLING_UNIT_BYTES) * CARRIER_BILLING_UNIT_BYTES); +} + +function db_ensure_data_usage_schema(PDO $pdo): void +{ + static $done = false; + if ($done) { + return; + } + + try { + $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"); + } + } catch (Throwable $e) { + // 스키마 보강 실패가 차량 제어/조회 자체를 막으면 안 된다. + } + + $done = true; +} + +function record_tcp_usage( + string $source, + string $cmd, + int $sentBytes, + int $receivedBytes, + bool $tcpOk, + string $tcpError = '', + ?string $requestId = null +): void { + db_insert_data_usage( + db(), + $source, + $cmd, + $sentBytes, + $receivedBytes, + $tcpOk, + $tcpError, + $requestId + ); +} + +function tcp_request_payload(string $cmd): string +{ + return json_encode([ + 'type' => 'R', + 'type_sub' => 'car_controll', + 'data' => [ + 'command' => '+SCMD=' . MODEM . '/C:' . $cmd, + 'modem' => MODEM, + 'user' => USER, + 'uid' => UID, + 'type' => TYPE, + ], + ], JSON_UNESCAPED_SLASHES); +} + +function db_ensure_tcp_queue_schema(PDO $pdo): void +{ + static $done = false; + if ($done) { + return; + } + + $pdo->exec("CREATE TABLE IF NOT EXISTS car_tcp_request_queue ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + request_id VARCHAR(64) NOT NULL UNIQUE, + car_id VARCHAR(20) NOT NULL DEFAULT 'car', + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + source VARCHAR(20) NOT NULL, + cmd VARCHAR(10) NOT NULL, + status VARCHAR(32) NOT NULL, + request_payload MEDIUMTEXT NOT NULL, + sent_bytes INT NOT NULL DEFAULT 0, + received_bytes INT NOT NULL DEFAULT 0, + total_bytes INT NOT NULL DEFAULT 0, + tcp_ok TINYINT NULL, + tcp_error VARCHAR(100) NULL, + raw_full MEDIUMTEXT NULL, + connect_ms INT NOT NULL DEFAULT 0, + read_ms INT NOT NULL DEFAULT 0, + tcp_ms INT NOT NULL DEFAULT 0, + ui_deadline_ts DOUBLE NOT NULL DEFAULT 0, + usage_deadline_ts DOUBLE NOT NULL DEFAULT 0, + worker_pid INT NULL, + KEY idx_status_id (status, id), + KEY idx_request_id (request_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + + $done = true; +} + +function tcp_queue_now(): string +{ + return date('Y-m-d H:i:s'); +} + +function tcp_queue_update(PDO $pdo, string $requestId, array $values): void +{ + if (!$values) { + return; + } + + $values['updated_at'] = tcp_queue_now(); + $sets = []; + $params = [':request_id' => $requestId]; + + foreach ($values as $key => $value) { + $param = ':' . $key; + $sets[] = "$key = $param"; + $params[$param] = $value; + } + + $stmt = $pdo->prepare("UPDATE car_tcp_request_queue SET " . implode(', ', $sets) . " WHERE request_id = :request_id"); + $stmt->execute($params); +} + function tcp_queue_insert(string $cmd, string $usageSource, string $requestId): array { - $pdo = db(); + $pdo = db(true); db_ensure_tcp_queue_schema($pdo); - - $now = microtime(true); - $payload = tcp_request_payload($cmd); - - $stmt = $pdo->prepare("INSERT INTO car_tcp_request_queue ( - request_id, car_id, created_at, updated_at, source, cmd, status, - request_payload, ui_deadline_ts, usage_deadline_ts - ) VALUES ( - :request_id, 'car', :created_at, :updated_at, :source, :cmd, 'queued', - :request_payload, :ui_deadline_ts, :usage_deadline_ts - )"); - $stmt->execute([ - ':request_id' => $requestId, - ':created_at' => tcp_queue_now(), - ':updated_at' => tcp_queue_now(), - ':source' => substr($usageSource, 0, 20), - ':cmd' => substr($cmd, 0, 10), - ':request_payload' => $payload, - ':ui_deadline_ts' => $now + TCP_TOTAL_TIMEOUT, - ':usage_deadline_ts' => $now + TCP_USAGE_SETTLEMENT_TIMEOUT, - ]); - - return [ - 'request_id' => $requestId, - 'cmd' => $cmd, - 'source' => $usageSource, - 'request_payload' => $payload, - 'ui_deadline_ts' => $now + TCP_TOTAL_TIMEOUT, - 'usage_deadline_ts' => $now + TCP_USAGE_SETTLEMENT_TIMEOUT, - ]; + + $now = microtime(true); + $payload = tcp_request_payload($cmd); + + $stmt = $pdo->prepare("INSERT INTO car_tcp_request_queue ( + request_id, car_id, created_at, updated_at, source, cmd, status, + request_payload, ui_deadline_ts, usage_deadline_ts + ) VALUES ( + :request_id, 'car', :created_at, :updated_at, :source, :cmd, 'queued', + :request_payload, :ui_deadline_ts, :usage_deadline_ts + )"); + $stmt->execute([ + ':request_id' => $requestId, + ':created_at' => tcp_queue_now(), + ':updated_at' => tcp_queue_now(), + ':source' => substr($usageSource, 0, 20), + ':cmd' => substr($cmd, 0, 10), + ':request_payload' => $payload, + ':ui_deadline_ts' => $now + TCP_TOTAL_TIMEOUT, + ':usage_deadline_ts' => $now + TCP_USAGE_SETTLEMENT_TIMEOUT, + ]); + + return [ + 'request_id' => $requestId, + 'cmd' => $cmd, + 'source' => $usageSource, + 'request_payload' => $payload, + 'ui_deadline_ts' => $now + TCP_TOTAL_TIMEOUT, + 'usage_deadline_ts' => $now + TCP_USAGE_SETTLEMENT_TIMEOUT, + ]; } -function tcp_queue_get(string $requestId): ?array +function tcp_queue_has_pending_collector_se(PDO $pdo): bool { - $pdo = db(); db_ensure_tcp_queue_schema($pdo); + tcp_queue_expire_queued_before_send($pdo); - $stmt = $pdo->prepare("SELECT * FROM car_tcp_request_queue WHERE request_id = :request_id LIMIT 1"); - $stmt->execute([':request_id' => $requestId]); - $row = $stmt->fetch(); - return $row ?: null; + $stmt = $pdo->prepare(" + SELECT 1 + FROM car_tcp_request_queue + WHERE source = 'collector_se' + AND cmd = 'se' + AND status IN ('queued', 'processing', 'ui_timeout_pending') + AND usage_deadline_ts > :now + LIMIT 1 + "); + $stmt->execute([':now' => microtime(true)]); + + return (bool)$stmt->fetchColumn(); } - + +function tcp_queue_get(string $requestId): ?array +{ + $pdo = db(); + db_ensure_tcp_queue_schema($pdo); + + $stmt = $pdo->prepare("SELECT * FROM car_tcp_request_queue WHERE request_id = :request_id LIMIT 1"); + $stmt->execute([':request_id' => $requestId]); + $row = $stmt->fetch(); + return $row ?: null; +} + function tcp_queue_claim_next(PDO $pdo): ?array { db_ensure_tcp_queue_schema($pdo); @@ -323,385 +343,435 @@ function tcp_queue_claim_next(PDO $pdo): ?array $pdo->beginTransaction(); try { - $stmt = $pdo->query("SELECT * FROM car_tcp_request_queue WHERE status = 'queued' ORDER BY id ASC LIMIT 1 FOR UPDATE"); - $row = $stmt->fetch(); - if (!$row) { - $pdo->commit(); - return null; - } - - $update = $pdo->prepare("UPDATE car_tcp_request_queue SET status = 'processing', updated_at = :updated_at, worker_pid = :worker_pid WHERE request_id = :request_id"); - $update->execute([ - ':updated_at' => tcp_queue_now(), - ':worker_pid' => getmypid() ?: null, - ':request_id' => $row['request_id'], - ]); - $pdo->commit(); - - $row['status'] = 'processing'; - $row['worker_pid'] = getmypid() ?: null; - return $row; - } catch (Throwable $e) { - if ($pdo->inTransaction()) { - $pdo->rollBack(); - } - throw $e; - } + $stmt = $pdo->query(" + SELECT * + FROM car_tcp_request_queue + WHERE status = 'queued' + ORDER BY + CASE WHEN cmd IN ('ef', 'en', 'hn', 'hf', 'dl', 'du', 'tu') THEN 0 ELSE 1 END, + id ASC + LIMIT 1 + FOR UPDATE + "); + $row = $stmt->fetch(); + if (!$row) { + $pdo->commit(); + return null; + } + + $update = $pdo->prepare("UPDATE car_tcp_request_queue SET status = 'processing', updated_at = :updated_at, worker_pid = :worker_pid WHERE request_id = :request_id"); + $update->execute([ + ':updated_at' => tcp_queue_now(), + ':worker_pid' => getmypid() ?: null, + ':request_id' => $row['request_id'], + ]); + $pdo->commit(); + + $row['status'] = 'processing'; + $row['worker_pid'] = getmypid() ?: null; + return $row; + } catch (Throwable $e) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + throw $e; + } +} + +function tcp_queue_has_usage(PDO $pdo, string $requestId): bool +{ + $stmt = $pdo->prepare("SELECT 1 FROM car_data_usage WHERE request_id = :request_id LIMIT 1"); + $stmt->execute([':request_id' => $requestId]); + return (bool)$stmt->fetchColumn(); } -function tcp_queue_has_usage(PDO $pdo, string $requestId): bool +function tcp_queue_expire_queued_before_send(PDO $pdo): void { - $stmt = $pdo->prepare("SELECT 1 FROM car_data_usage WHERE request_id = :request_id LIMIT 1"); - $stmt->execute([':request_id' => $requestId]); - return (bool)$stmt->fetchColumn(); + $stmt = $pdo->prepare(" + UPDATE car_tcp_request_queue + SET status = 'expired_before_send', + updated_at = :updated_at, + tcp_ok = 0, + tcp_error = 'queue_expired_before_send', + sent_bytes = 0, + received_bytes = 0, + total_bytes = 0, + connect_ms = 0, + read_ms = 0, + tcp_ms = 0 + WHERE status = 'queued' + AND ui_deadline_ts > 0 + AND ui_deadline_ts < :now + ORDER BY id ASC + LIMIT " . TCP_STALE_QUEUE_BATCH_LIMIT + ); + $stmt->execute([ + ':updated_at' => tcp_queue_now(), + ':now' => microtime(true), + ]); } function tcp_queue_recover_stale(PDO $pdo): void { + tcp_queue_expire_queued_before_send($pdo); + $now = microtime(true); - $stmt = $pdo->prepare(" - SELECT * - FROM car_tcp_request_queue - WHERE status IN ('processing', 'ui_timeout_pending') - AND usage_deadline_ts > 0 - AND usage_deadline_ts < :now - ORDER BY id ASC - LIMIT 20 - "); - $stmt->execute([':now' => $now]); - $rows = $stmt->fetchAll(); - - foreach ($rows as $row) { - $requestId = (string)($row['request_id'] ?? ''); - if ($requestId === '') { - continue; - } - - $sentBytes = max(0, (int)($row['sent_bytes'] ?? 0)); - $receivedBytes = max(0, (int)($row['received_bytes'] ?? 0)); - $connectMs = max(0, (int)($row['connect_ms'] ?? 0)); - $readMs = max(0, (int)($row['read_ms'] ?? 0)); - $hasUsage = tcp_queue_has_usage($pdo, $requestId); - - if ($hasUsage) { - tcp_queue_update($pdo, $requestId, [ - 'status' => 'worker_lost_usage_recorded', - 'tcp_ok' => 0, - 'tcp_error' => 'worker_lost_after_usage_recorded', - ]); - continue; - } - - if ($sentBytes <= 0) { - tcp_queue_record_usage_and_status( - $pdo, - $row, - 'expired_before_send', - 0, - 0, - false, - 'worker_lost_before_send', - '', - $connectMs, - $readMs - ); - continue; - } - - tcp_queue_record_usage_and_status( - $pdo, - $row, - 'settled_sent_only', - $sentBytes, - $receivedBytes, - false, - 'worker_lost_late_settlement_sent_only', - '', - $connectMs, - $readMs - ); - } -} - -function tcp_queue_record_usage_and_status( - PDO $pdo, - array $job, - string $status, - int $sentBytes, - int $receivedBytes, - bool $tcpOk, - string $tcpError, - string $rawFull = '', - int $connectMs = 0, - int $readMs = 0 -): void { - $requestId = (string)$job['request_id']; - $source = (string)$job['source']; - $cmd = (string)$job['cmd']; - - db_insert_data_usage($pdo, $source, $cmd, $sentBytes, $receivedBytes, $tcpOk, $tcpError, $requestId); - - tcp_queue_update($pdo, $requestId, [ - 'status' => $status, - 'sent_bytes' => max(0, $sentBytes), - 'received_bytes' => max(0, $receivedBytes), - 'total_bytes' => max(0, $sentBytes + $receivedBytes), - 'tcp_ok' => $tcpOk ? 1 : 0, - 'tcp_error' => $tcpError !== '' ? substr($tcpError, 0, 100) : null, - 'raw_full' => $rawFull !== '' ? $rawFull : null, - 'connect_ms' => max(0, $connectMs), - 'read_ms' => max(0, $readMs), - 'tcp_ms' => max(0, $connectMs + $readMs), - ]); -} - -function tcp_queue_maybe_store_status(PDO $pdo, array $job, string $rawFull): void -{ - $trimError = ''; - $rawTrim = make_trim($rawFull, $trimError); - if ($rawTrim === '') { - return; - } - - $data = parse_trim($rawTrim); - if (!is_valid_status_data($data)) { - return; - } - - db_insert_status($pdo, (string)$job['cmd'], $rawFull, $rawTrim, $data); -} - -function tcp_worker_process_job(array $job): void -{ - $pdo = db(true); - db_ensure_tcp_queue_schema($pdo); - - $cmd = (string)$job['cmd']; - $requestId = (string)$job['request_id']; - $payload = (string)$job['request_payload']; - $uiDeadline = (float)$job['ui_deadline_ts']; - $usageDeadline = (float)$job['usage_deadline_ts']; - $connectStart = microtime(true); - $sentBytes = 0; - $receivedBytes = 0; - $response = ''; - $tcpError = ''; - $uiTimedOut = false; - - if (microtime(true) >= $uiDeadline) { - tcp_queue_record_usage_and_status($pdo, $job, 'expired_before_send', 0, 0, false, 'queue_expired_before_send', '', 0, 0); - return; - } - - $connectTimeout = max(0.01, min(TCP_TOTAL_TIMEOUT, $uiDeadline - microtime(true))); - $fp = @stream_socket_client("tcp://" . TCP_HOST . ":" . TCP_PORT, $errno, $errstr, $connectTimeout); - $connectMs = (int)round((microtime(true) - $connectStart) * 1000); - - if (!$fp) { - tcp_queue_record_usage_and_status($pdo, $job, 'connect_error', 0, 0, false, "connect_error: $errno $errstr", '', $connectMs, 0); - return; - } - - stream_set_blocking($fp, false); - $written = @fwrite($fp, $payload); - if ($written === false || $written < strlen($payload)) { - $sentBytes = ($written === false) ? 0 : (int)$written; - @fclose($fp); - tcp_queue_record_usage_and_status($pdo, $job, 'write_failed', $sentBytes, 0, false, 'write_failed', '', $connectMs, 0); - return; - } - - $sentBytes = (int)$written; - tcp_queue_update($pdo, $requestId, [ - 'sent_bytes' => $sentBytes, - 'total_bytes' => $sentBytes, - ]); - - $readStart = microtime(true); - while (microtime(true) < $usageDeadline) { - $now = microtime(true); - - if (!$uiTimedOut && $now >= $uiDeadline) { - $uiTimedOut = true; - tcp_queue_update($pdo, $requestId, [ - 'status' => 'ui_timeout_pending', - 'tcp_error' => 'total_timeout_4_95s_late_settlement_pending', - 'received_bytes' => strlen($response), - 'total_bytes' => $sentBytes + strlen($response), - 'connect_ms' => $connectMs, - 'read_ms' => (int)round(($now - $readStart) * 1000), - 'tcp_ms' => $connectMs + (int)round(($now - $readStart) * 1000), - ]); - } - - $nextWake = min($usageDeadline, $uiTimedOut ? $now + 1.0 : $uiDeadline); - $wait = max(0.001, min(1.0, $nextWake - $now)); - $sec = (int)$wait; - $usec = (int)(($wait - $sec) * 1000000); - - $read = [$fp]; - $w = null; - $e = null; - $sel = @stream_select($read, $w, $e, $sec, $usec); - if ($sel === false) { - $tcpError = 'stream_select_failed'; - break; - } - - if ($sel > 0) { - $chunk = @fread($fp, 4096); - if ($chunk === false) { - $tcpError = 'read_failed'; - break; - } - - if ($chunk !== '') { - $response .= $chunk; - $receivedBytes = strlen($response); - tcp_queue_update($pdo, $requestId, [ - 'received_bytes' => $receivedBytes, - 'total_bytes' => $sentBytes + $receivedBytes, - ]); - - if (preg_match(RAW_FULL_REGEX, trim($response))) { - break; - } - } - } - - if (feof($fp)) { - break; - } - } - - @fclose($fp); - - $readMs = (int)round((microtime(true) - $readStart) * 1000); - $receivedBytes = strlen($response); - $trimmed = trim($response); - - if ($trimmed === '') { - tcp_queue_record_usage_and_status($pdo, $job, 'settled_sent_only', $sentBytes, 0, false, 'late_total_timeout_60s_sent_only', '', $connectMs, $readMs); - return; - } - - if (!preg_match(RAW_FULL_REGEX, $trimmed)) { - tcp_queue_record_usage_and_status( - $pdo, - $job, - 'settled_partial', - $sentBytes, - $receivedBytes, - false, - $tcpError !== '' ? $tcpError : 'late_invalid_or_partial_response', - $trimmed, - $connectMs, - $readMs - ); - return; - } - - $status = $uiTimedOut ? 'settled_late' : 'settled'; - $error = $uiTimedOut ? 'late_response_after_ui_timeout' : ''; - tcp_queue_record_usage_and_status($pdo, $job, $status, $sentBytes, $receivedBytes, true, $error, $trimmed, $connectMs, $readMs); - if ($uiTimedOut) { - tcp_queue_maybe_store_status($pdo, $job, $trimmed); - } -} - -function tcp_request_wait_for_queue( - string $requestId, - int &$tcpMs, - int &$connectMs, - int &$readMs, - string &$tcpError, - int &$sentBytes, - int &$receivedBytes -): string { - $deadline = microtime(true) + TCP_TOTAL_TIMEOUT; - - while (microtime(true) < $deadline) { - $row = tcp_queue_get($requestId); - if (!$row) { - usleep(50000); - continue; - } - - $status = (string)$row['status']; - $sentBytes = (int)($row['sent_bytes'] ?? 0); - $receivedBytes = (int)($row['received_bytes'] ?? 0); - $connectMs = (int)($row['connect_ms'] ?? 0); - $readMs = (int)($row['read_ms'] ?? 0); - $tcpMs = (int)($row['tcp_ms'] ?? ($connectMs + $readMs)); - $tcpError = (string)($row['tcp_error'] ?? ''); - - if ($status === 'settled' && (int)($row['tcp_ok'] ?? 0) === 1) { - return (string)($row['raw_full'] ?? ''); - } - - if (in_array($status, ['ui_timeout_pending', 'connect_error', 'write_failed', 'settled_sent_only', 'settled_partial'], true)) { - if ($tcpError === '') { - $tcpError = $status; - } - return ''; - } - - usleep(50000); - } - - $row = tcp_queue_get($requestId); - if ($row) { - $sentBytes = (int)($row['sent_bytes'] ?? 0); - $receivedBytes = (int)($row['received_bytes'] ?? 0); - $connectMs = (int)($row['connect_ms'] ?? 0); - $readMs = (int)($row['read_ms'] ?? 0); - $tcpMs = (int)($row['tcp_ms'] ?? ($connectMs + $readMs)); - } - $tcpError = 'queue_wait_timeout_late_settlement_pending'; - return ''; -} - -function tcp_request( - string $cmd, - int &$tcpMs, - int &$connectMs, - int &$readMs, - string &$tcpError, - string $usageSource = 'api_control', - ?string $requestId = null, - int &$sentBytes = 0, - int &$receivedBytes = 0 -): string -{ - $tcpMs = 0; - $connectMs = 0; - $readMs = 0; - $tcpError = ''; - $sentBytes = 0; - $receivedBytes = 0; - + $stmt = $pdo->prepare(" + SELECT * + FROM car_tcp_request_queue + WHERE status IN ('processing', 'ui_timeout_pending') + AND usage_deadline_ts > 0 + AND usage_deadline_ts < :now + ORDER BY id ASC + LIMIT 20 + "); + $stmt->execute([':now' => $now]); + $rows = $stmt->fetchAll(); + + foreach ($rows as $row) { + $requestId = (string)($row['request_id'] ?? ''); + if ($requestId === '') { + continue; + } + + $sentBytes = max(0, (int)($row['sent_bytes'] ?? 0)); + $receivedBytes = max(0, (int)($row['received_bytes'] ?? 0)); + $connectMs = max(0, (int)($row['connect_ms'] ?? 0)); + $readMs = max(0, (int)($row['read_ms'] ?? 0)); + $hasUsage = tcp_queue_has_usage($pdo, $requestId); + + if ($hasUsage) { + tcp_queue_update($pdo, $requestId, [ + 'status' => 'worker_lost_usage_recorded', + 'tcp_ok' => 0, + 'tcp_error' => 'worker_lost_after_usage_recorded', + ]); + continue; + } + + if ($sentBytes <= 0) { + tcp_queue_record_usage_and_status( + $pdo, + $row, + 'expired_before_send', + 0, + 0, + false, + 'worker_lost_before_send', + '', + $connectMs, + $readMs + ); + continue; + } + + tcp_queue_record_usage_and_status( + $pdo, + $row, + 'settled_sent_only', + $sentBytes, + $receivedBytes, + false, + 'worker_lost_late_settlement_sent_only', + '', + $connectMs, + $readMs + ); + } +} + +function tcp_queue_record_usage_and_status( + PDO $pdo, + array $job, + string $status, + int $sentBytes, + int $receivedBytes, + bool $tcpOk, + string $tcpError, + string $rawFull = '', + int $connectMs = 0, + int $readMs = 0 +): void { + $requestId = (string)$job['request_id']; + $source = (string)$job['source']; + $cmd = (string)$job['cmd']; + + db_insert_data_usage($pdo, $source, $cmd, $sentBytes, $receivedBytes, $tcpOk, $tcpError, $requestId); + + tcp_queue_update($pdo, $requestId, [ + 'status' => $status, + 'sent_bytes' => max(0, $sentBytes), + 'received_bytes' => max(0, $receivedBytes), + 'total_bytes' => max(0, $sentBytes + $receivedBytes), + 'tcp_ok' => $tcpOk ? 1 : 0, + 'tcp_error' => $tcpError !== '' ? substr($tcpError, 0, 100) : null, + 'raw_full' => $rawFull !== '' ? $rawFull : null, + 'connect_ms' => max(0, $connectMs), + 'read_ms' => max(0, $readMs), + 'tcp_ms' => max(0, $connectMs + $readMs), + ]); +} + +function tcp_queue_maybe_store_status(PDO $pdo, array $job, string $rawFull): void +{ + $trimError = ''; + $rawTrim = make_trim($rawFull, $trimError); + if ($rawTrim === '') { + return; + } + + $data = parse_trim($rawTrim); + if (!is_valid_status_data($data)) { + return; + } + + db_insert_status($pdo, (string)$job['cmd'], $rawFull, $rawTrim, $data); +} + +function tcp_worker_process_job(array $job): void +{ + $pdo = db(true); + db_ensure_tcp_queue_schema($pdo); + + $cmd = (string)$job['cmd']; + $requestId = (string)$job['request_id']; + $payload = (string)$job['request_payload']; + $uiDeadline = (float)$job['ui_deadline_ts']; + $usageDeadline = (float)$job['usage_deadline_ts']; + $connectStart = microtime(true); + $sentBytes = 0; + $receivedBytes = 0; + $response = ''; + $tcpError = ''; + $uiTimedOut = false; + + if (microtime(true) >= $uiDeadline) { + tcp_queue_record_usage_and_status($pdo, $job, 'expired_before_send', 0, 0, false, 'queue_expired_before_send', '', 0, 0); + return; + } + + $connectTimeout = max(0.01, min(TCP_TOTAL_TIMEOUT, $uiDeadline - microtime(true))); + $fp = @stream_socket_client("tcp://" . TCP_HOST . ":" . TCP_PORT, $errno, $errstr, $connectTimeout); + $connectMs = (int)round((microtime(true) - $connectStart) * 1000); + + if (!$fp) { + tcp_queue_record_usage_and_status($pdo, $job, 'connect_error', 0, 0, false, "connect_error: $errno $errstr", '', $connectMs, 0); + return; + } + + stream_set_blocking($fp, false); + $written = @fwrite($fp, $payload); + if ($written === false || $written < strlen($payload)) { + $sentBytes = ($written === false) ? 0 : (int)$written; + @fclose($fp); + tcp_queue_record_usage_and_status($pdo, $job, 'write_failed', $sentBytes, 0, false, 'write_failed', '', $connectMs, 0); + return; + } + + $sentBytes = (int)$written; + tcp_queue_update($pdo, $requestId, [ + 'sent_bytes' => $sentBytes, + 'total_bytes' => $sentBytes, + ]); + + $readStart = microtime(true); + while (microtime(true) < $usageDeadline) { + $now = microtime(true); + + if (!$uiTimedOut && $now >= $uiDeadline) { + $uiTimedOut = true; + tcp_queue_update($pdo, $requestId, [ + 'status' => 'ui_timeout_pending', + 'tcp_error' => 'total_timeout_4_95s_late_settlement_pending', + 'received_bytes' => strlen($response), + 'total_bytes' => $sentBytes + strlen($response), + 'connect_ms' => $connectMs, + 'read_ms' => (int)round(($now - $readStart) * 1000), + 'tcp_ms' => $connectMs + (int)round(($now - $readStart) * 1000), + ]); + } + + $nextWake = min($usageDeadline, $uiTimedOut ? $now + 1.0 : $uiDeadline); + $wait = max(0.001, min(1.0, $nextWake - $now)); + $sec = (int)$wait; + $usec = (int)(($wait - $sec) * 1000000); + + $read = [$fp]; + $w = null; + $e = null; + $sel = @stream_select($read, $w, $e, $sec, $usec); + if ($sel === false) { + $tcpError = 'stream_select_failed'; + break; + } + + if ($sel > 0) { + $chunk = @fread($fp, 4096); + if ($chunk === false) { + $tcpError = 'read_failed'; + break; + } + + if ($chunk !== '') { + $response .= $chunk; + $receivedBytes = strlen($response); + tcp_queue_update($pdo, $requestId, [ + 'received_bytes' => $receivedBytes, + 'total_bytes' => $sentBytes + $receivedBytes, + ]); + + if (preg_match(RAW_FULL_REGEX, trim($response))) { + break; + } + } + } + + if (feof($fp)) { + break; + } + } + + @fclose($fp); + + $readMs = (int)round((microtime(true) - $readStart) * 1000); + $receivedBytes = strlen($response); + $trimmed = trim($response); + + if ($trimmed === '') { + tcp_queue_record_usage_and_status($pdo, $job, 'settled_sent_only', $sentBytes, 0, false, 'late_total_timeout_60s_sent_only', '', $connectMs, $readMs); + return; + } + + if (!preg_match(RAW_FULL_REGEX, $trimmed)) { + tcp_queue_record_usage_and_status( + $pdo, + $job, + 'settled_partial', + $sentBytes, + $receivedBytes, + false, + $tcpError !== '' ? $tcpError : 'late_invalid_or_partial_response', + $trimmed, + $connectMs, + $readMs + ); + return; + } + + $status = $uiTimedOut ? 'settled_late' : 'settled'; + $error = $uiTimedOut ? 'late_response_after_ui_timeout' : ''; + tcp_queue_record_usage_and_status($pdo, $job, $status, $sentBytes, $receivedBytes, true, $error, $trimmed, $connectMs, $readMs); + if ($uiTimedOut) { + tcp_queue_maybe_store_status($pdo, $job, $trimmed); + } +} + +function tcp_request_wait_for_queue( + string $requestId, + int &$tcpMs, + int &$connectMs, + int &$readMs, + string &$tcpError, + int &$sentBytes, + int &$receivedBytes +): string { + $deadline = microtime(true) + TCP_TOTAL_TIMEOUT; + + while (microtime(true) < $deadline) { + $row = tcp_queue_get($requestId); + if (!$row) { + usleep(50000); + continue; + } + + $status = (string)$row['status']; + $sentBytes = (int)($row['sent_bytes'] ?? 0); + $receivedBytes = (int)($row['received_bytes'] ?? 0); + $connectMs = (int)($row['connect_ms'] ?? 0); + $readMs = (int)($row['read_ms'] ?? 0); + $tcpMs = (int)($row['tcp_ms'] ?? ($connectMs + $readMs)); + $tcpError = (string)($row['tcp_error'] ?? ''); + + if ($status === 'settled' && (int)($row['tcp_ok'] ?? 0) === 1) { + return (string)($row['raw_full'] ?? ''); + } + + if (in_array($status, ['ui_timeout_pending', 'connect_error', 'write_failed', 'settled_sent_only', 'settled_partial'], true)) { + if ($tcpError === '') { + $tcpError = $status; + } + return ''; + } + + usleep(50000); + } + + $row = tcp_queue_get($requestId); + if ($row) { + $sentBytes = (int)($row['sent_bytes'] ?? 0); + $receivedBytes = (int)($row['received_bytes'] ?? 0); + $connectMs = (int)($row['connect_ms'] ?? 0); + $readMs = (int)($row['read_ms'] ?? 0); + $tcpMs = (int)($row['tcp_ms'] ?? ($connectMs + $readMs)); + } + $tcpError = 'queue_wait_timeout_late_settlement_pending'; + return ''; +} + +function tcp_request( + string $cmd, + int &$tcpMs, + int &$connectMs, + int &$readMs, + string &$tcpError, + string $usageSource = 'api_control', + ?string $requestId = null, + int &$sentBytes = 0, + int &$receivedBytes = 0 +): string +{ + $tcpMs = 0; + $connectMs = 0; + $readMs = 0; + $tcpError = ''; + $sentBytes = 0; + $receivedBytes = 0; + if ($requestId === null || $requestId === '') { $requestId = substr(bin2hex(random_bytes(16)), 0, 32); } + if ($cmd === 'se' && $usageSource === 'collector_se') { + try { + if (tcp_queue_has_pending_collector_se(db(true))) { + $tcpError = 'skipped_duplicate_pending_collector_se'; + return ''; + } + } catch (Throwable $e) { + $tcpError = 'queue_pending_check_failed'; + record_tcp_usage($usageSource, $cmd, 0, 0, false, $tcpError, $requestId); + return ''; + } + } + try { tcp_queue_insert($cmd, $usageSource, $requestId); } catch (Throwable $e) { - $tcpError = 'queue_insert_failed'; - record_tcp_usage($usageSource, $cmd, 0, 0, false, $tcpError, $requestId); - return ''; - } - - return tcp_request_wait_for_queue( - $requestId, - $tcpMs, - $connectMs, - $readMs, - $tcpError, - $sentBytes, - $receivedBytes - ); -} + $tcpError = 'queue_insert_failed'; + record_tcp_usage($usageSource, $cmd, 0, 0, false, $tcpError, $requestId); + return ''; + } + + return tcp_request_wait_for_queue( + $requestId, + $tcpMs, + $connectMs, + $readMs, + $tcpError, + $sentBytes, + $receivedBytes + ); +} function make_trim(string $rawFull, string &$trimError = ''): string { @@ -813,14 +883,14 @@ function is_valid_status_data(array $data): bool return true; } -function db_insert_status( +function db_insert_status( PDO $pdo, string $cmd, string $rawFull, string $rawTrim, array $data -): void { - $v = (float)($data['battery_voltage'] ?? 0); +): void { + $v = (float)($data['battery_voltage'] ?? 0); // 1V는 저장 금지 if ($v <= 1.0) { @@ -835,28 +905,28 @@ function db_insert_status( (int)$data['remote_start_preparing'] === 1 || (int)$data['remote_start_running'] === 1; - if (!$engineOn && $v >= 14.2) { - return; - } - - $ts = date('Y-m-d H:i:s'); - - $dupStmt = $pdo->prepare("" - . "SELECT seq FROM car_status " - . "WHERE id='car' AND ts = :ts AND cmd <=> :cmd AND raw_trim <=> :raw_trim " - . "ORDER BY seq ASC LIMIT 1" - ); - $dupStmt->execute([ - ':ts' => $ts, - ':cmd' => $cmd, - ':raw_trim' => $rawTrim, - ]); - - if ($dupStmt->fetchColumn()) { - return; - } - - $sql = "INSERT INTO car_status ( + if (!$engineOn && $v >= 14.2) { + return; + } + + $ts = date('Y-m-d H:i:s'); + + $dupStmt = $pdo->prepare("" + . "SELECT seq FROM car_status " + . "WHERE id='car' AND ts = :ts AND cmd <=> :cmd AND raw_trim <=> :raw_trim " + . "ORDER BY seq ASC LIMIT 1" + ); + $dupStmt->execute([ + ':ts' => $ts, + ':cmd' => $cmd, + ':raw_trim' => $rawTrim, + ]); + + if ($dupStmt->fetchColumn()) { + return; + } + + $sql = "INSERT INTO car_status ( id, ts, cmd, boundary, engine, driving, battery_voltage, door_fl, door_fr, door_rl, door_rr, door_trunk, @@ -870,10 +940,10 @@ function db_insert_status( :hazard, :raw_full, :raw_trim )"; - $stmt = $pdo->prepare($sql); - $stmt->execute([ - ':ts' => $ts, - ':cmd' => $cmd, + $stmt = $pdo->prepare($sql); + $stmt->execute([ + ':ts' => $ts, + ':cmd' => $cmd, ':boundary' => $data['boundary'], ':engine' => $data['engine'], @@ -897,30 +967,30 @@ function db_insert_status( ]); } -function db_latest(PDO $pdo): ?array -{ - $stmt = $pdo->query("SELECT * FROM car_status WHERE id='car' ORDER BY ts DESC LIMIT 1"); - $row = $stmt->fetch(); - return $row ?: null; -} - -function db_latest_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 db_logs(PDO $pdo, int $limit): array -{ - $limit = max(1, min(500, $limit)); - $stmt = $pdo->prepare("SELECT ts, cmd, raw_full, raw_trim FROM car_status WHERE id='car' ORDER BY ts DESC LIMIT $limit"); - $stmt->execute(); +function db_latest(PDO $pdo): ?array +{ + $stmt = $pdo->query("SELECT * FROM car_status WHERE id='car' ORDER BY ts DESC LIMIT 1"); + $row = $stmt->fetch(); + return $row ?: null; +} + +function db_latest_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 db_logs(PDO $pdo, int $limit): array +{ + $limit = max(1, min(500, $limit)); + $stmt = $pdo->prepare("SELECT ts, cmd, raw_full, raw_trim FROM car_status WHERE id='car' ORDER BY ts DESC LIMIT $limit"); + $stmt->execute(); return $stmt->fetchAll(); } diff --git a/monitor.php b/monitor.php index 49defe8..f38ad5d 100644 --- a/monitor.php +++ b/monitor.php @@ -3,3597 +3,3493 @@ declare(strict_types=1); date_default_timezone_set('Asia/Seoul'); -$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')); +require_once __DIR__ . '/../common/auth.php'; +$carSecretFile = __DIR__ . '/../../config/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 CAR_MONITOR_COOKIE = 'car_monitor_access'; -const CAR_MONITOR_AUTH_TTL = 2592000; const CAR_MONITOR_SETTINGS_COOKIE = 'car_monitor_settings_access'; -const CAR_MONITOR_SETTINGS_TTL = 900; -const CAR_MONITOR_SETTINGS_PASSWORD = '1026!)Corjs'; -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], -]; - +const CAR_MONITOR_SETTINGS_TTL = 900; +const CAR_MONITOR_SETTINGS_PASSWORD = '1026!)Corjs'; +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_asset_url(string $path): string { - $relativePath = preg_replace('#^/car/#', '', $path) ?? ltrim($path, '/'); - $fullPath = __DIR__ . '/' . ltrim($relativePath, '/'); - $version = 'dev'; - + $relativePath = preg_replace('#^/custom/car/#', '', $path) ?? ltrim($path, '/'); + $fullPath = __DIR__ . '/' . ltrim($relativePath, '/'); + $version = 'dev'; + if (is_file($fullPath)) { - $mtime = filemtime($fullPath) ?: time(); - $hash = is_readable($fullPath) ? substr(hash_file('sha256', $fullPath) ?: '', 0, 10) : ''; - $version = $mtime . ($hash !== '' ? '-' . $hash : ''); + $hash = is_readable($fullPath) ? substr(hash_file('sha256', $fullPath) ?: '', 0, 16) : ''; + $version = $hash !== '' ? $hash : 'unreadable'; } - - return $path . '?v=' . rawurlencode($version); -} - -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 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_cookie_secret(): string -{ - global $carSecretConfig; - - $secret = (string)($carSecretConfig['monitor_auth']['secret'] ?? $carSecretConfig['auth_token'] ?? ''); - if ($secret === '') { - throw new RuntimeException('Car monitor auth secret is missing.'); - } - - return $secret; -} - -function car_monitor_cookie_options(int $expires): array -{ - return [ - 'expires' => $expires, - 'path' => '/car/', - 'secure' => (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'), - 'httponly' => true, - 'samesite' => 'Strict', - ]; -} - -function car_monitor_cookie_value(int $expires, bool $admin = false): string -{ - $body = base64_encode(json_encode([ - 'exp' => $expires, - 'iat' => time(), - 'admin' => $admin, - ], JSON_UNESCAPED_SLASHES)); - $sig = hash_hmac('sha256', $body, car_monitor_cookie_secret()); - - return $body . '.' . $sig; -} - -function car_monitor_issue_cookie(bool $admin = false): void -{ - $expires = time() + CAR_MONITOR_AUTH_TTL; - setcookie(CAR_MONITOR_COOKIE, car_monitor_cookie_value($expires, $admin), car_monitor_cookie_options($expires)); -} - -function car_monitor_cookie_payload(): ?array -{ - $cookie = $_COOKIE[CAR_MONITOR_COOKIE] ?? ''; - if (!is_string($cookie) || strpos($cookie, '.') === false) { - return null; - } - - [$body, $sig] = explode('.', $cookie, 2); - $expected = hash_hmac('sha256', $body, car_monitor_cookie_secret()); - if (!hash_equals($expected, $sig)) { - return null; - } - - $decoded = base64_decode($body, true); - if ($decoded === false) { - return null; - } - - $payload = json_decode($decoded, true); - if (!is_array($payload)) { - return null; - } - - return (int)($payload['exp'] ?? 0) >= time() ? $payload : null; -} - -function car_monitor_has_cookie(): bool -{ - return is_array(car_monitor_cookie_payload()); -} - + + return $path . '?v=' . rawurlencode($version); +} + +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 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_cookie_secret(): string +{ + global $carSecretConfig; + + $secret = (string)($carSecretConfig['monitor_auth']['secret'] ?? $carSecretConfig['auth_token'] ?? ''); + if ($secret === '') { + throw new RuntimeException('Car monitor auth secret is missing.'); + } + + return $secret; +} + +function car_monitor_cookie_options(int $expires): array +{ + return [ + 'expires' => $expires, + 'path' => '/custom/car/', + 'secure' => (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'), + 'httponly' => true, + 'samesite' => 'Strict', + ]; +} + function car_monitor_settings_cookie_value(int $expires): string -{ - $body = base64_encode(json_encode([ - 'aud' => 'car-monitor-settings', - 'exp' => $expires, - 'iat' => time(), - ], JSON_UNESCAPED_SLASHES)); - $sig = hash_hmac('sha256', $body, car_monitor_cookie_secret()); - - return $body . '.' . $sig; -} - -function car_monitor_issue_settings_cookie(): void -{ - $expires = time() + CAR_MONITOR_SETTINGS_TTL; - setcookie(CAR_MONITOR_SETTINGS_COOKIE, car_monitor_settings_cookie_value($expires), car_monitor_cookie_options($expires)); -} - -function car_monitor_has_settings_access(): bool -{ - $cookie = $_COOKIE[CAR_MONITOR_SETTINGS_COOKIE] ?? ''; - if (!is_string($cookie) || strpos($cookie, '.') === false) { - return false; - } - - [$body, $sig] = explode('.', $cookie, 2); - $expected = hash_hmac('sha256', $body, car_monitor_cookie_secret()); - if (!hash_equals($expected, $sig)) { - return false; - } - - $decoded = base64_decode($body, true); - $payload = $decoded !== false ? json_decode($decoded, true) : null; - return is_array($payload) - && ($payload['aud'] ?? '') === 'car-monitor-settings' - && (int)($payload['exp'] ?? 0) >= time(); -} - -function car_monitor_require_settings_access(): void -{ - if (car_monitor_has_settings_access()) { - return; - } - - json_response([ - 'status' => 'forbidden', - 'message' => '관리자 비밀번호가 필요합니다.', - ], 403); -} - +{ + $body = base64_encode(json_encode([ + 'aud' => 'car-monitor-settings', + 'exp' => $expires, + 'iat' => time(), + ], JSON_UNESCAPED_SLASHES)); + $sig = hash_hmac('sha256', $body, car_monitor_cookie_secret()); + + return $body . '.' . $sig; +} + +function car_monitor_issue_settings_cookie(): void +{ + $expires = time() + CAR_MONITOR_SETTINGS_TTL; + setcookie(CAR_MONITOR_SETTINGS_COOKIE, car_monitor_settings_cookie_value($expires), car_monitor_cookie_options($expires)); +} + +function car_monitor_has_settings_access(): bool +{ + $cookie = $_COOKIE[CAR_MONITOR_SETTINGS_COOKIE] ?? ''; + if (!is_string($cookie) || strpos($cookie, '.') === false) { + return false; + } + + [$body, $sig] = explode('.', $cookie, 2); + $expected = hash_hmac('sha256', $body, car_monitor_cookie_secret()); + if (!hash_equals($expected, $sig)) { + return false; + } + + $decoded = base64_decode($body, true); + $payload = $decoded !== false ? json_decode($decoded, true) : null; + return is_array($payload) + && ($payload['aud'] ?? '') === 'car-monitor-settings' + && (int)($payload['exp'] ?? 0) >= time(); +} + +function car_monitor_require_settings_access(): void +{ + if (car_monitor_has_settings_access()) { + return; + } + + json_response([ + 'status' => 'forbidden', + 'message' => '관리자 비밀번호가 필요합니다.', + ], 403); +} + function car_monitor_launcher_session(): array { static $session = null; - + if (is_array($session)) { return $session; } - $cookieHeader = trim((string)($_SERVER['HTTP_COOKIE'] ?? '')); - if ($cookieHeader !== '') { - $remoteSession = car_monitor_launcher_api_request('GET', 'https://chaegeon.com/custom/launcher/api.php?session=1', null, $cookieHeader); - if (is_array($remoteSession) && !empty($remoteSession['authenticated'])) { - car_monitor_issue_cookie(); - $session = ['authenticated' => true]; - return $session; - } - } - - $cookiePayload = car_monitor_cookie_payload(); - if (is_array($cookiePayload)) { - $session = ['authenticated' => true]; + if (custom_is_site_admin()) { + custom_issue_auth_cookie(true); + $session = ['authenticated' => true, 'admin' => true]; return $session; } - $session = ['authenticated' => false]; + if (custom_has_auth_cookie()) { + $session = [ + 'authenticated' => true, + 'admin' => custom_auth_cookie_is_admin(), + ]; + return $session; + } + + $session = ['authenticated' => false, 'admin' => false]; return $session; } -function car_monitor_launcher_api_request(string $method, string $url, ?array $payload = null, string $cookieHeader = ''): ?array -{ - $body = false; - - if (function_exists('curl_init')) { - $ch = curl_init($url); - if ($ch !== false) { - $headers = ['User-Agent: car-monitor-auth/1.0']; - if ($cookieHeader !== '') { - $headers[] = 'Cookie: ' . $cookieHeader; - } - if ($payload !== null) { - $headers[] = 'Content-Type: application/json'; - } - - curl_setopt_array($ch, [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_CONNECTTIMEOUT => 2, - CURLOPT_TIMEOUT => 4, - CURLOPT_CUSTOMREQUEST => strtoupper($method), - CURLOPT_HTTPHEADER => $headers, - ]); - if ($payload !== null) { - curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE)); - } - $body = curl_exec($ch); - curl_close($ch); - } - } - - if ($body === false) { - $context = stream_context_create([ - 'http' => [ - 'method' => strtoupper($method), - 'timeout' => 4, - 'header' => ($cookieHeader !== '' ? "Cookie: {$cookieHeader}\r\n" : '') - . "User-Agent: car-monitor-auth/1.0\r\n" - . ($payload !== null ? "Content-Type: application/json\r\n" : ''), - 'content' => $payload !== null ? json_encode($payload, JSON_UNESCAPED_UNICODE) : '', - ], - ]); - $body = @file_get_contents($url, false, $context); - } - - $data = is_string($body) ? json_decode($body, true) : null; - return is_array($data) ? $data : null; -} - -function car_monitor_has_access(): bool -{ - $session = car_monitor_launcher_session(); - return !empty($session['authenticated']); -} - +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 (function_exists('custom_block_login_denied')) { + custom_block_login_denied($json); + } + 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'); - ?> - - - - - -차량 모니터 인증 - - - -
-
-
- - - - - 'unauthorized', + 'message' => '런처 인증이 필요합니다.', + ], 401); + } + + http_response_code(401); + header('Content-Type: text/html; charset=utf-8'); + header('Cache-Control: no-cache, no-store, must-revalidate'); + ?> + + + + + +차량 모니터 인증 + + + +
+
+
+ + + + + ['비밀번호가 틀립니다.']]); } - - 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); - } - - $remote = car_monitor_launcher_api_request('POST', 'https://chaegeon.com/custom/launcher/api.php', [ - 'action' => 'login', - 'password' => $password, - ]); - if (is_array($remote) && ($remote['result'] ?? '') === 'ok') { - car_monitor_issue_cookie(); + + 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 (custom_verify_password($password)) { + custom_issue_auth_cookie(false); json_response(['result' => 'ok']); } - - json_response(['result' => 'fail'], 401); -} - -function car_monitor_settings_auth_api(): void -{ - car_monitor_require_access(true); - - if ($_SERVER['REQUEST_METHOD'] !== 'POST') { - json_response(['error' => 'method_not_allowed'], 405); - } - - $data = request_payload(); - $password = (string)($data['password'] ?? ''); - if (!hash_equals(CAR_MONITOR_SETTINGS_PASSWORD, $password)) { - json_response([ - 'status' => 'forbidden', - 'message' => '관리자 비밀번호가 올바르지 않습니다.', - ], 403); - } - - car_monitor_issue_settings_cookie(); - json_response([ - 'status' => 'success', - 'settings_authenticated' => true, - 'expires_in' => CAR_MONITOR_SETTINGS_TTL, - ]); -} - -if (isset($_GET['mode']) && $_GET['mode'] === 'auth-login') { - car_monitor_auth_api(); -} - -if (isset($_GET['mode']) && $_GET['mode'] === 'settings-auth') { - car_monitor_settings_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_settings_access(); - - 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
-
-
-
- - - - - - - - - - - - - - - -
TIMECMDENGBATDOORREMOTEHAZAGETRIM
-
-
-
-
-
-
- - - - - - - - - - - + + json_response(['result' => 'fail'], 401); +} + +function car_monitor_settings_auth_api(): void +{ + car_monitor_require_access(true); + + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + json_response(['error' => 'method_not_allowed'], 405); + } + + $data = request_payload(); + $password = (string)($data['password'] ?? ''); + if (!hash_equals(CAR_MONITOR_SETTINGS_PASSWORD, $password)) { + json_response([ + 'status' => 'forbidden', + 'message' => '관리자 비밀번호가 올바르지 않습니다.', + ], 403); + } + + car_monitor_issue_settings_cookie(); + json_response([ + 'status' => 'success', + 'settings_authenticated' => true, + 'expires_in' => CAR_MONITOR_SETTINGS_TTL, + ]); +} + +if (isset($_GET['mode']) && $_GET['mode'] === 'auth-login') { + car_monitor_auth_api(); +} + +if (isset($_GET['mode']) && $_GET['mode'] === 'settings-auth') { + car_monitor_settings_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_settings_access(); + + 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
+
+
+
+ + + + + + + + + + + + + + + +
TIMECMDENGBATDOORREMOTEHAZAGETRIM
+
+
+
+
+
+
+ + + + + + + + + + + diff --git a/tcp_worker.php b/tcp_worker.php index a66dfb6..50da14d 100644 --- a/tcp_worker.php +++ b/tcp_worker.php @@ -1,44 +1,44 @@ -getMessage() . "\n"); - usleep(1000000); - } -} + + tcp_worker_process_job($job); + } catch (Throwable $e) { + fwrite(STDERR, date('c') . ' ' . $e->getMessage() . "\n"); + usleep(1000000); + } +}