diff --git a/NiroTelemetrySnapshot.php b/NiroTelemetrySnapshot.php new file mode 100644 index 0000000..86f8b54 --- /dev/null +++ b/NiroTelemetrySnapshot.php @@ -0,0 +1,468 @@ + 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; + } +} diff --git a/README.md b/README.md index 008cac7..b546c51 100644 --- a/README.md +++ b/README.md @@ -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 + '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 화면이 뜨는 일을 줄입니다. diff --git a/monitor.php b/monitor.php index 18cf741..60be40b 100644 --- a/monitor.php +++ b/monitor.php @@ -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)) { @@ -1094,6 +1095,15 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'ajax') { ], 500); } } + +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; } + } @@ -1928,6 +2087,119 @@ $carMonitorSession = car_monitor_launcher_session(); +
+
+
+
+ +

Gen5W 실시간 텔레메트리

+
+
헤드유닛 MICOM 서비스 전체 snapshot에서 선별한 차량 상태
+
+
+ 연결 중 + - + - + - +
+
+ +
+
+
주행 상태
+
-
+
+
+
기어 · 속도
+
-
+
+
+
누적 주행거리
+
-
+
+
+
외기 온도
+
-
+
+
+ +
+
+
주행 · 전원
+
+
IGN
-
+
주차 브레이크
-
+
주행 모드
-
+
카메라
-
+
주행 가능 거리
-
+
연료 경고
-
+
+
+ +
+
HEV · 배터리
+
+
12V SOC
-
+
LDC 12V
-
+
HEV 모드
-
+
충전 상태
-
+
모터 사용
-
+
무선 충전
-
+
+
+ +
+
도어 · 안전
+
+
도어
-
+
잠금
-
+
트렁크
-
+
비상등
-
+
전조등
-
+
DTC 경고
-
+
운전석 벨트
-
+
동승석 벨트
-
+
+
+ +
+
공조
+
+
공조 전원
-
+
A/C
-
+
AUTO · DUAL
-
+
풍량
-
+
운전석 설정
-
+
동승석 설정
-
+
+
+ +
+
내비 · 안내
+
+
경로 안내
-
+
다음 회전
-
+
제한 속도
-
+
경로 이탈
-
+
단속 지점 거리
-
+
단속 제한 속도
-
+
+
+ +
+
헤드유닛 · 미디어
+
+
MICOM
-
+
부팅 상태
-
+
전원 상태
-
+
AV 전원
-
+
미디어 소스
-
+
음량 · 음소거
-
+
+
+
+
+
@@ -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() { @@ -2630,6 +2999,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) { diff --git a/tests/NiroTelemetrySnapshotTest.php b/tests/NiroTelemetrySnapshotTest.php new file mode 100644 index 0000000..3651bd8 --- /dev/null +++ b/tests/NiroTelemetrySnapshotTest.php @@ -0,0 +1,59 @@ + $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";