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:
We specify the parameters used to set the brightness by defining a custom class. It’s a bit contrived here, but does shows the general principle. Using a class also makes the automatically generated documentation a little bit clearer.
If someone
GET
s/
we return the HTML document saved inindex.html
.If someone
GETs
/api
we return the union of lower-level data. This reduces the latency which would be incurred if we made multiple sequential requests.
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.
The
api
inapi:app
means that we run the server inapi.py
.We specify the host address of 0.0.0.0 so that the server runs on all the Pi’s network interfaces: without this we could only access the API from the Pi itself.
By default, uvicorn listens on port 8000, so the root URL of the server is http://restapi.local:800011
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.
References
- 1. https://en.wikipedia.org/wiki/Internet_of_things
- 2. https://en.wikipedia.org/wiki/Hypertext_Transfer_Protocol
- 3. https://en.wikipedia.org/wiki/MQTT
- 4. https://en.wikipedia.org/wiki/JSON
- 5. https://en.wikipedia.org/wiki/Representational_state_transfer
- 6. https://docs.python.org/3/glossary.html#term-decorator
- 7. https://docs.pydantic.dev/#example
- 8. https://www.rfc-editor.org/rfc/rfc7231#section-4.3.4
- 9. https://datatracker.ietf.org/doc/html/rfc9110
- 10. https://www.uvicorn.org
- 11. http://restapi.local:8000
- 12. http://restapi.local:8000/api
- 13. http://restapi.local:8000/docs
- 14. https://httpie.io
- 15. https://requests.readthedocs.io/en/latest/#
- 16. https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API