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 komunikacji, który umożliwia wysyłanie i odbieranie danych bajt po bajcie.

Interfejs Web Serial API umożliwia witrynom odczytywanie i zapisywanie danych na urządzeniu szeregowym za pomocą kodu JavaScript. Urządzenia szeregowe są połączone przez port szeregowy w systemie użytkownika lub przez wyjmowane urządzenia USB i Bluetooth, które emulują port szeregowy.

Inaczej mówiąc, interfejs Web Serial API łączy internet z światem fizycznym, umożliwiając witrynom komunikację z urządzeniami szeregowymi, takimi jak mikrokontrolery i drukarki 3D.

Ten interfejs API jest też świetnym uzupełnieniem WebUSB, ponieważ systemy operacyjne wymagają, aby aplikacje komunikowały się z niektórymi portami szeregowymi za pomocą interfejsu serial API wyższego poziomu, a nie interfejsu USB API niskiego poziomu.

Sugerowane zastosowania

W sektorze edukacyjnym, hobbystycznym i przemysłowym użytkownicy podłączają do komputerów urządzenia peryferyjne. Te urządzenia są często kontrolowane przez mikrokontrolery za pomocą połączenia szeregowego używanego przez niestandardowe oprogramowanie. 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 pakiecie za pomocą platformy, takiej jak Electron. W innych przypadkach użytkownik musi wykonać dodatkową czynność, na przykład skopiować skompilowaną aplikację na urządzenie za pomocą dysku flash USB.

We wszystkich tych przypadkach wygodę użytkownika zwiększy bezpośrednia komunikacja 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. Wprowadzenie na rynek Zakończono

Korzystanie z interfejsu Web Serial API

Wykrywanie cech

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

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

Otwórz port szeregowy

Interfejs Web Serial API jest asynchroniczny z założenia. Zapobiega to blokowaniu interfejsu użytkownika witryny podczas oczekiwania na dane wejściowe. Jest to ważne, ponieważ dane szeregowe mogą być odbierane w dowolnym momencie i wymagają odsłuchiwania.

Aby otworzyć port szeregowy, najpierw uzyskaj dostęp do obiektu SerialPort. W tym celu możesz albo poprosić użytkownika o wybranie jednego portu szeregowego, wywołując funkcję navigator.serial.requestPort() w odpowiedzi na gest użytkownika, taki jak dotyk lub kliknięcie myszką, albo wybrać jeden z portów z listy navigator.serial.getPorts(), która zwraca listę portów szeregowych, do których strona 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ą one używane 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 prośbą o podłączenie do portu szeregowego na stronie internetowej
Prośba o wybranie urządzenia BBC micro:bit

Wywołanie funkcji requestPort() powoduje wyświetlenie użytkownikowi prośby o wybranie urządzenia i zwraca obiekt SerialPort. Gdy masz obiekt SerialPort, wywołanie funkcji port.open() z wybraną szybkością transmisji danych spowoduje otwarcie portu szeregowego. Element słownika baudRate określa szybkość przesyłania danych przez linię szeregową. Wyrażony w bitach na sekundę (b/s). Sprawdź w dokumentacji urządzenia prawidłową wartość, ponieważ jeśli zostanie podana nieprawidłowa wartość, wszystkie wysyłane i odbierane dane będą niezrozumiałe. W przypadku niektórych urządzeń USB i Bluetooth emulujących port szeregowy ta wartość może być bezpiecznie ustawiona 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 podanych niżej opcji. Opcje te są opcjonalne i mają wygodne wartości domyślne.

  • dataBits: liczba bitów danych na ramkę (7 lub 8).
  • stopBits: liczba bitów zatrzymania na końcu klatki (1 lub 2).
  • parity: tryb parzy ("none", "even" lub "odd").
  • bufferSize: rozmiar buforów odczytu i zapisu, które należy utworzyć (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ą obiekty ReadableStreamWritableStream. Będą one używane do odbierania danych z urządzenia szeregowego i do wysyłania ich do niego. Oba używają instancji Uint8Array do przesyłania danych.

Po otrzymaniu nowych danych z urządzenia szeregowego funkcja port.readable.getReader().read() zwraca asynchronicznie 2 właściwości: value i done. Jeśli done ma wartość prawda, port szeregowy został zamknięty lub nie ma już więcej danych. Wywołanie port.readable.getReader() powoduje utworzenie czytnika i zablokowanie na nim readable. Gdy urządzenie readable jest zablokowane, 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 niektórych warunkach mogą wystąpić niekrytyczne błędy odczytu portu szeregowego, takie jak przepełnienie bufora, błędy kadrowania lub błędy parzy. Są one zgłaszane jako wyjątki i można je złapać, dodając kolejną pętlę nad poprzednią, która sprawdza port.readable. Działa to, ponieważ dopóki błędy nie są krytyczne, nowy ReadableStream jest tworzony automatycznie. Jeśli wystąpi krytyczny błąd, np. zostanie usunięte urządzenie seryjne, 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 zwróci tekst, możesz przekierować port.readable przez TextDecoderStream, jak pokazano poniżej. TextDecoderStream to strumień transformacji, 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);
}

Podczas odczytu z potoku za pomocą czytnika „Bring Your Own Buffer” możesz kontrolować sposób przydzielania pamięci. Wywołaj port.readable.getReader({ mode: "byob" }), aby uzyskać interfejs ReadableStreamBYOBReader i podaj własny ArrayBuffer przy wywoływaniu funkcji read(). Web Serial API obsługuje tę funkcję w Chrome 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 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);

Pisanie do portu szeregowego

Aby wysłać dane do urządzenia szeregowego, prześlij je do port.writable.getWriter().write(). Wywołanie funkcji releaseLock() w programie port.writable.getWriter() jest wymagane, aby później można było 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ą polecenia TextEncoderStream przesłanego 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");

Zamknij port szeregowy

port.close() zamyka port szeregowy, jeśli elementy readable i writableodblokowane, co oznacza, że wywołano releaseLock() w przypadku odpowiedniego czytnika i zapisującego.

await port.close();

Jednak podczas ciągłego odczytu danych z urządzenia szeregowego za pomocą pętli port.readable będzie zawsze zablokowany, dopóki nie napotka błędu. W tym przypadku wywołanie reader.cancel() spowoduje, że reader.read() zostanie rozwiązany natychmiast z wartością { value: undefined, done: true }, co pozwoli pętli wywołać 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, gdy używasz przekształcania strumieni. Zadzwoń pod numer reader.cancel() tak jak wcześniej. Następnie zadzwoń do writer.close()port.close(). W ten sposób błędy są rozprzestrzeniane przez strumienie transformacji do portu szeregowego. Ponieważ propagacja błędów nie następuje natychmiast, musisz użyć wcześniej utworzonych obietnic readableStreamClosedwritableStreamClosed, aby wykryć, kiedy port.readableport.writable zostały odblokowane. Anulowanie reader powoduje przerwanie strumienia. Dlatego musisz wykryć i zignorować błąd, który się wydarzy.

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

Odsłuchiwanie połączeń i rozłączeń

Jeśli port szeregowy jest zapewniony przez urządzenie USB, można je podłączyć do systemu lub odłączyć od systemu. Gdy witryna ma przyznane uprawnienia do 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 wysyłać zapytania i ustawiać sygnały udostępniane przez port szeregowy w celu wykrywania urządzenia i kontroli przepływu danych. Te sygnały są zdefiniowane jako wartości logiczne. Na przykład niektóre urządzenia, takie jak Arduino, przechodzą w tryb programowania, gdy sygnał Data Terminal Ready (DTR) jest włączony.

Aby skonfigurować sygnały wyjściowe i uzyskać sygnały wejściowe, należy wywołać odpowiednio funkcję port.setSignals() i port.getSignals(). Poniżej znajdziesz przykłady użycia.

// 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 odbierasz dane z urządzenia szeregowego, niekoniecznie są one dostępne od razu. Może być dowolnie dzielone na fragmenty. Więcej informacji znajdziesz w artykule Koncepcje dotyczące interfejsu Streams API.

Aby to zrobić, możesz użyć wbudowanych przekształceń danych, takich jak TextDecoderStream, lub utworzyć własne przekształcenie danych, które pozwoli Ci przeanalizować przychodzący strumień danych i zwrócić przeanalizowane dane. Strumień transformacji znajduje się pomiędzy urządzeniem szeregowym a pętlą odczytu, która pobiera strumień. Może ona zastosować dowolną transformację przed wykorzystaniem danych. Wyobraź sobie to jak taśmę produkcyjną: gdy element przemieszcza się po taśmie, każdy etap modyfikuje go, tak aby do czasu dotarcia do miejsca docelowego był w pełni funkcjonalny.

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

Możesz na przykład utworzyć klasę transformacji strumienia, która pobiera strumień i dzieli go na części na podstawie znaków końca wiersza. Jego metoda transform() jest wywoływana za każdym razem, gdy strumień otrzyma nowe dane. Może dodać dane do kolejki 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żywać klasy transform stream, musisz przekierować do niej strumień wejściowy. W trzecim przykładzie kodu w sekcji Odczyt z portu szeregowego oryginalny strumień danych wejściowych był przesyłany potokiem tylko przez TextDecoderStream, więc trzeba było wywołać pipeThrough(), aby wprowadzić go za pomocą potoku 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() w programie port.readable, aby podzielić strumienie na te, które idą do urządzenia szeregowego i z niego. Utworzone 2 strumienie można używać niezależnie, co pozwala na wydrukowanie jednego 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.

Odmowa dostępu do portu szeregowego

Witryna może wyczyścić uprawnienia dostępu do portu szeregowego, którego już nie chce przechowywać, wywołując funkcję forget() w wystąpieniu SerialPort. Na przykład w przypadku edukacyjnej aplikacji internetowej używanej na współdzielonym komputerze z wieloma urządzeniami duża liczba gromadzonych uprawnień generowanych przez użytkowników może pogorszyć komfort korzystania z aplikacji.

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

Usługa forget() jest dostępna w Chrome 103 i nowszych wersjach. Sprawdź, czy ta funkcja jest obsługiwana w tych miejscach:

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 wewnętrznej stronie about://device-log, na której możesz zobaczyć wszystkie zdarzenia związane z urządzeniem szeregowym w jednym miejscu.

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

Ćwiczenia z programowania

Google Developer Codelab użyjesz interfejsu Web Serial API do interakcji z płytką BBC micro:bit, aby wyświetlać obrazy na jej matrycy LED 5 x 5.

Obsługa przeglądarek

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

Watolina

Na Androidzie obsługa portów szeregowych na USB jest możliwa za pomocą interfejsu WebUSB API i polyfilla Serial API. Kod polyfill jest ograniczony do sprzętu i platform, na których urządzenie jest dostępne przez interfejs WebUSB API, ponieważ nie zostało zarezerwowane przez wbudowany sterownik urządzenia.

Bezpieczeństwo i prywatność

Autorzy specyfikacji zaprojektowali i wdrożyli interfejs Web Serial API, korzystając z podstawowych zasad zdefiniowanych w dokumentacji Controlling Access to Powerful Web Platform Features (Kontrolowanie dostępu do zaawansowanych funkcji platformy internetowej), w tym 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 seryjnego naraz. W odpowiedzi na prośbę użytkownika musi on wykonać określone czynności, aby wybrać konkretne urządzenie z numerem seryjnym.

Aby zrozumieć kompromisy związane z bezpieczeństwem, zapoznaj się z sekcjami bezpieczeństwoprywatność w artykule Web Serial API Explainer.

Prześlij opinię

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

Prześlij informacje o projektowaniu interfejsu API

Czy interfejs API nie działa zgodnie z oczekiwaniami? A może brakuje Ci metod lub właściwości, których potrzebujesz, aby zrealizować swój pomysł?

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

Zgłaszanie problemów z implementacją

Czy znalazłeś/znalazłaś błąd w implementacji Chrome? Czy implementacja różni się od specyfikacji?

Zgłoś błąd na stronie https://new.crbug.com. Podaj jak najwięcej szczegółów, dołącz proste instrukcje odtwarzania błędu i ustaw Składniki na Blink>Serial. Glitch to świetne narzędzie do szybkiego i łatwego udostępniania informacji o powtarzalności problemu.

Pokaż pomoc

Zamierzasz używać interfejsu Web Serial API? Twoja publiczna pomoc pomaga zespołowi Chrome ustalać priorytety funkcji i pokazuje innym dostawcom przeglądarek, jak ważne jest ich wsparcie.

Wyślij tweeta do @ChromiumDev, używając hashtaga #SerialAPI, i podaj, gdzie i jak go używasz.

Przydatne linki

Prezentacje

Podziękowania

Dziękujemy Reilly GrantJoe Medley za sprawdzenie tego artykułu. Zdjęcie fabryki samolotu wykonane przez Birmingham Museums Trust na kanale Unsplash.