Web Dashboard Guide For Microcontrollers

Building a web dashboard for a microcontroller such as STM32 or ESP32 does not have to be complex or time-consuming. This guide shows a simple, practical way to create a professional embedded web dashboard using AI-generated HTML and the Mongoose Library. You will learn how to design a Web UI, bind it to device data, and connect it to your firmware using a built-in embedded web server with real-time WebSocket and JSON-RPC communication. The approach focuses on fast integration - generate a UI, add minimal C glue code, and embed everything directly into your firmware. By following the steps, you will build a working device dashboard that can monitor and control hardware in real time, without implementing a full web stack.

Step 1. Add Mongoose to your firmware

The first step is to add the mongoose.c and mongoose.h to your firmware. Once integrated, it unlocks a wide range of networking capabilities for microcontrollers such as STM32 and ESP32. Mongoose can serve an embedded web dashboard, enable remote device control via MQTT, provide TLS security, support over-the-air firmware updates, and more.

Mongoose works with existing TCP/IP stacks like lwIP, or can operate independently using its built-in networking stack. The same flexibility applies to TLS: you can use external libraries such as mbedTLS or WolfSSL, or rely on Mongoose’s built-in TLS implementation.

For a quick and reliable start, it is recommended to use the built-in network stack, as it minimizes dependencies and simplifies integration. The initial goal is to run a minimal "Hello, world" embedded web server. From there, you can incrementally extend it into a full real-time web dashboard.

If you are using STM32, refer to: https://github.com/cesanta/mongoose-stm32-tcpip-examples

For other platforms, see: https://github.com/cesanta/mongoose/tree/master/tutorials

Step 2. Develop Web Dashboard HTML

In this step, create a directory, and develop your device dashboard there.

The simplest is to use the AI. Using a series of plain English prompts, it is possible to integrate a very descent device dashboard. For example, this prompt creates a simple dashboard with 3 toggle buttons:

Generate index.html file - a device dashboard for my microcontroller. It should have a toolbar on top, and a panel with 3 checkboxes for LED control, nicely styled as toggle buttons.

Alternatively, you can use AI coding agent like Claude or Codex, and create your dashboard in a directory, for example like this:

web_root/
         index.html
         style.css
         logo.svg

Another possibility is to use a ready-to-go example.

Step 3. Add dashboard.js

Edit your index.html file and add the following snippet to its header:

In the Dashboard.init() call, edit the data object to represent the state of your device. The object should be flat, and contain only numbers, booleans, and strings. The values there are mock values and will be replaced at runtime with the real values reported by a device.

IMPORTANT: data keys must be valid identifiers: digits, numbers, and underscores. Dots and other special characters must not be used.

Step 4. Instrument HTML elements

Binding elements

  • Add data-bind="FIELDSET.FIELD" to toggles, inputs and dropdowns, to bind them to a specific field.
  • Add data-autosave="1" if you want user edits to the bound keys automatically saved to the device
  • Add data-save="FIELSET" to the buttons create a "save" button for user edits. It will watch the values that in the fieldset and if they are edited, a button would activate, otherwise it'll stay disabled
  • Add data-cancel="FIELDSET" similar to data-save, but for cancelling edits

See this example code from the settings-panel:

Expressions

You can add ${...} JS expressions in your HTML. When the expression is evaluated, its evaluation context contains your Dashboard data, and all global JS variables like window. For example, ${settings.volume + 1} will be evaluated exactly as you think.

Conditional display

Expressions are useful for the conditional display: expression can add CSS classes, or CSS styles depending on some conditions. For example, this snippet adds orange warning style, or red alert style, to the metric display:

Normally, the notification text is hidden. But depending on a condition, the warning or the alert notification is shown.

Step 5. Instrument main.c

On the device side, Mongoose provides helper functions that handle get and set commands and automatically generate change notifications - read the next section about the JSON-RPC and the commands.

Split your device data into so-called "fields", group fields by field-sets, and give them names. Dashboard code will serialise a field-set into a JSON object, where each field is a key.

Let's take device settings panel as an example in the tutorials/device-dashboard example. The device settings is represensed as follows:

When dashboard wants to get the values for your device settings, it calls the sync_settings() function in a reading mode (is_write == false). Your code should synchronise those bool variables with your device - in other words, your code should set s_log_level, s_name, s_volume variables. Dashboard then takes those, serialises into JSON, and sends to the UI.

Similarly, when user makes a change in the UI for either of those settings, the UI sends a JSON string with a change, like this:

{"settings": {"volume": 12.5, "log_level": 2, "name": "Edinburgh"}}

Dashboard code parses that string into s_log_level, s_name, s_volume variables - because, thanks to the fields definition, it knows their types, sizes and locations. Then it calls your sync_settings() function in a writing mode (is_write == true), and your function must take the values from the variables and write to your device.

NOTE: Mongoose dashboard-related definitions and helpers are defined in dash.h and are part of mongoose.h.

Step 6. Embed UI into the firmware binary

First, amalgamate all your UI into a single, self-sufficient HTML file with no external dependencies. There are multiple ways of doing that. An easy one is to use the monolith tool:

$ monolith web_root/index.html > dashboard.html

Now, having the self-contained dashboard.html, we can zip it and turn into a .c file which has a C/C++ byte array representing file data. Again, there are multiple ways of doing that - we recommend the pack.js tool, part of Mongoose:

$ curl -sO https://raw.githubusercontent.com/cesanta/mongoose/master/resources/pack.js
$ node pack.js dashboard.html::gzip > dashboard.html.c

Done! Just include this dashboard.html.c to your project C/C++ files to build.

NOTE: the provided examples automate all these steps - e.g. see this Makefile. You can automate your build process in the similar way.

For the theory behind this, see serving HTML/CSS/JS article. This makes sure that your UI is resilient to filesystem errors (e.g. failing SD card), and is always in-sync with the device backend code: imagine you updated the UI on SD card, but your firmware rolled back to the previous version, and now your dashboard is broken.

How it works

The dashboard.js library, when included in the HTML, creates a global window.Dashboard object with the following properties:

  • Dashboard.init() - initialisation function
  • Dashboard.call(name, args) - executes a function on a device and returns device's response
  • Dashboard.on(name, func) - register handler function

When your HTML is loaded, the dashboard.js creates a Websocket connection to the /api/websocket endpoint. That establishes a realtime, bidirectional communication channel between the UI and the device. If the connection is broken, dashboard.js tries to reconnect every second.

The UI and the device communicate with each other using JSON-RPC over websocket, using 3 simple commands: get, get, and change.

The get command is sent from the Web UI to the device. It fetches data fields from the device and integrates them into the Dashboard.data object.

Without parameters, the get command fetches all fields:

// Request and response
{"id": 1, "method": "get"}
{"id": 1, "result": {"led1": true, "led2": false, "volume": 17}}

With a parameter, get fetches only the requested field:

// Request and response
{"id": 1, "method": "get", "params": "volume"}
{"id": 1, "result": {"volume": 17}}

Once the Websocket connection is established, dashboard.js issues the get command with no parameters, thus fetching all fields from a device and populating the Dashboard.data object. The object that is specified there in the init() function gets replaced, since the source of truth for the data is the device.

The set command is sent from the Web UI to the device. It tells the device to set a certain field to a certain value. It can set multiple fields in one command. It returns true if the change was applied, or false otherwise.

// Request and response
{"id": 1, "method": "set", "params": {"led1": true, "volume":0}}
{"id": 1, "result": true}

The change command is notification sent by a device to all connected clients, informing about the value change for a certain field. Since it is a JSON-RPC notification, it does not have an id, and does not expect a response:

{"method": "change", "params": {"volume":12}}

When change is received, device.js applies it to the Dashboard.data object.

NOTE: if there are multiple browsers connected to one device, any change made on one browser, automatically reflects on all other browsers because of this automatic change broadcasting.

When device.js mutates the Dashboard.data object, it scans all HTML elements on the dashboard page, and looks for the data-... attributes in them. If such an attribute is found, dashboard.js modifies the HTML element accordingly.

NOTE: the Dashboard.call() essentially relays the call from the UI to the device, so you can think of it as a call is executing on a device and you see device's response. By default however only the get and set methods are handled.

Dashboard example

The Mongoose GitHub repository includes a ready to use device dashboard example. It uses dashboard.js and the mg_dash_* API described earlier in this guide. You can reuse and adapt this example for your own firmware. Follow the steps below.

On Windows, start a WSL (Windows Subsystem for Linux) shell. On Linux or macOS, use your regular terminal.

Install required tools: git, make, gcc, nodejs, monolith, watchexec. To install them on Linux / WSL, execute:

sudo apt -y update
sudo apt -y install build-essential make git

To install them on MacOS, execute:

/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
brew install gcc make git node monolith watchexec

Clone the repository, make a copy of the dashboard example, and start it:

git clone https://github.com/cesanta/mongoose
cp -r mongoose/tutorials/device-dashboard mongoose/tutorials/my-dashboard
cd mongoose/tutorials/my-dashboard
make watch

This starts a local device simulator on port 8000.

Open http://localhost:8000 in your browser.

You can now modify web_root/index.html for the web UI and mongoose_dashboard.c for the device backend. Every change triggers an automatic rebuild and restart via make watch, giving you a tight edit and test loop.

A practical way to begin is to use an AI enabled editor. For example, run a prompt like:

in mongoose_dashboard.c, add the "description" string field to the "settings"
fields. In web_root/index.html, add it to the settings tab as text input

This approach lets you iterate quickly across both backend and frontend.

If you have questions, contact us at tech-support at cesanta dot com.