Switch the relays of a Raspberry Pi relay HAT from your browser, Node-RED or Home Assistant.
Got a Pi with a relay HAT? The Quick start gets you from download to switching in the browser in about ten minutes.
A relay HAT turns a Raspberry Pi into a switch for things the Pi cannot drive itself: a heat pump's utility lock or PV surplus input, a pump, a gate, a light. relayhat makes those relays usable from everywhere on your network:
- a REST API over HTTPS with one bearer token per caller, for Node-RED, Home Assistant, ioBroker, openHAB or a
curlin a script, - a built-in web page with a pilot light per relay and a two-step switch that a stray tap cannot trigger,
- relays that keep their state when the configuration is reloaded and come back in a state you
choose (
off,onor the last one) after a reboot or power cut, - a switch lock per relay against rapid toggling, and a record of who switched last.
No cloud, no database, no runtime: a single binary, configured with one YAML file.
- Web page per device: pilot light in the relay's colour, your own words for on and off ("Locked" / "Released"), time in the current state, last switch with the name of the caller's token — readable on a phone, light and dark mode
- Two-step switching in the browser: the first tap arms the button for 3 seconds, the second
switches; a double tap,
Escor the timeout cancel - Start state per relay:
off,onorlast, restored from a state file that is written atomically, so a power cut cannot corrupt it - Switch lock (
minSwitchInterval): a relay refuses to switch again within, say, 5 seconds or 10 minutes of the last switch — for every client, answered with HTTP 429 - Audit trail: every switch is logged with relay, old and new state, token name and client; the last one is shown on the page and kept across restarts
- HTTPS REST API (TLS 1.3) with one bearer token per caller, stored only as a SHA-256 hash and
managed with
relayhat token; IP allowlist / blocklist, JSON errors - Hot reload via
SIGHUP: a broken file is refused, the relays do not even flicker - Release builds for every Raspberry Pi architecture, from the Pi Zero (ARMv6) to 64-bit systems
1. Download the archive for your Pi from the latest release:
| Archive | Raspberry Pi model |
|---|---|
linux_armv6 |
Pi 1 and Zero (1st gen); also runs on every newer Pi |
linux_armv7 |
Pi 2 / 3 / 4 / 5 / Zero 2 W with a 32-bit OS |
linux_arm64 |
Pi 3 / 4 / 5 / 400 / Zero 2 W with a 64-bit OS |
VERSION=2.0.1 ARCH=armv6 # see the release page for the latest version
BASE=https://github.com/womat/relayhat/releases/download/v$VERSION
curl -LO $BASE/relayhat_${VERSION}_linux_$ARCH.tar.gz -LO $BASE/checksums.txt
sha256sum -c checksums.txt --ignore-missing
tar xzf relayhat_${VERSION}_linux_$ARCH.tar.gz2. Install binary, example configuration and a certificate. Everything belongs to root except
data/, where relayhat keeps the relay states; the service user relayhat may read etc/:
sudo groupadd -r -f relayhat
sudo useradd -r -s /usr/sbin/nologin -g relayhat relayhat
sudo usermod -aG gpio relayhat
sudo install -d -o root -g root -m 755 /opt/relayhat /opt/relayhat/bin
sudo install -d -o root -g relayhat -m 750 /opt/relayhat/etc
sudo install -d -o relayhat -g relayhat -m 750 /opt/relayhat/data
sudo install -o root -g root -m 755 relayhat /opt/relayhat/bin/
sudo install -o root -g relayhat -m 640 config/config.yaml /opt/relayhat/etc/
sudo openssl req -x509 -nodes -newkey rsa:2048 -days 825 \
-keyout /opt/relayhat/etc/key.pem -out /opt/relayhat/etc/cert.pem -subj "/CN=$(hostname)"
sudo chown root:relayhat /opt/relayhat/etc/key.pem /opt/relayhat/etc/cert.pem
sudo chmod 640 /opt/relayhat/etc/key.pemWhy root: whoever can write config.yaml or the token file can add a token of their own, and
whoever can write bin/relayhat can replace the program — and both switch your relays.
3. Configure /opt/relayhat/etc/config.yaml: set env: prod and one entry per relay — see
Configuration and Hardware. Then create a token for each caller,
e.g. the browser and Node-RED:
sudo /opt/relayhat/bin/relayhat token add browser
sudo /opt/relayhat/bin/relayhat token add noderedEach prints its token once; keep it, e.g. in a password manager. See Tokens.
4. Start it as a service, with the unit from the archive, and open the firewall:
sudo install -m 644 contrib/relayhat.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now relayhat
sudo ufw allow 8443/tcp # if ufw is active
journalctl -u relayhat -n 20 # "Module started successfully"The unit runs relayhat in a sandbox: no capabilities, a read-only system except data/, only the
GPIO chip as a device. A logDestination file has to go to data/ as well.
5. Open https://<your-pi>:8443/, accept the self-signed certificate and enter the token.
relayhat was written for the relay HATs of BC Robotics and runs on every Raspberry Pi with a 40-pin header. It drives one GPIO output per relay: high switches the relay on.
| Board | Relays | GPIO (BCM) | Header pins |
|---|---|---|---|
| Raspberry Pi Zero Relay HAT | 2 | 4, 17 | 7, 11 |
| Raspberry Pi 4 Channel Relay HAT | 4 | 4, 17, 27, 22 | 7, 11, 13, 15 |
Each relay brings its common (COM), normally open (NO) and normally closed (NC) contact out to a screw terminal. Wire your load to COM and NO if it should run while the relay is on, to COM and NC if it should run while it is off — which is also what happens while relayhat is stopped or the Pi is without power.
Any other relay board with logic-level, active-high inputs works the same way: give each relay its GPIO in the configuration. Boards whose relays switch on when the input is pulled low would work inverted and are not supported.
Warning
The relays can switch mains voltage. Wiring 230 V / 120 V is a job for a qualified electrician; keep it in a closed enclosure, away from the Pi's low-voltage side. If in doubt, switch a contactor or an input of the device (such as a heat pump's control input) instead of the load itself.
BC Robotics also has a getting-started tutorial for soldering the header and the terminal connections.
Open https://<pi>:<listenPort>/ in a browser. The page asks for a token once
(relayhat token add browser), keeps it in the browser's local storage and sends it as a bearer
token; Sign out forgets it, and a rejected token brings the prompt back.
- One card per relay with its
label,descriptionand GPIO, a pilot light in the relay'scolor, the state (onText/offText) and how long it has been in it, and the last switch with the name of the caller's token (address in the tooltip). - Two-step switching: the first tap arms the button (
Confirm: …, a bar runs down for 3 seconds), only a second tap within that time switches. A fast double tap,Escor the timeout cancel. When another client switches the relay meanwhile, the pending confirmation is dropped. - Switch lock: a relay with
minSwitchIntervalshows it below its button; after every switch, from the page or any other client, the button is disabled and counts down (🔒 Locked · 0:04). The lock applies to the page too, there is no override. - Refreshes every 3 seconds while the tab is visible; if the Pi does not answer, a banner says so and the values are greyed out.
- Self-contained: no external fonts or scripts, so it works on a network without internet access.
The page itself is public, as it holds no data; everything it shows and switches needs a
token. Limit who can reach it with allowedIPs, e.g. to your home network.
Switching is a PATCH to /relays/{name}/on or /off with a bearer token; the answer is the
relay's new state. Give every caller a token of its own, so lastChange.user says who switched.
Node-RED: an http request node with method PATCH, URL
https://<your-pi>:8443/relays/relay1/{{payload}} (send on or off as msg.payload), authentication
bearer authentication with its token (relayhat token add nodered; Node-RED keeps it in its
encrypted credentials, not in the flow) and a TLS configuration without Verify server certificate for the self-signed
certificate.
Home Assistant: a rest_command per state, used in automations or scripts:
rest_command:
relay1_on:
url: https://<your-pi>:8443/relays/relay1/on
method: patch
headers:
Authorization: !secret relayhat_bearer # "Bearer <token>"
verify_ssl: false
relay1_off:
url: https://<your-pi>:8443/relays/relay1/off
method: patch
headers:
Authorization: !secret relayhat_bearer # "Bearer <token>"
verify_ssl: falseRepeating the current state is harmless: it is answered with 200, does not count as a switch and is allowed during the switch lock — a flow may send its state on every run.
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | / |
– | Web page |
| GET | /version |
– | App name and version |
| GET | /health |
Token | Runtime health metrics |
| GET | /relays |
Token | List all relays |
| GET | /relays/{name} |
Token | Get relay state |
| PATCH | /relays/{name}/{state} |
Token | Set relay (on / off) |
Authentication with a bearer token from relayhat token add: Authorization: Bearer <token>. HTTPS
is TLS 1.3 only. Errors are returned as {"error": "..."} with the HTTP
status (401 without a valid token, 404, 400 for an invalid state, 429 with a Retry-After header while the relay's switch
lock runs); a 500 carries only internal server error, the cause is in the log. Every switch is
logged with relay, GPIO, old and new state, the token name and the client address.
A relay is returned as:
{
"name": "relay1",
"state": "off",
"description": "Utility lock signal of the heat pump",
"gpio": 4,
"display": { "label": "Heat pump", "color": "red", "onText": "Locked", "offText": "Released" },
"lastChange": {
"time": "2026-10-07T14:02:13+02:00",
"ageSeconds": 2820.4,
"source": "api",
"client": "192.168.1.20",
"user": "nodered"
},
"lock": { "intervalSeconds": 5, "remainingSeconds": 0 }
}display comes from the configuration and is meant for the web page. lastChange is the last
switch: source is api, or start when relayhat switched the relay to its start state; client is
the address and user the name of the token of the caller.
lastChange is kept in the stateFile and survives a restart. lock is present with
minSwitchInterval only; remainingSeconds is how long the relay still refuses to switch. A request
for the state the relay is already in succeeds without switching, so it neither changes lastChange
nor starts the lock.
# Get all relays
curl -k https://<your-pi>:8443/relays -H "Authorization: Bearer $TOKEN"
# Get a single relay
curl -k https://<your-pi>:8443/relays/relay1 -H "Authorization: Bearer $TOKEN"
# Turn a relay on / off
curl -k -X PATCH https://<your-pi>:8443/relays/relay1/on -H "Authorization: Bearer $TOKEN"
curl -k -X PATCH https://<your-pi>:8443/relays/relay1/off -H "Authorization: Bearer $TOKEN"Default location: /opt/relayhat/etc/config.yaml
${VAR}is replaced with the environment variableVAR. Only this form is expanded; a bare$stays as it is.- Unknown keys are an error, so a typo cannot silently fall back to a default.
- Durations need a unit (
5s,10m,0s); a bare number,0included, is rejected. - The configuration is validated on start and before every reload:
envisdevorprod,tokenFileis set and readable,certFileandkeyFileexist withenv: prod, every relay uses a GPIO between 2 and 27, no GPIO is used twice, and no relay name contains/or is.or..(the name is a URL path segment). startStateper relay sets the state after the process starts:off(default),on, orlast– the state before, read fromstateFile. A missing, empty or damaged state file is not an error; the relays withlastthen start off and the log says why.stateFilealso keeps the last switch of every relay, so the web page shows it after a restart. It is written on start and after every switch;stateFile: ""keeps nothing. The format of 1.7 (relay1: on) is still read.minSwitchIntervalper relay (e.g.5s,10m, at most24h) refuses a switch within that time of the last one with HTTP 429, for every client. It runs from the last switch, also across a reload or restart; start states are never refused.label,color,onTextandoffTextonly change how the web page shows a relay;colorisgreen,redoramber. The relay name stays the API path, so renaming a relay on the page never breaks a client.
# =============================================================================
# relayhat configuration
#
# ${VAR} is replaced with the environment variable VAR (only this form, a bare
# "$" stays as it is), e.g. a password: ${RELAYHAT_PASSWORD}.
# Unknown keys are an error, so a typo cannot silently fall back to a default.
# =============================================================================
# logLevel defines the minimum log level.
# Messages with at least this level are logged.
# Allowed values: debug | info | warn | error
logLevel: info
# logDestination defines where logs are written to.
# Supported values: stdout | stderr | null | /path/to/logfile
logDestination: stdout
# environment: dev | prod
# dev falls back to the embedded self-signed certificate when certFile is
# missing; prod refuses to start without certFile.
env: dev
# stateFile keeps the state of every relay and its last switch (when, by which
# client), for startState: last and the "Last switch" in the web page. It is
# written after every switch and once on start; the directory must exist and be
# writable. Set it to "" to keep nothing (startState: last then needs a file).
stateFile: /opt/relayhat/data/state.yaml
# =============================================================================
# Webserver configuration (HTTPS)
# =============================================================================
webserver:
# Host address the HTTPS server listens on (0.0.0.0 = all interfaces)
listenHost: 0.0.0.0
# Port the HTTPS server listens on
listenPort: 8443
# Bearer tokens accepted on the protected endpoints, one "<name> <sha256>" per line.
# Manage it with "relayhat token add|remove|list"; a relative path is relative to this file.
# The file holds hashes only, but whoever can write it can add a token: owner root.
tokenFile: /opt/relayhat/etc/token-hashes
# TLS private key file
keyFile: /opt/relayhat/etc/key.pem
# TLS certificate file
certFile: /opt/relayhat/etc/cert.pem
# Blocked IP addresses or networks (empty = none blocked)
# Examples: 192.168.0.1, 192.168.0.0/16, 10.0.0.0/8
blockedIPs: []
# - 192.168.0.1
# - 192.168.0.0/16
# Allowed IP addresses or networks (empty = all allowed)
# Note: ::1 is the IPv6 loopback address
# Examples: 127.0.0.1, ::1, 192.168.0.0/16
allowedIPs: []
# - 127.0.0.1
# - ::1
# - 192.168.0.0/16
# =============================================================================
# relay configuration
#
# name: relay name used in the API (/relays/{name})
# gpio: BCM number 2..27, each gpio at most once
# startState: state after the process starts (boot, crash, systemctl restart):
# off (default) switched off
# on switched on
# last the state before, from stateFile; off if it has none (first start,
# missing or damaged file)
# A SIGHUP reload keeps the state of every relay whose gpio stays configured;
# startState applies only to relays that are added by the reload.
# A stop (SIGTERM/SIGINT) switches all relays off.
# minSwitchInterval: after a switch the relay refuses to be switched again for
# this long, by any client including the web page (HTTP 429), e.g. 5s or 10m;
# 0s or missing disables it. The start counts as a switch, start states are
# never refused, and a request for the state the relay is already in is no
# switch.
#
# Web page only (optional, the API paths stay /relays/{name}):
# label: name on the card, default the relay name
# color: pilot light when on: green (default) | red | amber
# onText: word for the on state, default ON
# offText: word for the off state, default OFF
#
# gpio 4 and 17: Pi Zero Relay HAT and 4-Channel Relay HAT;
# gpio 22 and 27: 4-Channel Relay HAT only.
# =============================================================================
relay:
relay1:
label: "Heat pump"
description: "Utility lock signal of the heat pump"
gpio: 4
startState: off
minSwitchInterval: 5s
color: red
onText: "Locked"
offText: "Released"
relay2:
description: "gpio 17, Pi Zero Relay HAT and 4-Channel Relay HAT"
gpio: 17
relay3:
description: "gpio 22, 4-Channel Relay HAT only"
gpio: 22
relay4:
description: "gpio 27, 4-Channel Relay HAT only"
gpio: 27Every caller gets its own token: a browser, Node-RED, a script. relayhat keeps only the SHA-256 hash
of each, in the file named by webserver.tokenFile, one line per caller:
# /opt/relayhat/etc/token-hashes
browser 3f2a…c91e
nodered 8b04…d7a2
The token itself exists only with the caller. Whoever reads the file cannot call relayhat with it,
so it needs no more protection than the config — but whoever can write it can add a token of
their own: keep it root:relayhat 0640, like config.yaml.
| Command | Does |
|---|---|
sudo relayhat token add <name> |
Writes the hash, then prints the token once, on stdout alone |
sudo relayhat token add <name> -dry-run |
Prints token and line, writes nothing |
sudo relayhat token remove <name> |
Deletes the line |
sudo relayhat token list |
Prints the names, never a hash |
sudo systemctl reload relayhat |
Applies a change; the relays keep their state |
-config works as for the service. A new token file gets 0640 and the owner and group of
config.yaml. add refuses a name that is taken and prints no token when it cannot write the
file, so a token that is shown but stored nowhere cannot happen. A name is 1–64 letters, digits,
., _ or -.
Rotating without a gap: relayhat token add nodered-2, move the caller to the new token,
relayhat token remove nodered, reload. A lost token is not recovered but replaced the same way.
In the log: at start and after every reload relayhat names the tokens it accepts
(Bearer tokens loaded … names=[browser nodered]). Every switch is logged with user; the first
request of each caller from each address is logged at info (user=browser request.client=… firstAccess=true), every rejected one as a warning with its address; the polls in between at debug.
- The REST API and the web page are served over HTTPS only, TLS 1.3. Use your own certificate and
env: prodon every device, see TLS Certificate. - Give every caller its own token (
relayhat token add <name>); everything except/,/versionand the TLS handshake needs one. A token can switch every relay; relayhat stores only the hashes and records who switched. - Keep
/opt/relayhatowned by root exceptdata/, as in the Quick start, and run the unit fromcontrib/with its sandbox. - Limit who can reach the port with
allowedIPs, e.g. to your home network, and do not expose it to the internet.
Report vulnerabilities privately, see SECURITY.md.
Send SIGHUP to reload the configuration without restarting the process:
sudo systemctl reload relayhat
# or
kill -HUP $(pidof relayhat)A reload also rereads the token file. The config file is validated first; if it is broken, the reload is refused, logged, and the service
keeps running unchanged. Relays whose GPIO is still configured keep their state across the reload
(also when they are renamed) — the GPIO line is handed over, not reopened, so the relay does not
flicker. Removed relays are switched off, new ones start in their startState. The last switch of a
relay is kept as well.
Some problems only show when the service starts with the new settings, e.g. a port or GPIO line in
use. Then Start with the new configuration failed, continuing with the previous one is logged and
the service starts again with the settings it ran with before; the relays are handed over to it and
keep their state. A request that arrives while a reload is in progress gets HTTP 503.
| Event | Relay state afterwards |
|---|---|
SIGHUP reload |
unchanged |
process start (boot, crash, systemctl restart) |
startState: off, on or last |
stop (SIGTERM/SIGINT, systemctl stop) |
off |
A config that does not load or validate at start ends relayhat with exit code 78; the unit does not
restart it then (RestartPreventExitStatus=78), and systemctl status relayhat shows why.
| Flag | Default | Description |
|---|---|---|
--config |
/opt/relayhat/etc/config.yaml |
Path to the configuration file |
--debug |
false |
Enable debug logging to stdout (overrides config) |
--version |
false |
Print the application version and exit |
--about |
false |
Print application details and exit |
--help |
false |
Print this help message and exit |
relayhat token add|remove|list manages the bearer tokens, see Tokens.
The config file path can also be set via the environment variable CONFIG_FILE; --config wins
over it.
relayhat serves HTTPS only. With env: prod it needs certFile and keyFile; the Quick start
creates a self-signed pair valid for 825 days, the maximum browsers accept. With env: dev it falls
back to a certificate embedded in the binary when certFile does not exist — convenient for a first
try, but that key ships in every release, so never use it on a reachable device.
# Backup
sudo tar czf /tmp/relayhat-backup.tar.gz /opt/relayhat
# Restore
sudo tar xzf /tmp/relayhat-backup.tar.gz -C / # as root, tar keeps owners and modes
sudo systemctl restart relayhatEvery release on the releases page carries archives for
all Raspberry Pi architectures with the binary, config/config.yaml, contrib/relayhat.service,
README.md and LICENSE,
plus a checksums.txt and a changelog. Versions follow
semantic versioning; a breaking change of the API or the configuration raises
the major version.
relayhat --version reports the release a binary was built from. A local build reports something
like 2.0.1-3-g0c13781-dirty instead, which is how the two are told apart on a device.
Building from source needs Go and make: clone the repository and run make help for the targets;
make test (the tests, Linux only) and make lint (go vet, golangci-lint, govulncheck) run what CI
checks. CLAUDE.md describes the architecture, the tests and the release process.
The API key is replaced by bearer tokens, lastChange.host by lastChange.user, HTTPS is TLS 1.3
only, and the unit lives in the repository:
- Make
/opt/relayhatroot-owned exceptdata/, as in the Quick start. - Put the new binary next to the old one as
bin/relayhat.new, and a copy of the config asetc/config.yaml.newwithapiKey: …replaced bytokenFile: token-hashes. The running 1.x is not touched by either. - Create the tokens with the new binary, one per caller:
sudo /opt/relayhat/bin/relayhat.new token add nodered -config /opt/relayhat/etc/config.yaml.new. This only writes the token file; it opens no GPIO. - Move
relayhat.newandconfig.yaml.newinto place, installcontrib/relayhat.service, thensudo systemctl daemon-reload && sudo systemctl restart relayhat. Like every restart, this switches the relays off for a moment; withstartState: lastthey come back. A config that still hasapiKeyis refused with a message that says so. - Every caller sends
Authorization: Bearer <token>;X-API-Keyis no longer accepted. In Node-RED, set the http request nodes to bearer authentication. The web page asks for the token once. - A client that told its own switches by
lastChange.hostuseslastChange.usernow;lastChange.clientstays. The state file of 1.x is read, itshostignored. - A client limited to TLS 1.2 can no longer connect.
relayhat is released under the MIT License - see LICENSE for the full text.
The source tree contains no third-party code, but a compiled binary statically links the modules below. Their terms apply to anyone distributing that binary, not to the sources here.
| Module | License |
|---|---|
github.com/womat/golib/v2 |
MIT |
github.com/womat/gpio |
MIT |
github.com/warthog618/go-gpiocdev |
MIT |
gopkg.in/yaml.v3 |
MIT and Apache-2.0 |
golang.org/x/sys |
BSD-3-Clause |
Swagger UI build only (-tags swagger): |
|
github.com/swaggo/swag, http-swagger, files |
MIT |
github.com/go-openapi/*, go.yaml.in/yaml/v3 |
Apache-2.0 |
github.com/KyleBanks/depth |
MIT |
golang.org/x/net, mod, sync, tools |
BSD-3-Clause |
All of these are permissive; none obliges relayhat to change its license.
