직렬 포트 읽기 및 쓰기

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

François Beaufort
François Beaufort

Web Serial API란 무엇인가요?

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

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

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

이 API는 운영체제에서 애플리케이션이 하위 수준 USB 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: 생성해야 하는 읽기 및 쓰기 버퍼의 크기입니다(1,600만 바이트 미만이어야 함).
  • 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를 제공합니다. Chrome 106 이상에서는 Web Serial API가 이 기능을 지원합니다.

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와 같은 일부 내장 변환 스트림을 사용하거나 수신 스트림을 파싱하고 파싱된 데이터를 반환할 수 있는 자체 변환 스트림을 만들면 됩니다. 변환 스트림은 직렬 기기와 스트림을 소비하는 읽기 루프 사이에 있습니다. 데이터가 사용되기 전에 임의의 변환을 적용할 수 있습니다. 조립 라인과 비슷하다고 생각하면 됩니다. 위젯이 라인을 따라 내려오면 라인의 각 단계에서 위젯을 수정하므로 최종 목적지에 도달할 때쯤에는 완전히 작동하는 위젯이 됩니다.

비행기 공장 사진
World War II Castle Bromwich Aeroplane Factory

예를 들어 스트림을 소비하고 줄바꿈을 기반으로 청크하는 변환 스트림 클래스를 만드는 방법을 생각해 보세요. 스트림에서 새 데이터를 수신할 때마다 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 매트릭스에 이미지를 표시합니다.

브라우저 지원

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

폴리필

Android에서는 WebUSB API와 Serial API polyfill을 사용하여 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로 설정하세요.

지원 표시

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

#SerialAPI 해시태그를 사용하여 @ChromiumDev에 트윗을 보내 어디에서 어떻게 사용하고 있는지 알려주세요.

유용한 링크

데모

감사의 말씀

이 문서를 검토해 준 Reilly GrantJoe Medley에게 감사의 인사를 전합니다. 비행기 공장 사진: 버밍엄 박물관 신탁(Unsplash 제공)