feat: stream monitor updates over websocket

This commit is contained in:
seo committed 2026-10-22 14:48:55 +09:00
1 parent 5aac4bc208
commit 629e9c5dc5
3 files changed
+344 -21

No files matched your search

+259 -15
View File
@@ -858,6 +858,41 @@ function json_response(array $payload, int $status = 200): void
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
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
{
@@ -1007,6 +1042,10 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'auth-login') {
car_monitor_auth_api();
}
if (isset($_GET['mode']) && $_GET['mode'] === 'ws-ticket') {
car_monitor_issue_ws_ticket();
}
car_monitor_issue_pwa_cookie_if_requested();
if (isset($_GET['mode']) && $_GET['mode'] === 'ajax') {
@@ -1015,21 +1054,27 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'ajax') {
try {
$pdo = car_db();
$latest = $pdo->query(""
$latest = $pdo->query(""
. "SELECT * FROM car_status "
. "WHERE id='car' "
. "ORDER BY ts DESC LIMIT 1"
. "ORDER BY seq DESC LIMIT 1"
)->fetch() ?: null;
$stmtLogs = $pdo->prepare(""
. "SELECT ts, cmd, boundary, engine, driving, battery_voltage, "
$afterSeq = max(0, (int)($_GET['after_seq'] ?? 0));
$logOrder = $afterSeq > 0 ? 'ASC' : 'DESC';
$logFilter = $afterSeq > 0 ? ' AND seq > :after_seq' : '';
$stmtLogs = $pdo->prepare(""
. "SELECT seq, 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 "
. "FROM car_status "
. "WHERE id='car' "
. "ORDER BY ts DESC LIMIT :limit"
. "WHERE id='car'{$logFilter} "
. "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->execute();
$logs = $stmtLogs->fetchAll();
@@ -1071,6 +1116,8 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'ajax') {
'engine_state_duration' => current_engine_state_duration($pdo, $latest),
'poll_interval' => $isFastPollState ? 5 : 10,
'log_count' => count($logs),
'last_seq' => (int)($latest['seq'] ?? $afterSeq),
'logs_reset' => $afterSeq === 0,
],
]);
} catch (Throwable $e) {
@@ -2501,7 +2548,8 @@ $carMonitorSession = car_monitor_launcher_session();
};
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); ?>;
const CMD_MAP = {
@@ -2518,12 +2566,22 @@ $carMonitorSession = car_monitor_launcher_session();
let refreshTimer = null;
let usageTimer = 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 usageInFlight = false;
let wakeLockSentinel = null;
let wakeLockWanted = false;
let latestUsageData = null;
let latestTelemetryData = null;
let wsTelemetryState = {};
let wsTelemetryMeta = {};
let latestLogRows = [];
let latestLogMeta = {};
let latestLogSeq = 0;
let calibrationModalOpen = false;
let calibrationSaveInFlight = false;
const FALLBACK_BACK_TARGET = 'https://chaegeon.com/';
@@ -2540,10 +2598,10 @@ $carMonitorSession = car_monitor_launcher_session();
initTheme();
initWakeLock();
initCalibrationSettings();
fetchData();
refreshTimer = setInterval(fetchData, 1000);
fetchTelemetry();
telemetryTimer = setInterval(fetchTelemetry, 5000);
connectCarMonitorSocket();
realtimeFallbackDelayTimer = setTimeout(() => {
if (!carMonitorSocketConnected) startRealtimeFallback();
}, 3000);
fetchUsage();
});
@@ -2785,12 +2843,11 @@ $carMonitorSession = car_monitor_launcher_session();
}
function fetchData() {
fetch('?mode=ajax', { cache: 'no-store' })
fetch(`?mode=ajax&after_seq=${encodeURIComponent(latestLogSeq)}`, { cache: 'no-store' })
.then(res => res.json())
.then(payload => {
if (payload.status !== 'success') return;
updateLogs(payload.logs || [], payload.meta || {});
usageRefreshIntervalSec = normalizedUsageInterval(payload.meta?.poll_interval);
applyLogRows(payload.logs || [], payload.meta || {}, Boolean(payload.meta?.logs_reset));
})
.catch(() => {});
}
@@ -2805,6 +2862,193 @@ $carMonitorSession = car_monitor_launcher_session();
.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) {
latestTelemetryData = telemetry;
const live = document.getElementById('telemetry-live');
@@ -2852,7 +3096,7 @@ $carMonitorSession = car_monitor_launcher_session();
const isHealthy = telemetry.online && !telemetry.stale;
live.className = `telemetry-chip ${isHealthy ? 'live' : (telemetry.online ? 'warn' : 'down')}`;
live.querySelector('span').textContent = isHealthy
? (ko ? '5초 실시간' : 'Live · 5s')
? (ko ? '실시간' : 'Live')
: (telemetry.online ? (ko ? '지연' : 'Delayed') : (ko ? '오프라인' : 'Offline'));
age.className = `telemetry-chip ${telemetry.stale ? 'warn' : ''}`.trim();
age.querySelector('span').textContent = elapsed === null ? '-' : `${elapsed} ${ko ? '전' : 'ago'}`;