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ć dodatkowy krok, np. skopiować skompilowane aplikacje na urządzenie za pomocą pamięci 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. Zbieraj opinie i ulepszaj projekt 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:

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

Otwieranie portu szeregowego

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 pożądaną szybkością transmisji danych spowoduje otwarcie portu szeregowego. Element słownika baudRate określa szybkość przesyłania danych przez linię szeregową. Jest wyrażana w jednostkach bitów na sekundę (bps). Sprawdź w dokumentacji urządzenia prawidłową wartość, ponieważ jeśli zostanie podana nieprawidłowa wartość, wszystkie wysyłane i odbierane dane będą nieczytelne. W przypadku niektórych urządzeń USB i Bluetooth, które emulują port szeregowy, można bezpiecznie ustawić dowolną wartość, ponieważ emulacja ją ignoruje.

// 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. 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 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 służyć do odbierania danych z urządzenia szeregowego i do ich wysyłania na to urządzenie. Oba te konta używają instancji Uint8Array do przesyłania danych.

Gdy na urządzeniu szeregowym pojawią się nowe dane, funkcja port.readable.getReader().read() asynchronicznie zwraca 2 właściwości: value i wartość logiczną done. Jeśli done ma wartość prawda, port szeregowy został zamknięty lub nie ma już więcej danych. Wywołanie port.readable.getReader() tworzy pod odczytujący i blokuje readable. Gdy readable jest zablokowany, port szeregowy nie może zostać zamknięty.

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. Wyjątki te są zgłaszane jako wyjątki i można je przechwycić, dodając kolejną pętlę do poprzedniej, 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 funkcję port.readable.getReader({ mode: "byob" }), aby uzyskać interfejs ReadableStreamBYOBReader, i podaj własny obiekt ArrayBuffer podczas wywoływania 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 funkcji 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, przekaż 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 readablewritableodblokowane, co oznacza, że dla odpowiednich czytników i nagrywarek została wywołana funkcja releaseLock().

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(), jak poprzednio. 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 przechwycić i zignorować powstały 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();

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

Jeśli port szeregowy jest udostępniany przez urządzenie USB, to urządzenie może być połączone lub rozłączone z systemem. 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.

Ustawianie sygnałów wyjściowych i pobieranie sygnałów wejściowych odbywa się odpowiednio przez wywołanie funkcji port.setSignals()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 otrzymasz dane z urządzenia szeregowego, niekoniecznie otrzymasz wszystkie dane naraz. Może być podzielony na dowolne fragmenty. Więcej informacji znajdziesz w artykule Koncepcje 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. Metoda transform() jest wywoływana za każdym razem, gdy strumień otrzyma nowe dane. Może ona umieścić dane w kole 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 3 przykładzie kodu w sekcji Odczytaj z portu szeregowego pierwotny strumień danych wejściowych był przekazywany tylko przez TextDecoderStream, więc musimy wywołać pipeThrough(), aby przekazać go przez 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 danych wysyłane do urządzenia szeregowego lub 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

Strona internetowa może usunąć uprawnienia do portu szeregowego, którego nie chce już zachowywać, 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 gromadzonych uprawnień generowanych przez użytkowników może pogorszyć komfort korzystania z aplikacji.

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

Funkcja forget() jest dostępna w Chrome w wersji 103 lub nowszej. Sprawdź, czy jest obsługiwana w przypadku tych funkcji:

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 z wewnętrzną stroną 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 USB jest możliwa za pomocą interfejsu WebUSB API i polyfilla Serial API. Ta funkcja zastępcza jest ograniczona do sprzętu i platform, w których przypadku urządzenie jest dostępne za pomocą interfejsu WebUSB API, ponieważ nie zostało ono zadeklarowane 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.

Poinformuj nas o projektowaniu interfejsu API

Czy interfejs API nie działa zgodnie z oczekiwaniami? A może brakuje metod lub właściwości, których potrzebujesz do wdrożenia swojego pomysłu?

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 samolotów autorstwa Birmingham Museums Trust na Unsplash.