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 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 b(string $ch): int { return ($ch === 'i') ? 1 : 0; } function at(string $s, int $i): string { return $s[$i] ?? ''; } function get_client_ip(): string { $ip = $_SERVER['HTTP_CF_CONNECTING_IP'] ?? $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? ''; if (strpos($ip, ',') !== false) { $ip = trim(explode(',', $ip)[0]); } return trim($ip); } function normalize_cmd(?string $cmd): string { $cmd = strtolower(trim((string)$cmd)); return in_array($cmd, ALLOWED_CMD, true) ? $cmd : 'se'; } 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 { $stmt = $pdo->prepare("INSERT INTO car_data_usage ( id, ts, source, cmd, sent_bytes, received_bytes, total_bytes, tcp_ok, tcp_error, request_id ) VALUES ( 'car', :ts, :source, :cmd, :sent_bytes, :received_bytes, :total_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), ':tcp_ok' => $tcpOk ? 1 : 0, ':tcp_error' => $tcpError !== '' ? substr($tcpError, 0, 100) : null, ':request_id' => $requestId, ]); } catch (Throwable $e) { // 사용량 기록 실패가 차량 제어/조회 자체를 막으면 안 된다. } } 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(); 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, ]; } 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); tcp_queue_recover_stale($pdo); $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; } } 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_recover_stale(PDO $pdo): void { $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; if ($requestId === null || $requestId === '') { $requestId = substr(bin2hex(random_bytes(16)), 0, 32); } 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 ); } function make_trim(string $rawFull, string &$trimError = ''): string { $trimError = ''; $rawFull = trim($rawFull); if (!preg_match(RAW_FULL_REGEX, $rawFull, $m)) { $trimError = 'invalid_format_regex'; return ''; } if (($m['modem'] ?? '') !== MODEM) { $trimError = 'invalid_modem'; return ''; } $E0 = $m['E0'] ?? ''; $D0 = $m['D0'] ?? ''; $F0 = $m['F0'] ?? ''; $E = substr($E0, 0, -1); $D = substr($D0, 0, -2); $F = substr($F0, 0, -2); if ($E === '' || $D === '' || $F === '') { $trimError = 'trim_empty_after_cut'; return ''; } if (strlen("E:$E") < 10) { $trimError = 'E_too_short'; return ''; } if (strlen("D:$D") < 7) { $trimError = 'D_too_short'; return ''; } if (strlen("F:$F") < 10) { $trimError = 'F_too_short'; return ''; } return "E:$E/D:$D/F:$F"; } function parse_trim(string $rawTrim): array { $p = explode('/', $rawTrim); $E = $p[0] ?? ''; $D = $p[1] ?? ''; $F = $p[2] ?? ''; $boundary = b(at($E, 2)); $engine = b(at($E, 3)); $driving = ( $boundary === 0 && b(at($E, 3)) === 1 && b(at($E, 4)) === 1 && b(at($E, 5)) === 1 && b(at($E, 6)) === 1 ) ? 1 : 0; $volt10 = (int)substr($E, 7, 3); $batteryVoltage = (float)number_format($volt10 / 10, 1, '.', ''); $doorFl = b(at($D, 2)); $doorFr = b(at($D, 3)); $doorRl = b(at($D, 4)); $doorRr = b(at($D, 5)); $doorTrunk = b(at($D, 6)); $rsCode = substr($F, 2, 2); $remoteStartPreparing = ($rsCode === 'to') ? 1 : 0; $remoteStartRunning = ($rsCode === 'si') ? 1 : 0; $mmss = substr($F, 4, 4); $remoteStartRemaining = substr($mmss, 0, 2) . ":" . substr($mmss, 2, 2); $hazard = b(at($F, 9)); return [ 'boundary' => $boundary, 'engine' => $engine, 'driving' => $driving, 'battery_voltage' => $batteryVoltage, 'door_fl' => $doorFl, 'door_fr' => $doorFr, 'door_rl' => $doorRl, 'door_rr' => $doorRr, 'door_trunk' => $doorTrunk, 'remote_start_preparing' => $remoteStartPreparing, 'remote_start_running' => $remoteStartRunning, 'remote_start_remaining' => $remoteStartRemaining, 'hazard' => $hazard, ]; } function is_valid_status_data(array $data): bool { $v = (float)($data['battery_voltage'] ?? 0); // 1V는 실제 배터리값이 아니라 통신/파싱 이상값으로 보고 저장 차단 if ($v <= 1.0) { return false; } // 완전히 말이 안 되는 값만 차단 // 시동 ON 중 15V대는 정상일 수 있으므로 여기서 15.0으로 자르면 안 됨 if ($v < 9.0 || $v > 16.5) { return false; } return true; } function db_insert_status( PDO $pdo, string $cmd, string $rawFull, string $rawTrim, array $data ): void { $v = (float)($data['battery_voltage'] ?? 0); // 1V는 저장 금지 if ($v <= 1.0) { return; } // 시동 OFF 상태에서만 말도 안 되는 고전압 튐 차단 // 평소 OFF 전압이 12.5~13.4V라 했으므로 14.2V 이상은 튐으로 판단 $engineOn = (int)$data['engine'] === 1 || (int)$data['driving'] === 1 || (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 ( id, 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 ) VALUES ( 'car', :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 )"; $stmt = $pdo->prepare($sql); $stmt->execute([ ':ts' => $ts, ':cmd' => $cmd, ':boundary' => $data['boundary'], ':engine' => $data['engine'], ':driving' => $data['driving'], ':battery_voltage' => $data['battery_voltage'], ':door_fl' => $data['door_fl'], ':door_fr' => $data['door_fr'], ':door_rl' => $data['door_rl'], ':door_rr' => $data['door_rr'], ':door_trunk' => $data['door_trunk'], ':remote_start_preparing' => $data['remote_start_preparing'], ':remote_start_running' => $data['remote_start_running'], ':remote_start_remaining' => $data['remote_start_remaining'], ':hazard' => $data['hazard'], ':raw_full' => $rawFull, ':raw_trim' => $rawTrim, ]); } 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(); }