Building an IoT Device Manager in Go

A practical guide to managing heterogeneous IoT devices with Go’s interfaces, generics, and concurrency.

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.

Environmental Sensor

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
}

Full source code on GitHub

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
}

Full version

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 .