925 lines
27 KiB
PHP
925 lines
27 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
$carSecretFile = '/home/seo/secret/car.php';
|
|
if (!is_file($carSecretFile)) {
|
|
throw new RuntimeException('Missing car secret config: ' . $carSecretFile);
|
|
}
|
|
|
|
$carSecretConfig = require $carSecretFile;
|
|
$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 RAW_FULL_REGEX = '/(?<modem>\d{11})\/R:(?<r>[a-z])\/E:(?<E0>[io]{5}\d{3}[io])\/D:(?<D0>[oi]{7})\/L:(?<L0>o{5})\/F:(?<F0>[ots][oi]\d{4}[oi]{4})\/S:(?<S0>[^\/]+)/';
|
|
|
|
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 {
|
|
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();
|
|
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();
|
|
}
|