[Node.js] Serialport 구현하기

[Node.js] Serialport 구현하기

Node.js에서 Serial 통신 구현하기 ⚙️✨


1. Serial 통신이란? 🤔

Serial 통신은 데이터를 비트(bit) 단위로 순차적으로 전송하는 통신 방식이다. 일반적으로 하드웨어 장치(예: 센서, 모뎀, GPS 모듈 등)와 소프트웨어 간 데이터를 주고받을 때 사용된다.

Node.js에서는 serialport 라이브러리를 사용하여 Serial 통신을 구현할 수 있다. 이 라이브러리는 직렬 포트를 제어하고 데이터를 송수신하기 위한 강력한 도구를 제공한다.


2. serialport 설치하기 🛠️

먼저 serialport 라이브러리를 설치해야 한다.


npm install serialport

3. Serial 통신 기본 구성 🚀

1) Serial 포트 열기와 닫기

Serial 포트를 열고 닫는 것은 장치와의 연결을 설정하고 해제하는 기본 작업이다.

아래 코드는 Serial 포트를 여는 openPort와 닫는 closePort 함수를 구현한 예제이다.


import { SerialPort } from 'serialport';

const port = new SerialPort({
    path: 'COM3', // 포트 경로
    baudRate: 115200, // 통신 속도
    autoOpen: false, // 자동으로 포트를 열지 않음
});

// Serial 포트 열기
export const openPort = async () => {
    return new Promise((resolve, reject) => {
        port.open((err) => {
            if (err) {
                console.error(`Failed to open serial port: ${err.message}`);
                reject(err);
            } else {
                console.log('Serial port is open');
                resolve();
            }
        });
    });
};

// Serial 포트 닫기
export const closePort = async () => {
    return new Promise((resolve, reject) => {
        port.close((err) => {
            if (err) {
                console.error(`Failed to close serial port: ${err.message}`);
                reject(err);
            } else {
                console.log('Serial port is closed');
                resolve();
            }
        });
    });
};

2) 데이터 송수신 구현

시리얼 데이터를 송신(write)하고 수신(read)하는 작업은 Serial 통신의 핵심이다.

데이터 수신:
데이터를 수신하려면 이벤트 리스너를 통해 처리할 수 있다.


port.on('data', (data) => {
    console.log(`Received data: ${data}`);
});

데이터 송신:


export const writePort = async (command) => {
    return new Promise((resolve, reject) => {
        port.write(command, (err) => {
            if (err) {
                console.error(`Error writing to serial port: ${err.message}`);
                reject(err);
            } else {
                console.log(`Data written: ${command}`);
                resolve();
            }
        });
    });
};

4. 실무 예제: SMS 서비스 구현하기 📡

실무에서 개발하여 사용중인 코드를 활용해 Serial 통신을 통한 SMS 서비스를 구현하는 과정을 살펴보자.


1) Serial 통신 모듈 (serial_module.js) ⚙️

이 모듈은 Serial 포트를 열고 닫으며, 데이터를 송수신하고 리스너를 관리하는 역할을 한다.

주요 기능:

  • Serial 포트 열기와 닫기 (openPortclosePort)
  • RX 데이터 변환(parser)
  • 데이터 송수신 (writePort)
  • 누적 데이터를 가져오기 및 초기화 (getCollectedDataAndClear)
  • 데이터 및 에러 리스너 관리 (registerListenersremoveAllListeners)

1-1) Serial Open, Close, Listener 구현

import { DelimiterParser, SerialPort } from 'serialport';
import { logger } from '#util/logger.js';
import { config } from '#config/config.js';
import { sleep } from '#util/wb_lib.js';

const port = new SerialPort({
    path: config.serial.port,
    baudRate: config.serial.baudRate,
    autoOpen: false,
});

// 연속적인 바이트 흐름인 시리얼 데이터를 캐리지리턴 기준으로 한 줄로 처리하여 보기 편하게 변환
const parser = port.pipe(new DelimiterParser({ delimiter: Buffer.from('\r\n') }));

// 데이터를 수집하기 위한 변수
let collectedData = '';

// serial port open
export const openPort = async () => {
    await sleep(500);

    if (port.isOpen) {
        logger.info(`[openPort] Serial port is already OPEN`);
        return;
    }

    return new Promise((resolve, reject) => {
        port.open((err) => {
            if (err) {
                logger.error(`[openPort] Failed to open serial port: ${err.message}`);
                reject(new Error(`Serial Port Open Error: ${err.message}`));
            } else {
                logger.info(`[openPort] Serial port is OPEN`);
                registerListeners(); // 포트를 열 때 리스너 등록
                resolve();
            }
        });
    });
};

// serial port close
export const closePort = async () => {
    await sleep(500);

    if (!port.isOpen) {
        logger.info(`[closePort] Serial port is already CLOSED`);
        return;
    }

    return new Promise((resolve, reject) => {
        port.close((err) => {
            if (err) {
                logger.error(`[closePort] Failed to close serial port: ${err.message}`);
                reject(new Error(`Serial Port Close Error: ${err.message}`));
            } else {
                logger.info(`[closePort] Serial Port is CLOSED`);
                removeAllListeners(); // 포트를 닫을 때만 리스너 해제
                resolve();
            }
        });
    });
};

// 리스너를 등록
const registerListeners = () => {
    if (!parser.listenerCount('data')) {
        parser.on('data', onData);
    }
    if (!parser.listenerCount('error')) {
        parser.on('error', onError);
    }
};

// 리스너 해제
const removeAllListeners = () => {
    logger.info(`[removeAllListeners] Removing all parser listeners`);
    parser.removeAllListeners('data');
    parser.removeAllListeners('error');
};

코드 설명:

  • autoOpen: 시리얼 통신은 소스에서 직접 열기, 닫기를 제어 할 것이므로 autoOpen은 false로 설정한다. (default값: true)
  • 원할한 동작을 위하여 sleep 함수를 별도로 구현하여 500ms정도 딜레이를 준다.(거의 필수..)
  • port.isOpen: 서비스단에서 시리얼 통신 초기화를 진행해야하는데, 이미 열려있거나 닫혀있는 상태에서 open, close를 하면 애러가 발생하므로 예외처리를 추가한다.
  • registerListeners(): Port를 Open할때 리스너도 생성을 해서 Data를 받을 수 있도록 준비한다.
  • removeAllListeners(): Port를 Close할때 리스너를 해제하여 누적되어있던 Data를 초기화 한다.
  • parser: 시리얼 데이터는 string 형식으로 쭉 데이터가 통신하는 방식이 아니라 bit단위로 순차적으로 전송하는 통신방식이므로 로우데이터를 받으면 사용자는 데이터를 확인하는데에 어려움이 있다. 이를 쉽게 보기위하여 parser 기능을 사용한다.

1-2) Serial Write, Receive 구현

// serial data write
export const writePort = async (command) => {
    await sleep(500);
    return new Promise((resolve, reject) => {
        port.write(command, (err) => {
            sleep(500);
            if (err) {
                logger.error(`[writePort] Error writing to serial port: ${err.message}`);
                reject(new Error(`Serial Port Write Error: ${err.message}`));
            } else {
                logger.info(`[writePort] Success Serial to write data: ${command}`);
                resolve();
            }
        });
    });
};

// 데이터를 수신하면서 누적하는 리스너
const onData = (data) => {
    const response = data.toString();
    collectedData += response + '\n'; // 데이터를 누적
    logger.info(`RX: ${response}`);
};

// 에러 리스너
const onError = (err) => {
    logger.error(`[parserError] Serial port error occurred: ${err.message}`);

    return Promise.reject(new Error(`Serial Port Response Error: ${err.message}`));
};

// 누적된 데이터를 호출하는 함수 (호출 후 초기화)
export const getCollectedDataAndClear = async () => {
    await sleep(1000); // 대기 시간을 두어 수신이 완료될 시간을 줌
    const data = collectedData;
    collectedData = ''; // 데이터 초기화
    logger.info(`[getCollectedDataAndClear] Collected data: \n${data}`);
    return data;
};


코드 설명:

  • onData(data): 시리얼 데이터는 한번에 리시브 받을수도있고, 여러번 거쳐서 리시블르 받을수도 있다. 그래서 리시브를 받을 때 마다 변수에 차곡차곡 저장을 해놓고 각 리시브 마다 캐리지리턴을 한다.
  • onError(err): 리시브를 받을 때 에러처리를 구현한다. 에러처리는 모든 소스에서 제일 중요하다
  • getCollectedDataAndClear(): 서비스단으로 Receive된 데이터를 리턴한다. 이후 새로운 데이터를 다시 쌓을 수 있도록 초기화를 한다.

2) SMS 서비스 (sms_svc.js) 📤

이 서비스는 다음과 같은 순서로 SMS를 처리한다:

  1. Serial 포트 초기화
  2. SMS 메시지 전송 (sendSms)
  3. Serial 포트 Close

import iconv from 'iconv-lite';
import { logger } from '#util/logger.js';
import * as serial from '#module/serial_module.js';
import * as schema from '#schema/sms_schema.js';
import { sleep } from '#util/wb_lib.js';


export const smsService = async (smsData, onErrorCallback) => {
    let isSuccess = false;

    try {
        const smsInfo = {
            title: smsData.title,
            ment: smsData.ment,
            phoneNum: smsData.phoneNum,
        };

        // smsInfo validation
        await schema.SmsInput.validateAsync(smsInfo);

        // 시리얼 포트 초기화
        await serial.closePort();
        await sleep(500);
        await serial.openPort();
        await sleep(500);

        // sms 전송
        await sendSms(smsInfo, onErrorCallback);
        await sleep(500);
        // port close
        await serial.closePort();

        isSuccess = true;
    } catch (error) {
        logger.error(`[smsService] Error`);
        if (onErrorCallback) {
            onErrorCallback(error);
        }
        throw error;
    } finally {
        if (!isSuccess) {
            await serial.closePort();
        }
    }
};


코드 설명:

  • 라우터에서 Serial 데이터를 받고, 서비스를 처리한다.
  • 시리얼 포트는 항상 초기화를 진행해야 하며 에러가 발생해도 무조건 포트는 close를 할 수 있도록 설계를 해야한다.


const sendSms = async (smsInfo, onErrorCallback) => {
    let isSuccess = false;
    try {
        // AT 커멘드 전송
        const command = `AT+CMGS="${smsInfo.phoneNum}"\r\n`;
        await serial.writePort(Buffer.from(command, 'ascii'));
        // AT 커맨드 전송 후 RX Phone Number
        let response = await serial.getCollectedDataAndClear();
        logger.info(`[sendSms] user phone num: ${response}`);

        // sms ment 전송
        const smsMent = `${smsInfo.ment}\x1A`;
        const encodedSmsMent = iconv.encode(smsMent, 'euc-kr'); // SMS ment를 EUC-KR로 인코딩
        await serial.writePort(encodedSmsMent);
        logger.info(`[sendSms] sms ment is: ${smsInfo.ment}`);
        // sms ment 전송 후 RX sms ment
        response = await serial.getCollectedDataAndClear();
        if (response.includes('OK')) {
            logger.info(`[sendSms] sms ment send Success!`);
        }

        isSuccess = true;
    } catch (error) {
        logger.error(`[sendSms] Error`);
        if (onErrorCallback) {
            onErrorCallback(error);
        }
        throw error;
    } finally {
        if (!isSuccess) {
            await serial.closePort();
        }
    }
};


코드 설명:

  • await serial.writePort(Buffer.from(command, 'ascii'));: 시리얼 장비마다 다르겠지만, 이 장비는 데이터를 ascii로 전송해야 되어서 string을 ascii로 변환하였다.
  • encodedSmsMent = iconv.encode(smsMent, 'euc-kr');: 시리얼 장비에서 Receive 받은 데이터는 euc-kr로 인코딩 되어있다. 모듈에서는 parser에서 별다른 인코딩 설정을 하지 않아서 디폴트값인 UTF-8로 되어있다. 그래서 한글 데이터가 들어오면 글자가 깨지는 경우가 발생하여 각 장비에 맞게 인코딩을 일일히 해줘야 한다.
  • response = await serial.getCollectedDataAndClear();: 서비스단에서 시리얼 모듈의 getCollectedDataAndClear() 함수를 호출하여 전송받은 데이터를 확인하고 이에 맞는 비지니스 로직을 수행한다.

5. 정리 🏁

Node.js에서 Serial 통신은 serialport 라이브러리를 통해 구현할 수 있다. 이번 포스팅에서는 실무 코드 예제를 바탕으로 Serial 포트 초기화, 데이터 송수신, 리스너 관리 등의 핵심 기능을 다뤘다.

참고https://serialport.io/docs/

댓글