Web Serial API: 시리얼 포트로 읽고 쓰기(번역)

햄햄·2022년 7월 10일
4

원문: Read from and write to a serial port

Web Serial API는 웹사이트가 직렬 장치와 통신할 수 있게 해준다.

시리얼 포트는 데이터를 바이트 단위로 주고 받을 수 있게 해주는 양방향 통신 인터페이스이다. Web Serial API는 자바스크립트를 이용해 웹 사이트가 직렬 장치를 읽고 쓸수 있는 방법을 제공한다. 직렬 장치는 사용자 시스템이나 시리얼 포트를 에뮬레이트한 USB와 블루투스를 통해 연결된다.

즉, Web Serial API는 웹 사이트가 마이크로컨트롤러와 3D 프린터와 같은 직렬 장치와 통신할 수 있게 함으로써 실물 세상과 웹을 연결하는 것이다.

운영 체제 어플리케이션 또한 저레벨의 USB API보단 고레벨의 Serial API를 이용해 일부 시리얼 포트와 통신해야 하기 때문에 Web Serial API는 WebUSB의 좋은 동반자가 된다.

사용 사례

교육, 취미, 산업 분야에서 사용자는 컴퓨터와 주변 장치를 연결시킨다. 이 장치들은 종종 맞춤형 소프트웨어에서 사용하는 직렬 연결을 통해 마이크로 컨트롤러의 제어를 받는다. 이러한 장치들을 제어하는 몇몇 맞춤형 소프트웨어는 웹 기술로 제작되었다.

사용자가 수동으로 설치한 에이전트 어플리케이션을 통해 웹 사이트가 장치와 통신할 수도 있고, 어플리케이션이 Electron과 같은 프레임워크를 통해 패키지 어플리케이션으로 제공될 수도 있다. 혹은 사용자가 USB 플래시 드라이버를 통해 장치에 컴파일된 어플리케이션을 복사하는 것과 같은 추가 단계를 수행해야 할 수도 있다.

이 모든 케이스에서, 웹 사이트와 웹 사이트가 제어하는 장치 간의 직접적인 통신을 제공함으로써 사용자 경험이 향상될 수 있다.

Web Serial API 사용해보기

기능 감지

Web Serial API를 지원하는지 다음과 같이 확인할 수 있다.

if ("serial" in navigator) {
  // Web Serial API가 지원된다.
}

시리얼 포트 열기

Web Serial API는 기본적으로 비동기식이다. 입력을 기다리고 듣는 동안 웹 사이트 UI가 블로킹되는 것을 방지할 수 있는데, 직렬 데이터는 언제든 수신될 수 있기 때문에 중요한 부분이다.

먼저 SerialPort 객체에 접근하여 시리얼 포트를 연다. 이를 위해 사용자의 제스처(터치 혹은 클릭)에 응답하는 navigator.serial.requestPort()를 호출함으로써 사용자가 시리얼 포트를 선택할 수 있는 프롬프트를 열거나, 웹 사이트가 접근 권한을 가진 시리얼 포트 목록을 리턴하는 navigator.serial.getPorts()에서 하나를 고를 수 있다.

document.querySelector('button').addEventListener('click', async () => {
  // 사용자가 시리얼 포트를 선택할 수 있도록 프롬프트를 띄운다.
  const port = await navigator.serial.requestPort();
});
// 이전에 사용자가 웹 사이트에 접근 권한을 부여했던 모든 시리얼 포트를 가져온다.
const ports = await navigator.serial.getPorts();

navigator.serial.requestPort() 함수는 필터 객체를 인자로 받을 수 있다. 필터는 USB에 연결된 모든 직렬 장치 중 USB Vendor IdusbVendorId(필수)와 USB Product IdentifiersusbProductId(선택)와 일치되는 장치를 찾는 데 사용된다.

// Arduino Uno USB Vendor/Product IDs로 장치를 필터링한다.
const filters = [
  { usbVendorId: 0x2341, usbProductId: 0x0043 },
  { usbVendorId: 0x2341, usbProductId: 0x0001 }
];

// 사용자가 Arduino Uno device를 선택할 수 있게 프롬프트를 띄운다.
const port = await navigator.serial.requestPort({ filters });

const { usbProductId, usbVendorId } = port.getInfo();

장치 선택 프롬프트

requestPort()를 호출하면 사용자가 장치를 선택하는 프롬프트를 띄우고 SerialPort 객체를 반환한다. SerialPort 객체가 있으면 원하는 baud rate로 port.open()을 호출하여 시리얼 포트를 열 수 있다. baudRate는 직렬 회선으로 데이터가 전송되는 속도를 지정한다. bit-per-second(pbs)단위로 표현된다. 이 값이 잘못 지정되면 주고 받는 데이터가 잘못될 수 있으므로 장치 설명서로 정확한 값을 확인해야 한다. 시리얼 포트를 에뮬레이트하는 일부 USB와 블루투스는 에뮬레이션에 의해 값이 무시되므로 아무 값이나 안전하게 설정해도 된다.

// 사용자가 아무 시리얼 포트나 선택할 수 있게 프롬프트를 띄운다.
const port = await navigator.serial.requestPort();

// 시리얼 포트가 오픈되길 기다린다.
await port.open({ baudRate: 9600 });

시리얼 포트를 오픈할 때 아래의 옵션 중 하나를 지정할 수 있다. 이 옵션들은 선택 사항이고 default value를 가진다.

  • dataBits: 프레임 당 데이터 비트 수 (7 혹은 8)
  • stopBits: 프레임 끝의 정지 비트의 수 (1 혹은 2)
  • parity: The parity mode("none", "even" 혹은 "odd")
  • bufferSize: 생성되어야 하는 읽고 쓰는 버퍼의 사이즈 (16MB 이하)
  • flowControl: The flow control mode ("none" 혹은 "hardware")

시리얼 포트 읽기

Web Serail API의 입출력 스트림은 Streams API에 의해 처리된다.

만약 스트림을 처음 접한다면 Streams API 개념을 확인해보자. 이 아티클은 스트림과 스트림 처리에 대해 자세히 다루지 않는다.

시리얼 포트 연결이 설정된 후, SerialPort 객체의 readable, writable 프로퍼티가 ReadableStreamWritableStream을 리턴한다. 이들은 직렬 장치에 데이터를 송수신하는 데 사용된다. 둘다 데이터 전송을 위해 Uint8Array 인스턴스를 사용한다.

직렬 장치에서 새로운 데이터가 왔을 때, port.readable.getReader().read()는 비동기적으로 두 프로퍼티를 리턴한다. 바로 valuedone 불리언이다. done이 true이면 시리얼 포트는 클로즈되고 더 이상 데이터가 들어오지 않는다. port.readable.getReader()를 호출하면 reader를 생성하고 그것에 readable을 잠근다(lock). readable이 잠겨있는(locked)동안 시리얼 포트는 클로즈될 수 없다.

const reader = port.readable.getReader();

// 직렬 장치로부터 오는 데이터를 듣는다.
while (true) {
  const { value, done } = await reader.read();
  if (done) {
    // 나중에 시리얼 포트가 클로즈될 수 있도록 해준다.
    reader.releaseLock();
    break;
  }
  // value는 Uint8Array이다.
  console.log(value);
}

버퍼 오버플로, 프레이밍 에러, 패러티 에러 같은 일부 조건에서 치명적이진 않은, 시리얼 포트 읽기 에러가 발생할 수 있다. 이들은 exception으로 던져지고, 이전 루프 상단에 port.readeable을 체크하는 또다른 루프를 추가함으로서 캐치할 수 있다. 이는 에러가 치명적이지 않고 새로운 ReadableStream이 자동으로 생성되기 때문에 작동한다. 직렬 장치가 제거되는 것과 같은 치명적인 에러가 발생하면 port.readable은 null이 된다.

**while (port.readable) {**
  const reader = port.readable.getReader();

**try {**
    while (true) {
      const { value, done } = await reader.read();
      if (done) {
        // Allow the serial port to be closed later.
        reader.releaseLock();
        break;
      }
      if (value) {
        console.log(value);
      }
    }
**} catch (error) {**
    // TODO: Handle non-fatal read error.
  }
}

직렬 장치가 텍스트를 다시 보내면 아래와 같이 TextDecoderStream을 통해 port.readable을 파이프할 수 있다. TextDecoderStream은 모든 Uint8Array 청크를 가져와 문자열로 변환하는 변환 스트림이다.

**const textDecoder = new TextDecoderStream();**
**const readableStreamClosed = port.readable.pipeTo(textDecoder.writable);**
**const reader = textDecoder.readable.getReader();**

// 직렬 장치에서 오는 데이터를 듣는다.
while (true) {
  const { value, done } = await reader.read();
  if (done) {
    // 나중에 시리얼 포트가 닫힐 수 있도록 해준다.
    reader.releaseLock();
    break;
  }
  **// value는 문자열이다.**
  console.log(value);
}

시리얼 포트에 쓰기

직렬 장치에 데이터를 보내려면 port.writable.getWriter().write()에 데이터를 전달해야 한다. 나중에 시리얼 포트를 닫으려면 port.wrtiable.getWriter()에서 releaseLock()을 호출해야한다.

const writer = port.writable.getWriter();

const data = new Uint8Array([104, 101, 108, 108, 111]); // hello
await writer.write(data);


// 나중에 시리얼 포트가 닫힐 수 있도록 해준다.
writer.releaseLock();

아래와 같이 port.writable에 파이프된 TextEncoderStream을 통해 직렬 장치에 텍스트를 보낸다.

const textEncoder = new TextEncoderStream();
const writableStreamClosed = textEncoder.readable.pipeTo(port.writable);

const writer = textEncoder.writable.getWriter();

await writer.write("hello");

시리얼 포트 닫기

port.close()는 포트의 readable, writable 멤버가 잠겨있지 않다면(unlocked), 즉 해당 reader와 writer에 releaseLock()이 호출 되었다면 시리얼 포트를 닫는다.

await port.close();

그런데 루프를 이용해 직렬 장치로부터 데이터를 계속해서 읽을 땐 port.readable은 에러가 발생할 때까지 항상 잠겨있을 것이다. 이런 경우엔 reader.cancel()을 호출하여 reader.read()가 즉시 { value: undefined, done: true }를 resolve하도록 강제할 수 있고, 따라서 루프가 reader.releaseLock()을 호출할 수 있게 된다.

// 변환 스트림이 없을 때

let keepReading = true;
let reader;

async function readUntilClosed() {
  while (port.readable && keepReading) {
    reader = port.readable.getReader();
    try {
      while (true) {
        const { value, done } = await reader.read();
        if (done) {
          // reader.cancel()이 호출됐다.
          break;
        }
        // value는 Uint8Array이다.
        console.log(value);
      }
    } catch (error) {
      // Handle error...
    } finally {
      // 시리얼 포트가 나중에 닫힐 수 있도록 해준다.
      reader.releaseLock();
    }
  }

  await port.close();
}

const closedPromise = readUntilClosed();

document.querySelector('button').addEventListener('click', async () => {
  // 사용자가 시리얼 포트를 닫기 위해 버튼을 눌렀다.
  keepReading = false;
  // reader.read()가 즉시 resolve하고 이어서
  // 위 예제 루프에서 reader.releaseLock()을 호출하도록 강제한다.
  reader.cancel();
  await closedPromise;
});

변환 스트림(TextDecoderStreamTextEncoderStream)을 사용할 땐 시리얼 포트를 닫는 것이 좀 더 복잡하다. 전과 같이 reader.cancel()을 호출한다. 그리고 writer.close()port.close()를 호출한다. 이는 변환 스트림을 통해 시리얼 포트로 에러를 전파한다. 에러 전파는 바로 발생하지 않기 때문에 readableStreamClosedwritableStreamClosed 프러미스를 사용하여 port.readableport.writable의 잠금이 풀리는 시점을 감지해야 한다. reader를 캔슬하면 스트림이 중단된다. 이것이 결과로 나온 에러를 캐치하고 무시해야하는 이유이다.

// 변환 스트림이 있을 때

const textDecoder = new TextDecoderStream();
const readableStreamClosed = port.readable.pipeTo(textDecoder.writable);
const reader = textDecoder.readable.getReader();

// 직렬 장치로부터 오는 데이터를 듣는다.
while (true) {
  const { value, done } = await reader.read();
  if (done) {
    reader.releaseLock();
    break;
  }
  // value는 문자열이다.
  console.log(value);
}

const textEncoder = new TextEncoderStream();
const writableStreamClosed = textEncoder.readable.pipeTo(port.writable);

reader.cancel();
await readableStreamClosed.catch(() => { /* 에러를 무시한다 */ });

writer.close();
await writableStreamClosed;

await port.close();

연결 및 연결 해제 듣기

USB 장치에서 시얼 포트를 제공하는 경우 해당 장치는 시스템과 연결되거나 연결이 해제될 수 있다. 웹사이트에 시리얼 포트 액세스 권한이 부여되면 connectdisconnect 이벤트를 모니터링해야 한다.

navigator.serial.addEventListener("connect", (event) => {
  // TODO: Automatically open event.target or warn user a port is available.
  // TODO: 자동으로 event.target을 열거나 사용자에게 포트가 사용 가능하다고 경고한다.
});

navigator.serial.addEventListener("disconnect", (event) => {
  // TODO: UI에서 |event.target|을 제거한다
  // 시리얼 포트가 열려있으면 스트림 에러도 관찰된다.
});

Chrome 89 이전에는 connectdisconnect 이벤트가 port 속성에서 사용할 수 있는SerialPort 인터페이스로 사용자 지정 SerialConnectionEvent 객체를 작동시켰다. 이 변화를 처리하기 위해 event.port || event.target를 사용할 수 있다.

신호 처리

시리얼 포트를 연결한 후 장치 감지 및 흐름 제어를 위해 시리얼 포트에서 노출된 신호를 명시적으로 쿼리하고 설정할 수 있다. 이 신호들은 booelan 값으로 정의된다. 예를 들어 Data Terminal Ready(DTR) 신호가 토글되면 Arduino같은 장치는 프로그래밍 모드에 돌입하게 된다.

port.setSignals()port.getSignals()를 호출해서 출력 신호를 설정하고 입력 신호를 받을 수 있다. 아래 사용 예제를 보자.

// Serial Break 신호를 끈다.
await port.setSignals({ break: false });

// Data Terminal Ready (DTR) 신호를 켠다.
await port.setSignals({ dataTerminalReady: true });

// Request To Send (RTS) 신호를 끈다.
await port.setSignals({ requestToSend: false });
const signals = await port.getSignals();
console.log(`Clear To Send:       ${signals.clearToSend}`);
console.log(`Data Carrier Detect: ${signals.dataCarrierDetect}`);
console.log(`Data Set Ready:      ${signals.dataSetReady}`);
console.log(`Ring Indicator:      ${signals.ringIndicator}`);

스트림 변환

직렬 장치에서 데이터를 받을 때 한번에 모든 데이터를 받을 수 있는 것은 아니다. 임의로 청크될 수 있다. 더 많은 정보는 Stream API 개념에서 확인할 수 있다.

이를 처리하기 위해 TextDecoderStream과 같은 내장된 변환 스트림을 사용하거나 수신 스트림을 파싱하고 파싱된 데이터를 반환하는 변환 스트림을 생성할 수 있다. 변환 스트림은 직렬 장치와 스트림을 소비하는 read 루프 사이에 위치한다. 데이터가 소비되기 전에 임의적으로 변환을 적용할 수 있다. 조립 라인처럼 생각해보라. 위젯이 라인으로 내려오면 라인의 각 단계가 위젯을 수정하여 최종 목적지에 도달할 때 쯤이면 완전히 작동하는 위젯이 되도록 한다.

World War II Castle Bromwich Aeroplane Factory

예로 스트림을 소비하고 줄 바꿈을 기반으로 청크하는 변환 스트림 클래스를 어떻게 만드는지 생각해보자. 이것의 transform() 메소드는 스트림에서 새 데이터를 받을 때마다 호출된다. 데이터를 큐에 넣거나 나중을 위해 저장할 수도 있다. flush() 메소드는 스트림이 닫혔을 때 호출되며 아직 처리되지 않은 모든 데이터를 다룬다.

변환 스트림 클래스를 사용하려면 들어오는 스트림을 이를 통해 파이프해야 한다. Read from a serial port 아래의 세번째 코드 예제에서 원래 입력 스트림은 TextDecoderStream을 통해서만 파이프되었으므로 pipeThrough()를 호출하여 LinkBreakTransformer를 통해 이를 파이프 해야한다.

class LineBreakTransformer {
  constructor() {
    // 새 줄까지 스트림 데이터를 홀딩하기 위한 컨테이너
    this.chunks = "";
  }

  transform(chunk, controller) {
    // 기존 청크에 새 청크를 추가한다.
    this.chunks += chunk;
    // 청크의 각 줄바꿈에 대해 파싱된 줄을 보낸다.
    const lines = this.chunks.split("\r\n");
    this.chunks = lines.pop();
    lines.forEach((line) => controller.enqueue(line));
  }

  flush(controller) {
    // 스트림이 닫혔을 때 남아있는 청크를 모두 플러시한다.
    controller.enqueue(this.chunks);
  }
}
const textDecoder = new TextDecoderStream();
const readableStreamClosed = port.readable.pipeTo(textDecoder.writable);
const reader = textDecoder.readable
  .pipeThrough(new TransformStream(new LineBreakTransformer()))
  .getReader();

직렬 장치 통신 문제를 디버깅하려면port.readabletee() 메소드를 사용하여 직렬 장치로 들어오거나 나가는 스트림을 분할하라. 생성된 두개의 스트림은 독립적으로 사용할 수 있으며 이를 통해 검사를 위해 콘솔에 하나를 출력할 수 있다.

const [appReadable, devReadable] = port.readable.tee();

// appReadable을 통해 들어오는 데이터로 UI를 업데이트하고
// devReadable을 통해 JS 콘솔에 데이터를 찍어 검사할 수 있다.

시리얼 포트에 대한 액세스 취소

웹 사이트는 SerialPort 인스턴스의 forget()을 호출하여 더 이상 유지하고 싶지 않은 시리얼 포트 접근 권한을 정리할 수 있다. 예를 들어, 여러 장치가 있는 공유 컴퓨터에서 사용되는 교육용 웹 어플리케이션의 경우 사용자 생성 권한이 많이 누적되어 사용자 경험이 나빠진다.

// 이 시리얼 포트에 대한 접근 권한을 자발적으로 취소한다.
await port.forget();

forget()은 Chrome 103 이상에서 가능하므로 이 기능이 지원되는지 다음과 같이 확인해보라.

if ("serial" in navigator && "forget" in SerialPort.prototype) {
  // forget()이 지원됨
}

Dev Tips

Chrome에서 Web Serial API를 디버깅하는 것은 모든 직렬 장치 관련 이벤트를 한 곳에서 볼 수 있는 내부 페이지(about://device-log)를 사용하면 쉽다.

Internal page in Chrome for debugging the Web Serial API.

profile
@Ktown4u 개발자

0개의 댓글