diff --git a/NiroTelemetrySnapshot.php b/NiroTelemetrySnapshot.php index d6504f0..7ad0d15 100644 --- a/NiroTelemetrySnapshot.php +++ b/NiroTelemetrySnapshot.php @@ -32,111 +32,133 @@ final class NiroTelemetrySnapshot public static function summarize(array $snapshot, array $config = []): array { $state = is_array($snapshot['state'] ?? null) ? $snapshot['state'] : []; - $sampleEpoch = self::integer($state['_sample_epoch'] ?? 0); + $sampleEpoch = self::integer($state['_sample_epoch'] ?? ($snapshot['received_at'] ?? 0)); $age = $sampleEpoch > 0 ? max(0, time() - $sampleEpoch) : null; $staleAfter = max(10, (int)($config['stale_after'] ?? 20)); + $online = (string)($snapshot['availability'] ?? '') === 'online'; $serviceTotal = 0; $serviceAvailable = 0; - foreach ($state as $key => $value) { - if (!str_starts_with((string)$key, 'meta_') || !str_ends_with((string)$key, '_dumpsys_ok')) { - continue; - } + $serviceGroups = [ + ['audio_mode', 'rear_view', 'screen_on'], + ['speed', 'gear_code', 'gear_p_code'], + ['ignition', 'driver_door', 'engine_running'], + ['hev_mode_code', 'total_distance_to_empty', 'odometer'], + ['head_unit_power', 'av_disabled', 'display_off'], + ['n_framework_available', 'n_current_drive_mode_code'], + ['climate_on', 'driver_temperature', 'outside_temperature'], + ]; + foreach ($serviceGroups as $keys) { $serviceTotal++; - if ((string)$value === 'true') { - $serviceAvailable++; + foreach ($keys as $key) { + if (array_key_exists($key, $state)) { + $serviceAvailable++; + break; + } } } $keyMap = [ - 'ignition' => 'system_root_ign', - 'reverse' => 'system_root_reverse', - 'parking' => 'system_root_parking', - // The IndividualDriveMode block is a persisted/default structure on - // V038. Its Vehicle Speed and Gear Status stay at 0/P while the car - // is moving. The root fields are the live MICOM callbacks. - 'gear' => 'system_root_gear', - 'speed' => 'system_root_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' => 'hev_micom_hevstatus_mtotaldte', - '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' => 'cluster_tbt_info_operationstatus', - 'next_turn_distance' => 'cluster_tbt_subinfo_distance', - 'speed_limit' => 'cluster_slif_navi_info_speed_limit', - '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', + 'ignition' => ['ignition'], + 'reverse' => ['rear_view'], + 'parking' => ['parked', 'gear_p_code'], + 'gear' => ['gear_code', 'gear_p_code'], + 'speed' => ['speed'], + 'previous_speed' => [], + 'parking_brake' => ['parking_brake'], + 'camera' => ['rear_view'], + 'drive_mode' => ['drive_mode_code', 'n_current_drive_mode_code'], + 'fuel_warning' => ['fuel_warning'], + 'distance_to_empty' => ['total_distance_to_empty'], + 'battery_soc_12v' => ['battery_soc_12v'], + 'battery_above_60' => [], + 'ldc_battery' => ['ldc_12v_charge'], + 'hev_mode' => ['hev_mode_code'], + 'hev_charge_state' => ['hev_charge_state_code'], + 'motor_usage' => ['motor_usage_code'], + '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'], + 'lock_state' => [], + 'hazard' => ['hazard'], + 'head_lamp' => ['head_lamp'], + 'tail_lamp' => ['tail_lamp'], + 'fog_lamp' => ['fog_lamp'], + 'driver_seatbelt' => ['driver_seatbelt_code'], + 'passenger_seatbelt' => ['passenger_seatbelt_code'], + 'airbag' => ['airbag_code'], + 'climate_on' => ['climate_on'], + 'air_conditioner' => ['air_conditioner'], + 'climate_auto' => ['climate_auto'], + 'climate_dual' => ['climate_dual'], + 'driver_temperature' => ['driver_temperature'], + 'passenger_temperature' => ['passenger_temperature'], + 'outside_temperature' => ['outside_temperature'], + 'blower_level' => ['blower_level'], + 'intake_mode' => ['intake_code'], + 'co2_warning' => ['co2_warning_code'], + 'route_guide' => [], + 'next_turn_distance' => [], + 'speed_limit' => [], + 'camera_distance' => [], + 'camera_speed_limit' => [], + 'av_power' => ['av_power'], + 'head_unit_power' => ['head_unit_power'], + 'audio_mode' => ['audio_mode'], + 'audio_focus' => [], + 'audio_volume' => [], + 'mute' => [], + 'micom_connected' => ['n_framework_available'], + 'boot_completed' => ['head_unit_powered'], + 'boot_sequence_finished' => [], + 'wireless_charging' => ['wireless_charging_code'], + 'dtc_warning' => [], + 'pdw_front_left' => [], + 'pdw_front_right' => [], + 'pdw_rear_left' => [], + 'pdw_rear_right' => [], + 'n_framework_available' => ['n_framework_available'], + 'n_drive_mode_valid' => ['n_drive_mode_valid'], + 'n_current_drive_mode' => ['n_current_drive_mode_code'], + 'n_drive_mode_type' => ['n_drive_mode_type_code'], + 'n_asd_on' => ['n_asd_on'], + 'n_rev_indication' => ['n_rev_indication_code'], + 'n_drive_mode2_type' => ['n_drive_mode2_type_code'], + 'n_terrain_supported' => ['n_terrain_supported'], ]; $metrics = []; - foreach ($keyMap as $name => $key) { - $value = array_key_exists($key, $state) ? (string)$state[$key] : null; + foreach ($keyMap as $name => $keys) { + $value = null; + foreach ($keys as $key) { + if (array_key_exists($key, $state)) { + $value = (string)$state[$key]; + break; + } + } $metrics[$name] = [ 'value' => $value, 'supported' => $value !== null && !self::unsupported($value), ]; } + $metricCount = count(array_filter($metrics, static fn(array $metric): bool => $metric['supported'])); return [ 'configured' => true, 'available' => $state !== [], - 'online' => (string)($snapshot['availability'] ?? '') === 'online', - 'stale' => $age === null || $age > $staleAfter, + 'online' => $online, + 'stale' => !$online || $age === null || $age > $staleAfter, 'age_seconds' => $age, 'sample_epoch' => $sampleEpoch, - 'metric_count' => max(0, count($state) - (array_key_exists('_sample_epoch', $state) ? 1 : 0)), + 'metric_count' => $metricCount, 'services' => [ 'available' => $serviceAvailable, 'total' => $serviceTotal, @@ -211,6 +233,8 @@ final class NiroTelemetrySnapshot $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'); + $statePrefix = rtrim($stateTopic, '/') . '/'; + $stateFilter = $statePrefix . '#'; $availabilityTopic = (string)($config['availability_topic'] ?? 'niro/gen5w/status'); $connection = self::openConnection($config, $host, $port, $timeout); $reader = $connection['reader']; @@ -233,13 +257,14 @@ final class NiroTelemetrySnapshot $subscribe = pack('n', 1) . self::mqttString($stateTopic) . chr(0) + . self::mqttString($stateFilter) . chr(0) . self::mqttString($availabilityTopic) . chr(0); self::writePacket($writer, 0x82, $subscribe); - $state = null; + $state = []; $availability = null; $deadline = microtime(true) + $timeout; - while (microtime(true) < $deadline && ($state === null || $availability === null)) { + while (microtime(true) < $deadline && ($state === [] || $availability === null)) { $packet = self::readPacket($reader); if (($packet['type'] ?? 0) !== 3) { continue; @@ -251,14 +276,25 @@ final class NiroTelemetrySnapshot throw new RuntimeException('mqtt_state_not_object'); } $state = $decoded; + } elseif (str_starts_with($topic, $statePrefix)) { + $entity = substr($topic, strlen($statePrefix)); + if (preg_match('/^[a-z0-9_]+$/', $entity) !== 1) { + continue; + } + if ($body === '') { + unset($state[$entity]); + } else { + $state[$entity] = trim($body); + } } elseif ($topic === $availabilityTopic) { $availability = trim($body); } } - if (!is_array($state)) { + if ($state === []) { throw new RuntimeException('mqtt_state_missing'); } + $state['_sample_epoch'] = time(); return [ 'received_at' => time(), diff --git a/README.md b/README.md index b546c51..b30b5f0 100644 --- a/README.md +++ b/README.md @@ -81,14 +81,14 @@ Car는 차량 모뎀/게이트웨이에서 받은 상태를 수집해 DB에 저 ## Gen5W MQTT 텔레메트리 -`NiroTelemetrySnapshot.php`는 차량이 5초마다 발행하는 retained `niro/gen5w/state`와 availability `niro/gen5w/status`를 읽습니다. 브라우저에는 약 2,000개 원시 snapshot 전체를 보내지 않고 운전에 의미 있는 선별 지표와 수집 상태만 반환합니다. 현재 화면은 다음 정보를 결합합니다. +`NiroTelemetrySnapshot.php`는 차량의 Binder callback이 값 변경 시 즉시 발행하는 retained `niro/gen5w/state/{entity-id}`와 availability `niro/gen5w/status`를 읽습니다. 구형 polling v6 rollback을 위해 `niro/gen5w/state` JSON도 함께 해석하지만 운영 기본값은 개별 scalar topic입니다. 브라우저에는 검증된 선별 지표와 수집 상태만 반환합니다. 현재 화면은 다음 정보를 결합합니다. - 주행 상태, 속도, 기어, 주차 브레이크, 카메라 상태와 주행 가능 거리 - 12V SOC, LDC 충전, HEV 모드·충전 상태, 모터 사용과 누적 주행거리 - 네 도어의 열림·잠금, 트렁크, 등화, 안전벨트와 DTC 경고 - 공조 전원, A/C, AUTO·DUAL, 풍량, 좌우 설정 온도와 외기 온도 -- 경로 안내, 다음 회전 거리, 제한 속도, 경로 이탈과 단속 지점 정보 -- MICOM 연결, 부팅, 헤드유닛·AV 전원, 미디어 소스와 음량 +- 헤드유닛·AV 전원, 미디어 소스와 NMode readback +- 현재 event bridge에서 계약이 없는 경로·단속·음량 값은 이전 raw cache로 채우지 않고 미지원으로 표시 차량 세대상 제공하지 않는 값은 `-1`, `255`, `65535`, `4294967295`, `0xff`, `0xffffffff` sentinel로 구분해 `미지원`으로 표시합니다. 값의 존재만으로 기능이 활성화됐다고 판단하지 않으며, 원시 ECU 쓰기나 제어 명령은 MQTT 경로에서 수행하지 않습니다. @@ -114,7 +114,7 @@ return [ ]; ``` -`car_dashboard` MQTT 계정은 `niro/gen5w/state`와 `niro/gen5w/status` 두 topic의 read 권한만 가집니다. `NiroTelemetrySnapshot.php`는 같은 웹 요청이 1초마다 반복돼도 broker snapshot은 최대 4초 동안 웹 전용 캐시에서 재사용합니다. 새 수집이 실패하면 직전 cache를 표시하되 `stale`과 cache fallback 상태를 함께 반환합니다. +`car_dashboard` MQTT 계정은 `niro/gen5w/state`, `niro/gen5w/state/#`와 `niro/gen5w/status`만 읽을 수 있습니다. `NiroTelemetrySnapshot.php`는 같은 웹 요청이 반복돼도 broker snapshot은 최대 4초 동안 웹 전용 캐시에서 재사용합니다. 새 수집이 실패하면 직전 cache를 표시하되 `stale`과 cache fallback 상태를 함께 반환하며, availability가 offline이면 마지막 값을 보존하되 즉시 stale로 판정합니다. 요약 계약 회귀시험은 CLI에서만 실행됩니다. diff --git a/monitor.php b/monitor.php index fabdc6d..661230a 100644 --- a/monitor.php +++ b/monitor.php @@ -3072,8 +3072,8 @@ $carMonitorSession = car_monitor_launcher_session(); ? 'R' : (parking ? 'P' : (speed !== null && speed > 0 ? 'D' : (ko ? '정차' : 'Stopped'))); set('telemetry-gear-speed', `${gear} · ${speed ?? '-'} km/h`, speed !== null || value('parking') !== null || value('reverse') !== null); - const odometer = numeric('odometer_raw'); - set('telemetry-odometer', odometer === null ? null : `${(odometer / 10).toLocaleString(undefined, { minimumFractionDigits: 1, maximumFractionDigits: 1 })} km`); + const odometer = numeric('odometer'); + set('telemetry-odometer', odometer === null ? null : `${odometer.toLocaleString(undefined, { minimumFractionDigits: 1, maximumFractionDigits: 1 })} km`); const outside = numeric('outside_temperature'); set('telemetry-outside-temp', outside === null ? null : `${outside.toFixed(1)} °C`); diff --git a/tests/NiroTelemetrySnapshotTest.php b/tests/NiroTelemetrySnapshotTest.php index 078735f..1700cd8 100644 --- a/tests/NiroTelemetrySnapshotTest.php +++ b/tests/NiroTelemetrySnapshotTest.php @@ -22,37 +22,37 @@ $summary = NiroTelemetrySnapshot::summarize([ 'availability' => 'online', 'state' => [ '_sample_epoch' => $now - 3, - 'meta_system_dumpsys_ok' => 'true', - 'meta_hscan_dumpsys_ok' => 'false', - 'system_root_ign' => 'true', - 'system_root_speed' => '27', - 'system_root_gear' => '5', - // These persisted/default fields can disagree with the live root values - // and must never drive the dashboard. - 'system_individualdrivemode_vehicle_speed' => '0', - 'system_individualdrivemode_gear_status' => 'P(0x0)', - 'system_root_mbatterysoc' => '93', - 'hev_micom_hevstatus_mtotaldte' => '761', - '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)', - 'cluster_tbt_info_operationstatus' => '1', + 'ignition' => 'true', + 'speed' => '27', + 'gear_code' => '5', + 'battery_soc_12v' => '93', + 'total_distance_to_empty' => '761', + 'odometer' => '259653.7', + 'driver_door' => 'false', + 'outside_temperature' => '25.0', + 'audio_mode' => '54', + 'hev_mode_code' => '2', + 'head_unit_power' => '0', + 'n_framework_available' => 'true', + 'n_drive_mode_valid' => 'true', + 'n_current_drive_mode_code' => '2', + 'n_terrain_supported' => 'false', ], ], ['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'] === 15, 'metric count excludes sample epoch'); -check($summary['services'] === ['available' => 1, 'total' => 2], 'service health is counted'); +check($summary['metric_count'] === 17, 'only mapped supported metrics are counted'); +check($summary['services'] === ['available' => 7, 'total' => 7], 'source families are counted'); check($summary['metrics']['speed']['value'] === '27', 'speed mapping'); check($summary['metrics']['gear']['value'] === '5', 'live root 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']['value'] === '761', 'live HEV range mapping'); -check($summary['metrics']['route_guide']['value'] === '1', 'live cluster route state mapping'); +check($summary['metrics']['odometer']['value'] === '259653.7', 'odometer is already km'); +check($summary['metrics']['n_current_drive_mode']['value'] === '2', 'N readback mapping'); +check($summary['metrics']['route_guide']['supported'] === false, 'removed raw route value stays unsupported'); check(!array_key_exists('state', $summary), 'raw snapshot is not exposed'); $stale = NiroTelemetrySnapshot::summarize([