Initial car project import

This commit is contained in:
seo
2026-06-07 00:33:58 +09:00
commit 36588b93f1
14 changed files with 2883 additions and 0 deletions
+491
View File
@@ -0,0 +1,491 @@
<?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('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 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(): 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,
]);
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(
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;
$request = 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);
$t0 = microtime(true);
$deadline = $t0 + TCP_TOTAL_TIMEOUT;
$remain = $deadline - microtime(true);
if ($remain <= 0) {
record_tcp_usage($usageSource, $cmd, 0, 0, false, 'deadline_expired_before_connect', $requestId);
return '';
}
$fp = @stream_socket_client(
"tcp://" . TCP_HOST . ":" . TCP_PORT,
$errno,
$errstr,
$remain
);
$connectMs = (int)round((microtime(true) - $t0) * 1000);
if (!$fp) {
$tcpError = "connect_error: $errno $errstr";
$tcpMs = $connectMs;
record_tcp_usage($usageSource, $cmd, 0, 0, false, $tcpError, $requestId);
return '';
}
stream_set_blocking($fp, false);
$written = @fwrite($fp, $request);
if ($written === false || $written < strlen($request)) {
$sentBytes = ($written === false) ? 0 : (int)$written;
fclose($fp);
$tcpError = 'write_failed';
record_tcp_usage($usageSource, $cmd, $sentBytes, 0, false, $tcpError, $requestId);
return '';
}
$sentBytes = (int)$written;
$r0 = microtime(true);
$response = '';
while (microtime(true) < $deadline) {
$read = [$fp];
$w = null;
$e = null;
$remain = $deadline - microtime(true);
if ($remain <= 0) break;
$sec = (int)$remain;
$usec = (int)(($remain - $sec) * 1000000);
$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;
if (preg_match(RAW_FULL_REGEX, trim($response))) {
break;
}
}
}
if (feof($fp)) {
break;
}
}
$readMs = (int)round((microtime(true) - $r0) * 1000);
fclose($fp);
$tcpMs = $connectMs + $readMs;
$receivedBytes = strlen($response);
if (trim($response) === '') {
if ($tcpError === '') {
$tcpError = 'total_timeout_4_95s';
}
record_tcp_usage($usageSource, $cmd, $sentBytes, $receivedBytes, false, $tcpError, $requestId);
return '';
}
if (!preg_match(RAW_FULL_REGEX, trim($response))) {
$tcpError = 'invalid_or_partial_response';
record_tcp_usage($usageSource, $cmd, $sentBytes, $receivedBytes, false, $tcpError, $requestId);
return '';
}
record_tcp_usage($usageSource, $cmd, $sentBytes, $receivedBytes, true, $tcpError, $requestId);
return $response;
}
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;
}
$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' => date('Y-m-d H:i:s'),
':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();
}