Initial car project import
This commit is contained in:
+11
@@ -0,0 +1,11 @@
|
||||
.env
|
||||
.agents/
|
||||
.codex/
|
||||
*.log
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sql
|
||||
cache/
|
||||
tmp/
|
||||
secrets/
|
||||
secret/
|
||||
@@ -0,0 +1,40 @@
|
||||
# Car
|
||||
|
||||
PHP based vehicle service for state collection, TCP command dispatch, monitoring, and mobile data usage display.
|
||||
|
||||
## Main Features
|
||||
|
||||
- Vehicle status collection and storage.
|
||||
- TCP command dispatch for allowed vehicle commands.
|
||||
- Monitoring UI with separated status and usage AJAX endpoints.
|
||||
- Data usage and billing display with adjustment metadata.
|
||||
- TCP failure reason and receive freshness metadata.
|
||||
|
||||
## Main APIs
|
||||
|
||||
- `api.php?action=status`
|
||||
- `api.php?action=command`
|
||||
- `monitor.php?mode=ajax`
|
||||
- `monitor.php?mode=usage`
|
||||
|
||||
## Structure
|
||||
|
||||
- `api.php`: vehicle status and control API.
|
||||
- `monitor.php`: monitoring UI and AJAX responses.
|
||||
- `common.php`: external secret loading and shared DB/API helpers.
|
||||
- `collector_se.php`: CLI/cron state collector.
|
||||
- `sw.js`: service worker.
|
||||
- `assets/`: icons and static assets.
|
||||
|
||||
## Secrets
|
||||
|
||||
Runtime settings are loaded from `/home/seo/secret/car.php`. Do not commit that file.
|
||||
|
||||
Expected values include TCP settings, DB credentials, API token, and allowed IP policy.
|
||||
|
||||
## Security
|
||||
|
||||
- Vehicle API uses API token or allowed IP policy.
|
||||
- Control commands are limited to known command codes.
|
||||
- Secret files must remain outside the repository with restricted permissions.
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/common.php';
|
||||
|
||||
$requestId = substr(bin2hex(random_bytes(16)), 0, 32);
|
||||
$requestMethod = $_SERVER['REQUEST_METHOD'] ?? '';
|
||||
$userAgent = $_SERVER['HTTP_USER_AGENT'] ?? '';
|
||||
$clientIp = get_client_ip();
|
||||
$tsStart = microtime(true);
|
||||
|
||||
if (!in_array($clientIp, ALLOWED_IPS, true)) {
|
||||
json_exit([
|
||||
'error' => 'FORBIDDEN_IP',
|
||||
'request_id' => $requestId,
|
||||
'client_ip' => $clientIp
|
||||
], 403);
|
||||
}
|
||||
|
||||
$params = ($requestMethod === 'POST') ? $_POST : $_GET;
|
||||
$token = $params['token'] ?? '';
|
||||
|
||||
if (!hash_equals(AUTH_TOKEN, $token)) {
|
||||
json_exit([
|
||||
'error' => 'FORBIDDEN_TOKEN',
|
||||
'request_id' => $requestId
|
||||
], 403);
|
||||
}
|
||||
|
||||
$pdo = db();
|
||||
|
||||
if (($params['log'] ?? '') == '1') {
|
||||
$limit = isset($params['limit']) ? (int)$params['limit'] : 50;
|
||||
$logs = db_logs($pdo, $limit);
|
||||
|
||||
json_exit([
|
||||
'request_id' => $requestId,
|
||||
'limit' => $limit,
|
||||
'count' => count($logs),
|
||||
'logs' => $logs
|
||||
]);
|
||||
}
|
||||
|
||||
$cmdRequested = $params['cmd'] ?? 'se';
|
||||
$cmd = normalize_cmd($cmdRequested);
|
||||
|
||||
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'],
|
||||
|
||||
'data' => [
|
||||
'boundary' => (int)$latest['boundary'],
|
||||
'engine' => (int)$latest['engine'],
|
||||
'driving' => (int)$latest['driving'],
|
||||
'battery_voltage' => (float)$latest['battery_voltage'],
|
||||
'door_fl' => (int)$latest['door_fl'],
|
||||
'door_fr' => (int)$latest['door_fr'],
|
||||
'door_rl' => (int)$latest['door_rl'],
|
||||
'door_rr' => (int)$latest['door_rr'],
|
||||
'door_trunk' => (int)$latest['door_trunk'],
|
||||
'remote_start_preparing' => (int)$latest['remote_start_preparing'],
|
||||
'remote_start_running' => (int)$latest['remote_start_running'],
|
||||
'remote_start_remaining' => $latest['remote_start_remaining'],
|
||||
'hazard' => (int)$latest['hazard'],
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
if (!in_array($cmd, CONTROL_CMD, true)) {
|
||||
json_exit([
|
||||
'error' => 'INVALID_CMD',
|
||||
'request_id' => $requestId,
|
||||
'cmd' => $cmd
|
||||
], 400);
|
||||
}
|
||||
|
||||
if ($requestMethod !== 'POST') {
|
||||
json_exit([
|
||||
'error' => 'METHOD_NOT_ALLOWED_USE_POST',
|
||||
'request_id' => $requestId,
|
||||
'cmd' => $cmd
|
||||
], 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);
|
||||
|
||||
if ($rawFull === '') {
|
||||
json_exit([
|
||||
'request_id' => $requestId,
|
||||
'cmd_requested' => $cmdRequested,
|
||||
'cmd' => $cmd,
|
||||
'ts' => date('Y-m-d H:i:s'),
|
||||
'exec_ms' => $execMs,
|
||||
'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);
|
||||
}
|
||||
|
||||
$rawTrim = make_trim($rawFull, $trimError);
|
||||
$data = ($rawTrim !== '') ? parse_trim($rawTrim) : null;
|
||||
|
||||
if ($rawTrim !== '' && $data !== null && is_valid_status_data($data)) {
|
||||
db_insert_status(
|
||||
$pdo,
|
||||
$cmd,
|
||||
$rawFull,
|
||||
$rawTrim,
|
||||
$data
|
||||
);
|
||||
}
|
||||
|
||||
json_exit([
|
||||
'request_id' => $requestId,
|
||||
'cmd_requested' => $cmdRequested,
|
||||
'cmd' => $cmd,
|
||||
'ts' => date('Y-m-d H:i:s'),
|
||||
'exec_ms' => $execMs,
|
||||
'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,
|
||||
'accepted' => true,
|
||||
'ack_only' => ($rawTrim === ''),
|
||||
'raw_full' => $rawFull,
|
||||
'raw_trim' => $rawTrim,
|
||||
'data' => $data
|
||||
]);
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="112" fill="#0f172a"/>
|
||||
<path d="M126 306h260c20 0 36 16 36 36v18c0 11-9 20-20 20h-22a44 44 0 0 1-86 0h-76a44 44 0 0 1-86 0h-22c-11 0-20-9-20-20v-18c0-20 16-36 36-36Z" fill="#38bdf8"/>
|
||||
<path d="M164 238c8-27 34-46 63-46h70c30 0 56 20 64 49l17 65H134l30-68Z" fill="#60a5fa"/>
|
||||
<path d="M214 218h86c18 0 34 12 39 30l9 34H172l20-45c4-12 13-19 22-19Z" fill="#dbeafe"/>
|
||||
<circle cx="175" cy="380" r="28" fill="#020617"/>
|
||||
<circle cx="337" cy="380" r="28" fill="#020617"/>
|
||||
<path d="M118 142h276" stroke="#facc15" stroke-width="28" stroke-linecap="round"/>
|
||||
<path d="M166 106h180" stroke="#22c55e" stroke-width="20" stroke-linecap="round" opacity=".9"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 762 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 362 B |
Binary file not shown.
|
After Width: | Height: | Size: 4.4 KiB |
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "Car Monitor",
|
||||
"short_name": "Car Monitor",
|
||||
"description": "Vehicle status monitor",
|
||||
"start_url": "/car/monitor.php",
|
||||
"scope": "/car/",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"background_color": "#0f172a",
|
||||
"theme_color": "#0f172a",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/car/assets/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/car/assets/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/common.php';
|
||||
|
||||
const NON_DRIVING_INTERVAL_SEC = 10;
|
||||
const LAST_ATTEMPT_FILE = '/tmp/car_poll_se.last_attempt';
|
||||
|
||||
$lockFp = fopen('/tmp/car_poll_se.lock', 'c');
|
||||
if ($lockFp === false) {
|
||||
exit(0);
|
||||
}
|
||||
|
||||
if (!flock($lockFp, LOCK_EX | LOCK_NB)) {
|
||||
exit(0);
|
||||
}
|
||||
|
||||
register_shutdown_function(function () use ($lockFp): void {
|
||||
@flock($lockFp, LOCK_UN);
|
||||
@fclose($lockFp);
|
||||
});
|
||||
|
||||
function is_fast_poll_state(): bool
|
||||
{
|
||||
$latest = db_latest(db());
|
||||
|
||||
if (!$latest) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return
|
||||
(int)($latest['driving'] ?? 0) === 1 ||
|
||||
(int)($latest['engine'] ?? 0) === 1 ||
|
||||
(int)($latest['remote_start_preparing'] ?? 0) === 1 ||
|
||||
(int)($latest['remote_start_running'] ?? 0) === 1;
|
||||
}
|
||||
|
||||
function should_skip_non_driving(): bool
|
||||
{
|
||||
if (!is_file(LAST_ATTEMPT_FILE)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$last = (float)trim((string)@file_get_contents(LAST_ATTEMPT_FILE));
|
||||
if ($last <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (microtime(true) - $last) < NON_DRIVING_INTERVAL_SEC;
|
||||
}
|
||||
|
||||
function mark_attempt(): void
|
||||
{
|
||||
@file_put_contents(LAST_ATTEMPT_FILE, (string)microtime(true), LOCK_EX);
|
||||
}
|
||||
|
||||
function collect_once(): bool
|
||||
{
|
||||
$tcpMs = 0;
|
||||
$connectMs = 0;
|
||||
$readMs = 0;
|
||||
$tcpError = '';
|
||||
$trimError = '';
|
||||
|
||||
$sentBytes = 0;
|
||||
$receivedBytes = 0;
|
||||
$rawFull = tcp_request(
|
||||
'se',
|
||||
$tcpMs,
|
||||
$connectMs,
|
||||
$readMs,
|
||||
$tcpError,
|
||||
'collector_se',
|
||||
null,
|
||||
$sentBytes,
|
||||
$receivedBytes
|
||||
);
|
||||
if ($rawFull === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$rawTrim = make_trim($rawFull, $trimError);
|
||||
if ($rawTrim === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$data = parse_trim($rawTrim);
|
||||
if (!is_valid_status_data($data)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
db_insert_status(
|
||||
db(),
|
||||
'se',
|
||||
$rawFull,
|
||||
$rawTrim,
|
||||
$data
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!is_fast_poll_state() && should_skip_non_driving()) {
|
||||
exit(0);
|
||||
}
|
||||
|
||||
mark_attempt();
|
||||
collect_once();
|
||||
+491
@@ -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();
|
||||
}
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 384 B |
+1964
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
||||
const CACHE_NAME = 'car-monitor-v1';
|
||||
const CORE_ASSETS = [
|
||||
'/car/monitor.php',
|
||||
'/car/assets/favicon.svg',
|
||||
'/car/assets/icon-192.png',
|
||||
'/car/assets/icon-512.png',
|
||||
'/car/assets/apple-touch-icon.png',
|
||||
'/car/assets/site.webmanifest'
|
||||
];
|
||||
|
||||
self.addEventListener('install', event => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME)
|
||||
.then(cache => cache.addAll(CORE_ASSETS))
|
||||
.catch(() => undefined)
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', event => {
|
||||
event.waitUntil(
|
||||
caches.keys().then(keys => Promise.all(
|
||||
keys.filter(key => key !== CACHE_NAME).map(key => caches.delete(key))
|
||||
))
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', event => {
|
||||
const url = new URL(event.request.url);
|
||||
|
||||
if (url.origin !== location.origin || url.searchParams.get('mode') === 'ajax') {
|
||||
return;
|
||||
}
|
||||
|
||||
event.respondWith(
|
||||
fetch(event.request)
|
||||
.then(response => {
|
||||
const copy = response.clone();
|
||||
caches.open(CACHE_NAME).then(cache => cache.put(event.request, copy));
|
||||
return response;
|
||||
})
|
||||
.catch(() => caches.match(event.request))
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user