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' => '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; } }