Web Serial API i odczyt temperatury

NOTE

Jesteś w serii Lab. Prezentuję w niej moje eksperymenty, niekoniecznie w skończonej albo uporządkowanej formie.

Zabawy z Web Serial API

Ostatnio bawię się z Web Serial API, sprzężając JavaScript z mikrokontrolerami. Na tapetę wziąłem odczyt temperatury przy pomocy ESP8266 i prostego czujnika LM35. Po podłączeniu urządzenia do portu USB i zezwoleniu na dostęp do portu szeregowego, następuje wymiana danych pomiędzy aplikacją webową a ESP8266.

Narzędziownik
Vue.js
Warstwa UI oraz komunikacja z Web Serial API poprzez composable
JavaScript
Natywny Web Serial API
C++
Odczyt temperatury oraz komunikacja po porcie szeregowym
ESP8266
Mikrokontroler

Poniższe demo jest interaktywne. Niżej zamieszczam nagranie.

Can’t connect to serial port :-(.

Web Serial API

Read temperature using native Web Serial API, ESP8266, LM35 thermometer and Vue3

❯ no data yet

Działa to tak, że firmware oczekuje polecenia READ\n a po odczytaniu danych z czujnika wysyła OK:28.00\n gdzie 28.00 to odczytana temperatura. Oczywiście nazwy poleceń/format jest dowolny; u mnie tak pasowało.

Aplikacja została zrobiona w Vue.js i o ile masz przy sobie jakieś urządzenie, które wyśle dane w formacie OK:XX.YY\n wirtualny termometr pokaże ci temperaturę. ;)

Tak wygląda strona firmware:

const int PIN_TEMP = A0;

void setup() {
  Serial.begin(9600);
  Serial.println("OK:CONNECTED");
}

void loop() {
  if (Serial.available()) {
    // Expects "READ\n"
    String line = Serial.readStringUntil('\n');
    line.trim();

    if (line == "READ") {
      float temp = read_temperature();
      Serial.println("OK:" + String(temp));
    } else {
      Serial.println("Invalid input");
    }
  }
}

float read_temperature() {
  float reading = analogRead(PIN_TEMP);
  float voltage = (reading / 1024.0) * 3300;
  float temp = voltage / 10;

  return temp;
}

Po stronie frontend, najbardziej istotna część to połączenie po porcie szeregowym. Logikę umieściłem w composable.

import { computed, ref, watch } from "vue";

export type PortState = "closed" | "closing" | "open" | "opening";

function useSerialPort({ onData }: { onData: (line: string) => void }) {
  const canUseSerial = ref<boolean>("serial" in navigator);
  const portState = ref<PortState>("closed");
  const portRef = ref<SerialPort | null>(null);
  const readerRef = ref<ReadableStreamDefaultReader<Uint8Array> | null>(null);
  const writerRef = ref<WritableStreamDefaultWriter<Uint8Array> | null>(null);

  const isOpen = computed(() => portState.value === "open");
  const isClosed = computed(() => portState.value === "closed");
  const isOpening = computed(() => portState.value === "opening");

  async function connect() {
    try {
      await requestPort();
    } catch (e) {
      console.log("Could not connect to port", e);
    }
  }

  async function openPort(port: SerialPort) {
    try {
      await port.open({ baudRate: 9600 });

      portRef.value = port;
      writerRef.value = port.writable?.getWriter() ?? null;
      readerRef.value = port.readable?.getReader() ?? null;

      portState.value = "open";
      console.info("Connected.");
    } catch (e) {
      portState.value = "closed";
      console.log("Could not open port", e);
    }
  }

  async function requestPort() {
    try {
      const port = await navigator.serial.requestPort();
      portState.value = "opening";
      await openPort(port);
    } catch (e) {
      throw new Error("Could not request port", { cause: e });
    }
  }

  async function sendCommand(cmd: string) {
    if (!writerRef.value) return;

    await writerRef.value.write(new TextEncoder().encode(cmd + "\n"));
    console.debug("→", cmd);
  }

  function parseStream(value: string): string[] {
    let newlineIndex = -1;
    let buffer = value;
    const lines: string[] = [];
    const commandSeparator = "\n";

    while ((newlineIndex = buffer.indexOf(commandSeparator)) >= 0) {
      const line = buffer.slice(0, newlineIndex).trim();
      buffer = buffer.slice(newlineIndex + 1);

      if (line) lines.push(line);
    }

    return lines;
  }

  const stopReaderWatch = watch(readerRef, (reader, _old, onCleanup) => {
    if (!isOpen.value || !reader) return;

    let cancelled = false;

    onCleanup(() => {
      cancelled = true;
      reader.cancel().catch(() => {});
      portState.value = "closed";
      readerRef.value = null;
    });

    (async () => {
      const decoder = new TextDecoder();

      try {
        while (!cancelled) {
          const { value, done } = await reader.read();

          if (done) break;

          // `buffer` is decoded value from device, e.g `OK:29.00\n`
          // In this case device only sends one line (marked by '\n')
          const buffer: string = decoder.decode(value, { stream: true });
          const lines = parseStream(buffer);
          const lastLine = lines.at(-1);

          if (lastLine) onData(lastLine);
        }
      } catch (err) {
        portState.value = "closed";
        readerRef.value = null;
        console.log("Read loop error:", err);
      } finally {
        reader.releaseLock();
      }
    })();
  });

  function cleanup() {
    stopReaderWatch();
  }

  return {
    canUseSerial,
    portState,
    isOpen,
    isClosed,
    isOpening,
    portRef,
    readerRef,
    writerRef,
    connect,
    requestPort,
    openPort,
    sendCommand,
    cleanup,
  };
}

export default useSerialPort;

Nie wszystkie stany (szczególnie wyjątki) są obsłużone, ale nie ma większego znaczenia tutaj. Komponent następnie korzysta z composable:

const serialPort = useSerialPort({ onData: onData });

function onData(line: string) {
  if (!line.startsWith("OK:")) return;

  const value = parseFloat(line.slice(3)); // float after 'OK:' (3 characters)

  if (!Number.isNaN(value)) {
    rawTemp.value = value;
  }
}

Jeżeli port jest otwarty, możemy odczytać temperaturę:

serialPort.sendCommand("READ");

Należy pamiętać, by najpiew dać uprawnienia:

<template>
  <button @click="serialPort.connect">Connect to serial port</button>
</template>

Cześć! Jestem otwarty na nowe wyzwania zawodowe.

Brzmi interesująco? Skontaktuj się ze mną poprzez poprzez LinkedIn

Demo

Temperatura jest mierzona co 5000ms

Po pierwszym pomiarze przytykam palucha na termometrze i temperatura wzrasta. A ESP8266 jest podłączone tak:

LM35 podłączony do ESP8266
LM35 podłączony do ESP8266

Dane lecą po porcie szeregowym (tutaj USB micro).