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

Co to jest 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ą podłączone przez port szeregowy w systemie użytkownika lub przez wymienne 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 doskonale uzupełnia WebUSB, ponieważ systemy operacyjne wymagają, aby aplikacje komunikowały się z niektórymi portami szeregowymi za pomocą wyższego poziomu szeregowego interfejsu API zamiast niskopoziomowego interfejsu USB API.

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 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, na przykład 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. 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 polecenia:

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 witryny w oczekiwaniu na dane wejściowe, co jest ważne, ponieważ dane szeregowe mogą być odbierane w dowolnym momencie, co wymaga sposobu ich 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. Słownik baudRate określa szybkość przesyłania danych przez linię szeregową. Jest ono wyrażane 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ą niezrozumiałe. 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 klatkę (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").

Odczyt 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 seryjnego i do ich wysyłania na to urządzenie. Do przesyłania danych obie używają instancji Uint8Array.

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() tworzy pod odczytującego 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. 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. To działa, jeśli błędy nie są krytyczne, automatycznie tworzony jest nowy obiekt ReadableStream. 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 odeśle tekst, możesz użyć potoku 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 obiekt ArrayBuffer podczas wywoływania 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, 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 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 takim przypadku wywołanie metody reader.cancel() wymusi natychmiastowe wykonanie polecenia reader.read() przez wywołanie { 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(). Spowoduje to rozpowszechnianie błędów przez strumienie przekształcenia do bazowego 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ł 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(). Zobacz przykłady ich użycia 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 otrzymasz wszystkie dane naraz. 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 zastosować dowolne przekształcenie przed przetworzeniem 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 w zamku 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 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 Odczyt z portu szeregowego pierwotny strumień danych 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 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 usunąć uprawnienia do dostępu do portu szeregowego, którego nie chce już zachowywać, wywołując funkcję 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();

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. 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 zależy głównie od modelu uprawnień, który przyznaje dostęp tylko do jednego urządzenia szeregowego naraz. W odpowiedzi na prośbę użytkownika musi on wykonać określone czynności, aby wybrać konkretne urządzenie z numerem seryjnym.

Aby lepiej zrozumieć wady bezpieczeństwa, zapoznaj się z sekcjami dotyczącymi bezpieczeństwa i prywatności artykułu Web Serial API Explainer.

Prześlij opinię

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

Opowiedz nam o konstrukcji 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? A może 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

Czy zamierzasz korzystać z 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.