Introduction
Managing IoT devices becomes exponentially more complex as your network grows. You end up juggling a mix of sensors, actuators, and communication buses each with its own quirks and challenges.
In the OttO Project and companion Devices library, I attempted to develop a mini framework that would have a clear seperation of concerns between the hardware layer (GPIO, I2C, serial, ADC, PWN, UART, etc.) and the application layer (messaging, control logic, logging, deployment, etc).
By creating a clean seperation between the HW and application layers we can focus more on the application layer and simplify the development of managed IoT applications without necessarily getting into the weeds of device drivers. The goal is: a lightweight, generic, type-safe device system that can manage all kinds of things, from buttons, meters, motors to full robots.
The software is able to run on top of real hardware e.g. a Raspberry Pi without modifications as easily as it can run on a laptop with mocked devices.
The Device Interface
Here we’ll walk through the design of OttO’s device manager layer which sits above the particular devices and their underlying drivers. The device interface is powered by Go’s Generics allowing me to write a single generic interface that supports a multitude of underlying concrete implementations that either produce or consume virtually any type of data as well as perform real world activies.
There are The type of data the device interface supports includes primatives (int, bool, float64, strings, etc) as well as coplex Go data structs. A few example implementations can be found here.
NOTE: The introduction of generics to Go has been controversial as interfaces or other methods could have worked as well. I choose generics for this task partially because I wanted to get some experience with Go’s generics.
Mocking Hardware
This design creates a clear seperation between the device and application layers. As we will see later it also allows application developers to focus on building the application on a powerful Linux workstation without any actual hardware via mocking or faking the underlying hardware, and avoid the inconviniences of working on embedded systems for much of the application logic.
Running the same application on real hardware, for example a Raspberry Pi is as simple as compiling for the target architecture (e.g. ARM 64) and transfering to that device.
Architectural Overview
The idea is simple: each device implements a shared interface With Open(), Close(), Get() and Set(), using the DeviceManager to
coordinate them — whether they’re real hardware or mocks running in
memory.
In practice we have two repositories that represent three layers: drivers, devices and an application framework. The first two layers can be found in these two repositories devices the second can be found in otto
One key reason for separating the OttO and Device repositories allows OttO to be run as an IoT broker only, while other things can be built to provide that will utilize the underlying hardware.
The Device layer with Go Generics
Device Interface (from devices repo) In devices/device.go:
package devices
type Device[T any] interface {
ID() string
Type() Type
Open() error
Close() error
Get() (T, error)
Set(v T) error
}
That’s it — the entire abstraction layer.
Each device can now expose its own data type, for example:
- A Button produces a boolean: true/false
- A Meter can provide a range of integers: 0–100
- Voltage can produce a floatping point value: 12.6V
- Environment can produce a multi-valued structure:
{Temp, Humidity, Pressure float64}
Implementing Concrete Devices
The first example is a simple button device which provides a
Get() method that returns a single boolean type, on/off, true
or false. It does not get much simpler than this.
You can see the full Button driver implementation here
package devices
// Example 1: Button (devices/button.go)
type Button struct {
state bool
}
// Get returns the state of a button either on or off
func (b *Button) Get() (bool, error) {
return b.state, nil
}
// Set is not used on real devices but can be leveraged
// when mocking
func (b *Button) Set(v bool) error {
b.state = v
return nil
}
A More complex example
Example two implements a complex type struct consisting of temperature, humidity and pressure.
type Env struct {
Temp, Humidity, Pressure float64
}
type EnvSensor struct {
data Env
}
func (e *EnvSensor) Get() (Env, error) {
return e.data, nil
}
func (e *EnvSensor) Set(v Env) error {
e.data = v
return nil
}
These examples run natively on a Raspberry Pi with GPIO pins, or via mock data on just about any Linux distribution, or MAC and maybe even Windows. It would be fairly easy to port over to other SoC style boards like the BeagleBone or Nvidia Jetson.
The Drivers
The driver layer and sits the closest to the hardware and is agnostic to the how the data is used by the sensor or actuator that comsumes or produces let alone what the application is doing with the data.
This is the list of drivers currently supported
- Serial Ports
- Digital GPIO
- Analog GPIO via the ads1112 i2c chip (on the Raspberry Pi)
- i2c
This is the list of drivers that will likely be supported sometime in the near future:
- SPI
- 1-wire, etc
- Pulse Width Modulation (PWM)
The Devices
The devices consist of hardware that uses the underlying drivers, for example Buttons and LEDs use a single Digital GPIO pin, the GPS device uses a serial port, while the soil sensor (meter) uses an Analog to Digital Converter (ADC) made possible by the i2c ads1112 chip.
A major goal of the device layer is to leverage as much good, hard work from smart people. After all, I don’t have the chops or the time to do all that stuff myself!
Seperation concerns: device drivers from the framework
In this layer we have been able to leverage a lot of good, hard work from some really smart people. This was always a primay goal of this project: was to have the freedom to employ great code from a variety of great sources.
The Application Framework
The heart of the Application Framework is OttO which basically provides a variety of packages required to manage a fleet of IoT things, or just a single thing for that matter.
for pub/sub messaging, REST API, Websockets, HTTP for standard HTML user interfaces, etc.
The Application
There is a reference application known as the The Gardener an automated watering station that uses a soil moisture sensor to determine when to turn a water pump on, as well as light up an LED and update the text on an OLED display.
TODO : Insert architectural drawing here
Managing Application Devices
The DeviceManager keeps track of the devices that make up a thing. It is a registery that organizes a variety devices allowing the things application to do it’s thang.
A simplified version looks like this:
package manager
import (
"sync"
)
type DeviceManager struct {
devices map[string]any
mu sync.RWMutex
}
func NewDeviceManager() *DeviceManager {
return &DeviceManager{devices: make(map[string]any)}
}
func (dm *DeviceManager) Register(name string, dev any) {
dm.mu.Lock()
defer dm.mu.Unlock()
dm.devices[name] = dev
}
func (dm *DeviceManager) Get(name string) (any, bool) {
dm.mu.RLock()
defer dm.mu.RUnlock()
dev, ok := dm.devices[name]
return dev, ok
}
Using the Device Manager
In OttO, you can create and register devices dynamically:
dm := manager.NewDeviceManager()
dm.Register("on", button.New("On", 5))
dm.Register("off", button.New("Off", 6))
dm.Register("env", bme280.New("env", "/dev/i2c", 0x76))
// Read the environment sensor
if d, ok := dm.Get("env"); ok {
env, err := env.Get()
if err != nil {
return err
}
fmt.Printf("Temp: %.2f°C, Humidity: %.2f%%\n", env.Temp, env.Humidity, env.Pressure)
}
Concurrency and Polling Devices
In practice, OttO uses goroutines to read data from many devices at once — ideal for edge gateways and real-time dashboards.
// genericTimerLoop provides a standard timer loop for any device
func (md *ManagedDevice) GenericTimerLoop(duration time.Duration, done chan any) {
ticker := time.NewTicker(duration)
defer ticker.Stop()
for {
select {
case <-ticker.C:
md.ReadPub()
case <-done:
slog.Debug("Timer loop stopped", "device", md.Name)
return
}
}
}
func pollEnv() {
env := bme280.New("env", "/dev/i2c", 0x76)
d := dm.Register("env", env)
done := make(chan done any)
d.StartTimerLoop(15 * time.Second, done, d.GenericTimerLoop)
}
This pattern is already integrated into OttO’s runtime timer based polling layer using Go’s channels for non-blocking updates.
Testing with Mocks
The devices and oTTo packages have been designed with mocked development in mind, allowing focus on application logic and avoiding the tedium that comes with test full bench setups and prototypes.
package mocks
type MockMeter struct {
val int
}
func (m *MockMeter) Get() (int, error) { return m.val, nil }
func (m *MockMeter) Set(v int) error { m.val = v; return nil }
Mocking lets OttO simulate an entire IoT environment on your laptop — no physical hardware required.
func TestMockMeter(t *testing.T) {
m := &mocks.MockMeter{val: 42}
got, _ := m.Get()
require.Equal(t, 42, got)
}
Using OttO’s REST API we can query the configured devicess controlled by the
DeviceManager: provided by the /api/devices API endpoint.
http.HandleFunc("/api/devices", func(w http.ResponseWriter, r *http.Request) {
devices := dm.List()
json.NewEncoder(w).Encode(devices)
})
I started using the testify package to do test with, it does make things quite a bit more compact and expressive.
Extending the System
With the device manager in place we can now focus on other powerful features provided by OttO.
- Persistence: Save device state via SQLite or BoltDB
- Networking: Expose devices over REST or MQTT
- Plugins: Dynamically load Go modules for new device types
- Dashboards: Visualize data with a web UI or CLI
Performance and Deployment
Go’s lightweight runtime and static compilation make it perfect for IoT and edge systems:
GOOS=linux GOARCH=arm64 go build -o otto-arm64
scp otto-arm64 pi@raspberrypi.local:/usr/local/bin/
You can deploy OttO to:
- Raspberry Pi or BeagleBone
- Linux gateways and routers
- Docker containers on ARM or x86
- Cloud-based device simulators
Conclusion
By combining Go’s generics, interfaces, and goroutines, we’ve built a foundation that scales from one sensor to an entire IoT network.
The ecosystem now includes:
- Devices: Reusable drivers and interfaces
- OttO: The orchestrator and runtime manager
- Gardner: The watering application.
If you’re looking for a clean, composable way to manage embedded systems or smart devices — Go gives you type safety, concurrency, and simplicity out of the box.
🔗 References
OttO GitHub Repository Devices GitHub Repository Gardener RustyEddy.com
Rusty Eddy builds open software for embedded systems and IoT. Follow along at RustyEddy.com or on GitHub @rustyeddy .