직렬 포트 읽기 및 쓰기

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

François Beaufort
François Beaufort

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

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: 생성해야 하는 읽기 및 쓰기 버퍼의 크기입니다(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차 세계대전 Castle Bromwich 비행기 공장

예를 들어 스트림을 사용하고 줄바꿈을 기반으로 청크를 만드는 변환 스트림 클래스를 만드는 방법을 생각해 보겠습니다. 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.
}

개발자 팁

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

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님께 감사드립니다. UnsplashBirmingham Museums Trust에서 제공한 항공기 공장 사진