Odczyt z portu szeregowego i zapis na nim

Interfejs Web Serial API umożliwia witrynom komunikowanie się z urządzeniami szeregowymi.

François Beaufort
François Beaufort

Czym jest interfejs Web Serial API?

Port szeregowy to dwukierunkowy interfejs komunikacyjny, który umożliwia wysyłanie i odbieranie danych bajt po bajcie.

Interfejs Web Serial API umożliwia witrynom odczytywanie danych z urządzenia szeregowego i zapisywanie ich w nim za pomocą JavaScriptu. Urządzenia szeregowe są podłączane przez port szeregowy w systemie użytkownika lub przez wymienne urządzenia USB i Bluetooth, które emulują port szeregowy.

Innymi słowy, interfejs Web Serial API łączy internet ze światem fizycznym, umożliwiając witrynom komunikację z urządzeniami szeregowymi, takimi jak mikrokontrolery i drukarki 3D.

Ten interfejs API doskonale współpracuje też z WebUSB, ponieważ systemy operacyjne wymagają, aby aplikacje komunikowały się z niektórymi portami szeregowymi za pomocą interfejsu API szeregowego wyższego poziomu, a nie interfejsu API USB niższego poziomu.

Sugerowane przypadki użycia

W sektorach edukacyjnym, hobbystycznym i przemysłowym użytkownicy podłączają do komputerów urządzenia peryferyjne. Urządzenia te są często sterowane przez mikrokontrolery za pomocą połączenia szeregowego używanego przez oprogramowanie niestandardowe. Niektóre niestandardowe oprogramowanie do sterowania tymi urządzeniami jest tworzone przy użyciu technologii internetowych:

W niektórych przypadkach strony internetowe komunikują się z urządzeniem za pomocą aplikacji agenta, którą użytkownicy zainstalowali ręcznie. W innych przypadkach aplikacja jest dostarczana w postaci pakietu za pomocą platformy takiej jak Electron. W innych przypadkach użytkownik musi wykonać dodatkowy krok, np. skopiować skompilowaną aplikację na urządzenie za pomocą pamięci USB.

We wszystkich tych przypadkach komfort użytkowania wzrośnie dzięki bezpośredniej komunikacji między witryną a urządzeniem, którym steruje.

Obecny stan,

Krok Stan
1. Tworzenie wyjaśnienia Zakończono
2. Tworzenie wstępnej wersji specyfikacji Zakończono
3. Zbieranie opinii i ulepszanie projektu Zakończono
4. Wersja próbna origin Zakończono
5. Uruchom Zakończono

Korzystanie z interfejsu Web Serial API

Wykrywanie cech

Aby sprawdzić, czy interfejs Web Serial API jest obsługiwany, użyj tego kodu:

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

Otwieranie portu szeregowego

Interfejs Web Serial API jest z założenia asynchroniczny. Zapobiega to blokowaniu interfejsu witryny podczas oczekiwania na dane wejściowe, co jest ważne, ponieważ dane szeregowe mogą być odbierane w dowolnym momencie, co wymaga sposobu na ich nasłuchiwanie.

Aby otworzyć port szeregowy, najpierw uzyskaj dostęp do obiektu SerialPort. W tym celu możesz poprosić użytkownika o wybranie jednego portu szeregowego, wywołując navigator.serial.requestPort() w odpowiedzi na działanie użytkownika, takie jak dotknięcie lub kliknięcie myszą, albo wybrać jeden z navigator.serial.getPorts(), który zwraca listę portów szeregowych, do których witryna ma dostęp.

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();

Funkcja navigator.serial.requestPort() przyjmuje opcjonalny literał obiektu, który definiuje filtry. Służą one do dopasowywania dowolnego urządzenia szeregowego podłączonego przez USB do obowiązkowego identyfikatora dostawcy USB (usbVendorId) i opcjonalnych identyfikatorów produktu 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();
Zrzut ekranu z monitem portu szeregowego na stronie internetowej
Prompt użytkownika dotyczący wyboru BBC micro:bit

Wywołanie funkcji requestPort() powoduje wyświetlenie prośby o wybranie urządzenia i zwraca obiekt SerialPort. Gdy masz obiekt SerialPort, wywołanie port.open() z odpowiednią szybkością transmisji otworzy port szeregowy. Element baudRate słownika określa szybkość przesyłania danych przez linię szeregową. Jest ona wyrażana w bitach na sekundę (bps). W dokumentacji urządzenia znajdziesz prawidłową wartość, ponieważ jeśli zostanie ona podana nieprawidłowo, wszystkie wysyłane i odbierane dane będą bezsensowne. W przypadku niektórych urządzeń USB i Bluetooth, które emulują port szeregowy, tę wartość można bezpiecznie ustawić na dowolną wartość, ponieważ jest ona ignorowana przez emulację.

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

Podczas otwierania portu szeregowego możesz też określić dowolną z tych opcji: Te opcje są opcjonalne i mają wygodne wartości domyślne.

  • dataBits: liczba bitów danych na ramkę (7 lub 8).
  • stopBits: liczba bitów stopu na końcu ramki (1 lub 2).
  • parity: tryb parzystości ("none", "even" lub "odd").
  • bufferSize: rozmiar buforów odczytu i zapisu, które mają zostać utworzone (musi być mniejszy niż 16 MB).
  • flowControl: tryb kontroli przepływu ("none" lub "hardware").

Odczytywanie z portu szeregowego

Strumienie wejściowe i wyjściowe w interfejsie Web Serial API są obsługiwane przez interfejs Streams API.

Po nawiązaniu połączenia z portem szeregowym właściwości readablewritable obiektu SerialPort zwracają odpowiednio ReadableStreamWritableStream. Będą one używane do odbierania danych z urządzenia szeregowego i wysyłania do niego danych. Obie usługi używają instancji Uint8Array do przesyłania danych.

Gdy z urządzenia szeregowego nadejdą nowe dane, funkcja port.readable.getReader().read() asynchronicznie zwróci 2 właściwości: value i wartość logiczną done. Jeśli wartość done to „true”, port szeregowy został zamknięty lub nie ma już przychodzących danych. Wywołanie funkcji port.readable.getReader() tworzy czytnik i blokuje readable. Gdy readable jest zablokowany, nie można zamknąć portu szeregowego.

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

W określonych warunkach mogą wystąpić niektóre niekrytyczne błędy odczytu z portu szeregowego, takie jak przepełnienie bufora, błędy ramki lub błędy parzystości. Są one zgłaszane jako wyjątki i można je przechwycić, dodając kolejną pętlę nad poprzednią, która sprawdza port.readable. Działa to dlatego, że dopóki błędy nie są krytyczne, automatycznie tworzony jest nowy obiekt ReadableStream. Jeśli wystąpi błąd krytyczny, np. usunięcie urządzenia szeregowego, wartość port.readable stanie się 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.
  }
}

Jeśli urządzenie szeregowe odsyła tekst, możesz przekierować port.readable przez TextDecoderStream, jak pokazano poniżej. TextDecoderStream to strumień przekształcania, który pobiera wszystkie fragmenty Uint8Array i konwertuje je na ciągi znaków.

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

Możesz przejąć kontrolę nad sposobem przydzielania pamięci podczas odczytywania strumienia za pomocą czytnika „Bring Your Own Buffer”. Wywołaj port.readable.getReader({ mode: "byob" }), aby uzyskać interfejs ReadableStreamBYOBReader, i podaj własny ArrayBuffer podczas wywoływania read(). Pamiętaj, że Web Serial API obsługuje tę funkcję w Chrome w wersji 106 lub nowszej.

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

Oto przykład ponownego użycia bufora z właściwości 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`.
}

Oto kolejny przykład odczytywania określonej ilości danych z portu szeregowego:

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

Zapisywanie w porcie szeregowym

Aby wysłać dane do urządzenia szeregowego, przekaż je do funkcji port.writable.getWriter().write(). Wywołanie releaseLock() na urządzeniu port.writable.getWriter() jest wymagane, aby później zamknąć port szeregowy.

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();

Wyślij tekst na urządzenie za pomocą znaku TextEncoderStream przekierowanego do port.writable, jak pokazano poniżej.

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

const writer = textEncoder.writable.getWriter();

await writer.write("hello");

Zamykanie portu szeregowego

port.close() zamyka port szeregowy, jeśli jego elementy readablewritableodblokowane, co oznacza, że dla odpowiednich czytnika i zapisywarki wywołano funkcję releaseLock().

await port.close();

Jednak podczas ciągłego odczytywania danych z urządzenia szeregowego za pomocą pętli zmienna port.readable będzie zawsze zablokowana, dopóki nie wystąpi błąd. W tym przypadku wywołanie reader.cancel() spowoduje natychmiastowe rozwiązanie reader.read() za pomocą { value: undefined, done: true }, co umożliwi pętli wywołanie 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;
});

Zamknięcie portu szeregowego jest bardziej skomplikowane w przypadku używania strumieni przekształcających. Zadzwoń na numer reader.cancel() jak poprzednio. Następnie zadzwoń pod numery writer.close()port.close(). Powoduje to propagowanie błędów przez strumienie przekształceń do bazowego portu szeregowego. Ponieważ propagacja błędów nie następuje natychmiast, musisz użyć utworzonych wcześniej obietnic readableStreamClosedwritableStreamClosed, aby wykryć, kiedy odblokowane zostaną obietnice port.readableport.writable. Anulowanie reader powoduje przerwanie strumienia, dlatego musisz przechwycić i zignorować wynikający z tego błąd.

// 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();

Słuchanie połączeń i rozłączeń

Jeśli port szeregowy jest udostępniany przez urządzenie USB, można je podłączyć lub odłączyć od systemu. Gdy witryna uzyska uprawnienia dostępu do portu szeregowego, powinna monitorować zdarzenia 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.
});

Obsługa sygnałów

Po nawiązaniu połączenia z portem szeregowym możesz jawnie wysyłać zapytania i ustawiać sygnały udostępniane przez port szeregowy na potrzeby wykrywania urządzeń i sterowania przepływem. Te sygnały są zdefiniowane jako wartości logiczne. Na przykład niektóre urządzenia, takie jak Arduino, przejdą w tryb programowania, jeśli sygnał DTR (Data Terminal Ready) zostanie przełączony.

Ustawianie sygnałów wyjściowych i pobieranie sygnałów wejściowych odbywa się odpowiednio przez wywołanie funkcji port.setSignals()port.getSignals(). Przykłady użycia znajdziesz poniżej.

// 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}`);

Przekształcanie strumieni

Gdy otrzymasz dane z urządzenia szeregowego, niekoniecznie uzyskasz wszystkie dane naraz. Może być podzielony na dowolne części. Więcej informacji znajdziesz w artykule Pojęcia związane z interfejsem Streams API.

Aby sobie z tym poradzić, możesz użyć wbudowanych strumieni przekształceń, takich jak TextDecoderStream, lub utworzyć własny strumień przekształceń, który umożliwi Ci parsowanie strumienia przychodzącego i zwracanie sparsowanych danych. Strumień przekształcenia znajduje się między urządzeniem szeregowym a pętlą odczytu, która zużywa strumień. Może ona zastosować dowolną transformację przed użyciem danych. Wyobraź sobie linię montażową: gdy widżet przesuwa się wzdłuż linii, każdy etap modyfikuje go tak, aby po dotarciu do miejsca docelowego był w pełni funkcjonalny.

Zdjęcie fabryki samolotów
Fabryka samolotów z okresu II wojny światowej w Castle Bromwich

Zastanów się na przykład, jak utworzyć klasę strumienia przekształcającego, która pobiera strumień i dzieli go na części na podstawie znaków końca wiersza. Jej metoda transform() jest wywoływana za każdym razem, gdy strumień otrzymuje nowe dane. Może ona umieścić dane w kolejce lub zapisać je na później. Metoda flush() jest wywoływana po zamknięciu strumienia i przetwarza wszystkie dane, które nie zostały jeszcze przetworzone.

Aby użyć klasy strumienia przekształceń, musisz przekierować do niej strumień przychodzący. W trzecim przykładzie kodu w sekcji Odczytywanie z portu szeregowego pierwotny strumień wejściowy był przekazywany tylko przez TextDecoderStream, więc musimy wywołać pipeThrough(), aby przekazać go przez nasz nowy 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();

Aby debugować problemy z komunikacją z urządzeniem szeregowym, użyj metody tee()port.readable do rozdzielania strumieni danych wysyłanych do urządzenia szeregowego lub z niego. Utworzone 2 strumienie można wykorzystywać niezależnie od siebie, co pozwala wyświetlić jeden z nich w konsoli w celu sprawdzenia.

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.

Anulowanie dostępu do portu szeregowego

Strona może wyczyścić uprawnienia dostępu do portu szeregowego, którego nie chce już zachować, wywołując forget() w instancji SerialPort. Na przykład w przypadku edukacyjnej aplikacji internetowej używanej na współdzielonym komputerze z wieloma urządzeniami duża liczba zgromadzonych uprawnień wygenerowanych przez użytkowników pogarsza komfort korzystania z niej.

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

Funkcja forget() jest dostępna w Chrome w wersji 103 lub nowszej. Aby sprawdzić, czy jest obsługiwana, wykonaj te czynności:

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

Wskazówki dla programistów

Debugowanie interfejsu Web Serial API w Chrome jest łatwe dzięki stronie wewnętrznej, about://device-log na której możesz zobaczyć wszystkie zdarzenia związane z urządzeniami szeregowymi w jednym miejscu.

Zrzut ekranu strony wewnętrznej do debugowania interfejsu Web Serial API.
Strona wewnętrzna w Chrome do debugowania interfejsu Web Serial API.

Ćwiczenia z programowania

samouczku Google dla programistów użyjesz interfejsu Web Serial API, aby wchodzić w interakcje z płytką BBC micro:bit i wyświetlać obrazy na jej matrycy LED o wymiarach 5x5.

Obsługa przeglądarek

Interfejs Web Serial API jest dostępny na wszystkich platformach komputerowych (ChromeOS, Linux, macOS i Windows) w Chrome 89.

Watolina poliestrowa

Na Androidzie obsługa portów szeregowych opartych na USB jest możliwa dzięki interfejsowi WebUSB API i wypełnieniu interfejsu Serial API. Ten polyfill jest ograniczony do sprzętu i platform, na których urządzenie jest dostępne przez WebUSB API, ponieważ nie zostało przejęte przez wbudowany sterownik urządzenia.

Bezpieczeństwo i prywatność

Autorzy specyfikacji zaprojektowali i wdrożyli interfejs Web Serial API zgodnie z podstawowymi zasadami określonymi w dokumencie Controlling Access to Powerful Web Platform Features, w tym z zasadami kontroli użytkownika, przejrzystości i ergonomii. Możliwość korzystania z tego interfejsu API jest ograniczona głównie przez model uprawnień, który przyznaje dostęp tylko do jednego urządzenia szeregowego naraz. W odpowiedzi na prośbę użytkownika musi on aktywnie wybrać konkretne urządzenie szeregowe.

Aby poznać kompromisy w zakresie bezpieczeństwa, zapoznaj się z sekcjami bezpieczeństwoprywatność w wyjaśnieniu interfejsu Web Serial API.

Prześlij opinię

Zespół Chrome chętnie pozna Twoje opinie i wrażenia związane z interfejsem Web Serial API.

Opisz projekt interfejsu API

Czy w API jest coś, co nie działa zgodnie z oczekiwaniami? Czy brakuje metod lub właściwości, które są Ci potrzebne do realizacji pomysłu?

Zgłoś problem ze specyfikacją w repozytorium GitHub interfejsu Web Serial API lub dodaj swoje uwagi do istniejącego problemu.

Zgłaszanie problemu z implementacją

Czy w implementacji Chrome występuje błąd? Czy implementacja różni się od specyfikacji?

Zgłoś błąd na stronie https://new.crbug.com. Podaj jak najwięcej szczegółów, proste instrukcje odtwarzania błędu i ustaw Komponenty na Blink>Serial.

Okaż wsparcie

Czy zamierzasz używać interfejsu Web Serial API? Twoje publiczne wsparcie pomaga zespołowi Chrome ustalać priorytety funkcji i pokazuje innym dostawcom przeglądarek, jak ważne jest ich obsługiwanie.

Wyślij tweeta do @ChromiumDev z hasztagiem #SerialAPI i napisz, gdzie i jak korzystasz z tej funkcji.

Przydatne linki

Przykłady

Podziękowania

Dziękujemy Reilly GrantJoe Medley za sprawdzenie tego artykułu. Zdjęcie fabryki samolotów: Birmingham Museums Trust, Unsplash.