471 lines
19 KiB
PHP
471 lines
19 KiB
PHP
<?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',
|
|
// 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',
|
|
];
|
|
|
|
$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;
|
|
}
|
|
}
|