feat: stream monitor updates over websocket
This commit is contained in:
1 parent
5aac4bc208
commit
629e9c5dc5
3 files changed
+342
-19
No files matched your search
@@ -226,9 +226,7 @@ final class NiroTelemetrySnapshot
|
|||||||
|
|
||||||
$state = [];
|
$state = [];
|
||||||
$availability = null;
|
$availability = null;
|
||||||
$deadline = microtime(true) + $timeout;
|
foreach (self::collectPackets($reader, $timeout) as $packet) {
|
||||||
while (microtime(true) < $deadline && ($state === [] || $availability === null)) {
|
|
||||||
$packet = self::readPacket($reader);
|
|
||||||
if (($packet['type'] ?? 0) !== 3) {
|
if (($packet['type'] ?? 0) !== 3) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -398,6 +396,82 @@ final class NiroTelemetrySnapshot
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return list<array{type: int, flags: int, payload: string}> */
|
||||||
|
private static function collectPackets($socket, float $timeout): array
|
||||||
|
{
|
||||||
|
stream_set_blocking($socket, false);
|
||||||
|
$packets = [];
|
||||||
|
$buffer = '';
|
||||||
|
$deadline = microtime(true) + $timeout;
|
||||||
|
$lastDataAt = null;
|
||||||
|
|
||||||
|
while (microtime(true) < $deadline) {
|
||||||
|
if ($lastDataAt !== null && microtime(true) - $lastDataAt >= 0.5) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$remaining = max(0.0, min(0.1, $deadline - microtime(true)));
|
||||||
|
$seconds = (int)$remaining;
|
||||||
|
$microseconds = (int)(($remaining - $seconds) * 1000000);
|
||||||
|
$read = [$socket];
|
||||||
|
$write = null;
|
||||||
|
$except = null;
|
||||||
|
$selected = @stream_select($read, $write, $except, $seconds, $microseconds);
|
||||||
|
if ($selected === false) {
|
||||||
|
throw new RuntimeException('mqtt_select_failed');
|
||||||
|
}
|
||||||
|
if ($selected > 0) {
|
||||||
|
$chunk = fread($socket, 65536);
|
||||||
|
if ($chunk === false) {
|
||||||
|
throw new RuntimeException('mqtt_read_failed');
|
||||||
|
}
|
||||||
|
if ($chunk !== '') {
|
||||||
|
$buffer .= $chunk;
|
||||||
|
$lastDataAt = microtime(true);
|
||||||
|
if (strlen($buffer) > self::MAX_PACKET_BYTES * 2) {
|
||||||
|
throw new RuntimeException('mqtt_buffer_too_large');
|
||||||
|
}
|
||||||
|
} elseif (feof($socket)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (strlen($buffer) >= 2) {
|
||||||
|
$offset = 1;
|
||||||
|
$packetLength = 0;
|
||||||
|
$multiplier = 1;
|
||||||
|
do {
|
||||||
|
if (!isset($buffer[$offset])) {
|
||||||
|
break 2;
|
||||||
|
}
|
||||||
|
$digit = ord($buffer[$offset]);
|
||||||
|
$packetLength += ($digit & 127) * $multiplier;
|
||||||
|
$multiplier *= 128;
|
||||||
|
$offset++;
|
||||||
|
if ($offset > 5) {
|
||||||
|
throw new RuntimeException('mqtt_remaining_length_invalid');
|
||||||
|
}
|
||||||
|
} while (($digit & 128) !== 0);
|
||||||
|
|
||||||
|
if ($packetLength > self::MAX_PACKET_BYTES) {
|
||||||
|
throw new RuntimeException('mqtt_packet_too_large');
|
||||||
|
}
|
||||||
|
if (strlen($buffer) < $offset + $packetLength) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$header = ord($buffer[0]);
|
||||||
|
$packets[] = [
|
||||||
|
'type' => $header >> 4,
|
||||||
|
'flags' => $header & 0x0f,
|
||||||
|
'payload' => substr($buffer, $offset, $packetLength),
|
||||||
|
];
|
||||||
|
$buffer = substr($buffer, $offset + $packetLength);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $packets;
|
||||||
|
}
|
||||||
|
|
||||||
private static function readExact($socket, int $length): string
|
private static function readExact($socket, int $length): string
|
||||||
{
|
{
|
||||||
$buffer = '';
|
$buffer = '';
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Car는 차량 모뎀/게이트웨이에서 받은 상태를 수집해 DB에 저
|
|||||||
- 차량 상태 수집과 저장
|
- 차량 상태 수집과 저장
|
||||||
- 차량 상태 API
|
- 차량 상태 API
|
||||||
- 허용된 차량 명령 TCP 전송
|
- 허용된 차량 명령 TCP 전송
|
||||||
- monitor 화면과 AJAX 상태 갱신
|
- monitor 화면과 WebSocket 실시간 상태 갱신
|
||||||
- monitor 화면 WakeLock, 언어, 테마 토글
|
- monitor 화면 WakeLock, 언어, 테마 토글
|
||||||
- monitor 좌측 상단 이전 페이지 복귀 링크
|
- monitor 좌측 상단 이전 페이지 복귀 링크
|
||||||
- 모바일 및 좁은 카드 폭에 맞춘 사용량 2줄 배치, 로그 가로 스크롤, 시간 문구 축약
|
- 모바일 및 좁은 카드 폭에 맞춘 사용량 2줄 배치, 로그 가로 스크롤, 시간 문구 축약
|
||||||
@@ -23,7 +23,7 @@ Car는 차량 모뎀/게이트웨이에서 받은 상태를 수집해 DB에 저
|
|||||||
- 수집 공백을 반영한 차량 상태 경과 시간 표시
|
- 수집 공백을 반영한 차량 상태 경과 시간 표시
|
||||||
- 도어/경계/시동/공조 상태 해석
|
- 도어/경계/시동/공조 상태 해석
|
||||||
- 실시간 차량 도어/트렁크 열림 표시
|
- 실시간 차량 도어/트렁크 열림 표시
|
||||||
- Gen5W 헤드유닛의 약 2,000개 MQTT 지표를 5초 snapshot으로 결합한 실시간 텔레메트리 패널
|
- Gen5W MQTT retained snapshot과 변경 이벤트를 결합한 실시간 텔레메트리 패널
|
||||||
- 주행·전원, HEV·12V, 도어·안전, 공조, 내비 안내, 헤드유닛·미디어 상태의 반응형 요약
|
- 주행·전원, HEV·12V, 도어·안전, 공조, 내비 안내, 헤드유닛·미디어 상태의 반응형 요약
|
||||||
- PWA icon 제공
|
- PWA icon 제공
|
||||||
|
|
||||||
@@ -34,6 +34,7 @@ Car는 차량 모뎀/게이트웨이에서 받은 상태를 수집해 DB에 저
|
|||||||
- `monitor.php?mode=ajax`: monitor 상태 AJAX
|
- `monitor.php?mode=ajax`: monitor 상태 AJAX
|
||||||
- `monitor.php?mode=usage`: 데이터 사용량 AJAX
|
- `monitor.php?mode=usage`: 데이터 사용량 AJAX
|
||||||
- `monitor.php?mode=telemetry`: 인증된 브라우저용 Gen5W MQTT 텔레메트리 요약
|
- `monitor.php?mode=telemetry`: 인증된 브라우저용 Gen5W MQTT 텔레메트리 요약
|
||||||
|
- `monitor.php?mode=ws-ticket`: `seo.chaegeon.com` WebSocket용 30초 서명 티켓
|
||||||
- `monitor.php?mode=usage-calibration`: 관리자 전용 데이터 사용량 보정 기준점 조회/저장
|
- `monitor.php?mode=usage-calibration`: 관리자 전용 데이터 사용량 보정 기준점 조회/저장
|
||||||
- `monitor.php?mode=auth-login`: 브라우저 직접 접근 비밀번호 화면용 공통 암호 검증
|
- `monitor.php?mode=auth-login`: 브라우저 직접 접근 비밀번호 화면용 공통 암호 검증
|
||||||
|
|
||||||
@@ -78,6 +79,7 @@ Car는 차량 모뎀/게이트웨이에서 받은 상태를 수집해 DB에 저
|
|||||||
- `/volume1/web/config/car.php`: TCP, DB, token, 허용 IP 설정
|
- `/volume1/web/config/car.php`: TCP, DB, token, 허용 IP 설정
|
||||||
- `/volume1/web/config/car-mqtt.php`: 읽기 전용 MQTT 계정과 Tailscale 연결 설정
|
- `/volume1/web/config/car-mqtt.php`: 읽기 전용 MQTT 계정과 Tailscale 연결 설정
|
||||||
- `/volume1/web/config/car-cache/telemetry.json`: 웹 계정만 읽는 retained snapshot 단기 캐시
|
- `/volume1/web/config/car-cache/telemetry.json`: 웹 계정만 읽는 retained snapshot 단기 캐시
|
||||||
|
- `/volume1/web/config/car-ws-ticket.php`: 직접 요청 시 내용이 출력되지 않는 WebSocket 서명 키
|
||||||
|
|
||||||
## Gen5W MQTT 텔레메트리
|
## Gen5W MQTT 텔레메트리
|
||||||
|
|
||||||
@@ -116,6 +118,8 @@ return [
|
|||||||
|
|
||||||
`car_dashboard` MQTT 계정은 `niro/gen5w/state`, `niro/gen5w/state/#`와 `niro/gen5w/status`만 읽을 수 있습니다. `NiroTelemetrySnapshot.php`는 같은 웹 요청이 반복돼도 broker snapshot은 최대 4초 동안 웹 전용 캐시에서 재사용합니다. 새 수집이 실패하면 직전 cache를 표시하되 `stale`과 cache fallback 상태를 함께 반환하며, availability가 offline이면 마지막 값을 보존하되 즉시 stale로 판정합니다.
|
`car_dashboard` MQTT 계정은 `niro/gen5w/state`, `niro/gen5w/state/#`와 `niro/gen5w/status`만 읽을 수 있습니다. `NiroTelemetrySnapshot.php`는 같은 웹 요청이 반복돼도 broker snapshot은 최대 4초 동안 웹 전용 캐시에서 재사용합니다. 새 수집이 실패하면 직전 cache를 표시하되 `stale`과 cache fallback 상태를 함께 반환하며, availability가 offline이면 마지막 값을 보존하되 즉시 stale로 판정합니다.
|
||||||
|
|
||||||
|
정상 경로에서는 `car-monitor-ws.service`가 MQTT 연결 하나를 유지합니다. 접속 직후 retained 전체와 최근 로그 300건을 한 번 보내고 이후 변경된 MQTT 항목과 새 로그만 전송합니다. WS 연결 또는 인증이 실패한 동안에만 `mode=ajax`와 `mode=telemetry`를 5초마다 호출하며, `mode=ajax`는 `after_seq` 이후 로그만 반환합니다. 사용량은 별도 60초 주기로 조회합니다.
|
||||||
|
|
||||||
요약 계약 회귀시험은 CLI에서만 실행됩니다.
|
요약 계약 회귀시험은 CLI에서만 실행됩니다.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -125,7 +129,7 @@ php tests/NiroTelemetrySnapshotTest.php
|
|||||||
## 처리 흐름
|
## 처리 흐름
|
||||||
|
|
||||||
1. `collector_se.php`가 차량 상태를 수집해 DB에 저장합니다.
|
1. `collector_se.php`가 차량 상태를 수집해 DB에 저장합니다.
|
||||||
2. `monitor.php`는 상태 AJAX와 사용량 AJAX를 분리해 갱신합니다.
|
2. `monitor.php`는 WebSocket을 우선 사용하고 장애 시 HTTP 증분 조회로 전환합니다.
|
||||||
3. 제어 요청은 token/IP 정책을 검증합니다.
|
3. 제어 요청은 token/IP 정책을 검증합니다.
|
||||||
4. 명령 코드를 허용 목록과 대조합니다.
|
4. 명령 코드를 허용 목록과 대조합니다.
|
||||||
5. 요청을 `car_tcp_request_queue`에 저장합니다.
|
5. 요청을 `car_tcp_request_queue`에 저장합니다.
|
||||||
@@ -172,6 +176,7 @@ Worker 교체나 예외 종료로 `processing`, `ui_timeout_pending` 상태가
|
|||||||
|
|
||||||
- 차량 API는 API token 또는 허용 IP 정책을 사용합니다.
|
- 차량 API는 API token 또는 허용 IP 정책을 사용합니다.
|
||||||
- 차량 모니터 화면과 `monitor.php?mode=ajax`, `monitor.php?mode=usage`는 런처 공통 인증 쿠키 또는 사이트 관리자 세션이 유효할 때만 응답합니다.
|
- 차량 모니터 화면과 `monitor.php?mode=ajax`, `monitor.php?mode=usage`는 런처 공통 인증 쿠키 또는 사이트 관리자 세션이 유효할 때만 응답합니다.
|
||||||
|
- `monitor.php?mode=ws-ticket`도 같은 인증을 요구하며 티켓은 30초 후 만료되고 한 번만 사용할 수 있습니다.
|
||||||
- `monitor.php?mode=telemetry`도 동일한 브라우저 인증을 통과해야 하며 원시 MQTT snapshot과 broker 비밀번호는 응답하지 않습니다.
|
- `monitor.php?mode=telemetry`도 동일한 브라우저 인증을 통과해야 하며 원시 MQTT snapshot과 broker 비밀번호는 응답하지 않습니다.
|
||||||
- MQTT 계정은 표시 topic 두 개만 읽을 수 있고 publish 권한이 없습니다. 비밀 파일과 snapshot cache는 저장소 밖에 두고 웹 실행 계정만 읽도록 유지합니다.
|
- MQTT 계정은 표시 topic 두 개만 읽을 수 있고 publish 권한이 없습니다. 비밀 파일과 snapshot cache는 저장소 밖에 두고 웹 실행 계정만 읽도록 유지합니다.
|
||||||
- `monitor.php?mode=usage-calibration`은 사이트 관리자 세션이 확인될 때만 응답합니다. 일반 PIN 쿠키로 들어온 사용자는 화면에서 설정 아이콘이 보이지 않고, 직접 API를 호출해도 차단됩니다.
|
- `monitor.php?mode=usage-calibration`은 사이트 관리자 세션이 확인될 때만 응답합니다. 일반 PIN 쿠키로 들어온 사용자는 화면에서 설정 아이콘이 보이지 않고, 직접 API를 호출해도 차단됩니다.
|
||||||
|
|||||||
+257
-13
@@ -859,6 +859,41 @@ function json_response(array $payload, int $status = 200): void
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function car_monitor_base64_url(string $value): string
|
||||||
|
{
|
||||||
|
return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
|
||||||
|
}
|
||||||
|
|
||||||
|
function car_monitor_issue_ws_ticket(): void
|
||||||
|
{
|
||||||
|
car_monitor_require_access(true);
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||||
|
json_response(['status' => 'error', 'message' => 'method_not_allowed'], 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
$secretPath = __DIR__ . '/../../config/car-ws-ticket.php';
|
||||||
|
$secret = is_file($secretPath) ? require $secretPath : null;
|
||||||
|
if (!is_string($secret) || strlen(trim($secret)) < 32) {
|
||||||
|
json_response(['status' => 'unavailable', 'message' => 'ws_not_configured'], 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = time();
|
||||||
|
$payload = car_monitor_base64_url((string)json_encode([
|
||||||
|
'aud' => 'car-monitor-ws',
|
||||||
|
'iat' => $now,
|
||||||
|
'exp' => $now + 30,
|
||||||
|
'nonce' => bin2hex(random_bytes(16)),
|
||||||
|
], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));
|
||||||
|
$signature = car_monitor_base64_url(hash_hmac('sha256', $payload, trim($secret), true));
|
||||||
|
|
||||||
|
json_response([
|
||||||
|
'status' => 'success',
|
||||||
|
'ws_url' => 'wss://seo.chaegeon.com/car-monitor-ws',
|
||||||
|
'ticket' => $payload . '.' . $signature,
|
||||||
|
'expires_at' => $now + 30,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
function car_monitor_launcher_session(): array
|
function car_monitor_launcher_session(): array
|
||||||
{
|
{
|
||||||
static $session = null;
|
static $session = null;
|
||||||
@@ -1007,6 +1042,10 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'auth-login') {
|
|||||||
car_monitor_auth_api();
|
car_monitor_auth_api();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isset($_GET['mode']) && $_GET['mode'] === 'ws-ticket') {
|
||||||
|
car_monitor_issue_ws_ticket();
|
||||||
|
}
|
||||||
|
|
||||||
car_monitor_issue_pwa_cookie_if_requested();
|
car_monitor_issue_pwa_cookie_if_requested();
|
||||||
|
|
||||||
if (isset($_GET['mode']) && $_GET['mode'] === 'ajax') {
|
if (isset($_GET['mode']) && $_GET['mode'] === 'ajax') {
|
||||||
@@ -1018,18 +1057,24 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'ajax') {
|
|||||||
$latest = $pdo->query(""
|
$latest = $pdo->query(""
|
||||||
. "SELECT * FROM car_status "
|
. "SELECT * FROM car_status "
|
||||||
. "WHERE id='car' "
|
. "WHERE id='car' "
|
||||||
. "ORDER BY ts DESC LIMIT 1"
|
. "ORDER BY seq DESC LIMIT 1"
|
||||||
)->fetch() ?: null;
|
)->fetch() ?: null;
|
||||||
|
|
||||||
|
$afterSeq = max(0, (int)($_GET['after_seq'] ?? 0));
|
||||||
|
$logOrder = $afterSeq > 0 ? 'ASC' : 'DESC';
|
||||||
|
$logFilter = $afterSeq > 0 ? ' AND seq > :after_seq' : '';
|
||||||
$stmtLogs = $pdo->prepare(""
|
$stmtLogs = $pdo->prepare(""
|
||||||
. "SELECT ts, cmd, boundary, engine, driving, battery_voltage, "
|
. "SELECT seq, ts, cmd, boundary, engine, driving, battery_voltage, "
|
||||||
. "door_fl, door_fr, door_rl, door_rr, door_trunk, "
|
. "door_fl, door_fr, door_rl, door_rr, door_trunk, "
|
||||||
. "remote_start_preparing, remote_start_running, remote_start_remaining, "
|
. "remote_start_preparing, remote_start_running, remote_start_remaining, "
|
||||||
. "hazard, raw_full, raw_trim "
|
. "hazard, raw_full, raw_trim "
|
||||||
. "FROM car_status "
|
. "FROM car_status "
|
||||||
. "WHERE id='car' "
|
. "WHERE id='car'{$logFilter} "
|
||||||
. "ORDER BY ts DESC LIMIT :limit"
|
. "ORDER BY seq {$logOrder} LIMIT :limit"
|
||||||
);
|
);
|
||||||
|
if ($afterSeq > 0) {
|
||||||
|
$stmtLogs->bindValue(':after_seq', $afterSeq, PDO::PARAM_INT);
|
||||||
|
}
|
||||||
$stmtLogs->bindValue(':limit', LOG_LIMIT, PDO::PARAM_INT);
|
$stmtLogs->bindValue(':limit', LOG_LIMIT, PDO::PARAM_INT);
|
||||||
$stmtLogs->execute();
|
$stmtLogs->execute();
|
||||||
$logs = $stmtLogs->fetchAll();
|
$logs = $stmtLogs->fetchAll();
|
||||||
@@ -1071,6 +1116,8 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'ajax') {
|
|||||||
'engine_state_duration' => current_engine_state_duration($pdo, $latest),
|
'engine_state_duration' => current_engine_state_duration($pdo, $latest),
|
||||||
'poll_interval' => $isFastPollState ? 5 : 10,
|
'poll_interval' => $isFastPollState ? 5 : 10,
|
||||||
'log_count' => count($logs),
|
'log_count' => count($logs),
|
||||||
|
'last_seq' => (int)($latest['seq'] ?? $afterSeq),
|
||||||
|
'logs_reset' => $afterSeq === 0,
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
@@ -2501,7 +2548,8 @@ $carMonitorSession = car_monitor_launcher_session();
|
|||||||
};
|
};
|
||||||
|
|
||||||
let currentLang = 'ko';
|
let currentLang = 'ko';
|
||||||
const DEFAULT_USAGE_REFRESH_INTERVAL_SEC = 10;
|
const DEFAULT_USAGE_REFRESH_INTERVAL_SEC = 60;
|
||||||
|
const REALTIME_FALLBACK_INTERVAL_MS = 5000;
|
||||||
let CAR_MONITOR_SESSION = <?php echo json_encode($carMonitorSession, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); ?>;
|
let CAR_MONITOR_SESSION = <?php echo json_encode($carMonitorSession, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); ?>;
|
||||||
|
|
||||||
const CMD_MAP = {
|
const CMD_MAP = {
|
||||||
@@ -2518,12 +2566,22 @@ $carMonitorSession = car_monitor_launcher_session();
|
|||||||
let refreshTimer = null;
|
let refreshTimer = null;
|
||||||
let usageTimer = null;
|
let usageTimer = null;
|
||||||
let telemetryTimer = null;
|
let telemetryTimer = null;
|
||||||
|
let realtimeFallbackDelayTimer = null;
|
||||||
|
let carMonitorSocket = null;
|
||||||
|
let carMonitorSocketConnected = false;
|
||||||
|
let carMonitorSocketReconnectTimer = null;
|
||||||
|
let carMonitorSocketReconnectDelay = 1000;
|
||||||
let usageRefreshIntervalSec = DEFAULT_USAGE_REFRESH_INTERVAL_SEC;
|
let usageRefreshIntervalSec = DEFAULT_USAGE_REFRESH_INTERVAL_SEC;
|
||||||
let usageInFlight = false;
|
let usageInFlight = false;
|
||||||
let wakeLockSentinel = null;
|
let wakeLockSentinel = null;
|
||||||
let wakeLockWanted = false;
|
let wakeLockWanted = false;
|
||||||
let latestUsageData = null;
|
let latestUsageData = null;
|
||||||
let latestTelemetryData = null;
|
let latestTelemetryData = null;
|
||||||
|
let wsTelemetryState = {};
|
||||||
|
let wsTelemetryMeta = {};
|
||||||
|
let latestLogRows = [];
|
||||||
|
let latestLogMeta = {};
|
||||||
|
let latestLogSeq = 0;
|
||||||
let calibrationModalOpen = false;
|
let calibrationModalOpen = false;
|
||||||
let calibrationSaveInFlight = false;
|
let calibrationSaveInFlight = false;
|
||||||
const FALLBACK_BACK_TARGET = 'https://chaegeon.com/';
|
const FALLBACK_BACK_TARGET = 'https://chaegeon.com/';
|
||||||
@@ -2540,10 +2598,10 @@ $carMonitorSession = car_monitor_launcher_session();
|
|||||||
initTheme();
|
initTheme();
|
||||||
initWakeLock();
|
initWakeLock();
|
||||||
initCalibrationSettings();
|
initCalibrationSettings();
|
||||||
fetchData();
|
connectCarMonitorSocket();
|
||||||
refreshTimer = setInterval(fetchData, 1000);
|
realtimeFallbackDelayTimer = setTimeout(() => {
|
||||||
fetchTelemetry();
|
if (!carMonitorSocketConnected) startRealtimeFallback();
|
||||||
telemetryTimer = setInterval(fetchTelemetry, 5000);
|
}, 3000);
|
||||||
fetchUsage();
|
fetchUsage();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2785,12 +2843,11 @@ $carMonitorSession = car_monitor_launcher_session();
|
|||||||
}
|
}
|
||||||
|
|
||||||
function fetchData() {
|
function fetchData() {
|
||||||
fetch('?mode=ajax', { cache: 'no-store' })
|
fetch(`?mode=ajax&after_seq=${encodeURIComponent(latestLogSeq)}`, { cache: 'no-store' })
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then(payload => {
|
.then(payload => {
|
||||||
if (payload.status !== 'success') return;
|
if (payload.status !== 'success') return;
|
||||||
updateLogs(payload.logs || [], payload.meta || {});
|
applyLogRows(payload.logs || [], payload.meta || {}, Boolean(payload.meta?.logs_reset));
|
||||||
usageRefreshIntervalSec = normalizedUsageInterval(payload.meta?.poll_interval);
|
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
@@ -2805,6 +2862,193 @@ $carMonitorSession = car_monitor_launcher_session();
|
|||||||
.catch(() => updateTelemetry(null));
|
.catch(() => updateTelemetry(null));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startRealtimeFallback() {
|
||||||
|
if (refreshTimer || carMonitorSocketConnected) return;
|
||||||
|
fetchData();
|
||||||
|
fetchTelemetry();
|
||||||
|
refreshTimer = setInterval(() => {
|
||||||
|
if (!carMonitorSocketConnected) fetchData();
|
||||||
|
}, REALTIME_FALLBACK_INTERVAL_MS);
|
||||||
|
telemetryTimer = setInterval(() => {
|
||||||
|
if (!carMonitorSocketConnected) fetchTelemetry();
|
||||||
|
}, REALTIME_FALLBACK_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopRealtimeFallback() {
|
||||||
|
if (realtimeFallbackDelayTimer) {
|
||||||
|
clearTimeout(realtimeFallbackDelayTimer);
|
||||||
|
realtimeFallbackDelayTimer = null;
|
||||||
|
}
|
||||||
|
if (refreshTimer) clearInterval(refreshTimer);
|
||||||
|
if (telemetryTimer) clearInterval(telemetryTimer);
|
||||||
|
refreshTimer = null;
|
||||||
|
telemetryTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectCarMonitorSocket() {
|
||||||
|
if (!('WebSocket' in window)) {
|
||||||
|
startRealtimeFallback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (carMonitorSocket && [WebSocket.CONNECTING, WebSocket.OPEN].includes(carMonitorSocket.readyState)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearTimeout(carMonitorSocketReconnectTimer);
|
||||||
|
|
||||||
|
let auth;
|
||||||
|
try {
|
||||||
|
const response = await fetch('?mode=ws-ticket', { cache: 'no-store', credentials: 'same-origin' });
|
||||||
|
auth = await response.json();
|
||||||
|
if (!response.ok || auth.status !== 'success' || !auth.ws_url || !auth.ticket) {
|
||||||
|
throw new Error('ws_ticket_failed');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
startRealtimeFallback();
|
||||||
|
scheduleCarMonitorReconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
carMonitorSocket = new WebSocket(auth.ws_url);
|
||||||
|
} catch (error) {
|
||||||
|
startRealtimeFallback();
|
||||||
|
scheduleCarMonitorReconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
carMonitorSocket.addEventListener('open', () => {
|
||||||
|
carMonitorSocket.send(JSON.stringify({ type: 'auth', ticket: auth.ticket }));
|
||||||
|
});
|
||||||
|
carMonitorSocket.addEventListener('message', event => {
|
||||||
|
let message;
|
||||||
|
try {
|
||||||
|
message = JSON.parse(event.data);
|
||||||
|
} catch (error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (message?.type === 'snapshot') {
|
||||||
|
carMonitorSocketConnected = true;
|
||||||
|
carMonitorSocketReconnectDelay = 1000;
|
||||||
|
stopRealtimeFallback();
|
||||||
|
applyWsTelemetrySnapshot(message.data?.telemetry || {});
|
||||||
|
applyLogRows(message.data?.logs || [], message.data?.meta || {}, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (message?.type === 'telemetry_snapshot') {
|
||||||
|
applyWsTelemetrySnapshot(message.data || {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (message?.type === 'telemetry_patch') {
|
||||||
|
applyWsTelemetryPatch(message.data || {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (message?.type === 'mqtt_status') {
|
||||||
|
applyWsTelemetryMeta(message.data || {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (message?.type === 'logs') {
|
||||||
|
applyLogRows(message.data?.rows || [], message.data || {}, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (message?.type === 'heartbeat') {
|
||||||
|
applyWsTelemetryMeta(message.data?.telemetry || {});
|
||||||
|
latestLogMeta.server_time = Number(message.data?.server_time || 0);
|
||||||
|
updateLogs(latestLogRows, latestLogMeta);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
carMonitorSocket.addEventListener('close', () => {
|
||||||
|
carMonitorSocketConnected = false;
|
||||||
|
startRealtimeFallback();
|
||||||
|
scheduleCarMonitorReconnect();
|
||||||
|
});
|
||||||
|
carMonitorSocket.addEventListener('error', () => {
|
||||||
|
carMonitorSocket?.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleCarMonitorReconnect() {
|
||||||
|
clearTimeout(carMonitorSocketReconnectTimer);
|
||||||
|
const delay = carMonitorSocketReconnectDelay;
|
||||||
|
carMonitorSocketReconnectDelay = Math.min(30000, Math.round(carMonitorSocketReconnectDelay * 1.7));
|
||||||
|
carMonitorSocketReconnectTimer = setTimeout(connectCarMonitorSocket, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyLogRows(rows, meta = {}, reset = false) {
|
||||||
|
if (reset) {
|
||||||
|
latestLogRows = [];
|
||||||
|
latestLogSeq = 0;
|
||||||
|
}
|
||||||
|
const merged = new Map(latestLogRows.map(row => [String(row.seq || `${row.ts}-${row.cmd}`), row]));
|
||||||
|
rows.forEach(row => merged.set(String(row.seq || `${row.ts}-${row.cmd}`), row));
|
||||||
|
latestLogRows = [...merged.values()]
|
||||||
|
.sort((a, b) => Number(b.seq || 0) - Number(a.seq || 0))
|
||||||
|
.slice(0, <?php echo LOG_LIMIT; ?>);
|
||||||
|
latestLogSeq = Math.max(latestLogSeq, Number(meta.last_seq || 0), ...latestLogRows.map(row => Number(row.seq || 0)));
|
||||||
|
latestLogMeta = { ...latestLogMeta, ...meta };
|
||||||
|
updateLogs(latestLogRows, latestLogMeta);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyWsTelemetrySnapshot(telemetry) {
|
||||||
|
wsTelemetryState = { ...(telemetry.state || {}) };
|
||||||
|
wsTelemetryMeta = { ...telemetry };
|
||||||
|
delete wsTelemetryMeta.state;
|
||||||
|
updateTelemetry(buildWsTelemetry());
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyWsTelemetryPatch(patch) {
|
||||||
|
Object.entries(patch.state || {}).forEach(([key, value]) => { wsTelemetryState[key] = value; });
|
||||||
|
(patch.removed || []).forEach(key => { delete wsTelemetryState[key]; });
|
||||||
|
wsTelemetryMeta = { ...wsTelemetryMeta, ...patch };
|
||||||
|
delete wsTelemetryMeta.state;
|
||||||
|
delete wsTelemetryMeta.removed;
|
||||||
|
updateTelemetry(buildWsTelemetry());
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyWsTelemetryMeta(meta) {
|
||||||
|
wsTelemetryMeta = { ...wsTelemetryMeta, ...meta };
|
||||||
|
delete wsTelemetryMeta.state;
|
||||||
|
updateTelemetry(buildWsTelemetry());
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWsTelemetry() {
|
||||||
|
const keyMap = {
|
||||||
|
ignition: 'ignition', hev_ready: 'hev_ready', engine_running: 'engine_running',
|
||||||
|
parking: 'parked', gear: 'gear_code', speed: 'speed', parking_brake: 'parking_brake',
|
||||||
|
drive_mode: 'drive_mode_code', fuel_warning: 'fuel_warning', discharge_alert: 'discharge_alert',
|
||||||
|
distance_to_empty: 'total_distance_to_empty', electric_distance_to_empty: 'electric_distance_to_empty',
|
||||||
|
gas_distance_to_empty: 'gas_distance_to_empty', hev_battery_soc: 'hev_battery_soc',
|
||||||
|
battery_soc_12v: 'battery_soc_12v', ldc_12v_charge: 'ldc_12v_charge', odometer: 'odometer',
|
||||||
|
driver_unlock: 'driver_unlocked', passenger_unlock: 'passenger_unlocked',
|
||||||
|
rear_left_unlock: 'rear_left_unlocked', rear_right_unlock: 'rear_right_unlocked',
|
||||||
|
driver_door: 'driver_door', passenger_door: 'passenger_door', rear_left_door: 'rear_left_door',
|
||||||
|
rear_right_door: 'rear_right_door', trunk: 'trunk', hood: 'hood', hazard: 'hazard',
|
||||||
|
head_lamp: 'head_lamp', tail_lamp: 'tail_lamp', fog_lamp: 'fog_lamp', climate_on: 'climate_on',
|
||||||
|
air_conditioner: 'air_conditioner', climate_auto: 'climate_auto', climate_dual: 'climate_dual',
|
||||||
|
driver_temperature: 'driver_temperature', passenger_temperature: 'passenger_temperature',
|
||||||
|
rear_temperature: 'rear_temperature', outside_temperature: 'outside_temperature', blower_level: 'blower_level'
|
||||||
|
};
|
||||||
|
const unsupported = value => ['-1', '255', '65535', '4294967295', '0xff', '0xffffffff'].includes(String(value).trim());
|
||||||
|
const metrics = {};
|
||||||
|
Object.entries(keyMap).forEach(([name, key]) => {
|
||||||
|
const exists = Object.prototype.hasOwnProperty.call(wsTelemetryState, key);
|
||||||
|
const value = exists ? String(wsTelemetryState[key]) : null;
|
||||||
|
metrics[name] = { value, supported: exists && !unsupported(value) };
|
||||||
|
});
|
||||||
|
const sampleEpoch = Number(wsTelemetryMeta.sample_epoch || 0);
|
||||||
|
const age = sampleEpoch > 0 ? Math.max(0, Math.floor(Date.now() / 1000) - sampleEpoch) : null;
|
||||||
|
const online = wsTelemetryMeta.availability === 'online';
|
||||||
|
const staleAfter = Math.max(10, Number(wsTelemetryMeta.stale_after || 20));
|
||||||
|
return {
|
||||||
|
configured: true,
|
||||||
|
available: Object.keys(wsTelemetryState).length > 0,
|
||||||
|
online,
|
||||||
|
stale: !online || !wsTelemetryMeta.mqtt_connected || age === null || age > staleAfter,
|
||||||
|
age_seconds: age,
|
||||||
|
sample_epoch: sampleEpoch,
|
||||||
|
metrics
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function updateTelemetry(telemetry) {
|
function updateTelemetry(telemetry) {
|
||||||
latestTelemetryData = telemetry;
|
latestTelemetryData = telemetry;
|
||||||
const live = document.getElementById('telemetry-live');
|
const live = document.getElementById('telemetry-live');
|
||||||
@@ -2852,7 +3096,7 @@ $carMonitorSession = car_monitor_launcher_session();
|
|||||||
const isHealthy = telemetry.online && !telemetry.stale;
|
const isHealthy = telemetry.online && !telemetry.stale;
|
||||||
live.className = `telemetry-chip ${isHealthy ? 'live' : (telemetry.online ? 'warn' : 'down')}`;
|
live.className = `telemetry-chip ${isHealthy ? 'live' : (telemetry.online ? 'warn' : 'down')}`;
|
||||||
live.querySelector('span').textContent = isHealthy
|
live.querySelector('span').textContent = isHealthy
|
||||||
? (ko ? '5초 실시간' : 'Live · 5s')
|
? (ko ? '실시간' : 'Live')
|
||||||
: (telemetry.online ? (ko ? '지연' : 'Delayed') : (ko ? '오프라인' : 'Offline'));
|
: (telemetry.online ? (ko ? '지연' : 'Delayed') : (ko ? '오프라인' : 'Offline'));
|
||||||
age.className = `telemetry-chip ${telemetry.stale ? 'warn' : ''}`.trim();
|
age.className = `telemetry-chip ${telemetry.stale ? 'warn' : ''}`.trim();
|
||||||
age.querySelector('span').textContent = elapsed === null ? '-' : `${elapsed} ${ko ? '전' : 'ago'}`;
|
age.querySelector('span').textContent = elapsed === null ? '-' : `${elapsed} ${ko ? '전' : 'ago'}`;
|
||||||
|
|||||||
Reference in new issue
Block a user