EPC – EP-0099 Relay Control Panel

This is the fully working configuration for a web control panel for the 52Pi EP-0099 4-Channel I²C Relay board on a Tracker Raspberry Pi.

Design goals:

  • Runs on its own port (no interference with tracker UI)
  • Runs as an unprivileged user (no root web server)
  • Uses I²C only (no GPIO conflicts)
  • Uses a writable lock file path (prevents concurrent I²C writes)

Hardware

Board: 52Pi EP-0099 4-Channel I²C Relay
Bus: 1
Address: 0x10 (default DIP)

Relay control:

  • Registers: 0x01 → 0x04
  • Values: 0xFF = ON, 0x00 = OFF

Manual control (known working):

i2cset -y 1 0x10 0x01 0xFF
i2cset -y 1 0x10 0x01 0x00

System user + permissions

Create the service user:

sudo useradd -r -s /usr/sbin/nologin relayui

Add it to the i2c group:

sudo usermod -aG i2c relayui

Confirm I²C device permissions:

ls -l /dev/i2c-1

Expected style:

crw-rw---- 1 root i2c ... /dev/i2c-1

Install the app + Python environment

Create the app folder:

sudo mkdir -p /opt/relayui
sudo chown relayui:relayui /opt/relayui

Install venv support:

sudo apt-get install -y python3-venv

Create the venv and install deps:

sudo -u relayui python3 -m venv /opt/relayui/venv
sudo -u relayui /opt/relayui/venv/bin/pip install --upgrade pip
sudo -u relayui /opt/relayui/venv/bin/pip install flask smbus2 filelock

Application file: /opt/relayui/app.py

Create/edit:

sudo -u relayui nano /opt/relayui/app.py

Paste this entire file:

#!/usr/bin/env python3
import os
from flask import Flask, jsonify, request, Response
from smbus2 import SMBus
from filelock import FileLock

I2C_BUS = int(os.environ.get("RELAY_I2C_BUS", "1"))
I2C_ADDR = int(os.environ.get("RELAY_I2C_ADDR", "0x10"), 16)

# IMPORTANT: lock path must be writable by relayui user
LOCK_PATH = os.environ.get("RELAY_I2C_LOCK", "/tmp/relayui-i2c.lock")

BIND_HOST = os.environ.get("RELAY_BIND", "0.0.0.0")
BIND_PORT = int(os.environ.get("RELAY_PORT", "8787"))

app = Flask(__name__)

# In-memory last-commanded state
state = {1: False, 2: False, 3: False, 4: False}

def write_relay(channel: int, on: bool):
    if channel not in (1, 2, 3, 4):
        raise ValueError("Channel must be 1..4")

    # EP-0099 control: registers 0x01..0x04, values 0xFF=ON, 0x00=OFF
    value = 0xFF if on else 0x00

    with FileLock(LOCK_PATH):
        with SMBus(I2C_BUS) as bus:
            bus.write_byte_data(I2C_ADDR, channel, value)

    state[channel] = on

@app.get("/api/state")
def api_state():
    return jsonify(bus=I2C_BUS, addr=hex(I2C_ADDR), relays=state)

@app.post("/api/relay/<int:channel>")
def api_relay(channel):
    data = request.get_json(silent=True) or {}
    if "on" not in data:
        return jsonify(ok=False, error='JSON body must contain {"on": true/false}'), 400
    try:
        write_relay(channel, bool(data["on"]))
        return jsonify(ok=True, channel=channel, on=state[channel])
    except Exception as e:
        return jsonify(ok=False, error=str(e)), 500

@app.post("/api/all_off")
def api_all_off():
    try:
        for ch in (1, 2, 3, 4):
            write_relay(ch, False)
        return jsonify(ok=True)
    except Exception as e:
        return jsonify(ok=False, error=str(e)), 500

@app.get("/")
def index():
    return Response("Relay UI running", mimetype="text/plain")

if __name__ == "__main__":
    app.run(host=BIND_HOST, port=BIND_PORT)

Make it executable (optional):

sudo chmod +x /opt/relayui/app.py

systemd service: /etc/systemd/system/relayui.service

Create/edit:

sudo nano /etc/systemd/system/relayui.service

Paste:

[Unit]
Description=EP-0099 Relay Web UI
After=network.target

[Service]
Type=simple
User=relayui
Group=relayui
SupplementaryGroups=i2c
WorkingDirectory=/opt/relayui

Environment=RELAY_PORT=8787
Environment=RELAY_I2C_BUS=1
Environment=RELAY_I2C_ADDR=0x10

# IMPORTANT: writable lock file (fixes permission denied in /run)
Environment=RELAY_I2C_LOCK=/tmp/relayui-i2c.lock

ExecStart=/opt/relayui/venv/bin/python /opt/relayui/app.py
Restart=on-failure
RestartSec=2

NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true

[Install]
WantedBy=multi-user.target

Enable + start:

sudo systemctl daemon-reload
sudo systemctl enable --now relayui

Verification

Check status:

systemctl status relayui --no-pager

Confirm listening port:

ss -ltnp | grep 8787

Test relay 1 via API:

curl -s -X POST http://127.0.0.1:8787/api/relay/1 -H "Content-Type: application/json" -d '{"on": true}'

Expected:

  • relay clicks
  • JSON ok:true

Open in browser:

http://192.168.8.10:8787/

Notes

  • Port 8787 is a non-privileged port, so the service can bind without special capabilities.
  • The service runs as relayui (non-root) and only needs membership in the i2c group.
  • The lock file uses /tmp to avoid permission issues writing into /run.