fix: consume per-entity Gen5W telemetry

This commit is contained in:
seo-amugae committed 2026-10-20 20:02:36 +09:00
1 parent 5f7f4397a0
commit 5d2a6b2e44
4 files changed
+146 -110

No files matched your search

+120 -84
View File
@@ -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(),
+4 -4
View File
@@ -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에서만 실행됩니다.
+2 -2
View File
@@ -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`);
+20 -20
View File
@@ -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([