직렬 포트 읽기 및 쓰기

Web Serial API를 사용하면 웹사이트에서 직렬 기기와 통신할 수 있습니다.

François Beaufort
François Beaufort

Web Serial API란 무엇인가요?

직렬 포트는 바이트 단위로 데이터를 송수신할 수 있는 양방향 통신 인터페이스입니다.

Web Serial API는 웹사이트에서 JavaScript를 사용하여 직렬 기기에서 읽고 쓰는 방법을 제공합니다. 직렬 기기는 사용자 시스템의 직렬 포트 또는 직렬 포트를 에뮬레이션하는 이동식 USB 및 블루투스 기기를 통해 연결됩니다.

즉, Web Serial API는 웹사이트가 마이크로컨트롤러나 3D 프린터와 같은 직렬 기기와 통신할 수 있도록 하여 웹과 실제 세계를 연결합니다.

운영체제에서는 애플리케이션이 하위 수준의 USB API가 아닌 상위 수준의 직렬 API를 사용하여 일부 직렬 포트와 통신해야 하므로 이 API는 WebUSB와 함께 사용하기에 좋습니다.

추천 사용 사례

교육, 취미 및 산업 분야에서 사용자는 주변기기를 컴퓨터에 연결합니다. 이러한 기기는 맞춤 소프트웨어에 사용되는 직렬 연결을 통해 마이크로 컨트롤러로 제어되는 경우가 많습니다. 이러한 기기를 제어하는 일부 맞춤 소프트웨어는 웹 기술로 빌드됩니다.

경우에 따라 웹사이트는 사용자가 수동으로 설치한 에이전트 애플리케이션을 통해 기기와 통신합니다. 다른 경우에는 애플리케이션이 Electron과 같은 프레임워크를 통해 패키징된 애플리케이션으로 제공됩니다. 또한 USB 플래시 드라이브를 통해 컴파일된 애플리케이션을 기기에 복사하는 등의 추가 단계를 실행해야 하는 경우도 있습니다.

이러한 모든 경우 웹사이트와 웹사이트가 제어하는 기기 간에 직접 커뮤니케이션이 제공되어 사용자 환경이 개선됩니다.

현재 상태

단계 상태
1. 설명 만들기 완전함
2. 사양의 초기 초안 만들기 완전함
3. 의견 수집 및 디자인 반복 완전함
4. 오리진 트라이얼 완전함
5. 출시 완전함

Web Serial API 사용

기능 감지

Web Serial API가 지원되는지 확인하려면 다음을 사용하세요.

if ("serial" in navigator) {
  // The Web Serial API is supported.
}

직렬 포트 열기

Web Serial API는 기본적으로 비동기식입니다. 이렇게 하면 입력을 기다릴 때 웹사이트 UI가 차단되는 것을 방지할 수 있습니다. 이는 직렬 데이터를 언제든지 수신할 수 있어 수신 대기할 방법이 필요하기 때문에 중요합니다.

직렬 포트를 열려면 먼저 SerialPort 객체에 액세스합니다. 이를 위해 사용자에게 터치나 마우스 클릭과 같은 사용자 동작에 응답하여 navigator.serial.requestPort()를 호출하여 단일 직렬 포트를 선택하라는 메시지를 표시하거나 웹사이트에서 액세스 권한이 부여된 직렬 포트 목록을 반환하는 navigator.serial.getPorts()에서 하나를 선택할 수 있습니다.

document.querySelector('button').addEventListener('click', async () => {
  // Prompt user to select any serial port.
  const port = await navigator.serial.requestPort();
});
// Get all serial ports the user has previously granted the website access to.
const ports = await navigator.serial.getPorts();

navigator.serial.requestPort() 함수는 필터를 정의하는 선택적 객체 리터럴을 사용합니다. 이는 USB를 통해 연결된 모든 직렬 기기를 필수 USB 공급업체 (usbVendorId) 및 선택적 USB 제품 식별자 (usbProductId)와 매칭하는 데 사용됩니다.

// Filter on devices with the Arduino Uno USB Vendor/Product IDs.
const filters = [
  { usbVendorId: 0x2341, usbProductId: 0x0043 },
  { usbVendorId: 0x2341, usbProductId: 0x0001 }
];

// Prompt user to select an Arduino Uno device.
const port = await navigator.serial.requestPort({ filters });

const { usbProductId, usbVendorId } = port.getInfo();
웹사이트의 직렬 포트 프롬프트 스크린샷
BBC micro:bit을 선택하라는 사용자 메시지

requestPort()를 호출하면 사용자에게 기기를 선택하라는 메시지가 표시되고 SerialPort 객체가 반환됩니다. SerialPort 객체가 있으면 원하는 전송 속도로 port.open()를 호출하면 직렬 포트가 열립니다. baudRate 사전 멤버는 직렬선을 통해 데이터가 전송되는 속도를 지정합니다. 초당 비트 수 (bps) 단위로 표현됩니다. 기기 문서에서 올바른 값을 확인하세요. 이 값이 잘못 지정되면 주고받는 모든 데이터가 의미가 없어집니다. 직렬 포트를 에뮬레이션하는 일부 USB 및 블루투스 기기의 경우 이 값을 어떤 값으로든 안전하게 설정할 수 있습니다. 에뮬레이션에서 무시되기 때문입니다.

// Prompt user to select any serial port.
const port = await navigator.serial.requestPort();

// Wait for the serial port to open.
await port.open({ baudRate: 9600 });

직렬 포트를 열 때 아래 옵션을 지정할 수도 있습니다. 이 옵션은 선택사항이며 편리한 기본값이 있습니다.

  • dataBits: 프레임당 데이터 비트 수입니다 (7 또는 8).
  • stopBits: 프레임 끝에 있는 정지 비트 수입니다 (1 또는 2).
  • parity: 패리티 모드 ("none", "even" 또는 "odd")입니다.
  • bufferSize: 생성해야 하는 읽기 및 쓰기 버퍼의 크기입니다(16MB 미만이어야 함).
  • flowControl: 흐름 제어 모드 ("none" 또는 "hardware")입니다.

직렬 포트에서 읽기

Web Serial API의 입력 및 출력 스트림은 Streams API에 의해 처리됩니다.

직렬 포트 연결이 설정되면 SerialPort 객체의 readablewritable 속성이 ReadableStreamWritableStream을 반환합니다. 이들은 직렬 기기에서 데이터를 수신하고 데이터를 보내는 데 사용됩니다. 둘 다 데이터 전송을 위해 Uint8Array 인스턴스를 사용합니다.

새 데이터가 직렬 기기에서 도착하면 port.readable.getReader().read()valuedone 불리언이라는 두 속성을 비동기식으로 반환합니다. done가 true인 경우 직렬 포트가 닫혀 있거나 더 이상 들어오는 데이터가 없습니다. port.readable.getReader()를 호출하면 리더가 생성되고 readable가 잠금 설정됩니다. readable잠겨 있는 동안에는 직렬 포트를 닫을 수 없습니다.

const reader = port.readable.getReader();

// Listen to data coming from the serial device.
while (true) {
  const { value, done } = await reader.read();
  if (done) {
    // Allow the serial port to be closed later.
    reader.releaseLock();
    break;
  }
  // value is a Uint8Array.
  console.log(value);
}

심각하지 않은 직렬 포트 읽기 오류는 버퍼 오버플로, 프레이밍 오류 또는 패리티 오류와 같은 일부 조건에서 발생할 수 있습니다. 이는 예외로 발생하며 port.readable를 확인하는 이전 루프 위에 다른 루프를 추가하여 포착할 수 있습니다. 이는 오류가 심각하지 않은 한 새 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();

// Listen to data coming from the serial device.
while (true) {
  const { value, done } = await reader.read();
  if (done) {
    // Allow the serial port to be closed later.
    reader.releaseLock();
    break;
  }
  // value is a string.
  console.log(value);
}

'Bring Your Own Buffer' 리더를 사용해 스트림에서 읽을 때 메모리가 할당되는 방식을 관리할 수 있습니다. port.readable.getReader({ mode: "byob" })를 호출하여 ReadableStreamBYOBReader 인터페이스를 가져오고 read()를 호출할 때 자체 ArrayBuffer를 제공합니다. Web Serial API는 Chrome 106 이상에서 이 기능을 지원합니다.

try {
  const reader = port.readable.getReader({ mode: "byob" });
  // Call reader.read() to read data into a buffer...
} catch (error) {
  if (error instanceof TypeError) {
    // BYOB readers are not supported.
    // Fallback to port.readable.getReader()...
  }
}

다음은 value.buffer에서 버퍼를 재사용하는 방법의 예입니다.

const bufferSize = 1024; // 1kB
let buffer = new ArrayBuffer(bufferSize);

// Set `bufferSize` on open() to at least the size of the buffer.
await port.open({ baudRate: 9600, bufferSize });

const reader = port.readable.getReader({ mode: "byob" });
while (true) {
  const { value, done } = await reader.read(new Uint8Array(buffer));
  if (done) {
    break;
  }
  buffer = value.buffer;
  // Handle `value`.
}

다음은 직렬 포트에서 특정 양의 데이터를 읽는 방법을 보여주는 또 다른 예입니다.

async function readInto(reader, buffer) {
  let offset = 0;
  while (offset < buffer.byteLength) {
    const { value, done } = await reader.read(
      new Uint8Array(buffer, offset)
    );
    if (done) {
      break;
    }
    buffer = value.buffer;
    offset += value.byteLength;
  }
  return buffer;
}

const reader = port.readable.getReader({ mode: "byob" });
let buffer = new ArrayBuffer(512);
// Read the first 512 bytes.
buffer = await readInto(reader, buffer);
// Then read the next 512 bytes.
buffer = await readInto(reader, buffer);

직렬 포트에 쓰기

데이터를 직렬 기기로 전송하려면 port.writable.getWriter().write()에 데이터를 전달합니다. 직렬 포트를 나중에 닫으려면 port.writable.getWriter()에서 releaseLock()를 호출해야 합니다.

const writer = port.writable.getWriter();

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


// Allow the serial port to be closed later.
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()readablewritable 멤버가 잠금 해제되면 직렬 포트를 닫습니다. 즉, 각 리더와 작성자에 대해 releaseLock()가 호출되었다는 의미입니다.

await port.close();

그러나 루프를 사용하여 직렬 기기에서 데이터를 계속 읽는 경우 오류가 발생할 때까지 port.readable가 항상 잠깁니다. 이 경우 reader.cancel()를 호출하면 reader.read(){ value: undefined, done: true }로 즉시 확인되므로 루프가 reader.releaseLock()를 호출할 수 있습니다.

// Without transform streams.

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() has been called.
          break;
        }
        // value is a Uint8Array.
        console.log(value);
      }
    } catch (error) {
      // Handle error...
    } finally {
      // Allow the serial port to be closed later.
      reader.releaseLock();
    }
  }

  await port.close();
}

const closedPromise = readUntilClosed();

document.querySelector('button').addEventListener('click', async () => {
  // User clicked a button to close the serial port.
  keepReading = false;
  // Force reader.read() to resolve immediately and subsequently
  // call reader.releaseLock() in the loop example above.
  reader.cancel();
  await closedPromise;
});

변환 스트림을 사용할 때는 직렬 포트를 닫는 것이 더 복잡합니다. 이전과 마찬가지로 reader.cancel()를 호출합니다. 그런 다음 writer.close()port.close()를 호출합니다. 그러면 변환 스트림을 통해 기본 직렬 포트로 오류가 전파됩니다. 오류 전파는 즉시 발생하지 않으므로 앞에서 만든 readableStreamClosedwritableStreamClosed 프로미스를 사용하여 port.readableport.writable가 잠금 해제된 시점을 감지해야 합니다. reader를 취소하면 스트림이 취소됩니다. 따라서 결과 오류를 포착하고 무시해야 합니다.

// With transform streams.

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

// Listen to data coming from the serial device.
while (true) {
  const { value, done } = await reader.read();
  if (done) {
    reader.releaseLock();
    break;
  }
  // value is a string.
  console.log(value);
}

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

reader.cancel();
await readableStreamClosed.catch(() => { /* Ignore the error */ });

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.
});

navigator.serial.addEventListener("disconnect", (event) => {
  // TODO: Remove |event.target| from the UI.
  // If the serial port was opened, a stream error would be observed as well.
});

신호 처리

직렬 포트 연결을 설정한 후에는 직렬 포트에서 노출된 신호를 명시적으로 쿼리하고 기기 감지 및 흐름 제어를 위해 설정할 수 있습니다. 이러한 신호는 불리언 값으로 정의됩니다. 예를 들어, Arduino와 같은 일부 기기는 데이터 터미널 준비 (DTR) 신호가 전환되면 프로그래밍 모드로 전환됩니다.

출력 신호 설정 및 입력 신호 가져오기는 각각 port.setSignals()port.getSignals()를 호출하여 실행됩니다. 아래의 사용 예를 참고하세요.

// Turn off Serial Break signal.
await port.setSignals({ break: false });

// Turn on Data Terminal Ready (DTR) signal.
await port.setSignals({ dataTerminalReady: true });

// Turn off Request To Send (RTS) signal.
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}`);

스트림 변환

직렬 기기에서 데이터를 수신할 때 모든 데이터를 한 번에 가져올 필요는 없습니다. 임의로 청크될 수 있습니다. 자세한 내용은 Streams API 개념을 참고하세요.

이 문제를 해결하려면 TextDecoderStream과 같은 기본 제공 변환 스트림을 사용하거나 수신 스트림을 파싱하고 파싱된 데이터를 반환할 수 있는 자체 변환 스트림을 만들 수 있습니다. 변환 스트림은 직렬 기기와 스트림을 소비하는 읽기 루프 사이에 위치합니다. 데이터가 소비되기 전에 임의의 변환을 적용할 수 있습니다. 어셈블리 라인이라고 생각하면 됩니다. 위젯이 선을 아래로 내려오면 라인의 각 단계가 위젯을 수정하여 최종 대상에 도달할 때쯤이면 위젯이 완전히 작동하는 위젯이 됩니다.

비행기 공장 사진
2차 세계 대전 성 브롬위치 비행기 공장

예를 들어 스트림을 소비하고 줄바꿈에 따라 스트림을 분할하는 변환 스트림 클래스를 만드는 방법을 생각해 보세요. 스트림에서 새 데이터를 수신할 때마다 transform() 메서드가 호출됩니다. 데이터를 큐에 추가하거나 나중을 위해 저장할 수 있습니다. flush() 메서드는 스트림이 닫힐 때 호출되며 아직 처리되지 않은 모든 데이터를 처리합니다.

변환 스트림 클래스를 사용하려면 이 클래스를 통해 수신 스트림을 파이핑해야 합니다. 직렬 포트에서 읽기 아래의 세 번째 코드 예에서는 원래 입력 스트림이 TextDecoderStream를 통해서만 파이핑되었으므로 pipeThrough()를 호출하여 새 LineBreakTransformer를 통해 파이핑해야 합니다.

class LineBreakTransformer {
  constructor() {
    // A container for holding stream data until a new line.
    this.chunks = "";
  }

  transform(chunk, controller) {
    // Append new chunks to existing chunks.
    this.chunks += chunk;
    // For each line breaks in chunks, send the parsed lines out.
    const lines = this.chunks.split("\r\n");
    this.chunks = lines.pop();
    lines.forEach((line) => controller.enqueue(line));
  }

  flush(controller) {
    // When the stream is closed, flush any remaining chunks out.
    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();

// You may want to update UI with incoming data from appReadable
// and log incoming data in JS console for inspection from devReadable.

직렬 포트에 대한 액세스 권한 취소

웹사이트는 SerialPort 인스턴스에서 forget()를 호출하여 더 이상 유지할 필요가 없는 직렬 포트에 대한 액세스 권한을 삭제할 수 있습니다. 예를 들어 여러 기기가 있는 공유 컴퓨터에서 사용되는 교육용 웹 애플리케이션의 경우 누적된 사용자 생성 권한이 많으면 사용자 환경이 저하됩니다.

// Voluntarily revoke access to this serial port.
await port.forget();

forget()는 Chrome 103 이상에서 사용할 수 있으므로 다음에서 이 기능이 지원되는지 확인합니다.

if ("serial" in navigator && "forget" in SerialPort.prototype) {
  // forget() is supported.
}

개발자 팁

모든 직렬 기기 관련 이벤트를 한곳에서 확인할 수 있는 내부 페이지(about://device-log)를 사용하면 Chrome에서 Web Serial API를 쉽게 디버깅할 수 있습니다.

Web Serial API 디버깅을 위한 내부 페이지의 스크린샷
Web Serial API 디버깅을 위한 Chrome 내부 페이지

Codelab

Google 개발자 Codelab에서는 Web Serial API를 사용하여 BBC micro:bit 보드와 상호작용하여 5x5 LED 매트릭스에 이미지를 표시합니다.

브라우저 지원

Web Serial API는 Chrome 89의 모든 데스크톱 플랫폼 (ChromeOS, Linux, macOS, Windows)에서 사용할 수 있습니다.

폴리필

Android에서는 WebUSB API 및 Serial API 폴리필을 사용하여 USB 기반 직렬 포트를 지원할 수 있습니다. 이 폴리필은 내장 기기 드라이버에서 사용하지 않았으므로 WebUSB API를 통해 기기에 액세스할 수 있는 하드웨어 및 플랫폼으로 제한됩니다.

보안 및 개인 정보 보호

사양 작성자는 사용자 제어, 투명성, 인체공학 등 강력한 웹 플랫폼 기능에 대한 액세스 제어에 정의된 핵심 원칙을 사용하여 Web Serial API를 설계하고 구현했습니다. 이 API를 사용하는 기능은 주로 한 번에 하나의 직렬 기기에만 액세스를 부여하는 권한 모델에 의해 관리됩니다. 사용자 메시지에 응답하여 사용자는 특정 직렬 기기를 선택하기 위해 적극적인 조치를 취해야 합니다.

보안의 장단점을 알아보려면 Web Serial API 설명 도구의 보안개인 정보 보호 섹션을 확인하세요.

의견

Chrome팀은 Web Serial API에 대한 여러분의 생각과 경험을 알려주시기 바랍니다.

API 설계에 대해 알려주세요.

API에 예상대로 작동하지 않는 문제가 있나요? 아니면 아이디어를 구현해야 하는 메서드나 속성이 누락되었나요?

Web Serial API GitHub 저장소에서 사양 문제를 제출하거나 기존 문제에 의견을 추가하세요.

구현 관련 문제 신고

Chrome 구현에서 버그를 발견하셨나요? 아니면 구현이 사양과 다른가요?

https://new.crbug.com에서 버그를 신고합니다. 가능한 한 많은 세부정보를 포함하고, 버그 재현을 위한 간단한 안내를 제공하며, 구성요소Blink>Serial로 설정해야 합니다. Glitch는 빠르고 쉬운 재현을 공유하는 데 효과적입니다.

응원하기

Web Serial API를 사용할 계획이신가요? 공개 지원은 Chrome팀이 기능의 우선순위를 정하는 데 도움이 되며 다른 브라우저 공급업체에 이러한 기능을 지원하는 것이 얼마나 중요한지 보여줍니다.

해시태그 #SerialAPI를 사용하여 @ChromiumDev로 트윗을 보내고 사용 위치와 방법을 알려주세요.

유용한 링크

데모

감사의 말

이 도움말을 검토해 주신 Reilly Grant님과 Joe Medley님께 감사드립니다. 비행기 공장 사진: Unsplash, 버밍엄 박물관 트러스트에서 제공한 사진.