Skip to content

수동 진단 API 코드랩

개요

이 코드랩은 전문 기술자의 제어로 원격 차량 진단을 가능하게 하는 수동 진단(Manual Diagnosis) API를 구현하는 방법을 안내합니다. 진단 세션 관리, 실시간 데이터 스트리밍 처리, 진단 결과 처리 방법을 배우게 됩니다.

사전 요구 사항

  • REST API에 대한 기본 이해
  • JavaScript/TypeScript 친숙도
  • Node.js와 npm 설치
  • 차량 진단 및 ECU 통신 이해
  • 텍스트 에디터 또는 IDE

학습 목표

  • 원격 진단 세션 관리 구현
  • SSE를 통한 실시간 진단 데이터 스트리밍 처리
  • 사용자 동의 및 기술자 제어 관리
  • 진단 결과 처리 및 분석
  • 종합 진단 보고서 생성

설정

1. 프로젝트 초기화

bash
mkdir manual-diagnosis-demo
cd manual-diagnosis-demo
npm init -y
npm install axios node-fetch moment

2. 구성

config.js 파일을 생성하세요:

javascript
export const API_CONFIG = {
  baseURL: 'https://api.ecarus.run/api/v1/diagnosis',
  authToken: 'sk_4f9c7b8e2d1a6c0f3e7a9b5d8c1e4f2a7c6d9e0b3f5a8c1d4e7f9b2c6a1e3d',
  sampleVIN: 'KMHSH81C7LU123456',
  sampleTechnicianId: 'TECH_001',
  timeout: 30000 // 진단 작업용 30초
};

단계 1: 진단 세션 관리

세션 제어 구현

diagnosisSession.js를 생성하세요:

javascript
import axios from 'axios';
import { API_CONFIG } from './config.js';

class DiagnosisSession {
  constructor() {
    this.client = axios.create({
      baseURL: API_CONFIG.baseURL,
      headers: {
        'Authorization': `Bearer ${API_CONFIG.authToken}`,
        'Content-Type': 'application/json'
      },
      timeout: API_CONFIG.timeout
    });
  }

  async requestSession(vin, options = {}) {
    try {
      const payload = {
        reason: options.reason || 'Routine Check',
        technicianId: options.technicianId || API_CONFIG.sampleTechnicianId
      };

      const response = await this.client.post(
        `/vehicles/${vin}/manual/session-request`,
        payload
      );

      console.log('진단 세션 요청됨:', response.data);
      return response.data;
    } catch (error) {
      console.error('진단 세션 요청 실패:', error.response?.data || error.message);
      throw error;
    }
  }

  async stopSession(vin, sessionId, options = {}) {
    try {
      const payload = {
        sessionId,
        reason: options.reason || 'DIAGNOSIS_COMPLETED'
      };

      const response = await this.client.post(
        `/vehicles/${vin}/manual/session-stop`,
        payload
      );

      console.log('진단 세션 중지됨:', response.data);
      return response.data;
    } catch (error) {
      console.error('진단 세션 중지 실패:', error.response?.data || error.message);
      throw error;
    }
  }

  async getSessionStatus(vin, sessionId) {
    try {
      const response = await this.client.get(
        `/vehicles/${vin}/manual/session/${sessionId}/status`
      );

      console.log('세션 상태:', response.data);
      return response.data;
    } catch (error) {
      console.error('세션 상태 가져오기 실패:', error.response?.data || error.message);
      throw error;
    }
  }

  async getActiveSessions(vin) {
    try {
      const response = await this.client.get(
        `/vehicles/${vin}/manual/sessions`
      );

      console.log('활성 세션:', response.data);
      return response.data;
    } catch (error) {
      console.error('활성 세션 가져오기 실패:', error.response?.data || error.message);
      throw error;
    }
  }

  formatSessionDuration(startTime, endTime = null) {
    const start = new Date(startTime);
    const end = endTime ? new Date(endTime) : new Date();
    const duration = Math.floor((end - start) / 1000);

    const hours = Math.floor(duration / 3600);
    const minutes = Math.floor((duration % 3600) / 60);
    const seconds = duration % 60;

    return { hours, minutes, seconds, totalSeconds: duration };
  }

  assessSessionStatus(sessionData) {
    const status = sessionData.status;
    const consent = sessionData.consent;

    return {
      isActive: status === 'ACTIVE',
      needsConsent: consent.status === 'PENDING',
      isReady: status === 'ACTIVE' && consent.status === 'GRANTED',
      canExecuteCommands: status === 'ACTIVE' && consent.status === 'GRANTED',
      currentCommand: sessionData.currentCommand || null
    };
  }
}

export default DiagnosisSession;

세션 관리 테스트

test-session.js를 생성하세요:

javascript
import DiagnosisSession from './diagnosisSession.js';
import { API_CONFIG } from './config.js';

const sessionManager = new DiagnosisSession();

async function testSessionManagement() {
  const vin = API_CONFIG.sampleVIN;

  try {
    console.log('🔧 진단 세션 관리 테스트 중...');

    // 새 세션 요청
    console.log('새 진단 세션 요청 중...');
    const sessionRequest = await sessionManager.requestSession(vin, {
      reason: '엔진 성능 확인',
      technicianId: API_CONFIG.sampleTechnicianId
    });

    // 활성 세션 가져오기
    console.log('활성 세션 가져오는 중...');
    const activeSessions = await sessionManager.getActiveSessions(vin);

    if (activeSessions.sessions && activeSessions.sessions.length > 0) {
      const sessionId = activeSessions.sessions[0].sessionId;

      // 세션 상태 가져오기
      console.log('세션 상태 가져오는 중...');
      const sessionStatus = await sessionManager.getSessionStatus(vin, sessionId);

      const assessment = sessionManager.assessSessionStatus(sessionStatus);
      console.log('세션 평가:', assessment);

      // 데모용 세션 중지
      setTimeout(async () => {
        console.log('세션 중지 중...');
        await sessionManager.stopSession(vin, sessionId, {
          reason: 'DEMO_COMPLETED'
        });
      }, 5000);
    }

  } catch (error) {
    console.error('❌ 세션 관리 테스트 실패:', error.message);
  }
}

testSessionManagement();

단계 2: 사용자 동의 관리

동의 제어 구현

consentManager.js를 생성하세요:

javascript
import axios from 'axios';
import { API_CONFIG } from './config.js';

class ConsentManager {
  constructor() {
    this.client = axios.create({
      baseURL: API_CONFIG.baseURL,
      headers: {
        'Authorization': `Bearer ${API_CONFIG.authToken}`,
        'Content-Type': 'application/json'
      },
      timeout: API_CONFIG.timeout
    });
  }

  async provideConsent(vin, consentData) {
    try {
      const payload = {
        allow: consentData.allow !== false,
        requestId: consentData.requestId,
        timestamp: consentData.timestamp || new Date().toISOString()
      };

      const response = await this.client.post(
        `/vehicles/${vin}/manual/consent`,
        payload
      );

      console.log('동의 제공됨:', response.data);
      return response.data;
    } catch (error) {
      console.error('동의 제공 실패:', error.response?.data || error.message);
      throw error;
    }
  }

  async checkConsentStatus(vin, requestId) {
    try {
      // 일반적으로 세션 상태 확인의 일부
      const response = await this.client.get(
        `/vehicles/${vin}/manual/consent/${requestId}`
      );

      console.log('동의 상태:', response.data);
      return response.data;
    } catch (error) {
      console.error('동의 상태 확인 실패:', error.response?.data || error.message);
      throw error;
    }
  }

  validateConsentRequest(consentData) {
    const errors = [];

    if (!consentData.requestId) {
      errors.push('요청 ID가 필요합니다');
    }

    if (typeof consentData.allow !== 'boolean') {
      errors.push('동의 결정(allow)은 불리언이어야 합니다');
    }

    if (consentData.allow && !consentData.purpose) {
      errors.push('동의가 부여되면 목적이 필요합니다');
    }

    return {
      isValid: errors.length === 0,
      errors
    };
  }

  generateConsentMessage(sessionData) {
    return {
      title: '원격 진단 요청',
      message: `기술자 ${sessionData.technicianId}님이 귀하의 차량에 대해 원격 진단을 요청합니다.`,
      purpose: sessionData.reason,
      duration: '예상 15-30분',
      dataCollected: [
        '엔진 성능 데이터',
        '진단 트러블 코드 (DTC)',
        '센서 판독값',
        '시스템 상태 정보'
      ],
      timestamp: new Date().toISOString()
    };
  }

  async processConsentFlow(vin, sessionId, consentDecision) {
    try {
      console.log('🔄 동의 플로 처리 중...');

      // 동의 결정 검증
      const validation = this.validateConsentRequest(consentDecision);
      if (!validation.isValid) {
        throw new Error(`잘못된 동의 요청: ${validation.errors.join(', ')}`);
      }

      // 동의 제공
      const consentResult = await this.provideConsent(vin, consentDecision);

      // 동의 후 세션 상태 확인
      // 일반적으로 SSE 또는 폴링을 통해 수행
      console.log('✅ 동의가 성공적으로 처리됨');

      return {
        consented: consentDecision.allow,
        sessionId,
        timestamp: consentResult.timestamp
      };

    } catch (error) {
      console.error('동의 플로 실패:', error.message);
      throw error;
    }
  }
}

export default ConsentManager;

동의 관리 테스트

test-consent.js를 생성하세요:

javascript
import ConsentManager from './consentManager.js';
import DiagnosisSession from './diagnosisSession.js';
import { API_CONFIG } from './config.js';

const consentManager = new ConsentManager();
const sessionManager = new DiagnosisSession();

async function testConsentManagement() {
  const vin = API_CONFIG.sampleVIN;

  try {
    console.log('🔐 동의 관리 테스트 중...');

    // 먼저 세션 요청
    const sessionRequest = await sessionManager.requestSession(vin, {
      reason: '엔진 진단 확인',
      technicianId: API_CONFIG.sampleTechnicianId
    });

    // 동의 메시지 생성
    const consentMessage = consentManager.generateConsentMessage(sessionRequest);
    console.log('📱 동의 메시지:', consentMessage);

    // 데모용으로 사용자 동의 시뮬레이션
    const consentDecision = {
      allow: true,
      requestId: sessionRequest.requestId || 'REQ_001',
      purpose: '엔진 진단 확인'
    };

    // 동의 처리
    const consentResult = await consentManager.processConsentFlow(vin, sessionRequest.sessionId, consentDecision);
    console.log('✅ 동의 결과:', consentResult);

  } catch (error) {
    console.error('❌ 동의 관리 테스트 실패:', error.message);
  }
}

testConsentManagement();

단계 3: 진단 명령 제어

명령 실행 구현

diagnosisCommand.js를 생성하세요:

javascript
import axios from 'axios';
import { API_CONFIG } from './config.js';

class DiagnosisCommand {
  constructor() {
    this.client = axios.create({
      baseURL: API_CONFIG.baseURL,
      headers: {
        'Authorization': `Bearer ${API_CONFIG.authToken}`,
        'Content-Type': 'application/json'
      },
      timeout: API_CONFIG.timeout
    });
  }

  async sendCommand(vin, commandData) {
    try {
      const payload = {
        cmd: commandData.cmd,
        pids: commandData.pids || [],
        sessionId: commandData.sessionId
      };

      const response = await this.client.post(
        `/vehicles/${vin}/manual/command`,
        payload
      );

      console.log('명령 전송됨:', response.data);
      return response.data;
    } catch (error) {
      console.error('명령 전송 실패:', error.response?.data || error.message);
      throw error;
    }
  }

  async startLiveDataStream(vin, sessionId, pids = []) {
    try {
      const defaultPids = ['RPM', 'COOLANT_TEMP', 'SPEED', 'FUEL_PRESSURE'];
      const selectedPids = pids.length > 0 ? pids : defaultPids;

      const command = {
        cmd: 'CMD_LIVE_STREAM',
        pids: selectedPids,
        sessionId
      };

      return await this.sendCommand(vin, command);
    } catch (error) {
      console.error('라이브 데이터 스트림 시작 실패:', error.message);
      throw error;
    }
  }

  async readDTC(vin, sessionId) {
    try {
      const command = {
        cmd: 'CMD_READ_DTC',
        sessionId
      };

      return await this.sendCommand(vin, command);
    } catch (error) {
      console.error('DTC 코드 읽기 실패:', error.message);
      throw error;
    }
  }

  async clearDTC(vin, sessionId) {
    try {
      const command = {
        cmd: 'CMD_CLEAR_DTC',
        sessionId
      };

      return await this.sendCommand(vin, command);
    } catch (error) {
      console.error('DTC 코드 삭제 실패:', error.message);
      throw error;
    }
  }

  validateCommand(commandData) {
    const validCommands = [
      'CMD_LIVE_STREAM',
      'CMD_READ_DTC',
      'CMD_CLEAR_DTC',
      'CMD_RUN_TEST',
      'CMD_GET_VIN',
      'CMD_GET_CALIBRATION'
    ];

    const validPIDs = [
      'RPM', 'COOLANT_TEMP', 'SPEED', 'FUEL_PRESSURE',
      'THROTTLE_POS', 'O2_SENSOR', 'MAF', 'MAP',
      'IGNITION_TIMING', 'FUEL_INJECTOR', 'EVAP_SYSTEM'
    ];

    const errors = [];

    if (!validCommands.includes(commandData.cmd)) {
      errors.push(`잘못된 명령: ${commandData.cmd}`);
    }

    if (commandData.cmd === 'CMD_LIVE_STREAM' && (!commandData.pids || commandData.pids.length === 0)) {
      errors.push('라이브 스트림 명령에는 PID가 필요합니다');
    }

    if (commandData.pids) {
      const invalidPids = commandData.pids.filter(pid => !validPIDs.includes(pid));
      if (invalidPids.length > 0) {
        errors.push(`잘못된 PID: ${invalidPids.join(', ')}`);
      }
    }

    return {
      isValid: errors.length === 0,
      errors
    };
  }

  getCommandDescription(command) {
    const descriptions = {
      'CMD_LIVE_STREAM': '차량 센서에서 실시간 데이터 스트리밍 시작',
      'CMD_READ_DTC': '차량 ECU에서 진단 트러블 코드 읽기',
      'CMD_CLEAR_DTC': '진단 트러블 코드 삭제 (확인 필요)',
      'CMD_RUN_TEST': '차량 시스템에 특정 진단 테스트 실행',
      'CMD_GET_VIN': '차량 식별 번호 검색',
      'CMD_GET_CALIBRATION': 'ECU 캘리브레이션 정보 가져오기'
    };

    return descriptions[command] || '알 수 없는 명령';
  }

  formatPIDData(pid, value) {
    const units = {
      'RPM': 'RPM',
      'COOLANT_TEMP': '°C',
      'SPEED': 'km/h',
      'FUEL_PRESSURE': 'kPa',
      'THROTTLE_POS': '%',
      'O2_SENSOR': 'V',
      'MAF': 'g/s',
      'MAP': 'kPa',
      'IGNITION_TIMING': '°',
      'FUEL_INJECTOR': 'ms',
      'EVAP_SYSTEM': 'status'
    };

    return {
      pid,
      value,
      unit: units[pid] || '',
      formatted: `${value} ${units[pid] || ''}`
    };
  }
}

export default DiagnosisCommand;

명령 제어 테스트

test-command.js를 생성하세요:

javascript
import DiagnosisCommand from './diagnosisCommand.js';
import DiagnosisSession from './diagnosisSession.js';
import { API_CONFIG } from './config.js';

const commandManager = new DiagnosisCommand();
const sessionManager = new DiagnosisSession();

async function testCommandControl() {
  const vin = API_CONFIG.sampleVIN;

  try {
    console.log('⚙️ 진단 명령 제어 테스트 중...');

    // 세션 요청
    const sessionRequest = await sessionManager.requestSession(vin, {
      reason: '명령 테스트',
      technicianId: API_CONFIG.sampleTechnicianId
    });

    const sessionId = sessionRequest.sessionId;

    // 라이브 데이터 스트림 명령 테스트
    console.log('라이브 데이터 스트림 시작 중...');
    const streamCommand = await commandManager.startLiveDataStream(vin, sessionId, [
      'RPM', 'COOLANT_TEMP', 'SPEED'
    ]);

    console.log('스트림 명령 전송됨:', streamCommand);

    // DTC 읽기 명령 테스트
    console.log('DTC 코드 읽는 중...');
    const dtcCommand = await commandManager.readDTC(vin, sessionId);
    console.log('DTC 명령 전송됨:', dtcCommand);

    // 명령 검증
    const testCommand = {
      cmd: 'CMD_LIVE_STREAM',
      pids: ['RPM', 'INVALID_PID']
    };

    const validation = commandManager.validateCommand(testCommand);
    console.log('명령 검증:', validation);

    // 정리
    setTimeout(async () => {
      await sessionManager.stopSession(vin, sessionId, {
        reason: 'TEST_COMPLETED'
      });
    }, 3000);

  } catch (error) {
    console.error('❌ 명령 제어 테스트 실패:', error.message);
  }
}

testCommandControl();

단계 4: 실시간 데이터 스트리밍

진단 데이터용 SSE 클라이언트 구현

diagnosisSSE.js를 생성하세요:

javascript
export class DiagnosisSSEClient {
  constructor(url, authToken) {
    this.url = url;
    this.authToken = authToken;
    this.eventSource = null;
    this.listeners = new Map();
    this.dataBuffer = new Map();
    this.isStreaming = false;
  }

  connect() {
    return new Promise((resolve, reject) => {
      try {
        this.eventSource = new EventSource(this.url, {
          headers: {
            'Authorization': `Bearer ${this.authToken}`
          }
        });

        this.eventSource.onopen = () => {
          console.log('📡 진단 SSE 연결 열림');
          this.isStreaming = true;
          resolve();
        };

        this.eventSource.onerror = (error) => {
          console.error('진단 SSE 연결 오류:', error);
          this.isStreaming = false;
          if (this.eventSource.readyState === EventSource.CLOSED) {
            reject(new Error('진단 SSE 연결 닫힘'));
          }
        };

        this.eventSource.onmessage = (event) => {
          try {
            const data = JSON.parse(event.data);
            this.handleEvent(data);
          } catch (error) {
            console.error('진단 SSE 데이터 파싱 실패:', error);
          }
        };

      } catch (error) {
        reject(error);
      }
    });
  }

  on(eventType, callback) {
    if (!this.listeners.has(eventType)) {
      this.listeners.set(eventType, []);
    }
    this.listeners.get(eventType).push(callback);
  }

  handleEvent(data) {
    const eventType = data.eventType || 'default';
    const callbacks = this.listeners.get(eventType) || [];

    callbacks.forEach(callback => {
      try {
        callback(data);
      } catch (error) {
        console.error('진단 이벤트 콜백에서 오류:', error);
      }
    });

    // 특정 데이터 이벤트 처리
    if (eventType === 'LIVE_DATA') {
      this.handleLiveData(data);
    } else if (eventType === 'DTC_RESULT') {
      this.handleDTCResult(data);
    } else if (eventType === 'COMMAND_STATUS') {
      this.handleCommandStatus(data);
    }
  }

  handleLiveData(data) {
    const { sessionId, timestamp, data: liveData } = data;

    // 분석을 위해 버퍼에 저장
    if (!this.dataBuffer.has(sessionId)) {
      this.dataBuffer.set(sessionId, []);
    }

    const buffer = this.dataBuffer.get(sessionId);
    buffer.push({
      timestamp,
      data: liveData,
      formatted: this.formatLiveData(liveData)
    });

    // 마지막 100개의 데이터 포인트만 유지
    if (buffer.length > 100) {
      buffer.shift();
    }

    console.log('📊 라이브 데이터 수신됨:', {
      sessionId,
      timestamp,
      rpm: liveData.rpm,
      coolantTemp: liveData.coolantTemp,
      speed: liveData.speed
    });
  }

  handleDTCResult(data) {
    console.log('🔧 DTC 결과 수신됨:', data);

    const { dtcCodes, sessionId } = data;

    if (dtcCodes && dtcCodes.length > 0) {
      console.log(`${dtcCodes.length}개의 DTC 코드 발견:`);
      dtcCodes.forEach((code, index) => {
        console.log(`  ${index + 1}. ${code.code}: ${code.description} (${code.severity})`);
      });
    } else {
      console.log('✅ DTC 코드가 발견되지 않음');
    }
  }

  handleCommandStatus(data) {
    console.log('⚙️ 명령 상태 업데이트:', data);

    const { command, status, sessionId } = data;

    if (status === 'COMPLETED') {
      console.log(`✅ 명령 ${command}이(가) 성공적으로 완료됨`);
    } else if (status === 'FAILED') {
      console.log(`❌ 명령 ${command} 실패`);
    } else if (status === 'EXECUTING') {
      console.log(`⏳ 명령 ${command} 실행 중...`);
    }
  }

  formatLiveData(rawData) {
    const formatted = {};

    Object.entries(rawData).forEach(([key, value]) => {
      switch (key) {
        case 'rpm':
          formatted[key] = { value, unit: 'RPM', display: `${value} RPM` };
          break;
        case 'coolantTemp':
          formatted[key] = { value, unit: '°C', display: `${value}°C` };
          break;
        case 'speed':
          formatted[key] = { value, unit: 'km/h', display: `${value} km/h` };
          break;
        case 'fuelPressure':
          formatted[key] = { value, unit: 'kPa', display: `${value} kPa` };
          break;
        case 'throttlePosition':
          formatted[key] = { value, unit: '%', display: `${value}%` };
          break;
        default:
          formatted[key] = { value, unit: '', display: `${value}` };
      }
    });

    return formatted;
  }

  getDataBuffer(sessionId) {
    return this.dataBuffer.get(sessionId) || [];
  }

  clearDataBuffer(sessionId) {
    this.dataBuffer.delete(sessionId);
  }

  analyzeDataTrends(sessionId) {
    const buffer = this.getDataBuffer(sessionId);
    if (buffer.length < 10) {
      return null; // 분석할 데이터가 충분하지 않음
    }

    const analysis = {
      rpm: this.analyzeTrend(buffer.map(d => d.data.rpm)),
      coolantTemp: this.analyzeTrend(buffer.map(d => d.data.coolantTemp)),
      speed: this.analyzeTrend(buffer.map(d => d.data.speed))
    };

    return analysis;
  }

  analyzeTrend(values) {
    if (values.length < 2) return null;

    const validValues = values.filter(v => v !== null && v !== undefined);
    if (validValues.length < 2) return null;

    const avg = validValues.reduce((sum, val) => sum + val, 0) / validValues.length;
    const max = Math.max(...validValues);
    const min = Math.min(...validValues);

    // 간단한 트렌드 계산 (첫 번째 vs 마지막)
    const trend = validValues[validValues.length - 1] > validValues[0] ? 'increasing' :
                  validValues[validValues.length - 1] < validValues[0] ? 'decreasing' : 'stable';

    return { avg, max, min, trend, samples: validValues.length };
  }

  disconnect() {
    if (this.eventSource) {
      this.eventSource.close();
      this.eventSource = null;
      this.isStreaming = false;
      console.log('진단 SSE 연결 닫힘');
    }
  }
}

실시간 데이터 스트리밍 테스트

test-streaming.js를 생성하세요:

javascript
import { DiagnosisSSEClient } from './diagnosisSSE.js';
import DiagnosisCommand from './diagnosisCommand.js';
import DiagnosisSession from './diagnosisSession.js';
import { API_CONFIG } from './config.js';

const sseClient = new DiagnosisSSEClient(
  `${API_CONFIG.baseURL}/vehicles/${API_CONFIG.sampleVIN}/manual/live-data`,
  API_CONFIG.authToken
);

const commandManager = new DiagnosisCommand();
const sessionManager = new DiagnosisSession();

async function testRealTimeStreaming() {
  const vin = API_CONFIG.sampleVIN;

  try {
    console.log('📡 실시간 데이터 스트리밍 테스트 중...');

    // SSE 연결
    await sseClient.connect();

    // 이벤트 리스너 설정
    sseClient.on('LIVE_DATA', (data) => {
      console.log('📊 라이브 데이터:', data.data);
    });

    sseClient.on('DTC_RESULT', (data) => {
      console.log('🔧 DTC 결과:', data);
    });

    sseClient.on('COMMAND_STATUS', (data) => {
      console.log('⚙️ 명령 상태:', data);
    });

    // 세션 시작 및 명령
    const sessionRequest = await sessionManager.requestSession(vin, {
      reason: '실시간 스트리밍 테스트',
      technicianId: API_CONFIG.sampleTechnicianId
    });

    const sessionId = sessionRequest.sessionId;

    // 라이브 데이터 스트림 시작
    await commandManager.startLiveDataStream(vin, sessionId, [
      'RPM', 'COOLANT_TEMP', 'SPEED', 'FUEL_PRESSURE'
    ]);

    // DTC 코드 읽기
    await commandManager.readDTC(vin, sessionId);

    console.log('👂 실시간 데이터 수신 중... 중지하려면 Ctrl+C를 누르세요');

    // 주기적으로 트렌드 분석
    setInterval(() => {
      const trends = sseClient.analyzeDataTrends(sessionId);
      if (trends) {
        console.log('📈 데이터 트렌드:', trends);
      }
    }, 10000); // 10초마다

    // 정상 종료 처리
    process.on('SIGINT', async () => {
      console.log('\n🔄 종료 중...');
      await sessionManager.stopSession(vin, sessionId, {
        reason: 'TEST_COMPLETED'
      });
      sseClient.disconnect();
      process.exit(0);
    });

  } catch (error) {
    console.error('❌ 실시간 스트리밍 테스트 실패:', error.message);
  }
}

testRealTimeStreaming();

단계 5: 보고서 생성

진단 보고 구현

diagnosisReport.js를 생성하세요:

javascript
import axios from 'axios';
import { API_CONFIG } from './config.js';

class DiagnosisReport {
  constructor() {
    this.client = axios.create({
      baseURL: API_CONFIG.baseURL,
      headers: {
        'Authorization': `Bearer ${API_CONFIG.authToken}`,
        'Content-Type': 'application/json'
      },
      timeout: API_CONFIG.timeout
    });
  }

  async generateReport(vin, sessionId) {
    try {
      const response = await this.client.get(
        `/vehicles/${vin}/manual/session/${sessionId}/report`
      );

      console.log('진단 보고서 생성됨:', response.data);
      return response.data;
    } catch (error) {
      console.error('진단 보고서 생성 실패:', error.response?.data || error.message);
      throw error;
    }
  }

  async getDiagnosisHistory(vin, options = {}) {
    try {
      const params = {
        limit: options.limit || 50,
        offset: options.offset || 0,
        startDate: options.startDate,
        endDate: options.endDate
      };

      const response = await this.client.get(
        `/vehicles/${vin}/manual/history`,
        { params }
      );

      console.log('진단 기록:', response.data);
      return response.data;
    } catch (error) {
      console.error('진단 기록 가져오기 실패:', error.response?.data || error.message);
      throw error;
    }
  }

  formatReport(reportData) {
    const { summary, dtc, liveData, recommendations } = reportData;

    return {
      reportId: reportData.reportId,
      timestamp: reportData.timestamp,
      overallStatus: summary.overallStatus,
      summary: {
        status: summary.overallStatus,
        dtcCount: summary.dtcCount,
        criticalIssues: summary.criticalIssues,
        hasRecommendations: summary.recommendations && summary.recommendations.length > 0
      },
      issues: dtc.map(code => ({
        code: code.code,
        description: code.description,
        severity: code.severity,
        occurrences: code.occurrenceCount
      })),
      performance: {
        avgRpm: liveData.averageRpm,
        maxTemp: liveData.maxCoolantTemp,
        dataPoints: liveData.dataPoints
      },
      recommendations: recommendations.map(rec => ({
        priority: rec.priority,
        action: rec.action,
        description: rec.description
      }))
    };
  }

  assessReportSeverity(reportData) {
    const { summary, dtc } = reportData;

    let severity = 'NORMAL';
    let priority = 'LOW';

    if (summary.criticalIssues > 0) {
      severity = 'CRITICAL';
      priority = 'HIGH';
    } else if (dtc.some(code => code.severity === 'HIGH')) {
      severity = 'WARNING';
      priority = 'MEDIUM';
    } else if (summary.dtcCount > 0) {
      severity = 'INFO';
      priority = 'LOW';
    }

    return { severity, priority };
  }

  generateActionItems(reportData) {
    const { dtc, recommendations } = reportData;
    const actionItems = [];

    // DTC 기반 행동 추가
    dtc.forEach(code => {
      if (code.severity === 'HIGH' || code.severity === 'CRITICAL') {
        actionItems.push({
          type: 'IMMEDIATE',
          description: `중요 DTC 해결: ${code.code}`,
          details: code.description
        });
      }
    });

    // 추천 기반 행동 추가
    recommendations.forEach(rec => {
      actionItems.push({
        type: rec.priority,
        description: rec.action,
        details: rec.description
      });
    });

    // 우선순위로 정렬
    const priorityOrder = { 'HIGH': 0, 'MEDIUM': 1, 'LOW': 2, 'IMMEDIATE': -1 };
    actionItems.sort((a, b) => priorityOrder[a.type] - priorityOrder[b.type]);

    return actionItems;
  }

  exportReport(reportData, format = 'json') {
    switch (format.toLowerCase()) {
      case 'json':
        return JSON.stringify(reportData, null, 2);

      case 'text':
        return this.generateTextReport(reportData);

      case 'csv':
        return this.generateCSVReport(reportData);

      default:
        throw new Error(`지원되지 않는 내보내기 형식: ${format}`);
    }
  }

  generateTextReport(reportData) {
    const { summary, dtc, liveData, recommendations } = reportData;

    let report = `진단 보고서\n`;
    report += `================\n\n`;
    report += `보고서 ID: ${reportData.reportId}\n`;
    report += `타임스탬프: ${reportData.timestamp}\n`;
    report += `차량: ${reportData.vin}\n\n`;

    report += `요약\n`;
    report += `-------\n`;
    report += `전체 상태: ${summary.overallStatus}\n`;
    report += `DTC 개수: ${summary.dtcCount}\n`;
    report += `중요 문제: ${summary.criticalIssues}\n\n`;

    if (dtc.length > 0) {
      report += `진단 트러블 코드\n`;
      report += `-----------------------\n`;
      dtc.forEach((code, index) => {
        report += `${index + 1}. ${code.code} (${code.severity})\n`;
        report += `   ${code.description}\n`;
        report += `   발생 횟수: ${code.occurrenceCount}\n\n`;
      });
    }

    if (recommendations.length > 0) {
      report += `추천 사항\n`;
      report += `---------------\n`;
      recommendations.forEach((rec, index) => {
        report += `${index + 1}. [${rec.priority}] ${rec.action}\n`;
        report += `   ${rec.description}\n\n`;
      });
    }

    return report;
  }

  generateCSVReport(reportData) {
    const { dtc } = reportData;

    let csv = '코드,설명,심각도,발생 횟수\n';
    dtc.forEach(code => {
      csv += `"${code.code}","${code.description}","${code.severity}",${code.occurrenceCount}\n`;
    });

    return csv;
  }
}

export default DiagnosisReport;

보고서 생성 테스트

test-report.js를 생성하세요:

javascript
import DiagnosisReport from './diagnosisReport.js';
import DiagnosisSession from './diagnosisSession.js';
import { API_CONFIG } from './config.js';

const reportManager = new DiagnosisReport();
const sessionManager = new DiagnosisSession();

async function testReportGeneration() {
  const vin = API_CONFIG.sampleVIN;

  try {
    console.log('📄 보고서 생성 테스트 중...');

    // 진단 기록 가져오기
    const history = await reportManager.getDiagnosisHistory(vin, { limit: 5 });
    console.log(`${history.sessions.length}개의 과거 진단 세션 발견`);

    // 과거 세션이 있으면 가장 최근 세션에 대한 보고서 생성
    if (history.sessions && history.sessions.length > 0) {
      const latestSession = history.sessions[0];

      console.log('세션에 대한 보고서 생성 중:', latestSession.sessionId);
      const report = await reportManager.generateReport(vin, latestSession.sessionId);

      // 보고서 형식화 및 평가
      const formattedReport = reportManager.formatReport(report);
      const severity = reportManager.assessReportSeverity(report);
      const actionItems = reportManager.generateActionItems(report);

      console.log('📊 형식이 지정된 보고서:', formattedReport);
      console.log('🚨 심각도 평가:', severity);
      console.log('📋 행동 항목:', actionItems);

      // 다른 형식으로 내보내기
      console.log('\n📄 텍스트 보고서:');
      console.log(reportManager.exportReport(report, 'text'));

      console.log('\n📊 CSV 보고서:');
      console.log(reportManager.exportReport(report, 'csv'));
    } else {
      console.log('과거 진단 세션이 발견되지 않음');
    }

  } catch (error) {
    console.error('❌ 보고서 생성 테스트 실패:', error.message);
  }
}

testReportGeneration();

단계 6: 완전한 진단 애플리케이션

완전한 진단 시스템 생성

diagnosisApp.js를 생성하세요:

javascript
import DiagnosisSession from './diagnosisSession.js';
import ConsentManager from './consentManager.js';
import DiagnosisCommand from './diagnosisCommand.js';
import { DiagnosisSSEClient } from './diagnosisSSE.js';
import DiagnosisReport from './diagnosisReport.js';
import { API_CONFIG } from './config.js';

class DiagnosisApplication {
  constructor() {
    this.sessionManager = new DiagnosisSession();
    this.consentManager = new ConsentManager();
    this.commandManager = new DiagnosisCommand();
    this.reportManager = new DiagnosisReport();
    this.sseClient = null;
    this.currentSession = null;
    this.isStreaming = false;
  }

  async initialize() {
    try {
      console.log('🔧 진단 애플리케이션 초기화 중...');

      // 실시간 이벤트 연결
      await this.connectRealTimeEvents();

      console.log('✅ 진단 애플리케이션이 성공적으로 초기화됨');
    } catch (error) {
      console.error('❌ 진단 애플리케이션 초기화 실패:', error.message);
    }
  }

  async connectRealTimeEvents() {
    console.log('📡 실시간 진단 이벤트 연결 중...');

    const sseUrl = `${API_CONFIG.baseURL}/vehicles/${API_CONFIG.sampleVIN}/manual/live-data`;
    this.sseClient = new DiagnosisSSEClient(sseUrl, API_CONFIG.authToken);

    await this.sseClient.connect();

    this.sseClient.on('LIVE_DATA', (data) => {
      this.handleLiveData(data);
    });

    this.sseClient.on('DTC_RESULT', (data) => {
      this.handleDTCResult(data);
    });

    this.sseClient.on('COMMAND_STATUS', (data) => {
      this.handleCommandStatus(data);
    });
  }

  async startDiagnosisSession(options = {}) {
    try {
      console.log('🚀 진단 세션 시작 중...');

      // 세션 요청
      const sessionRequest = await this.sessionManager.requestSession(
        API_CONFIG.sampleVIN,
        {
          reason: options.reason || '종합 진단',
          technicianId: options.technicianId || API_CONFIG.sampleTechnicianId
        }
      );

      this.currentSession = sessionRequest;
      console.log('✅ 세션 요청됨:', sessionRequest.sessionId);

      // 필요한 경우 동의 처리
      if (options.autoConsent) {
        await this.handleConsentFlow(sessionRequest);
      }

      return sessionRequest;

    } catch (error) {
      console.error('❌ 진단 세션 시작 실패:', error.message);
      throw error;
    }
  }

  async handleConsentFlow(sessionData) {
    try {
      console.log('🔐 동의 플로 처리 중...');

      // 동의 메시지 생성
      const consentMessage = this.consentManager.generateConsentMessage(sessionData);
      console.log('📱 동의 메시지:', consentMessage);

      // 데모용으로 자동 동의 부여
      const consentDecision = {
        allow: true,
        requestId: sessionData.requestId || 'AUTO_CONSENT',
        purpose: sessionData.reason
      };

      const consentResult = await this.consentManager.processConsentFlow(
        API_CONFIG.sampleVIN,
        sessionData.sessionId,
        consentDecision
      );

      console.log('✅ 동의 부여됨:', consentResult);

    } catch (error) {
      console.error('❌ 동의 플로 실패:', error.message);
    }
  }

  async runComprehensiveDiagnosis() {
    try {
      if (!this.currentSession) {
        throw new Error('활성 세션이 없습니다. 먼저 세션을 시작하세요.');
      }

      console.log('🔍 종합 진단 실행 중...');

      const sessionId = this.currentSession.sessionId;

      // 라이브 데이터 스트리밍 시작
      console.log('📊 라이브 데이터 스트림 시작 중...');
      await this.commandManager.startLiveDataStream(API_CONFIG.sampleVIN, sessionId, [
        'RPM', 'COOLANT_TEMP', 'SPEED', 'FUEL_PRESSURE', 'THROTTLE_POS'
      ]);

      this.isStreaming = true;

      // DTC 코드 읽기
      console.log('🔧 DTC 코드 읽는 중...');
      await this.commandManager.readDTC(API_CONFIG.sampleVIN, sessionId);

      // 데이터 수집 대기
      console.log('⏳ 15초 동안 데이터 수집 중...');
      await this.sleep(15000);

      // 라이브 스트리밍 중지
      console.log('⏹️ 데이터 수집 중지 중...');
      this.isStreaming = false;

      // 보고서 생성
      console.log('📄 진단 보고서 생성 중...');
      const report = await this.reportManager.generateReport(API_CONFIG.sampleVIN, sessionId);

      // 결과 처리 및 표시
      await this.processDiagnosisResults(report);

      return report;

    } catch (error) {
      console.error('❌ 종합 진단 실패:', error.message);
      throw error;
    }
  }

  async processDiagnosisResults(report) {
    console.log('\n📊 진단 결과');
    console.log('===================');

    const formattedReport = this.reportManager.formatReport(report);
    const severity = this.reportManager.assessReportSeverity(report);
    const actionItems = this.reportManager.generateActionItems(report);

    console.log(`🚨 전체 상태: ${formattedReport.summary.status} (${severity.severity})`);
    console.log(`🔧 발견된 DTC 코드: ${formattedReport.summary.dtcCount}`);
    console.log(`⚠️ 중요 문제: ${formattedReport.summary.criticalIssues}`);

    if (formattedReport.issues.length > 0) {
      console.log('\n🔧 발견된 문제:');
      formattedReport.issues.forEach((issue, index) => {
        console.log(`  ${index + 1}. ${issue.code} (${issue.severity})`);
        console.log(`     ${issue.description}`);
      });
    }

    if (actionItems.length > 0) {
      console.log('\n📋 추천 행동:');
      actionItems.forEach((action, index) => {
        console.log(`  ${index + 1}. [${action.type}] ${action.description}`);
        if (action.details) {
          console.log(`     ${action.details}`);
        }
      });
    }

    console.log('\n📈 성능 데이터:');
    console.log(`  평균 RPM: ${formattedReport.performance.avgRpm}`);
    console.log(`  최대 온도: ${formattedReport.performance.maxTemp}°C`);
    console.log(`  수집된 데이터 포인트: ${formattedReport.performance.dataPoints}`);

    console.log('===================\n');
  }

  handleLiveData(data) {
    if (this.isStreaming) {
      const { rpm, coolantTemp, speed } = data.data;
      console.log(`📊 라이브: ${rpm} RPM | ${coolantTemp}°C | ${speed} km/h`);
    }
  }

  handleDTCResult(data) {
    console.log('🔧 DTC 분석 완료');
    if (data.dtcCodes && data.dtcCodes.length > 0) {
      data.dtcCodes.forEach(code => {
        console.log(`  ${code.code}: ${code.description} (${code.severity})`);
      });
    } else {
      console.log('  ✅ DTC 코드가 감지되지 않음');
    }
  }

  handleCommandStatus(data) {
    const { command, status } = data;
    const statusIcon = status === 'COMPLETED' ? '✅' :
                       status === 'FAILED' ? '❌' :
                       status === 'EXECUTING' ? '⏳' : '❓';

    console.log(`${statusIcon} ${command}: ${status}`);
  }

  async stopDiagnosisSession() {
    try {
      if (this.currentSession) {
        console.log('🛑 진단 세션 중지 중...');

        await this.sessionManager.stopSession(
          API_CONFIG.sampleVIN,
          this.currentSession.sessionId,
          { reason: 'DIAGNOSIS_COMPLETED' }
        );

        this.currentSession = null;
        this.isStreaming = false;

        console.log('✅ 세션이 성공적으로 중지됨');
      }
    } catch (error) {
      console.error('❌ 진단 세션 중지 실패:', error.message);
    }
  }

  async getDiagnosisHistory() {
    try {
      console.log('📜 진단 기록 가져오는 중...');

      const history = await this.reportManager.getDiagnosisHistory(API_CONFIG.sampleVIN, {
        limit: 10
      });

      console.log(`${history.sessions.length}개의 과거 세션 발견`);

      history.sessions.forEach((session, index) => {
        console.log(`  ${index + 1}. ${session.sessionId} - ${session.timestamp} (${session.status})`);
      });

      return history;

    } catch (error) {
      console.error('❌ 진단 기록 가져오기 실패:', error.message);
    }
  }

  sleep(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
  }

  async shutdown() {
    console.log('🔄 진단 애플리케이션 종료 중...');

    // 활성 세션이 있으면 중지
    await this.stopDiagnosisSession();

    // SSE 연결 해제
    if (this.sseClient) {
      this.sseClient.disconnect();
    }

    console.log('✅ 진단 애플리케이션 종료 완료');
  }
}

// 사용 예제
async function main() {
  const app = new DiagnosisApplication();

  try {
    await app.initialize();

    // 종합 진단 세션 시작
    await app.startDiagnosisSession({
      reason: '데모 종합 진단',
      autoConsent: true
    });

    // 진단 실행
    const report = await app.runComprehensiveDiagnosis();

    // 기록 가져오기
    await app.getDiagnosisHistory();

    // 정리
    setTimeout(async () => {
      await app.shutdown();
      process.exit(0);
    }, 2000);

  } catch (error) {
    console.error('애플리케이션 오류:', error.message);
    process.exit(1);
  }
}

// 정상 종료 처리
process.on('SIGINT', async () => {
  console.log('\n🔄 SIGINT 수신, 정상 종료 중...');
  if (app) {
    await app.shutdown();
  }
  process.exit(0);
});

main();

구현 테스트

완전한 진단 애플리케이션을 실행하세요:

bash
node diagnosisApp.js

예상 출력:

🔧 진단 애플리케이션 초기화 중...
📡 실시간 진단 이벤트 연결 중...
📡 Diagnosis SSE connection opened
✅ 진단 애플리케이션이 성공적으로 초기화됨
🚀 진단 세션 시작 중...
✅ 세션 요청됨: sess_001
🔐 동의 플로 처리 중...
📱 동의 메시지: { title: 'Remote Diagnosis Request', ... }
✅ 동의 부여됨: { consented: true, sessionId: 'sess_001', ... }
🔍 종합 진단 실행 중...
📊 라이브 데이터 스트림 시작 중...
🔧 DTC 코드 읽는 중...
⏳ 15초 동안 데이터 수집 중...
📊 라이브: 2500 RPM | 85.5°C | 0 km/h
📊 라이브: 1800 RPM | 87.2°C | 0 km/h
🔧 DTC 분석 완료
  P0520: 엔진 오일 압력 센서/스위치 회로 (MEDIUM)
⏹️ 데이터 수집 중지 중...
📄 진단 보고서 생성 중...

📊 진단 결과
===================
🚨 전체 상태: WARNING (WARNING)
🔧 발견된 DTC 코드: 1
⚠️ 중요 문제: 0

🔧 발견된 문제:
  1. P0520 (MEDIUM)
     엔진 오일 압력 센서/스위치 회로

📋 추천 행동:
  1. [MEDIUM] INSPECT_OIL_PRESSURE_SENSOR
     오일 압력 센서 및 배선 확인

📈 성능 데이터:
  평균 RPM: 2150
  최대 온도: 92.5°C
  수집된 데이터 포인트: 150
===================

📜 진단 기록 가져오는 중...
3개의 과거 세션 발견
  1. sess_001 - 2026-01-13T14:30:00Z (COMPLETED)
  2. sess_002 - 2026-01-10T09:15:00Z (COMPLETED)
  3. sess_003 - 2026-01-05T16:45:00Z (COMPLETED)

🔄 진단 애플리케이션 종료 중...
🛑 진단 세션 중지 중...
✅ 세션이 성공적으로 중지됨
📡 Diagnosis SSE connection closed
✅ 진단 애플리케이션 종료 완료

챌린지 연습

  1. 고급 진단: UDS (Unified Diagnostic Services)와 같은 고급 진단 프로토콜 지원 구현.

  2. 예측 유지보수: 과거 데이터를 기반으로 잠재적 고장을 예측하는 머신러닝 모델 추가.

  3. 다중 ECU 지원: 여러 ECU를 동시에 처리하는 병렬 진단으로 시스템 확장.

  4. 모바일 기술자 인터페이스: 기술자가 현장에서 진단을 수행할 수 있는 모바일 친화적 인터페이스 생성.

  5. 자동화된 테스트 시퀀스: 일반적인 진단 시나리오에 대한 사전 정의된 테스트 시퀀스 구현.

요약

이 코드랩에서 다음을 배웠습니다:

  • ✅ 원격 진단 세션 관리 구현
  • ✅ 사용자 동의 및 보안 요구 사항 처리
  • ✅ 진단 명령 실행 및 결과 처리
  • ✅ SSE를 통한 실시간 진단 데이터 스트리밍
  • ✅ 종합 진단 보고서 생성
  • ✅ 완전한 원격 진단 시스템 구축

수동 진단 API는 보안과 사용자 동의를 유지하면서 강력한 원격 차량 진단 기능을 제공합니다. 여기서 배운 구현 패턴은 다양한 원격 진단 및 모니터링 시나리오에 적용할 수 있습니다.

추가 리소스

Released under the MIT License.