fix: consume per-entity Gen5W telemetry
This commit is contained in:
1 parent
5f7f4397a0
commit
5d2a6b2e44
4 files changed
+146
-110
No files matched your search
+120
-84
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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`);
|
||||
|
||||
|
||||
@@ -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([
|
||||
|
||||
Reference in new issue
Block a user