This article shows a way to connect hardware to the local network via a small Linux computer like a Raspberry Pi. Connecting hardware to a computer often makes it more useful: it makes it easier to log data and automate things. Making the connection via a network rather than, say, USB, offers a couple of extra benefits. Most obviously it means that the computer can be some distance away from the hardware its controlling. Equally usefully the computer is electrically isolated from the hardware reducing the risk of disaster.

Unlike many IoT1 devices we will speak HTTP2 rather than MQTT3, and by making the device the HTTP server we don’t need any other infrastructure. In essence we will write a simple website, but instead of manipulating records in a database, out back-end will be hardware. This approach leads us to the tools and techniques of server-side web development rather than embedded software. For example, we will write code in python, send messages encoded in JSON4, and use RESTful5 ideas to design the API.

RESTful design

RESTful design is an important principle for designing APIs. One key idea is that we map URLs to things which are meaningful in our application, then make HTTP requests. For example, we might GET /api/1.0/bbq/temperature to see if it’s time to cook, or PUT /api/1.0/kitchen/light to brighten things up. Both the action e.g. GET in the request, and the status code returned e.g. 200 should be taken seriously.

Another important idea is that the server should know nothing of the client state: the API covers only the state of the server, this helps to keep the design modular.

FastAPI

The FastAPI library is a good way to implement APIs in Python. For example given a temp_read() function which talks to the hardware, here is a wrapper which exposes it in the API:

@app.get(apiroot + "/temperature")
def get_temp():
    return { 'temperature': temp_read() }

You can see that FastAPI uses decorators6 to map URLs to functions.

Here is another stub, this time to set the brightness of a light:

@app.put(apiroot + "/light")
def put_light(b: float):
    set_brightness(b)
    return { 'brightness': get_brightness() }

Here we use pydantic type annotations7 to define the type of data the API accepts. Note too that we PUT rather than POST the LED state because RFC72318 says that PUT is the appropriate verb when the new state obliterates the old. By contrast POST is appropriate when we are creating a new thing, and PATCH when we are modifying the state of an existing thing.

Error handling

Suppose we want to extend the example above to signal an error if the brightness is outside the valid range. This is done by returning a different status code and an error message. HTTP status codes are defined in RFC91109: code 400 used here indicates that the problem lies in the request made by the client.

def client_error(t):
    return PlainTextResponse(content = t
                , status_code = status.HTTP_400_BAD_REQUEST)

@app.put(apiroot + "/light")
def put_light(b: float):
    if b < 0.0 or b > 1.0:
        return client_error(f"Brightness {b} out of range [0,1]")
    else:
        led_set(b)
        return led_get()

Resource discovery

Devices on the local network usually have neither a DNS record nor a fixed IP address, which makes it inconvenient for clients trying to talk them. Happily though it is easy to get Linux boxes to advertise themselves in the .local domain.

A Toy Server

Let’s build a minimal toy server which puts the Pi’s temperature sensor and LED online. Happily these devices are easily accessible in /sys, so we don’t need to do any low-level work, though we do need sudo to get the relevant access permission.

Temperature

The Linux kernel makes the CPU temperature available in the /sys filesystem:

$ cat /sys/class/thermal/thermal_zone0/temp
37810

The temperature is returned in milliCelsius, so 37810 corresponds to an overprecise 37.81°C.

LED

The green LED on the Raspberry Pi is controlled by the Linux kernel. Usually it is configured to show disk activity, but this can be changed:

$ sudo sh -c "echo none > /sys/class/leds/led0/trigger"
$ sudo sh -c "echo 1 > /sys/class/leds/led0/brightness"
$ sudo sh -c "echo 0 > /sys/class/leds/led0/brightness"

Software preliminaries

Let’s assume that we have a new Raspberry Pi called restful, so that we can connect to it at restful.local. Starting from the stock Raspberry OS distribution, we need to install the fastapi library and uvicorn which fastapi uses to make a webserver. We also grab the source code from GitHub:

$ sudo apt install python3-fastapi python3-uvicorn uvicorn
$ git clone https://github.com/mjoldfield/restful-hardware-api.git
$ cd restful-hardware-api.git
$ ls
api.py     index.html

The server

The server code is in api.py:

from fastapi import FastAPI, status
from fastapi.responses import FileResponse, PlainTextResponse

from pydantic import BaseModel

import subprocess

#
# Define functions to talk to the hardware
#
# Here we use toy examples from /sys appropriate to
# a Raspberry Pi.
#
# They might need root access, so run stuff in a shell
# inside sudo.
#
def run_as_root(cmd):
    x = subprocess.run(["sudo", "su", "-c", cmd]
                       , capture_output=True
                       , text=True
                       , check=True)
    return x.stdout.strip()

def temp_read():
    raw = run_as_root("cat /sys/class/thermal/thermal_zone0/temp")
    t = f"{int(raw) / 1000.0:.1f}\N{DEGREE SIGN}C"
    return { 'temperature': t }

def led_init():
    run_as_root("echo none > /sys/class/leds/led0/trigger")

def led_set(x):
    b = 255 if x > 0 else 0
    run_as_root(f"echo {b} > /sys/class/leds/led0/brightness")

def led_get():
    b = float(run_as_root("cat /sys/class/leds/led0/brightness"))
    x = 1.0 if b > 0 else 0.0
    return { 'brightness': x }

#
# The main HTTP server starts here
#

led_init()

app = FastAPI()

apiroot = "/api"

def client_error(t):
    return PlainTextResponse(content = t
                , status_code = status.HTTP_400_BAD_REQUEST)

@app.get('/')
def get_index():
    return FileResponse('index.html')

@app.get(apiroot)
def get_all():
    return (temp_read() | led_get())

@app.get(apiroot + "/temperature")
def get_temp():
    return temp_read()

@app.get(apiroot + "/light")
def get_light():
    return led_get()

class LightControl(BaseModel):
    brightness: float

@app.put(apiroot + "/light")
def put_light(m: LightControl):
    b = m.brightness
    if b < 0.0 or b > 1.0:
        return client_error(f"Brightness {b} out of range [0,1]")
    else:
        led_set(b)
        return led_get()

Hopefully this code is reasonably clear even on the first reading.

There are several things to note:

Liftoff!

Finally, we need to run the server:

   $ uvicorn api:app --host 0.0.0.0

Uvicorn10 is a python HTTP server which runs code conforming to the ASGI specification and thus supports FastAPI.

From the browser

Visiting http://restapi.local:8000/api12 returns the sever state encoded in JSON. A more friendly interface is available at http://restapi.local:8000/docs13 which allows you to explore and test the API without writing any code. Better yet, because this page is automatically generated from the code which is actually running on the server, it will stay in sync as the code changes.

From the command line

Alternatively, we can access the API from the comment line with curl. Happily the web UI tells us exactly the command to use:

$ curl -X 'PUT' \
  'http://restapi.local:8000/api/light' \
  -H 'accept: application/json' \
  -H 'Content-Type: application/json' \
  -d '{ "brightness": 0 }'

{"brightness":0.0}

httpie14 is a more modern command which makes this a bit cleaner:

$ http PUT http://restapi.local:8000/api/light brightness=5.0
HTTP/1.1 400 Bad Request
content-length: 33
content-type: text/plain; charset=utf-8
date: Sun, 01 Jan 2023 16:14:02 GMT
server: uvicorn

Brightness 5.0 out of range [
    0,
    1
]

From python

The requests15 library is an easy way to talk to the API from python:

$ python3
>>> import requests
>>> r = requests.put('http://restapi.local:8000/api/light'
                     , json={ 'brightness': 0.2})
>>> r.content
b'{"brightness":1.0}'

A user interface of sorts

Althought the /docs page is a good way to explore the API, in practice we need a a simpler UI for day-to-day work. A static HTML page suffices for this: we can embed JavaScript in the HTML to interact with the API.

The code below uses the fetch16 to make HTTP requests: it's a modern version of XMLHttpRequest.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">

  <style>
    ...
  </style>

  <title>Toy restAPI</title>

  <script>
    const url_root = "/api";

    // Start here: call this when the page is loaded
    function init() {
      fetch_state();
      setInterval(fetch_state, 1000);
    }

    // Query state from the API, and update display.
    function fetch_state() {
      fetch(url_root)
        .then((response) => response.json())
        .then((data) => update_display(data))
    }

    // Update the display: HTML element IDs must match returned keys
    function update_display(d) {
      for (var k in d) {
        var e = document.getElementById(k);
        if (e) {
          e.innerText = d[k];
        }
      }
    }

    // Handy function to wrap a PUT command. Do it, then fetch the
    // new state.
    function put(url, args) {
      fetch(url_root + url, {
          method: 'PUT',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(args),
        })
        .then((r) => fetch_state());
    }

    function set_light(x) {
      put("/light", { "brightness": x });
    }
  </script>
</head>

<body onload="init()">
  <h1>Toy restAPI UI</h1>

  <h2>Temperature</h2>

  <p><span id="temperature"></span></p>

  <h2>Light</h2>

  <p>State: <span id="brightness"></span></p>

  <div class="f">
    <button onclick="set_light(0.0)">
      OFF
    </button>
    <button onclick="set_light(1.0)">
      ON
    </button>
  </div>

  <p><a href="/docs">API documentation</a> is available.</p>

</body>

</html>

Conclusions

Using HTTP to control hardware devices isn’t the most efficient way to do it, nor does it give the best performace. If you wanted to read data every 10ms or transfer vast amounts of data then this might not be a good solution. On the other hand, if the task is to read a few numbers or tweak the settings once a minute then it seems fine.

The whole thing is easy to set up, making it feasible to set up on-the-fly.