Skip to content

Interface and Application Programming

Hero shot

Group assignment

ThrustMeter user interface

As a personal assignment, I decided to develop a new user interface for my ThrustMeter.

My first ThrustMeter has a Python interface.

I decided to write the same interface, using WebSerial.

WebSerial test

Warning

WebSerial is not supported by Firefox. Use Chrome or Edge instead.

I followed the Chrome tutorial about WebSerial.

I started by writing a minimalist interface that can:

  • connect to an ESP32 with WebSerial
  • close the connection properly
  • send and receive data.

I ended with this HTML interface:

It has

  • a "Connect" button to open the connection
  • a "Disconnect" button to close the connection
  • a "Start" button to send a control character ('A') to the ESP32.

ESP32 will react by streaming data. The text area will display the number of received data.

Here is a video showing it in action:

ESP32 code

I wrote a test code for the ESP32. It reacts at the same control characters as the Thrustmeter.

It sends fake data by enumerating the range of ADC values.
It'll allow me to check if data transmission works well.

#define   SAMP_PER_US 1000


#define UNCONNECTED 0
#define IDLE        1
#define ACQUIRING   2
int8_t state;
uint16_t data;

void setup(void) {
  Serial.begin(115200);
  state = IDLE;
  data = 0;
  Serial.write('O');
}

void loop(void) {
  char c = 0;

  if (Serial.available() > 0) {
    c = Serial.read();
  }
  switch (state) {
    case IDLE:
      if (c == 'A') {   // host wants to connect
        state = ACQUIRING;
        data = 0;
      }
    break;
    case ACQUIRING:
      delayMicroseconds(SAMP_PER_US);
      data = (data + 1) % 4096;
      Serial.write(data & 0x00FF);
      Serial.write(data >> 8);
      if (c == 'S') {   // host wants to connect
        state = IDLE;
      }
    break;
    default:
      state = IDLE;
  }
}

HTML code explanation

The HTML and javascript code is shown in the following tabs.

You can click on the ⊕ symbols to have more informations on the corresponding line.

The // ... lines indicates that some lines where omitted to ease the comprehension.
A link to the complete code is available ine the Useful files

<div style="margin:0 auto; width:250px; height:100px; border:1px solid black; padding:20px;">   <!-- Interface container (1) -->
    <button id="connectBtn" style="background-color: #C0C0C0">Connect</button>                <!-- "Connect" button (2) -->
    <button id="disconnectBtn" style="background-color: #C0C0C0" disabled>Disconnect</button> <!-- "Disconnect" button (3) -->
    <button id="startBtn" style="background-color: #C0C0C0" disabled>Start</button>           <!-- "Start" button (4) -->
    <div>   <!-- Sub-container to display the number of received bytes (5) -->
        <label for="byteNb">Byte received:</label>
        <textarea id="byteNb" name="byteNb" rows="1" cols="4"> 0 </textarea>
    </div>
</div>
  1. The HTML code creates a container for the WebSerial interface.
  2. The Connect button triggers the WebSerial connection.
  3. The Disconnect button triggers the WebSerial disconnection. it's disabled by default.
  4. The Start button triggers the data acquisition.
  5. This labeled text area will be used to show that data are received from the serial port.
const connectBtn = document.querySelector("#connectBtn");

connectBtn.addEventListener('click', async () => {  // click event handler (1)
  try {
    port = await navigator.serial.requestPort();  // prompt user to select a serial port (2)
    await port.open({ baudRate: 115200 });        // wait for the serial port to open (3)
    console.log('Serial port opened');            // log the successful port opening (4)
    toggleButton(connectBtn);                     // disable the "Connect" button (5)
    toggleButton(disconnectBtn);                  // enable the "Disconnect" button
        // ...
  }
  catch (err) {         // Error handling (4)
    console.error(err);
  }
        // ...
} );
  1. This function is triggered by a click on the Connect button.
  2. Tells the web browser to list the serial port and asks the user to select the right one. This can be seen in the video above.
  3. Opens the serial port, with the desired baud rate.
  4. If serial.requestPort() or port.open() failed, the try block execution is interrupted and the catch block is executed.
  5. toggleButton() toggles the boolean disabled property of the given button.

The serial port's writable property returns a WritableStream that can be used to send data.

const connectBtn = document.querySelector("#connectBtn");

connectBtn.addEventListener('click', async () => {  // click event handler (1)
  try {
    port = await navigator.serial.requestPort();  // prompt user to select a serial port
    await port.open({ baudRate: 115200 });        // wait for the serial port to open
    console.log('Serial port opened');            // log the successful port opening
    toggleButton(connectBtn);                     // disable the "Connect" button
    toggleButton(disconnectBtn);                  // enable the "Disconnect" button
    // Create an output stream to write on the serial port (2)
    let encoder = new TextEncoderStream();
    outputDone = encoder.readable.pipeTo(port.writable);
    outputStream = encoder.writable;
        // ...
  }
  catch (err) {         // Error handling (3)
    console.error(err);
  }
        // ...
} );
  1. This function is triggered by a click on the Connect button.
  2. These lines create a text output stream and connect it to the serial port output. It'll be used in the Start button handler.
  3. If running these lines raises an exception, the try block execution is interrupted and the catch block is executed.
const byteNbTxt = document.querySelector("#byteNb");
let started = false;        // true if the acquisition has started

startBtn.addEventListener('click', async () => {    // click event handler (1)
  writer = outputStream.getWriter();    // writer for the serial port (2)
  if (!started) {                     // if the acquisition is not started (default)
    await writer.write('A');            // send 'A' to start the acquisition (3)
    started = true;                     // update the state variable
    startBtn.textContent = 'Stop';      // Change the button text
  } else {                            // if the acquisition is started
    started = false;                    // send 'S' to stop the acquisition (3)
    startBtn.textContent = 'Start';     // update the state variable
    await writer.write('S');            // Change the button text
  }
  writer.releaseLock();     // release the serial port (4)
} );
  1. This function is triggered by a click on the Start/Stop button.
  2. Creates a writer for outputStream. Needed to send data. It also lock the serial port, to prevent other threads to access it.
  3. Send a character to the Thrustmeter (and wait until it's sent)
  4. Serial port MUST be released to allow the other function to use it.

The serial port's readable property returns a ReadableStream that can be used to receive data.

const connectBtn = document.querySelector("#connectBtn");

connectBtn.addEventListener('click', async () => {  // click event handler (1)
  try {
    port = await navigator.serial.requestPort();  // prompt user to select a serial port
    await port.open({ baudRate: 115200 });        // wait for the serial port to open
    console.log('Serial port opened');            // log the successful port opening
    toggleButton(connectBtn);                     // disable the "Connect" button
    toggleButton(disconnectBtn);                  // enable the "Disconnect" button
    // Create an output stream to write on the serial port
    let encoder = new TextEncoderStream();
    outputDone = encoder.readable.pipeTo(port.writable);
    outputStream = encoder.writable;
    // Create a reader for the serial port (2)
    let decoder = new TextDecoderStream();
    inputDone = port.readable.pipeTo(decoder.writable);
    inputStream = decoder.readable;
    reader = inputStream.getReader();               // serial port reader creation (3)
    const { value, done } = await reader.read();    // wait to receive a data from the serial port (4)
    console.log('ThrustMeter ready');       // serial port configuration successful
    toggleButton(startBtn);                 // enable the "start" button
  }
  catch (err) {         // Error handling (5)
    console.error(err);
  }
  // [...]
} );
  1. This function is triggered by a click on the Connect button.
  2. Lines 15-17 create a text input stream and connect it to the serial port input.
  3. Creates a reader for inputStream. Needed to access its data.
  4. Blocking read of the input stream. Wait for the Thrustmeter to send a byte, when it's ready.
  5. If running these lines raises an exception, the try block execution is interrupted and the catch block is executed.
const connectBtn = document.querySelector("#connectBtn");

connectBtn.addEventListener('click', async () => {  // click event handler (1)
  try {
    // [...]
  }
  catch (err) {         // Error handling
    console.error(err);
  }
  count = 0   //  Nb of data received counter
  while (true) {    // read loop (2)
    const { value, done } = await reader.read();  // wait to receive a data from the serial port (3)
    if (value) {                                  // if a new data is received, update the counter
      count++;
      byteNbTxt.value = count;
    }
    if (done) {                         // if the serial port is closed (4)
      console.log('read loop ended');     // log the event
      reader.releaseLock();               // release the reader (5)
      break;                              // end the loop
    }
  }
} );
  1. This function contains the endless loop that reads data from the serial port.
  2. This "endless" loop listen the serial port input stream and count the received data. It ends when the disconnect button is clicked.
  3. Blocking read of the input stream. Wait for the Thrustmeter to send a new data.
  4. If reader.cancel() is called (see "disconnection" tab), reader.read() returns done as true. It's time to end the read loop.
  5. The reader lock on the serial port MUST be released to allow the serial port closing.
const disconnectBtn = document.querySelector("#disconnectBtn");
disconnectBtn.addEventListener('click', async () => {       // click event handler (1)
try {
  if (reader) {                         // if reader still exists (should always be true) (2)
    await reader.cancel();                // tell the reader to end and wait until it's done
    await inputDone.catch(() => {});      // tell the input stream to end and wait until it's done
    reader = null;
    inputDone = null;
  }
  if (outputStream) {                         // if the output stream still exists (3)
    await outputStream.getWriter().close();     // tell a writer to end and wait until it's done
    await outputDone;                           // wait for the output stream to end
    outputStream = null;
    outputDone = null;
  }
  await port.close();                   // tell the serial port to close and wait until it's done (4)
  port = null;
  console.log('Serial port closed');    // log the event
  toggleButton(connectBtn);             // enable "connect" button
  toggleButton(disconnectBtn);          // disable "disconnect" button
  toggleButton(startBtn);               // disable "start" button
  }
  catch (err) {       // Error handling (5)
    console.error(err);
  }
} );
  1. This function is triggered by a click on the Disconnect button.
  2. This block ends the reader. That'll trigger the end of the read loop in the Connect button handler (see Input stream handling tab).
  3. This block ends the output stream
  4. Both reader and writer MUST be ended before closing the serial port.
  5. If running the try block raises an exception, it's execution is interrupted and the catch block is executed.

HTML graphic interface

I never tried using HTML and Javascript to draw on a web page.

I read the canvasline.html given by Neil and modify it to obtain an animated sine wave.

The main difference is that I added a circular buffer to store the Y-axis data.
The idea is to copy the structure of my Python interface.

Code explanation

The HTML body is only a canvas contained in a <div>.

The canvas is where drawings will be done.

<body>
  <div style="margin: 0 auto; width: 600px; height: 300px; border: 1px solid black;">
    <canvas id="canvas" width="600" height="300"></canvas>
  </div>
</body>
  class CircularBuffer {
    // create and initialize the buffer with 0s (1)
    constructor(size) {
      this.buffer = new Array(size);
      this.size = size;
      this.next = 0;
      for (let i=0; i<size; i++) {
        this.buffer[i] = 0;
      }
    }
    // add a new data in the buffer. It replaces the oldest one (2)
    append(data) {
      this.buffer[this.next] = data;
      this.next = (this.next + 1) % this.size;  // Move to the next spot
    }
    // return the i_th element. Element 0 is the newest. (3)
    getData(i) {
      return (this.buffer[(this.next + i) % this.size]);
    }
  }
  1. The class has 3 fields: buffer: where the data are stored, size: the number of data and next: the position for the next data.
  2. append() writes the new data at the position given by next and increment next``, circling back tobuffer```'s beginning when its end is reached.
  3. getData() allows to access the buffer in the chronological order: getData(0) returns the oldest data and getData(size-1) returns the newest data.
// get the canvas size (1)
let canvas = document.getElementById("canvas");
let height = canvas.clientHeight;
let width = canvas.clientWidth;
let ctx = canvas.getContext("2d");  // we'll draw in 2D (2) 
ctx.lineWidth = 1;
let nbPts = width;  // nb of data points (3)
// X-axis data definition (4)
let t = []
for (i=0; i<nbPts; i++) {
  t[i] = i;
}
let buf = new CircularBuffer(nbPts);  // Y-axis data buffer (5)
let count = 0;                        // used to generate Y-axis data
let timerId = setInterval(draw, 10);  // call draw() every 10 ms (6)
  1. Canvas height and width are needed to scale the drawing
  2. Context object has the methods to draw, depending of the mode. '2D' is the most common.
  3. That way, each data point corresponds to a canvas column
  4. X-axis represents time and will not change.
  5. Y-axis data are stored in a circular buffer
  6. draw()'ll be called periodically to update the graph.
function draw() {
  ctx.clearRect(0, 0, width, height);   // erase the canvas (1)
  ctx.beginPath();                      // start a path (2)
  t_max = t[t.length-1];        // find max X value to scale
  ctx.moveTo( width*t[0]/t_max, height*( 0.5-buf.getData(0) ) ); // start at the first data point (3)
  // add lines between consecutive data points to the path (4)
  for (i=1; i<t.length; i++) {
    ctx.lineTo( width*t[i]/t_max, height*( 0.5-buf.getData(i) ) );
  }
  ctx.stroke();   // draw the path (5)
  // update the Y-axis data (6)
  count = (count + 1) % nbPts;
  buf.append( 0.5*Math.sin( count*2*Math.PI/nbPts ) );
}
  1. First, we erase the previous drawing
  2. A path is a set of lines (or curves). It can be open or closed.
  3. To start drawing our path, we go to the first point. `moveTo()``` doesn't add a graphical object to the path.
  4. The forloop adds a line that joins every consecutive data points to the path
  5. stroke() draw the path as a contour
  6. To simulate data acquisition, a new data point is added to the circular buffer.

WebSerial graphical interface

Code explanation

The HTML body combines the buttons of the WebSerial test with the canvas of the graphic interface.

<body>
  <div>
  <div style="margin: 0 auto; width: 250px; height: 60px; border: 1px solid black; padding: 20px;">
    <button id="connectBtn2" style="background-color: #C0C0C0">Connect</button>
    <button id="disconnectBtn2" style="background-color: #C0C0C0" disabled>Disconnect</button>
    <button id="startBtn2" style="background-color: #C0C0C0" disabled>Start</button>
  </div>
  <div id="mainDiv2" style="margin: 0 auto; width: 600px; height: 600px; border: 1px solid black;">
    <canvas id="canvas2" width="600" height="600"></canvas>
  </div>
</div>
</body>

The "Connect" button handler is simpler:

  • Reader creation is moved in the "Start" button handler
  • The read loop has moved in the draw() function.
  connectBtn2.addEventListener('click', async () => {
    try {
      port2 = await navigator.serial.requestPort();            // Prompt user to select a serial port
      await port2.open({ baudRate: 115200 });                  // Wait for the serial port to open
      console.log('Serial port opened');
      // Create an output stream to write on the serial port
      let encoder2 = new TextEncoderStream();
      outputDone2 = encoder2.readable.pipeTo(port2.writable);
      outputStream2 = encoder2.writable;
      console.log('Thrustmeter ready'); 
      toggleButton(connectBtn2);
      toggleButton(disconnectBtn2);
      toggleButton(startBtn2);
    }
    catch (err) {       // Error handling
      console.error(err);
    }
  } );
  startBtn2.addEventListener('click', async () => {
    writer2 = outputStream2.getWriter();  // get a writer
    // acquisition start
    if (!started2) {
      // create a reader and flush the input buffer (1)
      reader2 = port2.readable.getReader();
      const { value, done } = await reader2.read();
      await writer2.write('A');             // send the "start" character to the thrustmeter
      console.log('Acquisition started');   // log the event
      window.requestAnimationFrame(draw);   // call draw() to update the graph (2)
      started2 = true;                  // update the state
      startBtn2.textContent = 'Stop';   // update the button
    } else {
      await writer2.write('S');   // send the "stop" character to the thrustmeter
      // kill the reader
      if (reader2) {       
        await reader2.cancel();
        reader2 = null;
      }
      console.log('Acquisition stopped')  // log the event
      started2 = false;                   // update the state
      startBtn2.textContent = 'Start';    // update the button
    }
  1. First, flush the input buffer, in case some data remains from previous acqusition
  2. ask the web page to call draw() to update the canvas
  async function draw() {
    newData = await readInto(reader2, rxBuffer);
    if (newData) {
      for (i=0; i<bufferSize2; i+=2) {
        sample = (rxBuffer[i] + rxBuffer[i+1]*256) / 4095 ;
        if (sample > 1) {
          sample = 0;
        }
        buf2.append( sample );
      }
      ctx2.clearRect(0,0,width2,height2);
      ctx2.beginPath();
      ctx2.moveTo(width2*t2[0],height2*(1-buf2.getData(0)));
      t2_max = t2[t2.length-1];
      for (let i=1; i<t2.length; i++) {
        ctx2.lineTo(width2*t2[i]/t2_max,height2*(1-buf2.getData(i)));
      }
      ctx2.stroke();
      window.requestAnimationFrame(draw);
    }
  }

This function fills the buffer with data from the reader.

It returns a boolean telling if the operation was completed.

  async function readInto(reader, buffer) {
    res = false
    if (started2) {
      res = true
      let count = 0;
      // works until the buffer is filled
      while (count < bufferSize2) {
        const { value, done } = await reader.read();  // read data
        // if data received, save them in the buffer (1)
        if (!done) {
          for (i=0; i<value.byteLength; i++) {
            buffer[count++] = value[i];
          }
        } else {
          console.log("read ended")
          res = false
          break;
        }
      }
    }
    return res;
  }
  1. done is true if the reader was canceled (in the start handler).
    value.bytelength is the number of received bytes.
    value can be accessed as an array.

Problems encountered

buffer usage

WebSerial documentation explains a "Bring Your Own Buffer" (BYOB) mode to read data from the serial port.
I tried their example, but wasn't able to make it work for my application.

The problem is, probably, that I don't understand how the buffer are handled by the reader object:

reader = port.readable.getReader({ mode: "byob" });
const { value, done } = await reader.read(new Uint8Array(buffer));

It seems that sometimes, the read() method returns even when the buffer is not full, even with the await operator.

I'll have to learn about the promises in JavaScript.

Arduino UNO test

I tried my interface with my old Thrustmeter, based on an Arduino UNO and has strange results:

I modify the Arduino code so that it always sends the same value. It makes debug easier. It appears that sometimes a byte is missing.
As data are 16-bit values, it means that the following data displayed have MSb and LSB inverted until a new byte is missing.

It appears that reducing the sampling frequency i, the Arduino solved the problem.
I don't understand why, as it works with my Python interface and my WebSerial interface works with the ESP32.

Useful files