In a previous post, we explored why designing a smart device can be more complex (and costly) than expected, both for businesses and consumers. We then introduced an alternative approach that enables resource pooling and shared services, allowing us to focus on our core business. Finally, we proposed using WebAssembly to address key challenges in this model.
Now, it's time to put theory into practice, let’s dive into the code!
Smart Thermostat Specifications
We’re building a minimal thermostat implementation; while there are many additional features we could include and more advanced algorithms to optimize performance, this example is purely for illustration. Real specifications require much greater detail, my goal here is to provide a concise overview that quickly illustrates our design objectives.
Core Functionality
We aim to develop a smart thermostat to control an HVAC system, starting with heating-only (no AC support in the first release).
Hardware Considerations
- Currently, the system features one indoor temperature sensor.
- Future support for multiple sensors and zones is planned (e.g., adjusting temperatures in sleeping areas or integrating an outdoor sensor for optimized heating).
- The control mechanism is a simple 24V relay to activate (on/off switch) the unit but there are plans for the future to add support for PWM control.
Basic Features
- Users can set two target temperatures,
T1
andT2
, with a schedule that switches between them in 30-minute intervals. - The system could include several preset modes to override the schedule but we are going to start with these:
- Away – The system remains off unless the temperature drops below
TMin
. - Party – The system remains on, maintaining
T1
continuously. - Normal – The system follows the scheduled
T1
andT2
temperatures.
- Away – The system remains off unless the temperature drops below
Smart Features
- Temperature control and schedule are managed via the Hub UI, including mobile app access for remote control.
- Weather forecast data (if available) is used to determine cost-effective heating strategies.
- The system collects data to analyze HVAC efficiency, user habits, and develop future energy-saving optimizations.
Development Considerations
- Hardware development has not started yet, and the final board specifications remain undefined.
- The connection to the hub is TBD: it could use I2C, CAN, USB, or Bluetooth.
- Despite the lack of hardware, we want to begin software development immediately, ensuring a strong foundation before integration.
- We aim to sell this device at the same price of its "traditional" counterpart. We do not want recurring extra costs unless there is a definite ROI (for example data storage).
Hardware
Since we don’t have a working board yet, we’ll need a way to simulate it. A software-based simulation running as a separate service in our Hub could serve this purpose. We need to tentatively agree with the hardware team about a communication protocol, for now (also considering the future expected developments):
- Hub and device communicates using a line-by-line communication of ASCII encoded strings.
- The hub always initiates the communication.
- Commands are case sensitive and leading/trailing spaces are not discarded.
- The hub sends a "read temperature" command
r
to which the device replies with a single integer number which is the temperature in °C. It could optionally have a leading sign+
or-
. - The hub can send a "turn on the heating" command
1
or "turn off the heating" command0
. The device does as instructed without replying.
While a simulation helps in the early stages, it has limitations. To bridge the gap, we’ll quickly develop a device using an Arduino while waiting for the hardware team to finalize a prototype. Meanwhile, the software simulation will also serve as a crucial tool for integration tests, ensuring no time is wasted.
To start, we could implement something as simple as this (see Part 3 of this series, we are using AssemblyScript again in this example):
import { Context } from "firmwareless/hosting"
import { IoStream } from "firmwareless/lib"
const TEMPERATURE = "temperature";
const FURNACE = "furnace";
const READ_TEMPERATURE_COMMAND = "r";
const FURNACE_ON_COMMAND = "1";
const FURNACE_OFF_COMMAND = "0";
let channel: IoStream | null = null;
export function setup(context: Context) {
// This is the temperature we report when queried. To help
// debugging we can edit this value manually in the UI to
// observe the effects.
context.status.register<i8>({
id: TEMPERATURE,
type: "environment/temperature",
unit: "Celsius",
editable: true,
range: { nullable: true, minimum: 0, maximum: 40, step: 1 }
});
// This is the relay we control, we can observe this in the UI
// to determine if everything is working as expected.
context.status.register<u8>({
id: FURNACE,
type: "status/boolean",
});
// To simulate a physical device we open a stream for character
// device. The hosted firmware with our logic is agnostic
// of the transport mechanism!
channel = context.communication.stream.open({
mode: "read-write",
encoding: "ascii",
onWrite: handleWrite
});
}
export function teardown(context: Context) {
channel?.close();
}
function handleWrite(context: Context, stream: IoStream, data: string) {
if (data === READ_TEMPERATURE_COMMAND) {
stream.writeLine(context.status.get<i8>(TEMPERATURE).toString());
} else if (data === FURNACE_ON_COMMAND) {
context.status.set<u8>(FURNACE, 1);
} else if (data === FURNACE_OFF_COMMAND) {
context.status.set<u8>(FURNACE, 0);
}
}
It’s minimal, it lacks logging and even basic error checks, but it’s functional and gets us started.
Meanwhile, another team member is quickly assembling a physical device using an Arduino we had in a drawer. It’s an overkill—far more powerful (and expensive) than necessary—but it will get the job done in 30 minutes, and that’s what matters for now. In a future post, we’ll outline a real hardware design (maybe using a 50 cents ATtiny85 microcontroller, see Part 7 of this series).
We're going to use the onboard LED to check when the furnace should be on. In this example we used an Arduino Nano and an analogic LM35 temperature sensor, please refer to products' datasheets for the appropriate configuration and code. For example, in real applications you really need a few passive components to have clean readings (or in some cases any reading at all); from the LM35 datasheet:
In simple cases (surely if you're using a breadboard) you could use a R-C damper:
Now let's write the code:
#include "Arduino.h"
#include <stdint.h>
#define NUMBER_OF_READINGS_PER_MEASURE 4
#define TEMPERATURE_SENSOR_APIN A0
#define FURNACE_STATUS_INDICATOR LED_BUILTIN
#define READ_TEMPERATURE_COMMAND 'r'
#define FURNACE_ON_COMMAND '1'
#define FURNACE_OFF_COMMAND '0'
int8_t readTemperature();
void setFurnaceStatus(bool active);
char tryReadCommandFromMaster();
void setup() {
pinMode(FURNACE_STATUS_INDICATOR, OUTPUT);
Serial.begin(9600);
}
// We keep waiting for a command from the serial port, inputs
// are always commands (we ignore what we do not know) and the
// only output is the temperature (when we're asked to).
// Note that this is not what you would do in a real application
// but it mimics (more or less) how it could work with I2C.
void loop() {
switch (tryReadCommandFromMaster()) {
case READ_TEMPERATURE_COMMAND:
Serial.println(readTemperature());
break;
case FURNACE_ON_COMMAND:
setFurnaceStatus(true);
break;
case FURNACE_OFF_COMMAND:
setFurnaceStatus(false);
break;
}
}
// The readings are fairly noisy, for this example it's
// enough to calculate a simple average.
int8_t readTemperature() {
int16_t value = 0;
for (int8_t i=0; i < NUMBER_OF_READINGS_PER_MEASURE; ++i) {
value += analogRead(TEMPERATURE_SENSOR_APIN);
}
return (int8_t)((5 * value * 100.0) / 1024 / NUMBER_OF_READINGS_PER_MEASURE);
}
// This is a development board, we use a LED instead of
// turning on/off the heating.
void setFurnaceStatus(bool active) {
digitalWrite(FURNACE_STATUS_INDICATOR, active);
}
// We read one byte (instead of a full line) because currently
// the supported commands are one byte only and we ignore what
// we do not know how to process (for example spaces and new lines).
int16_t tryReadCommandFromMaster() {
if (Serial.available()) {
return Serial.read();
}
}
This is all what we need to start developing our Hosted Firmware.
Software
In this example we are not going to describe our setup with code, our firmware comes together with a JSON descriptor. Note that all the temperatures are in °C, the UI will present the values to the user using their preferred unit (for example °F).
{
"id": "76a38d89-d756-4412-ac87-604ff3cf84d0",
"vendor": "Acme",
"name": "Smart Thermostat Mod. 1",
"version": "1.0.0",
"compatibility": "1.0+",
"channel": {
"initiator": "host"
},
"config": {
"monitoringInterval": "15 minutes",
"schedulingInterval": "30 minutes",
"minimumTemperature": "8 °C",
"maximumTemperature": "30 °C"
},
"variables": [
{
"name": "furnace",
"storage": "uint8",
"type": "boolean"
},
{
"name": "current_temp",
"storage": "int8",
"type": "measure/temperature",
},
{
"name": "target_temp",
"storage": "int8",
"type": "measure/temperature",
},
{
"name": "desired_temp_1",
"storage": "int8",
"type": "measure/temperature",
"default": "16 °C"
},
{
"name": "desired_temp_2",
"storage": "int8",
"type": "measure/temperature",
"default": "18 °C"
},
{
"name": "schedule",
"label": "Schedule",
"type": "system/schedule",
"editable": true,
"editorOptions": {
"interval": "week",
"granularity": "{{ config.schedulingInterval }}",
"selection": "list",
"default": "0",
"listItems": [
{ "key", "0", "label", "Off" },
{ "key", "1", "label", "{{ variables.desired_temp_1 }}" },
{ "key", "2", "label", "{{ variables.desired_temp_2 }}" }
]
}
}
]
}
That's enough to start a quick MVP, we're going to add the missing bits later. The key detail to note is initiator
in the channel
section—it determines who initiates communication. In this case, our hosted firmware takes the lead. However, for battery-powered devices, the roles could be reversed: instead of initiating communication, the firmware would read buffered data and queue commands to be executed during the next connection, optimizing power consumption.
Our firmware starting point implementing a simple on/off control mechanism which follows the user-defined schedule:
import { Context, Scheduling } from "firmwareless/hosting"
import { IoStream, Interval, Temperature } from "firmwareless/lib"
const READ_TEMPERATURE_COMMAND = "r";
const FURNACE_ON_COMMAND = "1";
const FURNACE_OFF_COMMAND = "0";
let channel: IoStream | null = null;
export function setup(context: Context) {
// This is how often we are going to check for the temperature.
context.schedule(Interval.parse(context.config.get("monitoringInterval")), main);
// When these variables change value we need to recalculate
// our status because they represent the desired temperatures
// and our scheduling.
context.variables.onChange(["desired_temp_1", "desired_temp_2", "schedule"], main);
}
export function teardown(context: Context) {
// If we are going down then we want to be sure we are not
// leaving the heater on!
const stream = IoStream.Open(context.associatedDeviceId);
try {
stream.writeByte(FURNACE_OFF_COMMAND);
}
finally {
stream.close();
}
}
function main(context: Context) {
const stream = IoStream.Open(context.associatedDeviceId);
try {
applyFurnaceStatus(context, stream);
}
finally {
stream.close();
}
}
function applyFurnaceStatus(context: Context, stream: IoStream) {
const desiredTemperature = resolveDesiredTemperature(context);
context.variables.set("target_temp", desiredTemperature);
if (desiredTemperature === null) {
stream.writeByte(FURNACE_OFF_COMMAND);
return;
}
stream.writeByte(READ_TEMPERATURE_COMMAND);
stream.flush();
const temperature = Temperature.parse(stream.readLine(), "°C");
const status = temperature < desiredTemperature;
stream.writeByte(status ? FURNACE_ON_COMMAND : FURNACE_OFF_COMMAND);
context.variables.set("furnace", status);
}
function resolveDesiredTemperature(context: Context) {
// To "resolve" the desired temperature we need to read the list
// of scheduled values from the "schedule" variable and pick the
// selected one for the current date and time.
// It's so common that we have an helper function for that.
const temperatureId = Scheduling.resolve("schedule");
if (temperatureId === "1") {
return context.variables.get("desired_temp_1");
}
if (temperatureId === "2") {
return context.variables.get("desired_temp_2");
}
// A value of "0" or an unknown key means "off".
return null;
}
Please note that the code is minimal, focusing solely on our core business (building thermostats) which was our original goal.
Final Step: The UI
To make this approach truly effective, the UI must not be tied to a specific technology or framework (like React or even plain HTML). Instead, we need a technology-agnostic representation of the page—allowing different hubs to render it in various ways.
- One hub might use HTML, another could leverage Qt, while mobile apps might have entirely different rendering engines.
- The goal is flexibility: ensuring seamless adaptation across platforms without forcing a single UI paradigm.
Let's add a new section ui
to our JSON file (note that JSON might not be the best way to represent the UI, consider this just pseudo-code):
{
...
"ui": [
{
"type": "page",
"title": "Thermostat",
"content": {
"control": "ring",
"label": "Temperature",
"minimum": "{{ config.minimumTemperature }}",
"maximum": "{{ config.maximumTemperature }}",
"value": "{{ variables.current_temp}}",
"steps": [ "{{ variables.target_temp }}" ],
"text": true,
"actions": [
{
"type": "edit",
"target": "schedule",
"label": "Schedule"
},
{
"type": "edit",
"target": "desired_temp_1",
"label": "Temperature 1"
},
{
"type": "edit",
"target": "desired_temp_2",
"label": "Temperature 2"
}
]
}
}
]
}
Now, we can edit our schedule, monitor the temperature, and ensure everything is running smoothly.
Next
In the next post, we'll add some smart features and refine the algorithm to make it more efficient and less rudimentary.
Top comments (2)
Wow, this post is absolutely incredible! The deep dive into both hardware and software, along with real code examples and practical tips, is insanely valuable. You made a complex topic so approachable—can’t wait for the next part! 🚀🔥
Thank you, much appreciated! We're just starting to scratch the surface (even a simple thermostat's got a lot going on to do it properly), but I'm trying to keep these posts short to avoid obscuring the central point (move firmware to the edge, share resources, keep IoT devices cheap and sustainable).
In the next post, we're going to make the firmware smarter (and slightly less naive); in the next one, we'll probably play with the real hardware. It's not finished yet.