feat: add live Gen5W telemetry dashboard
This commit is contained in:
1 parent
46d95fd395
commit
d006fed6db
4 files changed
+1083
No files matched your search
@@ -0,0 +1,468 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
final class NiroTelemetrySnapshot
|
||||
{
|
||||
private const DEFAULT_CONFIG = '/volume1/web/config/car-mqtt.php';
|
||||
private const MAX_PACKET_BYTES = 524288;
|
||||
|
||||
public static function dashboard(): array
|
||||
{
|
||||
$config = self::config();
|
||||
if ($config === null) {
|
||||
return [
|
||||
'configured' => false,
|
||||
'available' => false,
|
||||
'message' => 'MQTT 모니터 설정이 없습니다.',
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
$snapshot = self::cachedSnapshot($config);
|
||||
return self::summarize($snapshot, $config);
|
||||
} catch (Throwable $e) {
|
||||
return [
|
||||
'configured' => true,
|
||||
'available' => false,
|
||||
'message' => '차량 텔레메트리를 불러오지 못했습니다.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
public static function summarize(array $snapshot, array $config = []): array
|
||||
{
|
||||
$state = is_array($snapshot['state'] ?? null) ? $snapshot['state'] : [];
|
||||
$sampleEpoch = self::integer($state['_sample_epoch'] ?? 0);
|
||||
$age = $sampleEpoch > 0 ? max(0, time() - $sampleEpoch) : null;
|
||||
$staleAfter = max(10, (int)($config['stale_after'] ?? 20));
|
||||
$serviceTotal = 0;
|
||||
$serviceAvailable = 0;
|
||||
|
||||
foreach ($state as $key => $value) {
|
||||
if (!str_starts_with((string)$key, 'meta_') || !str_ends_with((string)$key, '_dumpsys_ok')) {
|
||||
continue;
|
||||
}
|
||||
$serviceTotal++;
|
||||
if ((string)$value === 'true') {
|
||||
$serviceAvailable++;
|
||||
}
|
||||
}
|
||||
|
||||
$keyMap = [
|
||||
'ignition' => 'system_root_ign',
|
||||
'reverse' => 'system_root_reverse',
|
||||
'parking' => 'system_root_parking',
|
||||
'gear' => 'system_individualdrivemode_gear_status',
|
||||
'speed' => 'system_individualdrivemode_vehicle_speed',
|
||||
'previous_speed' => 'system_root_prevspeed',
|
||||
'parking_brake' => 'system_root_mparkingbrakestatus',
|
||||
'camera' => 'system_individualdrivemode_fron_rear_camera_status',
|
||||
'drive_mode' => 'system_individualdrivemode_mcurrentdrivemode',
|
||||
'fuel_warning' => 'system_root_fuelwarning',
|
||||
'distance_to_empty' => 'system_individualdrivemode_distance_to_empty',
|
||||
'battery_soc_12v' => 'system_root_mbatterysoc',
|
||||
'battery_above_60' => 'system_root_mbattery60above',
|
||||
'ldc_battery' => 'system_root_ldcbatterystate',
|
||||
'hev_mode' => 'hev_micom_hevstatus_mhevsystemmode',
|
||||
'hev_charge_state' => 'hev_micom_hevstatus_mchargestate',
|
||||
'motor_usage' => 'hev_micom_hevstatus_melectricmotorusage',
|
||||
'ldc_12v_charge' => 'hev_micom_hevstatus_cf_ldc_12vcharge',
|
||||
'odometer_raw' => 'hev_micom_hevstatus_odometer',
|
||||
'driver_unlock' => 'bcm_micom_bcmstatus_mdrvunlockstate',
|
||||
'passenger_unlock' => 'bcm_micom_bcmstatus_mpasunlockstate',
|
||||
'rear_left_unlock' => 'bcm_micom_bcmstatus_mrlunlockstate',
|
||||
'rear_right_unlock' => 'bcm_micom_bcmstatus_mrrunlockstate',
|
||||
'driver_door' => 'bcm_micom_bcmstatus_mdrvopenedstate',
|
||||
'passenger_door' => 'bcm_micom_bcmstatus_mpasopenedstate',
|
||||
'rear_left_door' => 'bcm_micom_bcmstatus_mrlopenedstate',
|
||||
'rear_right_door' => 'bcm_micom_bcmstatus_mrropenedstate',
|
||||
'trunk' => 'bcm_micom_bcmstatus_mtrunkopenedstate',
|
||||
'lock_state' => 'bcm_micom_bcmstatus_mlockstate',
|
||||
'hazard' => 'bcm_micom_bcmstatus_mlamphazard',
|
||||
'head_lamp' => 'bcm_micom_bcmstatus_mlamphead',
|
||||
'tail_lamp' => 'bcm_micom_bcmstatus_mlamptail',
|
||||
'fog_lamp' => 'bcm_micom_bcmstatus_mlampfog',
|
||||
'driver_seatbelt' => 'bcm_micom_bcmstatus_mdrvseatbelt',
|
||||
'passenger_seatbelt' => 'bcm_micom_bcmstatus_mpasseatbelt',
|
||||
'airbag' => 'bcm_micom_bcmstatus_mairbagstate',
|
||||
'climate_on' => 'datc_micom_datcstatus_mopstatus',
|
||||
'air_conditioner' => 'datc_micom_datcstatus_maconoff',
|
||||
'climate_auto' => 'datc_micom_datcstatus_mauto',
|
||||
'climate_dual' => 'datc_micom_datcstatus_mdual',
|
||||
'driver_temperature' => 'datc_micom_datcstatus_mdrtempc',
|
||||
'passenger_temperature' => 'datc_micom_datcstatus_mpstempc',
|
||||
'outside_temperature' => 'datc_micom_datcstatus_mambienttempc',
|
||||
'blower_level' => 'datc_micom_datcstatus_mmainblowerlevel',
|
||||
'intake_mode' => 'datc_micom_datcstatus_mintake',
|
||||
'co2_warning' => 'datc_micom_datcstatus_mco2warning',
|
||||
'route_guide' => 'system_individualdrivemode_route_guide',
|
||||
'next_turn_distance' => 'cluster_tbt_subinfo_distance',
|
||||
'speed_limit' => 'cluster_slif_navi_info_speed_limit',
|
||||
'off_route' => 'cluster_scc_hu_p_info_offroutestatus',
|
||||
'camera_distance' => 'cluster_scc_hu_p_info_rangecamdist',
|
||||
'camera_speed_limit' => 'cluster_scc_hu_p_info_rangecamspdlimit',
|
||||
'av_power' => 'power_root_av_power_status',
|
||||
'head_unit_power' => 'power_root_power_status',
|
||||
'audio_mode' => 'mode_root_current_mode_real',
|
||||
'audio_focus' => 'mode_root_modemanager_has_audiofocus',
|
||||
'audio_volume' => 'system_individualdrivemode_last_main_volume',
|
||||
'mute' => 'system_individualdrivemode_mute_status',
|
||||
'micom_connected' => 'system_root_micom_connected',
|
||||
'boot_completed' => 'system_root_boot_completed',
|
||||
'boot_sequence_finished' => 'system_root_bootsequence_finished',
|
||||
'wireless_charging' => 'system_root_wirelesscharing',
|
||||
'dtc_warning' => 'system_individualdrivemode_dtc_warning',
|
||||
'pdw_front_left' => 'system_individualdrivemode_pdw_fl',
|
||||
'pdw_front_right' => 'system_individualdrivemode_pdw_fr',
|
||||
'pdw_rear_left' => 'system_individualdrivemode_pdw_rl',
|
||||
'pdw_rear_right' => 'system_individualdrivemode_pdw_rr',
|
||||
];
|
||||
|
||||
$metrics = [];
|
||||
foreach ($keyMap as $name => $key) {
|
||||
$value = array_key_exists($key, $state) ? (string)$state[$key] : null;
|
||||
$metrics[$name] = [
|
||||
'value' => $value,
|
||||
'supported' => $value !== null && !self::unsupported($value),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'configured' => true,
|
||||
'available' => $state !== [],
|
||||
'online' => (string)($snapshot['availability'] ?? '') === 'online',
|
||||
'stale' => $age === null || $age > $staleAfter,
|
||||
'age_seconds' => $age,
|
||||
'sample_epoch' => $sampleEpoch,
|
||||
'metric_count' => max(0, count($state) - (array_key_exists('_sample_epoch', $state) ? 1 : 0)),
|
||||
'services' => [
|
||||
'available' => $serviceAvailable,
|
||||
'total' => $serviceTotal,
|
||||
],
|
||||
'cache' => [
|
||||
'received_at' => (int)($snapshot['received_at'] ?? 0),
|
||||
'fallback' => !empty($snapshot['cache_fallback']),
|
||||
],
|
||||
'metrics' => $metrics,
|
||||
];
|
||||
}
|
||||
|
||||
private static function config(): ?array
|
||||
{
|
||||
$path = getenv('NIRO_CAR_MQTT_CONFIG') ?: self::DEFAULT_CONFIG;
|
||||
if (!is_file($path) || is_link($path)) {
|
||||
return null;
|
||||
}
|
||||
$config = require $path;
|
||||
if (!is_array($config)) {
|
||||
return null;
|
||||
}
|
||||
foreach (['host', 'username', 'password'] as $required) {
|
||||
if (!isset($config[$required]) || trim((string)$config[$required]) === '') {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return $config;
|
||||
}
|
||||
|
||||
private static function cachedSnapshot(array $config): array
|
||||
{
|
||||
$cachePath = (string)($config['cache_path'] ?? sys_get_temp_dir() . '/niro-car-mqtt-state.json');
|
||||
$cacheTtl = max(1, min(15, (int)($config['cache_ttl'] ?? 4)));
|
||||
$cached = self::readCache($cachePath);
|
||||
if ($cached !== null && (time() - (int)($cached['received_at'] ?? 0)) <= $cacheTtl) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$lock = fopen($cachePath . '.lock', 'c');
|
||||
if ($lock === false) {
|
||||
throw new RuntimeException('cache_lock_open_failed');
|
||||
}
|
||||
try {
|
||||
if (!flock($lock, LOCK_EX)) {
|
||||
throw new RuntimeException('cache_lock_failed');
|
||||
}
|
||||
$cached = self::readCache($cachePath);
|
||||
if ($cached !== null && (time() - (int)($cached['received_at'] ?? 0)) <= $cacheTtl) {
|
||||
return $cached;
|
||||
}
|
||||
try {
|
||||
$fresh = self::fetch($config);
|
||||
self::writeCache($cachePath, $fresh);
|
||||
return $fresh;
|
||||
} catch (Throwable $e) {
|
||||
if ($cached !== null) {
|
||||
$cached['cache_fallback'] = true;
|
||||
return $cached;
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
} finally {
|
||||
flock($lock, LOCK_UN);
|
||||
fclose($lock);
|
||||
}
|
||||
}
|
||||
|
||||
private static function fetch(array $config): array
|
||||
{
|
||||
$host = trim((string)$config['host']);
|
||||
$port = max(1, min(65535, (int)($config['port'] ?? 1883)));
|
||||
$timeout = max(0.5, min(5.0, (float)($config['timeout'] ?? 2.0)));
|
||||
$stateTopic = (string)($config['state_topic'] ?? 'niro/gen5w/state');
|
||||
$availabilityTopic = (string)($config['availability_topic'] ?? 'niro/gen5w/status');
|
||||
$connection = self::openConnection($config, $host, $port, $timeout);
|
||||
$reader = $connection['reader'];
|
||||
$writer = $connection['writer'];
|
||||
|
||||
try {
|
||||
$clientId = 'car-dashboard-' . getmypid() . '-' . bin2hex(random_bytes(4));
|
||||
$variable = self::mqttString('MQTT') . chr(4) . chr(0xC2) . pack('n', 10);
|
||||
$payload = self::mqttString($clientId)
|
||||
. self::mqttString((string)$config['username'])
|
||||
. self::mqttString((string)$config['password']);
|
||||
self::writePacket($writer, 0x10, $variable . $payload);
|
||||
$connAck = self::readPacket($reader);
|
||||
if (($connAck['type'] ?? 0) !== 2 || strlen((string)$connAck['payload']) !== 2
|
||||
|| ord($connAck['payload'][1]) !== 0) {
|
||||
throw new RuntimeException('mqtt_connack_failed_'
|
||||
. (int)($connAck['type'] ?? 0) . '_'
|
||||
. bin2hex((string)($connAck['payload'] ?? '')));
|
||||
}
|
||||
|
||||
$subscribe = pack('n', 1)
|
||||
. self::mqttString($stateTopic) . chr(0)
|
||||
. self::mqttString($availabilityTopic) . chr(0);
|
||||
self::writePacket($writer, 0x82, $subscribe);
|
||||
|
||||
$state = null;
|
||||
$availability = null;
|
||||
$deadline = microtime(true) + $timeout;
|
||||
while (microtime(true) < $deadline && ($state === null || $availability === null)) {
|
||||
$packet = self::readPacket($reader);
|
||||
if (($packet['type'] ?? 0) !== 3) {
|
||||
continue;
|
||||
}
|
||||
[$topic, $body] = self::publishBody((string)$packet['payload'], (int)($packet['flags'] ?? 0));
|
||||
if ($topic === $stateTopic) {
|
||||
$decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
|
||||
if (!is_array($decoded)) {
|
||||
throw new RuntimeException('mqtt_state_not_object');
|
||||
}
|
||||
$state = $decoded;
|
||||
} elseif ($topic === $availabilityTopic) {
|
||||
$availability = trim($body);
|
||||
}
|
||||
}
|
||||
|
||||
if (!is_array($state)) {
|
||||
throw new RuntimeException('mqtt_state_missing');
|
||||
}
|
||||
|
||||
return [
|
||||
'received_at' => time(),
|
||||
'availability' => $availability ?? 'unknown',
|
||||
'state' => $state,
|
||||
];
|
||||
} finally {
|
||||
self::closeConnection($connection);
|
||||
}
|
||||
}
|
||||
|
||||
private static function openConnection(array $config, string $host, int $port, float $timeout): array
|
||||
{
|
||||
$transport = (string)($config['transport'] ?? 'tcp');
|
||||
if ($transport === 'tailscale_nc') {
|
||||
if (preg_match('/^[A-Za-z0-9._-]+$/', $host) !== 1) {
|
||||
throw new RuntimeException('tailscale_host_invalid');
|
||||
}
|
||||
$binary = (string)($config['tailscale_binary'] ?? '/var/packages/Tailscale/target/bin/tailscale');
|
||||
if (!is_file($binary) || !is_executable($binary)) {
|
||||
throw new RuntimeException('tailscale_binary_unavailable');
|
||||
}
|
||||
$descriptors = [
|
||||
0 => ['pipe', 'r'],
|
||||
1 => ['pipe', 'w'],
|
||||
2 => ['file', '/dev/null', 'a'],
|
||||
];
|
||||
$pipes = [];
|
||||
$process = proc_open([$binary, 'nc', $host, (string)$port], $descriptors, $pipes, null, null, [
|
||||
'bypass_shell' => true,
|
||||
]);
|
||||
if (!is_resource($process) || count($pipes) < 2) {
|
||||
throw new RuntimeException('tailscale_nc_failed');
|
||||
}
|
||||
stream_set_timeout($pipes[0], (int)ceil($timeout));
|
||||
stream_set_timeout($pipes[1], (int)ceil($timeout));
|
||||
return [
|
||||
'reader' => $pipes[1],
|
||||
'writer' => $pipes[0],
|
||||
'process' => $process,
|
||||
'pipes' => $pipes,
|
||||
];
|
||||
}
|
||||
|
||||
if ($transport !== 'tcp') {
|
||||
throw new RuntimeException('mqtt_transport_invalid');
|
||||
}
|
||||
$errno = 0;
|
||||
$errstr = '';
|
||||
$socket = @stream_socket_client("tcp://{$host}:{$port}", $errno, $errstr, $timeout);
|
||||
if (!is_resource($socket)) {
|
||||
throw new RuntimeException('mqtt_connect_failed');
|
||||
}
|
||||
stream_set_timeout($socket, (int)ceil($timeout));
|
||||
return [
|
||||
'reader' => $socket,
|
||||
'writer' => $socket,
|
||||
'process' => null,
|
||||
'pipes' => [$socket],
|
||||
];
|
||||
}
|
||||
|
||||
private static function closeConnection(array $connection): void
|
||||
{
|
||||
$closed = [];
|
||||
foreach ((array)($connection['pipes'] ?? []) as $pipe) {
|
||||
if (!is_resource($pipe)) {
|
||||
continue;
|
||||
}
|
||||
$id = (int)$pipe;
|
||||
if (isset($closed[$id])) {
|
||||
continue;
|
||||
}
|
||||
fclose($pipe);
|
||||
$closed[$id] = true;
|
||||
}
|
||||
if (is_resource($connection['process'] ?? null)) {
|
||||
proc_terminate($connection['process']);
|
||||
proc_close($connection['process']);
|
||||
}
|
||||
}
|
||||
|
||||
private static function publishBody(string $payload, int $flags): array
|
||||
{
|
||||
if (strlen($payload) < 2) {
|
||||
throw new RuntimeException('mqtt_publish_short');
|
||||
}
|
||||
$topicLength = unpack('n', substr($payload, 0, 2))[1];
|
||||
if ($topicLength < 1 || strlen($payload) < 2 + $topicLength) {
|
||||
throw new RuntimeException('mqtt_publish_topic_invalid');
|
||||
}
|
||||
$offset = 2 + $topicLength;
|
||||
$qos = ($flags >> 1) & 0x03;
|
||||
if ($qos > 0) {
|
||||
$offset += 2;
|
||||
}
|
||||
if (strlen($payload) < $offset) {
|
||||
throw new RuntimeException('mqtt_publish_payload_invalid');
|
||||
}
|
||||
return [substr($payload, 2, $topicLength), substr($payload, $offset)];
|
||||
}
|
||||
|
||||
private static function writePacket($socket, int $header, string $payload): void
|
||||
{
|
||||
$packet = chr($header) . self::remainingLength(strlen($payload)) . $payload;
|
||||
$written = 0;
|
||||
$length = strlen($packet);
|
||||
while ($written < $length) {
|
||||
$count = fwrite($socket, substr($packet, $written));
|
||||
if ($count === false || $count === 0) {
|
||||
throw new RuntimeException('mqtt_write_failed');
|
||||
}
|
||||
$written += $count;
|
||||
}
|
||||
}
|
||||
|
||||
private static function readPacket($socket): array
|
||||
{
|
||||
$first = self::readExact($socket, 1);
|
||||
$remaining = 0;
|
||||
$multiplier = 1;
|
||||
do {
|
||||
$digit = ord(self::readExact($socket, 1));
|
||||
$remaining += ($digit & 127) * $multiplier;
|
||||
$multiplier *= 128;
|
||||
if ($multiplier > 128 * 128 * 128 * 128) {
|
||||
throw new RuntimeException('mqtt_remaining_length_invalid');
|
||||
}
|
||||
} while (($digit & 128) !== 0);
|
||||
if ($remaining > self::MAX_PACKET_BYTES) {
|
||||
throw new RuntimeException('mqtt_packet_too_large');
|
||||
}
|
||||
$header = ord($first);
|
||||
return [
|
||||
'type' => $header >> 4,
|
||||
'flags' => $header & 0x0f,
|
||||
'payload' => self::readExact($socket, $remaining),
|
||||
];
|
||||
}
|
||||
|
||||
private static function readExact($socket, int $length): string
|
||||
{
|
||||
$buffer = '';
|
||||
while (strlen($buffer) < $length) {
|
||||
$chunk = fread($socket, $length - strlen($buffer));
|
||||
if ($chunk === false || $chunk === '') {
|
||||
$meta = stream_get_meta_data($socket);
|
||||
throw new RuntimeException(!empty($meta['timed_out']) ? 'mqtt_read_timeout' : 'mqtt_read_failed');
|
||||
}
|
||||
$buffer .= $chunk;
|
||||
}
|
||||
return $buffer;
|
||||
}
|
||||
|
||||
private static function mqttString(string $value): string
|
||||
{
|
||||
if (strlen($value) > 65535) {
|
||||
throw new RuntimeException('mqtt_string_too_long');
|
||||
}
|
||||
return pack('n', strlen($value)) . $value;
|
||||
}
|
||||
|
||||
private static function remainingLength(int $length): string
|
||||
{
|
||||
$encoded = '';
|
||||
do {
|
||||
$digit = $length % 128;
|
||||
$length = intdiv($length, 128);
|
||||
if ($length > 0) {
|
||||
$digit |= 128;
|
||||
}
|
||||
$encoded .= chr($digit);
|
||||
} while ($length > 0);
|
||||
return $encoded;
|
||||
}
|
||||
|
||||
private static function readCache(string $path): ?array
|
||||
{
|
||||
if (!is_file($path) || filesize($path) > self::MAX_PACKET_BYTES * 2) {
|
||||
return null;
|
||||
}
|
||||
$decoded = json_decode((string)file_get_contents($path), true);
|
||||
return is_array($decoded) && is_array($decoded['state'] ?? null) ? $decoded : null;
|
||||
}
|
||||
|
||||
private static function writeCache(string $path, array $snapshot): void
|
||||
{
|
||||
$json = json_encode($snapshot, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
|
||||
$temporary = $path . '.new.' . getmypid();
|
||||
if (file_put_contents($temporary, $json, LOCK_EX) === false) {
|
||||
throw new RuntimeException('cache_write_failed');
|
||||
}
|
||||
chmod($temporary, 0600);
|
||||
if (!rename($temporary, $path)) {
|
||||
@unlink($temporary);
|
||||
throw new RuntimeException('cache_replace_failed');
|
||||
}
|
||||
}
|
||||
|
||||
private static function unsupported(string $value): bool
|
||||
{
|
||||
return in_array(trim($value), ['-1', '255', '65535', '4294967295', '0xff', '0xffffffff'], true);
|
||||
}
|
||||
|
||||
private static function integer(mixed $value): int
|
||||
{
|
||||
return is_numeric($value) ? (int)$value : 0;
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,8 @@ Car는 차량 모뎀/게이트웨이에서 받은 상태를 수집해 DB에 저
|
||||
- 수집 공백을 반영한 차량 상태 경과 시간 표시
|
||||
- 도어/경계/시동/공조 상태 해석
|
||||
- 실시간 차량 도어/트렁크 열림 표시
|
||||
- Gen5W 헤드유닛의 약 2,000개 MQTT 지표를 5초 snapshot으로 결합한 실시간 텔레메트리 패널
|
||||
- 주행·전원, HEV·12V, 도어·안전, 공조, 내비 안내, 헤드유닛·미디어 상태의 반응형 요약
|
||||
- PWA icon 제공
|
||||
|
||||
## 주요 API
|
||||
@@ -31,6 +33,7 @@ Car는 차량 모뎀/게이트웨이에서 받은 상태를 수집해 DB에 저
|
||||
- `api.php?action=command`: 차량 명령 전송
|
||||
- `monitor.php?mode=ajax`: monitor 상태 AJAX
|
||||
- `monitor.php?mode=usage`: 데이터 사용량 AJAX
|
||||
- `monitor.php?mode=telemetry`: 인증된 브라우저용 Gen5W MQTT 텔레메트리 요약
|
||||
- `monitor.php?mode=usage-calibration`: 관리자 전용 데이터 사용량 보정 기준점 조회/저장
|
||||
- `monitor.php?mode=auth-login`: 브라우저 직접 접근 비밀번호 화면용 공통 암호 검증
|
||||
|
||||
@@ -73,6 +76,51 @@ Car는 차량 모뎀/게이트웨이에서 받은 상태를 수집해 DB에 저
|
||||
- Car DB tables: 차량 상태, 로그, 차트용 기록, TCP 요청 큐
|
||||
- 사용량 수집값, 통신사 과금 단위 추정값, 월별 보정 기준
|
||||
- `/volume1/web/config/car.php`: TCP, DB, token, 허용 IP 설정
|
||||
- `/volume1/web/config/car-mqtt.php`: 읽기 전용 MQTT 계정과 Tailscale 연결 설정
|
||||
- `/volume1/web/config/car-cache/telemetry.json`: 웹 계정만 읽는 retained snapshot 단기 캐시
|
||||
|
||||
## Gen5W MQTT 텔레메트리
|
||||
|
||||
`NiroTelemetrySnapshot.php`는 차량이 5초마다 발행하는 retained `niro/gen5w/state`와 availability `niro/gen5w/status`를 읽습니다. 브라우저에는 약 2,000개 원시 snapshot 전체를 보내지 않고 운전에 의미 있는 선별 지표와 수집 상태만 반환합니다. 현재 화면은 다음 정보를 결합합니다.
|
||||
|
||||
- 주행 상태, 속도, 기어, 주차 브레이크, 카메라 상태와 주행 가능 거리
|
||||
- 12V SOC, LDC 충전, HEV 모드·충전 상태, 모터 사용과 누적 주행거리
|
||||
- 네 도어의 열림·잠금, 트렁크, 등화, 안전벨트와 DTC 경고
|
||||
- 공조 전원, A/C, AUTO·DUAL, 풍량, 좌우 설정 온도와 외기 온도
|
||||
- 경로 안내, 다음 회전 거리, 제한 속도, 경로 이탈과 단속 지점 정보
|
||||
- MICOM 연결, 부팅, 헤드유닛·AV 전원, 미디어 소스와 음량
|
||||
|
||||
차량 세대상 제공하지 않는 값은 `-1`, `255`, `65535`, `4294967295`, `0xff`, `0xffffffff` sentinel로 구분해 `미지원`으로 표시합니다. 값의 존재만으로 기능이 활성화됐다고 판단하지 않으며, 원시 ECU 쓰기나 제어 명령은 MQTT 경로에서 수행하지 않습니다.
|
||||
|
||||
Synology Tailscale 패키지는 userspace networking으로 실행되어 일반 `tcp://100.x.x.x` 소켓에 Tailnet route가 생기지 않습니다. 따라서 `transport=tailscale_nc`일 때 PHP는 shell을 거치지 않는 `proc_open()` 배열 명령으로 Tailscale CLI의 `nc` 스트림을 열고 그 위에서 MQTT 3.1.1을 처리합니다. 호스트명은 영문·숫자·점·밑줄·하이픈만 허용하고 포트는 정수 범위로 제한합니다.
|
||||
|
||||
비밀 설정 예시는 다음과 같습니다. 실제 비밀번호는 Git에 넣지 않습니다.
|
||||
|
||||
```php
|
||||
<?php
|
||||
return [
|
||||
'transport' => 'tailscale_nc',
|
||||
'tailscale_binary' => '/var/packages/Tailscale/target/bin/tailscale',
|
||||
'host' => 'raspberry',
|
||||
'port' => 1883,
|
||||
'username' => 'car_dashboard',
|
||||
'password' => '비공개 읽기 전용 비밀번호',
|
||||
'state_topic' => 'niro/gen5w/state',
|
||||
'availability_topic' => 'niro/gen5w/status',
|
||||
'cache_path' => '/volume1/web/config/car-cache/telemetry.json',
|
||||
'cache_ttl' => 4,
|
||||
'stale_after' => 20,
|
||||
'timeout' => 3.0,
|
||||
];
|
||||
```
|
||||
|
||||
`car_dashboard` MQTT 계정은 `niro/gen5w/state`와 `niro/gen5w/status` 두 topic의 read 권한만 가집니다. `NiroTelemetrySnapshot.php`는 같은 웹 요청이 1초마다 반복돼도 broker snapshot은 최대 4초 동안 웹 전용 캐시에서 재사용합니다. 새 수집이 실패하면 직전 cache를 표시하되 `stale`과 cache fallback 상태를 함께 반환합니다.
|
||||
|
||||
요약 계약 회귀시험은 CLI에서만 실행됩니다.
|
||||
|
||||
```bash
|
||||
php tests/NiroTelemetrySnapshotTest.php
|
||||
```
|
||||
|
||||
## 처리 흐름
|
||||
|
||||
@@ -124,6 +172,8 @@ Worker 교체나 예외 종료로 `processing`, `ui_timeout_pending` 상태가
|
||||
|
||||
- 차량 API는 API token 또는 허용 IP 정책을 사용합니다.
|
||||
- 차량 모니터 화면과 `monitor.php?mode=ajax`, `monitor.php?mode=usage`는 런처 공통 인증 쿠키 또는 사이트 관리자 세션이 유효할 때만 응답합니다.
|
||||
- `monitor.php?mode=telemetry`도 동일한 브라우저 인증을 통과해야 하며 원시 MQTT snapshot과 broker 비밀번호는 응답하지 않습니다.
|
||||
- MQTT 계정은 표시 topic 두 개만 읽을 수 있고 publish 권한이 없습니다. 비밀 파일과 snapshot cache는 저장소 밖에 두고 웹 실행 계정만 읽도록 유지합니다.
|
||||
- `monitor.php?mode=usage-calibration`은 사이트 관리자 세션이 확인될 때만 응답합니다. 일반 PIN 쿠키로 들어온 사용자는 화면에서 설정 아이콘이 보이지 않고, 직접 API를 호출해도 차단됩니다.
|
||||
- 공통 암호로 로그인한 일반 인증은 30분 유지하고, 사이트 관리자 세션이 확인되면 관리자 장기 쿠키를 발급해 PWA에서 반복 인증이 뜨는 일을 줄입니다.
|
||||
- PWA로 설치한 차량 모니터는 manifest 시작 URL `/car/monitor.php?pwa=1`로 진입합니다. 이 요청은 기존 공통 인증 쿠키가 있으면 유지하고, 사이트 관리자 세션이면 관리자 쿠키를 발급한 뒤 `/car/monitor.php`로 되돌아가므로 설치형 앱 진입 때 반복 PIN 화면이 뜨는 일을 줄입니다.
|
||||
|
||||
+506
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
date_default_timezone_set('Asia/Seoul');
|
||||
|
||||
require_once __DIR__ . '/../common/auth.php';
|
||||
require_once __DIR__ . '/NiroTelemetrySnapshot.php';
|
||||
|
||||
$carSecretFile = __DIR__ . '/../../config/car.php';
|
||||
if (!is_file($carSecretFile)) {
|
||||
@@ -1095,6 +1096,15 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'ajax') {
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($_GET['mode']) && $_GET['mode'] === 'telemetry') {
|
||||
car_monitor_require_access(true);
|
||||
json_response([
|
||||
'status' => 'success',
|
||||
'telemetry' => NiroTelemetrySnapshot::dashboard(),
|
||||
'server_time' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
if (isset($_GET['mode']) && $_GET['mode'] === 'usage') {
|
||||
car_monitor_require_access(true);
|
||||
|
||||
@@ -1832,6 +1842,155 @@ $carMonitorSession = car_monitor_launcher_session();
|
||||
max-width: 140px;
|
||||
}
|
||||
}
|
||||
|
||||
.telemetry-card {
|
||||
position: relative;
|
||||
}
|
||||
.telemetry-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0 0 auto 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, #22c55e, #3b82f6 48%, #8b5cf6);
|
||||
}
|
||||
.telemetry-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1.15rem 1.25rem 0.95rem;
|
||||
border-bottom: 1px solid rgba(108, 117, 125, 0.12);
|
||||
}
|
||||
.telemetry-source {
|
||||
display: flex;
|
||||
gap: 0.55rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.telemetry-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
border: 1px solid rgba(100, 116, 139, 0.18);
|
||||
background: rgba(100, 116, 139, 0.08);
|
||||
color: var(--text-secondary);
|
||||
border-radius: 999px;
|
||||
padding: 0.32rem 0.62rem;
|
||||
font: 700 0.72rem/1 var(--font-main);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.telemetry-chip.live {
|
||||
color: var(--accent-green);
|
||||
border-color: rgba(16, 185, 129, 0.28);
|
||||
background: rgba(16, 185, 129, 0.11);
|
||||
}
|
||||
.telemetry-chip.warn {
|
||||
color: var(--accent-yellow);
|
||||
border-color: rgba(245, 158, 11, 0.28);
|
||||
background: rgba(245, 158, 11, 0.11);
|
||||
}
|
||||
.telemetry-chip.down {
|
||||
color: var(--accent-red);
|
||||
border-color: rgba(239, 68, 68, 0.28);
|
||||
background: rgba(239, 68, 68, 0.11);
|
||||
}
|
||||
.telemetry-hero-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
.telemetry-hero {
|
||||
min-width: 0;
|
||||
border-radius: 16px;
|
||||
padding: 0.9rem 1rem;
|
||||
border: 1px solid rgba(100, 116, 139, 0.12);
|
||||
background: rgba(100, 116, 139, 0.055);
|
||||
}
|
||||
.telemetry-hero-value {
|
||||
margin-top: 0.2rem;
|
||||
color: var(--text-primary);
|
||||
font-size: 1.35rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.telemetry-groups {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.8rem;
|
||||
padding: 0 1.25rem 1.25rem;
|
||||
}
|
||||
.telemetry-group {
|
||||
min-width: 0;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(100, 116, 139, 0.12);
|
||||
background: rgba(100, 116, 139, 0.045);
|
||||
overflow: hidden;
|
||||
}
|
||||
.telemetry-group-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.78rem 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 1px solid rgba(100, 116, 139, 0.1);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.telemetry-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0;
|
||||
}
|
||||
.telemetry-metric {
|
||||
min-width: 0;
|
||||
padding: 0.68rem 0.85rem;
|
||||
border-bottom: 1px solid rgba(100, 116, 139, 0.075);
|
||||
}
|
||||
.telemetry-metric:nth-last-child(-n + 2) {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.telemetry-metric-label {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.66rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.telemetry-metric-value {
|
||||
margin-top: 0.18rem;
|
||||
color: var(--text-primary);
|
||||
font: 800 0.88rem/1.25 var(--font-main);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.telemetry-metric-value.unsupported {
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
@media (max-width: 1199.98px) {
|
||||
.telemetry-groups { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 767.98px) {
|
||||
.telemetry-title-row { align-items: flex-start; }
|
||||
.telemetry-source { gap: 0.35rem; }
|
||||
.telemetry-hero-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.telemetry-groups { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 575.98px) {
|
||||
.telemetry-title-row { display: block; }
|
||||
.telemetry-source { justify-content: flex-start; margin-top: 0.75rem; }
|
||||
.telemetry-title-row,
|
||||
.telemetry-hero-grid,
|
||||
.telemetry-groups { padding-left: 1rem; padding-right: 1rem; }
|
||||
.telemetry-hero-value { font-size: 1.15rem; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -1928,6 +2087,119 @@ $carMonitorSession = car_monitor_launcher_session();
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="glass-card telemetry-card mb-4" aria-labelledby="telemetry-heading">
|
||||
<div class="telemetry-title-row">
|
||||
<div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<i class="fas fa-wave-square text-primary"></i>
|
||||
<h2 id="telemetry-heading" class="h6 fw-bold mb-0" data-i18n="telemetryTitle">Gen5W 실시간 텔레메트리</h2>
|
||||
</div>
|
||||
<div class="small text-secondary mt-1" data-i18n="telemetrySubtitle">헤드유닛 MICOM 서비스 전체 snapshot에서 선별한 차량 상태</div>
|
||||
</div>
|
||||
<div class="telemetry-source" aria-live="polite">
|
||||
<span id="telemetry-live" class="telemetry-chip"><i class="fas fa-circle"></i><span>연결 중</span></span>
|
||||
<span id="telemetry-age" class="telemetry-chip"><i class="far fa-clock"></i><span>-</span></span>
|
||||
<span id="telemetry-count" class="telemetry-chip"><i class="fas fa-layer-group"></i><span>-</span></span>
|
||||
<span id="telemetry-services" class="telemetry-chip"><i class="fas fa-microchip"></i><span>-</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="telemetry-hero-grid">
|
||||
<div class="telemetry-hero">
|
||||
<div class="label-text mb-0" data-i18n="vehicleMotion">주행 상태</div>
|
||||
<div id="telemetry-motion" class="telemetry-hero-value">-</div>
|
||||
</div>
|
||||
<div class="telemetry-hero">
|
||||
<div class="label-text mb-0" data-i18n="gearSpeed">기어 · 속도</div>
|
||||
<div id="telemetry-gear-speed" class="telemetry-hero-value">-</div>
|
||||
</div>
|
||||
<div class="telemetry-hero">
|
||||
<div class="label-text mb-0" data-i18n="odometer">누적 주행거리</div>
|
||||
<div id="telemetry-odometer" class="telemetry-hero-value">-</div>
|
||||
</div>
|
||||
<div class="telemetry-hero">
|
||||
<div class="label-text mb-0" data-i18n="outsideTemperature">외기 온도</div>
|
||||
<div id="telemetry-outside-temp" class="telemetry-hero-value">-</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="telemetry-groups">
|
||||
<div class="telemetry-group">
|
||||
<div class="telemetry-group-title"><i class="fas fa-gauge-high text-primary"></i><span data-i18n="drivingPower">주행 · 전원</span></div>
|
||||
<div class="telemetry-metrics">
|
||||
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="ignition">IGN</div><div id="tm-ignition" class="telemetry-metric-value">-</div></div>
|
||||
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="parkingBrake">주차 브레이크</div><div id="tm-parking-brake" class="telemetry-metric-value">-</div></div>
|
||||
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="driveMode">주행 모드</div><div id="tm-drive-mode" class="telemetry-metric-value">-</div></div>
|
||||
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="cameraState">카메라</div><div id="tm-camera" class="telemetry-metric-value">-</div></div>
|
||||
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="range">주행 가능 거리</div><div id="tm-range" class="telemetry-metric-value">-</div></div>
|
||||
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="fuelWarning">연료 경고</div><div id="tm-fuel-warning" class="telemetry-metric-value">-</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="telemetry-group">
|
||||
<div class="telemetry-group-title"><i class="fas fa-bolt text-warning"></i><span data-i18n="hevEnergy">HEV · 배터리</span></div>
|
||||
<div class="telemetry-metrics">
|
||||
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="batterySoc12v">12V SOC</div><div id="tm-battery-soc" class="telemetry-metric-value">-</div></div>
|
||||
<div class="telemetry-metric"><div class="telemetry-metric-label">LDC 12V</div><div id="tm-ldc-charge" class="telemetry-metric-value">-</div></div>
|
||||
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="hevMode">HEV 모드</div><div id="tm-hev-mode" class="telemetry-metric-value">-</div></div>
|
||||
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="chargeState">충전 상태</div><div id="tm-charge-state" class="telemetry-metric-value">-</div></div>
|
||||
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="motorUsage">모터 사용</div><div id="tm-motor-usage" class="telemetry-metric-value">-</div></div>
|
||||
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="wirelessCharging">무선 충전</div><div id="tm-wireless-charge" class="telemetry-metric-value">-</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="telemetry-group">
|
||||
<div class="telemetry-group-title"><i class="fas fa-shield-halved text-success"></i><span data-i18n="accessSafety">도어 · 안전</span></div>
|
||||
<div class="telemetry-metrics">
|
||||
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="doors">도어</div><div id="tm-doors" class="telemetry-metric-value">-</div></div>
|
||||
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="doorLocks">잠금</div><div id="tm-locks" class="telemetry-metric-value">-</div></div>
|
||||
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="trunk">트렁크</div><div id="tm-trunk" class="telemetry-metric-value">-</div></div>
|
||||
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="hazardLights">비상등</div><div id="tm-hazard" class="telemetry-metric-value">-</div></div>
|
||||
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="headLights">전조등</div><div id="tm-head-lamp" class="telemetry-metric-value">-</div></div>
|
||||
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="dtcWarning">DTC 경고</div><div id="tm-dtc" class="telemetry-metric-value">-</div></div>
|
||||
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="driverSeatbelt">운전석 벨트</div><div id="tm-driver-belt" class="telemetry-metric-value">-</div></div>
|
||||
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="passengerSeatbelt">동승석 벨트</div><div id="tm-passenger-belt" class="telemetry-metric-value">-</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="telemetry-group">
|
||||
<div class="telemetry-group-title"><i class="fas fa-temperature-half text-danger"></i><span data-i18n="climate">공조</span></div>
|
||||
<div class="telemetry-metrics">
|
||||
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="climatePower">공조 전원</div><div id="tm-climate" class="telemetry-metric-value">-</div></div>
|
||||
<div class="telemetry-metric"><div class="telemetry-metric-label">A/C</div><div id="tm-ac" class="telemetry-metric-value">-</div></div>
|
||||
<div class="telemetry-metric"><div class="telemetry-metric-label">AUTO · DUAL</div><div id="tm-auto-dual" class="telemetry-metric-value">-</div></div>
|
||||
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="fanLevel">풍량</div><div id="tm-fan" class="telemetry-metric-value">-</div></div>
|
||||
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="driverTemperature">운전석 설정</div><div id="tm-driver-temp" class="telemetry-metric-value">-</div></div>
|
||||
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="passengerTemperature">동승석 설정</div><div id="tm-passenger-temp" class="telemetry-metric-value">-</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="telemetry-group">
|
||||
<div class="telemetry-group-title"><i class="fas fa-route text-primary"></i><span data-i18n="navigation">내비 · 안내</span></div>
|
||||
<div class="telemetry-metrics">
|
||||
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="routeGuide">경로 안내</div><div id="tm-route" class="telemetry-metric-value">-</div></div>
|
||||
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="nextTurn">다음 회전</div><div id="tm-next-turn" class="telemetry-metric-value">-</div></div>
|
||||
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="speedLimit">제한 속도</div><div id="tm-speed-limit" class="telemetry-metric-value">-</div></div>
|
||||
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="offRoute">경로 이탈</div><div id="tm-off-route" class="telemetry-metric-value">-</div></div>
|
||||
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="safetyCameraDistance">단속 지점 거리</div><div id="tm-camera-distance" class="telemetry-metric-value">-</div></div>
|
||||
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="safetyCameraLimit">단속 제한 속도</div><div id="tm-camera-limit" class="telemetry-metric-value">-</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="telemetry-group">
|
||||
<div class="telemetry-group-title"><i class="fas fa-display text-info"></i><span data-i18n="headUnitMedia">헤드유닛 · 미디어</span></div>
|
||||
<div class="telemetry-metrics">
|
||||
<div class="telemetry-metric"><div class="telemetry-metric-label">MICOM</div><div id="tm-micom" class="telemetry-metric-value">-</div></div>
|
||||
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="bootState">부팅 상태</div><div id="tm-boot" class="telemetry-metric-value">-</div></div>
|
||||
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="powerState">전원 상태</div><div id="tm-power" class="telemetry-metric-value">-</div></div>
|
||||
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="avPower">AV 전원</div><div id="tm-av-power" class="telemetry-metric-value">-</div></div>
|
||||
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="mediaSource">미디어 소스</div><div id="tm-audio-mode" class="telemetry-metric-value">-</div></div>
|
||||
<div class="telemetry-metric"><div class="telemetry-metric-label" data-i18n="volumeMute">음량 · 음소거</div><div id="tm-volume" class="telemetry-metric-value">-</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="glass-card usage-card mb-4">
|
||||
<div class="usage-layout">
|
||||
<div class="usage-head">
|
||||
@@ -2208,6 +2480,52 @@ $carMonitorSession = car_monitor_launcher_session();
|
||||
dayShort: '일',
|
||||
hourShort: '시간',
|
||||
minShort: '분',
|
||||
telemetryTitle: 'Gen5W 실시간 텔레메트리',
|
||||
telemetrySubtitle: '헤드유닛 MICOM 서비스 전체 snapshot에서 선별한 차량 상태',
|
||||
vehicleMotion: '주행 상태',
|
||||
gearSpeed: '기어 · 속도',
|
||||
odometer: '누적 주행거리',
|
||||
outsideTemperature: '외기 온도',
|
||||
drivingPower: '주행 · 전원',
|
||||
ignition: 'IGN',
|
||||
parkingBrake: '주차 브레이크',
|
||||
driveMode: '주행 모드',
|
||||
cameraState: '카메라',
|
||||
range: '주행 가능 거리',
|
||||
fuelWarning: '연료 경고',
|
||||
hevEnergy: 'HEV · 배터리',
|
||||
batterySoc12v: '12V SOC',
|
||||
hevMode: 'HEV 모드',
|
||||
chargeState: '충전 상태',
|
||||
motorUsage: '모터 사용',
|
||||
wirelessCharging: '무선 충전',
|
||||
accessSafety: '도어 · 안전',
|
||||
doors: '도어',
|
||||
doorLocks: '잠금',
|
||||
trunk: '트렁크',
|
||||
hazardLights: '비상등',
|
||||
headLights: '전조등',
|
||||
dtcWarning: 'DTC 경고',
|
||||
driverSeatbelt: '운전석 벨트',
|
||||
passengerSeatbelt: '동승석 벨트',
|
||||
climate: '공조',
|
||||
climatePower: '공조 전원',
|
||||
fanLevel: '풍량',
|
||||
driverTemperature: '운전석 설정',
|
||||
passengerTemperature: '동승석 설정',
|
||||
navigation: '내비 · 안내',
|
||||
routeGuide: '경로 안내',
|
||||
nextTurn: '다음 회전',
|
||||
speedLimit: '제한 속도',
|
||||
offRoute: '경로 이탈',
|
||||
safetyCameraDistance: '단속 지점 거리',
|
||||
safetyCameraLimit: '단속 제한 속도',
|
||||
headUnitMedia: '헤드유닛 · 미디어',
|
||||
bootState: '부팅 상태',
|
||||
powerState: '전원 상태',
|
||||
avPower: 'AV 전원',
|
||||
mediaSource: '미디어 소스',
|
||||
volumeMute: '음량 · 음소거',
|
||||
},
|
||||
en: {
|
||||
appTitle: 'Car Monitor',
|
||||
@@ -2273,6 +2591,52 @@ $carMonitorSession = car_monitor_launcher_session();
|
||||
dayShort: 'd',
|
||||
hourShort: 'h',
|
||||
minShort: 'm',
|
||||
telemetryTitle: 'Gen5W Live Telemetry',
|
||||
telemetrySubtitle: 'Curated vehicle state from the full head-unit MICOM snapshot',
|
||||
vehicleMotion: 'Motion',
|
||||
gearSpeed: 'Gear · Speed',
|
||||
odometer: 'Odometer',
|
||||
outsideTemperature: 'Outside Temp.',
|
||||
drivingPower: 'Driving · Power',
|
||||
ignition: 'Ignition',
|
||||
parkingBrake: 'Parking Brake',
|
||||
driveMode: 'Drive Mode',
|
||||
cameraState: 'Camera',
|
||||
range: 'Range',
|
||||
fuelWarning: 'Fuel Warning',
|
||||
hevEnergy: 'HEV · Battery',
|
||||
batterySoc12v: '12V SOC',
|
||||
hevMode: 'HEV Mode',
|
||||
chargeState: 'Charge State',
|
||||
motorUsage: 'Motor Usage',
|
||||
wirelessCharging: 'Wireless Charge',
|
||||
accessSafety: 'Access · Safety',
|
||||
doors: 'Doors',
|
||||
doorLocks: 'Locks',
|
||||
trunk: 'Trunk',
|
||||
hazardLights: 'Hazards',
|
||||
headLights: 'Headlights',
|
||||
dtcWarning: 'DTC Warning',
|
||||
driverSeatbelt: 'Driver Belt',
|
||||
passengerSeatbelt: 'Passenger Belt',
|
||||
climate: 'Climate',
|
||||
climatePower: 'Climate Power',
|
||||
fanLevel: 'Fan Level',
|
||||
driverTemperature: 'Driver Setpoint',
|
||||
passengerTemperature: 'Passenger Setpoint',
|
||||
navigation: 'Navigation · Guidance',
|
||||
routeGuide: 'Route Guidance',
|
||||
nextTurn: 'Next Turn',
|
||||
speedLimit: 'Speed Limit',
|
||||
offRoute: 'Off Route',
|
||||
safetyCameraDistance: 'Camera Distance',
|
||||
safetyCameraLimit: 'Camera Limit',
|
||||
headUnitMedia: 'Head Unit · Media',
|
||||
bootState: 'Boot State',
|
||||
powerState: 'Power State',
|
||||
avPower: 'AV Power',
|
||||
mediaSource: 'Media Source',
|
||||
volumeMute: 'Volume · Mute',
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2294,6 +2658,7 @@ $carMonitorSession = car_monitor_launcher_session();
|
||||
let batteryChart = null;
|
||||
let refreshTimer = null;
|
||||
let usageTimer = null;
|
||||
let telemetryTimer = null;
|
||||
let usageRefreshIntervalSec = DEFAULT_USAGE_REFRESH_INTERVAL_SEC;
|
||||
let usageInFlight = false;
|
||||
let wakeLockSentinel = null;
|
||||
@@ -2301,6 +2666,7 @@ $carMonitorSession = car_monitor_launcher_session();
|
||||
let latestDashboardData = null;
|
||||
let latestDashboardMeta = null;
|
||||
let latestUsageData = null;
|
||||
let latestTelemetryData = null;
|
||||
let calibrationModalOpen = false;
|
||||
let calibrationSaveInFlight = false;
|
||||
const FALLBACK_BACK_TARGET = 'https://chaegeon.com/';
|
||||
@@ -2320,6 +2686,8 @@ $carMonitorSession = car_monitor_launcher_session();
|
||||
initChart();
|
||||
fetchData();
|
||||
refreshTimer = setInterval(fetchData, 1000);
|
||||
fetchTelemetry();
|
||||
telemetryTimer = setInterval(fetchTelemetry, 5000);
|
||||
fetchUsage();
|
||||
window.addEventListener('resize', debounce(updateResponsiveDurations, 150));
|
||||
});
|
||||
@@ -2472,6 +2840,7 @@ $carMonitorSession = car_monitor_launcher_session();
|
||||
}
|
||||
updateWakeLockButton();
|
||||
updateResponsiveDurations();
|
||||
if (latestTelemetryData) updateTelemetry(latestTelemetryData);
|
||||
}
|
||||
|
||||
function initTheme() {
|
||||
@@ -2631,6 +3000,143 @@ $carMonitorSession = car_monitor_launcher_session();
|
||||
.catch(() => updateLiveBadge(false));
|
||||
}
|
||||
|
||||
function fetchTelemetry() {
|
||||
fetch('?mode=telemetry', { cache: 'no-store' })
|
||||
.then(res => res.json())
|
||||
.then(payload => {
|
||||
if (payload.status !== 'success') return;
|
||||
updateTelemetry(payload.telemetry || null);
|
||||
})
|
||||
.catch(() => updateTelemetry(null));
|
||||
}
|
||||
|
||||
function updateTelemetry(telemetry) {
|
||||
latestTelemetryData = telemetry;
|
||||
const live = document.getElementById('telemetry-live');
|
||||
const age = document.getElementById('telemetry-age');
|
||||
const count = document.getElementById('telemetry-count');
|
||||
const services = document.getElementById('telemetry-services');
|
||||
const ko = currentLang === 'ko';
|
||||
|
||||
if (!telemetry || !telemetry.available) {
|
||||
live.className = 'telemetry-chip down';
|
||||
live.querySelector('span').textContent = telemetry?.configured === false
|
||||
? (ko ? '설정 필요' : 'Not configured')
|
||||
: (ko ? '연결 안 됨' : 'Unavailable');
|
||||
age.querySelector('span').textContent = '-';
|
||||
count.querySelector('span').textContent = '-';
|
||||
services.querySelector('span').textContent = '-';
|
||||
return;
|
||||
}
|
||||
|
||||
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')
|
||||
: (telemetry.online ? (ko ? '지연' : 'Delayed') : (ko ? '오프라인' : 'Offline'));
|
||||
age.className = `telemetry-chip ${telemetry.stale ? 'warn' : ''}`.trim();
|
||||
age.querySelector('span').textContent = telemetry.age_seconds === null
|
||||
? '-'
|
||||
: `${formatAge(Number(telemetry.age_seconds))} ${ko ? '전' : 'ago'}`;
|
||||
count.querySelector('span').textContent = `${Number(telemetry.metric_count || 0).toLocaleString()} ${ko ? '지표' : 'metrics'}`;
|
||||
services.querySelector('span').textContent = `${telemetry.services?.available ?? 0}/${telemetry.services?.total ?? 0} ${ko ? '서비스' : 'services'}`;
|
||||
|
||||
const metrics = telemetry.metrics || {};
|
||||
const value = name => metrics[name]?.supported ? String(metrics[name].value) : null;
|
||||
const numeric = name => {
|
||||
const raw = value(name);
|
||||
return raw !== null && raw.trim() !== '' && Number.isFinite(Number(raw)) ? Number(raw) : null;
|
||||
};
|
||||
const yes = name => ['true', '1', 'on'].includes(String(value(name)).toLowerCase());
|
||||
const onOff = name => value(name) === null ? null : (yes(name) ? t('on') : t('off'));
|
||||
const set = (id, text, supported = text !== null && text !== undefined && text !== '') => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.textContent = supported ? String(text) : (ko ? '미지원' : 'N/A');
|
||||
el.classList.toggle('unsupported', !supported);
|
||||
el.title = supported ? String(text) : '';
|
||||
};
|
||||
const rawState = name => {
|
||||
const raw = value(name);
|
||||
return raw === null ? null : raw.replace(/\s*\(0x[0-9a-f]+\)\s*$/i, '');
|
||||
};
|
||||
|
||||
const speed = numeric('speed');
|
||||
const parking = yes('parking');
|
||||
const reverse = yes('reverse');
|
||||
const motion = reverse
|
||||
? (ko ? '후진' : 'Reverse')
|
||||
: (speed !== null && speed > 0 ? (ko ? '주행 중' : 'Driving') : (parking ? t('parked') : (ko ? '정차' : 'Stopped')));
|
||||
set('telemetry-motion', motion, true);
|
||||
set('telemetry-gear-speed', `${rawState('gear') ?? '-'} · ${speed ?? '-'} km/h`, value('gear') !== null || speed !== null);
|
||||
const odometer = numeric('odometer_raw');
|
||||
set('telemetry-odometer', odometer === null ? null : `${(odometer / 10).toLocaleString(undefined, { minimumFractionDigits: 1, maximumFractionDigits: 1 })} km`);
|
||||
const outside = numeric('outside_temperature');
|
||||
set('telemetry-outside-temp', outside === null ? null : `${outside.toFixed(1)} °C`);
|
||||
|
||||
set('tm-ignition', onOff('ignition'));
|
||||
set('tm-parking-brake', value('parking_brake') === null ? null : (yes('parking_brake') ? (ko ? '체결' : 'Applied') : (ko ? '해제' : 'Released')));
|
||||
set('tm-drive-mode', rawState('drive_mode'));
|
||||
set('tm-camera', rawState('camera'));
|
||||
const range = numeric('distance_to_empty');
|
||||
set('tm-range', range === null ? null : `${range.toLocaleString()} km`);
|
||||
set('tm-fuel-warning', onOff('fuel_warning'));
|
||||
|
||||
const soc = numeric('battery_soc_12v');
|
||||
set('tm-battery-soc', soc === null ? null : `${soc}%`);
|
||||
set('tm-ldc-charge', onOff('ldc_12v_charge'));
|
||||
set('tm-hev-mode', rawState('hev_mode'));
|
||||
set('tm-charge-state', rawState('hev_charge_state'));
|
||||
set('tm-motor-usage', rawState('motor_usage'));
|
||||
set('tm-wireless-charge', rawState('wireless_charging'));
|
||||
|
||||
const doorNames = ['driver_door', 'passenger_door', 'rear_left_door', 'rear_right_door'];
|
||||
const supportedDoors = doorNames.filter(name => value(name) !== null);
|
||||
const openDoors = supportedDoors.filter(name => yes(name)).length;
|
||||
set('tm-doors', supportedDoors.length ? (openDoors ? `${openDoors}${ko ? '개 열림' : ' open'}` : (ko ? '모두 닫힘' : 'All closed')) : null);
|
||||
const lockNames = ['driver_unlock', 'passenger_unlock', 'rear_left_unlock', 'rear_right_unlock'];
|
||||
const supportedLocks = lockNames.filter(name => value(name) !== null);
|
||||
const unlocked = supportedLocks.filter(name => yes(name)).length;
|
||||
set('tm-locks', supportedLocks.length ? (unlocked ? `${unlocked}${ko ? '개 해제' : ' unlocked'}` : (ko ? '모두 잠김' : 'All locked')) : null);
|
||||
set('tm-trunk', value('trunk') === null ? null : (yes('trunk') ? (ko ? '열림' : 'Open') : (ko ? '닫힘' : 'Closed')));
|
||||
set('tm-hazard', onOff('hazard'));
|
||||
set('tm-head-lamp', onOff('head_lamp'));
|
||||
set('tm-dtc', onOff('dtc_warning'));
|
||||
set('tm-driver-belt', value('driver_seatbelt') === null ? null : (yes('driver_seatbelt') ? (ko ? '미착용' : 'Unbuckled') : (ko ? '착용/비활성' : 'Buckled/idle')));
|
||||
set('tm-passenger-belt', value('passenger_seatbelt') === null ? null : (yes('passenger_seatbelt') ? (ko ? '미착용' : 'Unbuckled') : (ko ? '착용/비활성' : 'Buckled/idle')));
|
||||
|
||||
set('tm-climate', onOff('climate_on'));
|
||||
set('tm-ac', onOff('air_conditioner'));
|
||||
const auto = onOff('climate_auto');
|
||||
const dual = onOff('climate_dual');
|
||||
set('tm-auto-dual', auto === null && dual === null ? null : `AUTO ${auto ?? '-'} · DUAL ${dual ?? '-'}`);
|
||||
const fan = numeric('blower_level');
|
||||
set('tm-fan', fan === null ? null : `${fan}${ko ? '단' : ''}`);
|
||||
const driverTemp = numeric('driver_temperature');
|
||||
const passengerTemp = numeric('passenger_temperature');
|
||||
set('tm-driver-temp', driverTemp === null ? null : `${driverTemp.toFixed(1)} °C`);
|
||||
set('tm-passenger-temp', passengerTemp === null ? null : `${passengerTemp.toFixed(1)} °C`);
|
||||
|
||||
set('tm-route', value('route_guide') === null ? null : (yes('route_guide') ? (ko ? '안내 중' : 'Guiding') : (ko ? '안내 없음' : 'Inactive')));
|
||||
set('tm-next-turn', rawState('next_turn_distance'));
|
||||
const speedLimit = numeric('speed_limit');
|
||||
set('tm-speed-limit', speedLimit === null ? null : `${speedLimit} km/h`);
|
||||
set('tm-off-route', rawState('off_route'));
|
||||
const cameraDistance = numeric('camera_distance');
|
||||
set('tm-camera-distance', cameraDistance === null ? null : `${cameraDistance.toLocaleString()} m`);
|
||||
set('tm-camera-limit', rawState('camera_speed_limit'));
|
||||
|
||||
set('tm-micom', onOff('micom_connected'));
|
||||
const boot = onOff('boot_sequence_finished') ?? onOff('boot_completed');
|
||||
set('tm-boot', boot);
|
||||
set('tm-power', rawState('head_unit_power'));
|
||||
set('tm-av-power', rawState('av_power'));
|
||||
set('tm-audio-mode', rawState('audio_mode'));
|
||||
const volume = numeric('audio_volume');
|
||||
const muted = yes('mute');
|
||||
set('tm-volume', volume === null ? null : `${volume} · ${muted ? (ko ? '음소거' : 'Muted') : (ko ? '재생' : 'Audible')}`);
|
||||
}
|
||||
|
||||
function fetchUsage() {
|
||||
if (usageInFlight) {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
http_response_code(404);
|
||||
exit;
|
||||
}
|
||||
|
||||
require_once dirname(__DIR__) . '/NiroTelemetrySnapshot.php';
|
||||
|
||||
function check(bool $condition, string $message): void
|
||||
{
|
||||
if (!$condition) {
|
||||
fwrite(STDERR, "FAIL: {$message}\n");
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$summary = NiroTelemetrySnapshot::summarize([
|
||||
'received_at' => $now,
|
||||
'availability' => 'online',
|
||||
'state' => [
|
||||
'_sample_epoch' => $now - 3,
|
||||
'meta_system_dumpsys_ok' => 'true',
|
||||
'meta_hscan_dumpsys_ok' => 'false',
|
||||
'system_root_ign' => 'true',
|
||||
'system_individualdrivemode_vehicle_speed' => '27',
|
||||
'system_individualdrivemode_gear_status' => 'D(0x5)',
|
||||
'system_root_mbatterysoc' => '93',
|
||||
'hev_micom_hevstatus_mdisplaysoc' => '255',
|
||||
'hev_micom_hevstatus_odometer' => '2596537',
|
||||
'bcm_micom_bcmstatus_mdrvopenedstate' => '0',
|
||||
'datc_micom_datcstatus_mambienttempc' => '25.0',
|
||||
'mode_root_current_mode_real' => 'BLUETOOTH STREAMING (0x36)',
|
||||
],
|
||||
], ['stale_after' => 20]);
|
||||
|
||||
check($summary['available'] === true, 'snapshot is available');
|
||||
check($summary['online'] === true, 'availability topic is respected');
|
||||
check($summary['stale'] === false, 'fresh sample is not stale');
|
||||
check($summary['metric_count'] === 11, 'metric count excludes sample epoch');
|
||||
check($summary['services'] === ['available' => 1, 'total' => 2], 'service health is counted');
|
||||
check($summary['metrics']['speed']['value'] === '27', 'speed mapping');
|
||||
check($summary['metrics']['gear']['value'] === 'D(0x5)', 'gear mapping');
|
||||
check($summary['metrics']['outside_temperature']['value'] === '25.0', 'climate mapping');
|
||||
check($summary['metrics']['driver_door']['supported'] === true, 'zero is supported');
|
||||
check($summary['metrics']['distance_to_empty']['supported'] === false, 'missing value is unsupported');
|
||||
check(!array_key_exists('state', $summary), 'raw snapshot is not exposed');
|
||||
|
||||
$stale = NiroTelemetrySnapshot::summarize([
|
||||
'received_at' => $now - 90,
|
||||
'availability' => 'offline',
|
||||
'state' => ['_sample_epoch' => $now - 90],
|
||||
], ['stale_after' => 20]);
|
||||
check($stale['online'] === false, 'offline state is exposed');
|
||||
check($stale['stale'] === true, 'old sample is stale');
|
||||
|
||||
echo "PASS\n";
|
||||
Reference in new issue
Block a user