Disclaimer: this article has been posted originally as a series of blog posts IoT Architectures Under Pressure. The current reference implementation of the hub mentioned in this article is available on GitHub (bear in mind that it's just the beginning!) and it has been introduce in another post Tinkwell: Firmware-less IoT and Lab Automation.
The code and electronic circuit designs provided in this post are intended for illustrative purposes only. They should not be considered best practices nor assumed to comply with industry safety standards or regulations. Before implementing any design, always refer to official documentation, safety guidelines, and certified standards applicable to your region or industry. The author assumes no responsibility for damages, malfunctions, or risks arising from the use of this information.
Introduction"This will be the year of the true IoT boom."
We hear this prediction year after year, again and again. The technology is ready: we have the right stack, the expertise, established protocols, affordable MCUs, and more ideas than budget to implement them. The benefits of an interconnected world—starting with our own homes—are clear to everyone.
Yet, the boom hasn't happened. Not quite.
Implementation Is More Expensive Than AnticipatedThe business case seems straightforward, and an initial cost estimation might appear reasonable, but unexpected expenses quickly creep in.
In the Old DaysTake a simple traditional (non-smart) thermostat, for example. Its retail value can be as low as €10, consisting of just a few components, a knob, and a plastic enclosure. Everything is designed by a single engineer, with minimal complexity tracking component lifecycles and End-of-Life notices. When producing at scale, the cost per unit is only a few Euros. Add a €0.50 Microchip ATtiny microcontroller and an LCD screen, and you now have a high-end device, ready to be mass-produced in the millions.
Go SmartThe logical next step is to make it Smart:
- It adds value for the customer offering energy savings and seamless smartphone control.
- It differentiates your product from competitors, whether they still offer traditional devices or less advanced smart alternatives.
- It helps attract new customers while enticing existing ones to upgrade.
- It opens the door for increasingly complex features, encouraging both old and new customers to stay up to date with the latest device.
- If you offer additional products, you can build an ecosystem that strengthens customer loyalty and retention.
- You're going to need to deal with GDPR (if you are selling in Europe) but the benefits outweigh the headaches.
You soon realize that:
- Development, debugging and testing are far more complex and they will require additional tools and investment.
- Your tiny MCU isn’t powerful enough to run the TCP stack, requiring an upgrade, perhaps to an Arm M0+ or even M4.
- You now need an engineer to design the board (and possibly its firmware) plus someone to develop the app (potentially more, depending on your technical choices).
- Cloud infrastructure becomes essential, meaning you'll need a developer to build and deploy it, unless you want to also hire a separate DevOps Engineer.
- The Cloud infrastructure comes with a recurring monthly cost (an on-premises solution is hardly feasible on a tight budget).
- User authentication requires a professional IAM solution, adding to your expenses.
- The Cyber Resilience Act (CRA) (in Europe, Product Security and Telecommunications Infrastructure Act 2022 (PSTIA) in UK and—if you want to gain your US customers' trust—the Cyber Trust Mark) makes it clear that security is no joke and you’ll need to hire someone to handle compliance properly.
- The Cloud infrastructure will need 24/7 on-call support.
Your product will be significantly more expensive for customers (up to €300 for top-notch models) and tracking component lifecycles is no longer a trivial task.
Post-Sales ChallengesA traditional thermostat comes with a small margin, but once it’s sold, your costs end (except for warranty obligations). A smart device, however, continues to incur expenses throughout its entire lifecycle.
- You have to ensure secure over-the-air updates, security fixes are essential and non-negotiable.
- You have to maintain your apps and backend staying compatible with new devices, OS versions, and supported LTS releases.
- Device provisioning isn't as smooth as expected, you're going to need more people working in Customer Support.
One option is to create products with short lifecycles, but as we've seen, that doesn’t encourage customers to embrace smart devices and it quickly erodes trust in your brand. There are plenty of well-known examples of this issue, but there’s no need to make this post controversial.
Is There a Better Way?Beyond cost and implementation hurdles, the IoT landscape presents additional challenges: connectivity and interoperability among devices. I strongly believe that if two IoT devices aren't integrated or integrable, they’re only realizing a fraction of their true potential—perhaps just 60%.
There must be a better way to tackle this problem. Maybe not for every use case but certainly for the vast majority.
Reimagining Smart Device DevelopmentDuring my time at Arm in the (now defunct) ISG group, we explored alternative solutions that showed promise for many common IoT use cases. My approach is built on a few key principles:
- Smart devices shouldn’t be more expensive than traditional counterparts—in some cases, they should even cost less.
- You should only develop what you truly need, sticking to the principle of "Pay for what you use".
- A product’s lifecycle shouldn’t be dictated by updates—or, from a user’s perspective, by the longevity of the company that produced it.
Post-sale costs often catch developers and customers off guard. Many products unexpectedly reach their end-of-support, leaving users with expensive devices they can no longer use at full capacity. This creates adoption anxiety, slowing the widespread integration of smart devices into our lives. Combined with high prices, poor security, and multiple cumbersome applications, these issues become significant barriers to adoption.
As companies and developers, we have a responsibility to support our customers to the best of our ability. Building smart devices isn’t just about innovation—it’s about ensuring long-term usability, security, and trust.
RationaleLet's consider a few real-life scenarios:
- When traveling from New York to Tokyo, most people don’t buy or even rent a private jet: they share existing infrastructure.
- You don’t need separate computers for Microsoft Word and email: one device can handle multiple tasks.
Similarly, many features we expect from smart devices should be shared across multiple systems, rather than being duplicated on individual devices:
- Internet connectivity.
- User interfaces (local control panels, web interfaces, mobile apps).
- User authentication.
- Security and over-the-air updates.
- Data collection.
In many cases (especially in a smart home setup) computing resources can also be shared. Input methods, CPU, memory, and graphics are often underutilized, making resource pooling a practical and efficient approach.
The very same idea we applied in serverless computing could be applied here: that's why I'm calling this firmware-less (with or without hyphen? leave a comment).
An OverviewLet's compare a possible traditional approach with a firmware-less approach.
A Traditional ApproachOur traditional smart thermostat is engineered somehow like this:
The fragmentation of interfaces and connectivity across smart home devices leads to redundancy, inefficiency, and complexity. Every new addition (whether an AC unit, alarm system, or smart speaker) introduces another screen, remote, or app, making interaction cumbersome instead of seamless.
A Firmware-less ApproachA more integrated approach could streamline smart home management, reducing unnecessary duplication and clutter while enhancing usability. Centralized control, shared UI frameworks, and edge computing could help consolidate resources so devices operate more efficiently and cohesively.
Smart devices could be thin clients, connecting to a hub that handles processing and UI rendering. Think of it like how cloud gaming works: instead of every gaming device needing high-end hardware, a central server does the heavy lifting.
At first both approaches seem similar but note a few differences:
- The edge device hosts our firmware (in theory our thermostat wouldn't even need a microcontroller at all).
- All the expensive resources are shared: the edge device hosts multiple firmware and they all share CPU, memory, Internet connection, UI and cloud services.
- Running on the same device it's easy for multiple applications to expose their own services and to use other services, when available, to add functionalities.
Which are the advantages?
- Lower development costs – Comparable to a traditional non-smart device.
- More affordable – Potentially cheaper than both other smart devices and even traditional non-smart thermostats.
- No running costs – Updates are only required for the hub, which can be sourced by the user from any vendor and maintained via a subscription program.
- Long-term relevance – Your product remains secure and up-to-date without additional company costs.
- Better user experience – Simplifies interactions and eliminates redundant interfaces.
- Stronger security management – Centralized control ensures consistent updates and authentication.
A service-oriented approach – Enables exciting possibilities:
- Your AC unit could integrate with the alarm system to detect occupancy, optimizing energy use or adjusting when windows are open.
- Your floor-cleaning robot could serve as an additional security layer within your alarm system.
- Automation tasks become easier to set up (we all love Node-RED but it's not for everyone!)
- Write a comment if you can think of other integration!
- A service-oriented approach – Enables exciting possibilities:Your AC unit could integrate with the alarm system to detect occupancy, optimizing energy use or adjusting when windows are open.Your floor-cleaning robot could serve as an additional security layer within your alarm system.Automation tasks become easier to set up (we all love Node-RED but it's not for everyone!)Write a comment if you can think of other integration!
Rather than reinventing the wheel, we should embrace a sustainable and efficient solution.
Key ObstaclesHowever, several challenges must be addressed:
- Firmware isolation – Each device’s software must be securely separated.
- Unknown hub architecture – The hub's OS, CPU, and other specifications are unpredictable.
- Avoiding ABI/ISA fragmentation – Supporting multiple ABIs and ISAs would be impractical and expensive.
A few possible approaches come to mind, but each has drawbacks:
- Virtual machines or lightweight containers – While they offer isolation, they don’t fully resolve compatibility issues. An emulation layer could help, but would increase cost and reduce efficiency in the long run.
- Interpreted languages – Using Python or JavaScript could simplify portability but introduces severe limitations. Pairing them with containers might improve isolation, but would still fall short of a universal solution.
- Bytecode virtual machines – Java VM offers better flexibility but restricts developers to supported languages, limiting versatility.
Ideally, we need a standardized, open, language-agnostic solution with a sound type system, enabling a Language-based System approach.
One promising option is WebAssembly (WASM)—a lightweight, efficient, and cross-platform execution model. WASM already supports an increasing number of programming languages, including C, which facilitates the porting of existing code.
Other options, such as BEAM (the Erlang VM), provide robustness but lack widespread adoption for general commercial consumer applications.
Solving Key Challenges with WebAssembly (WASM)Embracing WASM as our virtual ISA presents several advantages:
- Broad language support – Numerous programming languages target WASM with varying degrees of support. See Awesome WebAssembly Languages. This ensures familiarity for firmware developers and lowers the learning curve.
- Sound Type System – WASM guarantees type safety and memory safety within its semantics, enhancing reliability (source).
- Platform Agnostic Execution – As a virtual ISA, WASM can be compiled AOT, JIT, or interpreted, making it highly adaptable to present and future platforms.
Several WASM runtimes are available to execute our code. In this example, we'll use Wasmer, though other options exist. If we compile AOT (Ahead-of-Time), we don’t even need a runtime at all!
An exampleWe're going to write a fictional (and minimal!) firmware for our smart thermostat, let's assume we decided to use AssemblyScript (but it could have been in C, Rust, Go or any other supported language):
import { Context } from "firmwareless/hosting"
import { Interval, IoStream } from "firmwareless/lib"
export function setup(context: Context) {
context.status.register<i8>("current_temperature", "Temperature");
context.status.register<i8>({
id: "desired_temperature",
label: "Desired temperature",
type: "environment/temperature",
unit: "Celsius",
editable: true,
editor: "Knob",
indicator: "Ring",
range: { nullable: true, minimum: 12, maximum: 30, step: 1 }
});
context.status.register<u8>({
id: "furnace",
label: "Active",
type: "status/boolean",
indicator: "Led",
});
context.schedule(main, { interval: Interval.FromMinutes(1) });
}
function main(context: Context) {
const stream = IoStream.Open(context.associatedDeviceId);
const currentTemperature = stream.readInt8();
context.status.set("current_temperature", currentTemperature);
const desiredTemperature =
context.status.get("desired_temperature");
const furnaceIsActive = currentTemperature < desiredTemperature;
stream.write(furnaceIsActive);
context.status.set("furnace", furnaceIsActive);
}
Note how we described our setup programmatically but it could have been done with a simpler JSON descriptor. To compile it:
asc ./thermostat.ts -o ./thermostat.wasm --optimize
Now, the final step is uploading the firmware to a Public Firmware Repository (obviously it's not the only option, the device itself could provide its firmware to the host!).
When customers purchase our device, the hub will automatically download and install the firmware, simplifying onboarding and updates.The hub (or a separate cloud service) is responsible for compiling the code into a native executable, if we choose this approach. With Wasmer, the process is straightforward:
wasmer create-exe thermostat.wasm -o ./thermostat --target=aarch64-linux-gnu
This setup ensures seamless deployment while keeping the firmware adaptable across platforms.
Where Are We?At its core, this approach mirrors the way traditional desktop applications or device drivers function. Instead of embedding complex firmware directly into a device, we're simply offloading it to a hub: just as a computer executes software or loads drivers to communicate with peripherals.
We've developed a smart device that:
- Costs less than a traditional one—requiring only an I2C temperature sensor and a tiny relay.
- Empowers customers to spend less while choosing the hub that best suits their needs, with full control over updates.
- Supports multiple programming languages, allowing developers to use familiar tools without retraining.
- Simplifies development and debugging: the firmware runs unchanged on a development machine or even in a browser.
- Includes an up-to-date UI, security, and mobile app—all without writing a single line of code.
- Future-proof design ensures compatibility across CPUs, hub OSes, smartphone OSes, and input methods.
We can build an interconnected world through a more accessible and cost-effective approach.
Why democratic? Because by removing complexity, we empower anyone to design task-specific IoT devices without compromising usability or security.This approach fosters experimentation, you can prototype using:
- Arduino + shields
- Raspberry Pi + hats
- Any development board at hand
By lowering barriers to entry, IoT innovation becomes more inclusive, allowing developers of all levels to create functional, secure, and efficient devices without massive investments.
In this post, we'll outline the general architecture, how it differs from mainstream IoT models, and the flexibility it offers. Since no single solution fits all, integration must be seamless, allowing diverse devices to operate within the same ecosystem while accommodating varied requirements.
What About the Hub?Investing in a smart hub offers compelling business advantages:
- Focused Platform, Not Fragmented Devices – Instead of competing to build the "best" smart device in every category, you create a versatile, centralized system that enhances multiple devices seamlessly.
- Stable & Predictable Revenue Model – A subscription-based service keeps users' systems up-to-date while continuously delivering new features.
Data-Driven Insights & Optimization – With user-permissioned data from diverse sensors across multiple vendors, you can:
- Provide advanced functionalities and automation.
- Offer valuable analytics and predictive insights.
- Improve user experience, security, and device efficiency.
A firmware-less hub is not just about convenience: it creates a sustainable ecosystem that benefits both users and developers while establishing a scalable, revenue-generating foundation.
We are going to try to develop a basic reference implementation (which is not dedicated only to IoT applications) in C#, check the GitHub repository for more info.
Smart Thermostat SpecificationsWe’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 FunctionalityWe 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.
- 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. - 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, maintainingT1
continuously.Normal – The system follows the scheduledT1
andT2
temperatures.
- 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.
- 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).
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.
SoftwareIn 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 UITo 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.
SchedulingThe first improvement is to start the furnace a bit earlier, ensuring that by the target time, the desired temperature has already been reached.
To keep things as simple as possible, we will ignore several factors:
- Heat losses: These depend on various elements such as insulation, wall volume, and outside temperature. Ignoring other heat sources, we can approximate this with: h_net=h_in−h_out ≈ h_heater−U×A×(T_outside−T_inside. If you want a rough adjustment, adding 50% to the h_s calculation could help account for it.
- Time-dependent values: We assume they remain constant, even though they actually change over time.
- Additional heat sources: Sensible and latent heat contributions from people, lighting, and electronic devices.
- Environmental factors: Humidity and pressure variations.
While we are simplifying a lot, the reality is that we are building a smart thermostat, so these factors only matter up to a point. Future iterations of our device will collect data to learn precisely how many °C/hour°C/hour our system can achieve, accounting for seasonal variations and outside temperature.
In short, we're simply going to use:
h_s=c_p×ρ×q×Δt
Consequently:
T=h_s/P
Where:
h_s = Sensible Heat in kW.c_p = Specific Heat of Air in kJ/kg°C.ρ = Density of Air in kg/m3.q = Air Volume Flow in m3/s.Δt = t_target−t_current in °C.P = Heating element power in kW/h.T = Time required to reach the required temperature t_target in hours.
We know that c_p=1.006kJ/kg°C c_p=1.006kJ/kg°C and ρ=1.202kg/m3.
Let's calculate how long does it take to heat a room from 10 °C to 20 °C, assuming q=1 m3/s and P=10kW.
T=(1.006kJ/kg°C)×(1.202kg/m3)×(1m3/s)×(10°C)10kW=12.09212kW10kW/h≈1.2hours
Let's add a new configuration entry:
"heatingPower": "10 kW"
If you know your furnace's power in BTU/h then remember that 1 W is (more or less) 3.41 BTU/h. The code to calculate how many hours in advance we need to turn on the furnace:
const SPECIFIC_HEAT_OF_AIR = 1.006;
const DENSITY_OF_AIR = 1.202;
const AIR_VOLUME_FLOW = 1; // This might be configuration
function calculateHoursToTemperature(
context: Context,
currentTemperature: i8,
targetTemperature: i8) {
const dt = targetTemperature - currentTemperature;
if (dt < 0)
return 0;
const h = SPECIFIC_HEAT_OF_AIR * DENSITY_OF_AIR * AIR_VOLUME_FLOW * dt;
return h / context.config.get<Measure>("heatingPower").toFloat("kW/h");
}
Now we can refactor our initial code:
function applyFurnaceStatus(context: Context, stream: IoStream) {
// If we have a scheduled setting then we must honor it
const currentSchedule = getScheduledTemperature(context, "current");
if (currentSchedule.id !== null) {
setTemperatureAndFurnace(context, currentSchedule.value);
return;
}
// We do not have a target temperature for now, do we need to
// start heating to be ready for the next one?
const nextSchedule = getScheduledTemperature(context, "next");
if (nextSchedule.id === null) {
setTemperatureAndFurnace(context, currentSchedule.value);
return;
}
const hoursToReachTemperature = calculateHoursToTemperature(
context,
getCurrentTemperature(stream),
nextSchedule.value
);
if (hoursToReachTemperature >= nextSchedule.delay)
setTemperatureAndFurnace(context, nextSchedule.value);
}
function getCurrentTemperature(stream: IoStream) {
stream.writeByte(READ_TEMPERATURE_COMMAND);
stream.flush();
return F32.parseFloat(stream.readLine());
}
function getScheduledTemperature(context: Context, select: "current" | "next") {
const scheduling = Scheduling.resolve("schedule", select);
return { ...scheduling, value: toValue(scheduling) };
function toValue(id: string) {
if (id === "1")
return context.variables.get("desired_temp_1");
if (schedule.id === "2")
return context.variables.get("desired_temp_2");
return null;
}
}
function setTemperatureAndFurnace(context: Context, temperature: i8) {
context.variables.set("target_temp", temperature);
if (temperature === null) {
setFurnaceStatus(stream, false);
return;
}
const currentTemperature = getCurrentTemperature(stream);
const isFurnaceActive = currentTemperature < temperature;
setFurnaceStatus(stream, isFurnaceActive);
}
function setFurnaceStatus(stream: IoStream, active: bool) {
if (context.variables.get<bool>("furnace") != active) {
stream.writeByte(status ? FURNACE_ON_COMMAND : FURNACE_OFF_COMMAND);
context.variables.set("furnace", status);
}
}
Change Schedule According to Weather ForecastThere are a few more basic features to add, but with a working scheduler in place, we can finally introduce our first smart feature. When the temperature difference between inside and outside exceeds a configured threshold, we will increase h_s by 50% (based on configuration).To achieve this, we’ll leverage a service exposed by our host, following this contract in Google protobuf (because, let’s be honest, no one wants to write or read WASM bindings):
syntax = "proto3";
import "google/protobuf/timestamp.proto";
package Weather;
service ForecastService {
rpc get_current_weather (location) returns (current_weather);
// ...
}
message location {
float latitude = 1;
float longitude = 2;
// ...
}
message current_weather {
google.protobuf.Timestamp timestamp = 1;
float temperature = 2;
float feels_like = 3;
// ...
}
If you are wondering why protobuf: because under the hood we may expose all services with gRPC (enabling load balancing among multiple hubs, secure connections and even seamlessly integration with remote cloud services). We might see more of this when we are going to discuss a reference hub implementation in a separate series (because this approach isn't limited to IoT applications!).
Now we can add a few configuration options:
"criticalDifferenceWithExternalTemperature": "10 °C",
"heatLossesCompensation": 1.5
And change calculateHoursToTemperature()
accordingly:
function calculateHoursToTemperature(
context: Context,
currentTemperature: i8,
targetTemperature: i8) {
const dt = targetTemperature - currentTemperature;
if (dt < 0)
return 0;
let h = SPECIFIC_HEAT_OF_AIR * DENSITY_OF_AIR * AIR_VOLUME_FLOW * dt;
const externalTemperature = readExternalTemperature(context);
const maxExternalDelta = context.config.get<Measure>("criticalDifferenceWithExternalTemperature").toFloat("°C");
if (currentTemperature - externalTemperature >= maxExternalDelta)
h *= context.config.get<f32>("heatLossesCompensation");
return h / context.config.get<Measure>("heatingPower").toFloat("kW/h");
}
function readExternalTemperature(context: Context) {
return context.services.get<ForecastService>("Weather.ForecastService").getCurrentWeather().temperature;
}
Just like that, we’ve introduced our first true smart feature. Notice how we didn’t have to venture beyond our domain of thermostat design.
Hardware designIn this example we assume I2C communication with the hub (you might need a bus buffer like NXP P2B96 for long distances).
The entire device is built around an Atmel ATtiny85 microcontroller, a Texas Instruments LM35 temperature sensor and a Sanyou SRD relay. Other variations are surely possible with minimal changes.
With this design, the estimated cost for all components is around 8 USD (€7) but it's way less than that if you buy in bulk.
This is not a production-ready design but if you're going to build something then remember:
- C1, C2, R4 and R5 should be as close as possible to U1.
- C4 and R1 should be near U2.
- U2 should have its tiny heat sink and be as far as possible from other heat sources (ideally near the side of the PCB).
- If you are really using a long I2C line then, as first step, you could change R4 and R5 to 10 kΩ and add a TVS diode like Texas Instruments TPD4E1B06.
Power supply is outside the scope of this post but a super simple design (from a 24V DC input you might get from the furnace) could be:
Let's see the firmware, again we are going to keep it simple (refer to Atmel's Application Notes for a full I2C slave implementation in C) or jump to the C++ implementation (which is somewhat complete):
C Implementation#include <avr/io.h>
#include <avr/interrupt.h>
#include <util/delay.h>
#include <stdint.h>
#define SLAVE_ADDRESS 0x50
#define NUMBER_OF_SAMPLES 6
#define FURNACE_ON_COMMAND '1'
#define FURNACE_OFF_COMMAND '0'
#define READ_TEMPERATURE_COMMAND 'r'
#define STATE_WAIT_ADDRESS 0
#define STATE_RECEIVE_COMMAND 1
#define STATE_TRANSMIT_DATA 2
#define MAX(a, b) ((a) > (b) ? (a) : (b))
#define MIN(a, b) ((a) < (b) ? (a) : (b))
volatile uint8_t received_command = 0;
volatile uint8_t i2c_state = STATE_WAIT_ADDRESS;
// Reads a single temperature sample from LM35
uint16_t read_raw_temperature(void) {
// Select ADC channel, start conversion and waits for it to complete
ADMUX = (1 << REFS0) | (1 << MUX1);
ADCSRA |= (1 << ADSC);
while (ADCSRA & (1 << ADSC))
;
// Convert ADC reading to °C (LM35 calibration: 10mV/°C)
return (5 * ADC * 100) / 1024;
}
// Reads multiple temperatures to reduce the noise
uint8_t read_temperature(void) {
uint16_t sum = 0, min = UINT16_MAX, max = UINT16_MIN;
for (uint8_t i = 0; i < NUMBER_OF_SAMPLES; ++i) {
uint16_t sample = read_raw_temperature();
sum += sample;
min = MIN(min, sample);
max = MAX(max, sample);
_delay_ms(1);
}
// Compute average excluding min/max (reduces noise)
uint16_t temperature = (sum - min - max) / (NUMBER_OF_SAMPLES - 2);
return (uint8_t)(temperature >> 2);
}
void init_adc(void) {
ADCSRA = (1 << ADEN) // Enable ADC
| (1 << ADPS2) // Set ADC prescaler to 128 (stable readings)
| (1 << ADPS1)
| (1 << ADPS0);
}
void init_i2c(void) {
DDRB &= ~((1 << PB0) | (1 << PB2)); // Configure SDA & SCL as inputs
USIDR = 0;
USISR = (1 << USIOIF); // Reset USI overflow interrupt flag
USICR = (1 << USISIE) // Enable USI start condition interrupt
| (1 << USIWM1) | (1 << USIWM0) // Enable two-wire mode
| (1 << USICS1); // Set clock source
}
ISR(usi_start_condition_handler) {
USISR = (1 << USISIF) | (1 << USIOIF);
i2c_state = STATE_WAIT_ADDRESS;
}
ISR(usi_handler) {
uint8_t received = USIDR;
switch (i2c_state) {
case STATE_WAIT_ADDRESS:
handle_address_received(received);
break;
case STATE_RECEIVE_COMMAND:
handle_command_received(received);
break;
case STATE_TRANSMIT_DATA:
handle_data_transmit();
break;
default:
i2c_state = STATE_WAIT_ADDRESS;
break;
}
}
void reset_interrupt_flag(void) {
USISR = (1 << USIOIF);
}
void handle_address_received(uint8_t received) {
uint8_t address = received >> 1;
uint8_t rw = received & 0x01;
if (address == SLAVE_ADDRESS)
i2c_state = rw == 0 ? STATE_RECEIVE_COMMAND : STATE_TRANSMIT_DATA;
reset_interrupt_flag();
}
void handle_command_received(uint8_t command) {
received_command = command;
if (received_command == FURNACE_ON_COMMAND)
PORTB |= (1 << PB1); // Turn furnace ON
else if (received_command == FURNACE_OFF_COMMAND) {
PORTB &= ~(1 << PB1); // Turn furnace OFF
i2c_state = STATE_WAIT_ADDRESS;
reset_interrupt_flag();
}
void handle_data_transmit(void) {
if (received_command == READ_TEMPERATURE_COMMAND)
USIDR = readTemperature();
else
USIDIR = 0;
i2c_state = STATE_WAIT_ADDRESS;
reset_interrupt_flag();
}
int main(void) {
// Set PB1 as output for furnace control and ensure it's off
DDRB |= (1 << PB1);
PORTB &= ~(1 << PB1);
init_adc();
init_i2c();
sei();
while (1) {
_delay_ms(1000);
}
return 0;
}
Alternative C++ ImplementationThe code is pretty straightforward and if you are comfortable using some C++(ish) and the TinyWireS library then it becomes incredibly short:
#include <TinyWireS.h>
#define SLAVE_ADDRESS 0x50 // I2C address for this device
#define RELAY_PIN 1 // Relay Control Pin
#define LM35_PIN A2 // Analog pin for temperature sensor
#define NUMBER_OF_MEASURES 6 // Number of temperature samples (>= 3)
#define READING_DELAY 1000 // Delay between each temperature reading
#define FURNACE_ON_COMMAND '1'
#define FURNACE_OFF_COMMAND '0'
#define READ_TEMPERATURE_COMMAND 'r'
float lastKnownTemperature = 0;
void setup() {
TinyWireS.begin(SLAVE_ADDRESS);
TinyWireS.onRequest(sendTemperature);
TinyWireS.onReceive(handleCommand);
pinMode(RELAY_PIN, OUTPUT);
digitalWrite(RELAY_PIN, LOW);
}
void loop() {
lastKnownTemperature = readTemperature();
delay(READING_DELAY);
}
void handleCommand(int numBytes) {
if (TinyWireS.available()) {
char command = TinyWireS.read();
if (command == FURNACE_ON_COMMAND) {
digitalWrite(RELAY_PIN, HIGH);
} else if (command == FURNACE_OFF_COMMAND) {
digitalWrite(RELAY_PIN, LOW);
}
// Let's assume that any read request is preceded
// by a read command, it makes things simpler.
}
}
void sendTemperature() {
TinyWireS.write((int8_t)lastKnownTemperature);
}
float readTemperature() {
float minimum = FLT_MAX;
float maximum = FLT_MIN;
float sum = 0;
for (int i = 0; i < NUMBER_OF_MEASURES; i++) {
float temp = analogRead(LM35_PIN) * (5.0 / 1023.0) * 100.0;
if (temp < minimum) minimum = temp;
if (temp > maximum) maximum = temp;
sum += temp;
delay(10);
}
sum -= (minimum + maximum);
return sum / (NUMBER_OF_MEASURES - 2);
}
For completeness (assuming your're not using an IDE), a low speed fuses setup you might use in this case:
avrdude -c usbasp -p t85 -U lfuse:w:0x62:m -U hfuse:w:0xDF:m -U efuse:w:0xFF:m
And to compile and upload your firmware:
avr-gcc -mmcu=attiny85 -Os -c thermostat.cpp -o thermostat.o
avr-gcc -mmcu=attiny85 -o thermostat.elf thermostat.o
avr-objcopy -O ihex thermostat.elf thermostat.hex
avrdude -c usbasp -p t85 -U flash:w:thermostat.hex:i
ConclusionsFrom this minimal implementation, we can draw a few conclusions:
- Our hardware is incredibly simple (and thus cost-effective), even simpler than a traditional non-smart thermostat. Not only is it simple, but it is also highly similar to a traditional implementation, meaning transition costs are negligible.
- Our firmware is as streamlined as possible (both in our device and in the hub) eliminating the complexities often associated with connected devices (security, UI, compatibility, support, cloud infrastructure, etc.).
Developing a smart device can be both a sustainable and profitable option, benefiting vendors and customers alike.
Comments