Running on Boot#

A control program that runs only when started by hand is of limited use. To operate as a controller it must start at power-up, restart if it fails, and log where the record can be retrieved afterwards. On the SLM-RP4 this is handled by systemd.

The OpenPLC Runtime provides this already

A PLC program persists across a power cycle, restarts automatically, is supervised, and logs to a stream the editor can read, with nothing to configure.

This page describes providing the equivalent for your own program. See Introduction to Python and C++.

Releasing the backplane at boot#

A program that drives the backplane cannot share it with the OpenPLC Runtime. Stopping the runtime by hand is enough for a single session, but it starts again at the next power-up, so a program set to run at boot would find the bus already taken. See Sharing the Hardware.

The runtime is supplied as two containers. Docker starts the bootloader at power-up, and the bootloader then starts the runtime. Preventing the bootloader from starting therefore keeps both stopped.

Disable them:

sudo docker update --restart=no openplc-bootloader
sudo docker stop openplc-bootloader openplc-runtime

The first command is what survives a reboot. The stop command ends the current session, and the backplane is free from that point on.

To return the device to normal PLC operation:

sudo docker update --restart=unless-stopped openplc-bootloader
sudo docker start openplc-bootloader

Starting the bootloader is sufficient. It starts the runtime itself, and the device is reachable from the editor again after about twenty seconds.

The device is no longer a PLC while this is disabled

With the runtime stopped, the device does not answer the editor’s Search, does not accept program uploads, and does not run any PLC program that was previously loaded. Ports 8443, 8445 and 33333 are closed.

The stored project is not deleted. Re-enabling the runtime restores it.

Confirm the state before relying on it:

sudo docker inspect -f "{{.Name}} restart={{.HostConfig.RestartPolicy.Name}} running={{.State.Running}}" \
  openplc-runtime openplc-bootloader

openplc-bootloader should report restart=no, and both containers should report running=false. openplc-runtime has no restart policy of its own; the bootloader starts and supervises it.

A service unit#

Create the unit file:

sudo nano /etc/systemd/system/my-controller.service
[Unit]
Description=SLM-RP4 control program
After=network.target

[Service]
Type=simple
User=slm-rp4
WorkingDirectory=/home/slm-rp4/my-controller
Environment=PYTHONPATH=/home/slm-rp4/Desktop/synergy_python
Environment=PYTHONUNBUFFERED=1
ExecStart=/usr/bin/python3 /home/slm-rp4/my-controller/main.py
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=my-controller

[Install]
WantedBy=multi-user.target

Enable and start it:

sudo systemctl daemon-reload
sudo systemctl enable --now my-controller

Check it:

systemctl status my-controller
journalctl -u my-controller -f

What each line is doing#

User=slm-rp4 : Run unprivileged. The slm-rp4 user already has backplane access. Use root only where it is genuinely required, such as driving the RGB indicator.

WorkingDirectory= : Relative paths in your program resolve from here. Set it explicitly rather than relying on the default.

Environment=PYTHONPATH=... : Makes synergy_logic and slm_rp4_helpers importable from a program that lives outside the supplied library directory. Omit this line if your program runs from ~/Desktop/synergy_python. See Getting Started with Python.

Environment=PYTHONUNBUFFERED=1 : Python buffers standard output when it is not a terminal, which delays log entries and can discard them if the process is killed. This setting disables that buffering.

Restart=on-failure / RestartSec=5 : Restart on a crash or a non-zero exit, waiting five seconds between attempts.

Restart=always also restarts after a clean exit. Use it for a program that should never stop, but not for one that exits deliberately when its work is complete.

StandardOutput=journal : Send print() and stderr to the journal. Read them with journalctl.

Startup ordering#

A program that opens the backplane at startup may run before the SPI device is ready. Retry the connection instead of assuming the first attempt succeeds:

import sys
import time

import synergy_logic

base = None
for attempt in range(10):
    try:
        base = synergy_logic.Base()
        break
    except Exception as exc:
        print(f"backplane not ready ({exc}), retry {attempt + 1}/10")
        time.sleep(2)

if base is None:
    print("giving up")
    sys.exit(1)

The same code then handles a boot race, a slow power ramp, and a service restarted while the hardware is still settling.

Shutting down safely#

systemctl stop sends SIGTERM, waits, then sends SIGKILL. Handle the first one and put your outputs in a safe state.

import signal
import sys

running = True


def shutdown(signum, frame):
    global running
    running = False


signal.signal(signal.SIGTERM, shutdown)
signal.signal(signal.SIGINT, shutdown)

try:
    while running:
        control_loop()
finally:
    all_outputs_off()

Give yourself enough time to do it:

TimeoutStopSec=30

Without this, outputs stay where they were

A process killed without a handler leaves every output in its last commanded state. A valve that was open stays open. Nothing in Linux, in systemd or in the libraries will close it for you.

Handling SIGTERM is not optional for a program that drives real equipment.

Watching a C++ program instead#

Identical, with the binary in ExecStart:

[Service]
Type=simple
User=slm-rp4
WorkingDirectory=/home/slm-rp4/my-controller
ExecStart=/home/slm-rp4/my-controller/my-controller
Restart=on-failure
RestartSec=5

C++ has no output buffering problem in the Python sense, but printf to a pipe is still block-buffered. Call setvbuf(stdout, NULL, _IOLBF, 0) at startup, or fflush(stdout) after anything you need to see promptly.

Real-time scheduling#

For a control loop with tight timing:

[Service]
CPUSchedulingPolicy=fifo
CPUSchedulingPriority=50

Be careful with this

A SCHED_FIFO process at high priority that stops yielding can lock up the system. It will not be preempted by anything at lower priority, including the shell you would use to stop it.

Use it only for a loop that sleeps every iteration, and keep the priority below that of any other real-time process on the device. Test on a bench unit first, with a means of power-cycling it.

The absolute-deadline pattern in C++: Examples matters more than the scheduling policy for most applications. Get the loop structure right before reaching for real-time priorities.

Log rotation#

The journal rotates by itself, but a program logging to a file of its own will fill the disk eventually.

sudo nano /etc/logrotate.d/my-controller
/home/slm-rp4/my-controller/logs/*.log {
    daily
    rotate 14
    compress
    missingok
    notifempty
    copytruncate
}

Or cap the journal instead, in /etc/systemd/journald.conf:

SystemMaxUse=500M

A controller that has been running for two years with unrotated logs is a controller that stops when the filesystem fills.

Useful commands#

sudo systemctl start my-controller
sudo systemctl stop my-controller
sudo systemctl restart my-controller
sudo systemctl enable my-controller      # start at boot
sudo systemctl disable my-controller

systemctl status my-controller
journalctl -u my-controller -f           # follow
journalctl -u my-controller -n 100       # last 100 lines
journalctl -u my-controller --since "1 hour ago"

sudo systemctl daemon-reload             # after editing the unit file

daemon-reload after every edit. Forgetting it means systemd keeps running the old definition, which makes the resulting behaviour difficult to diagnose.

For the OpenPLC Runtime containers:

sudo docker update --restart=no openplc-bootloader             # do not start at boot
sudo docker update --restart=unless-stopped openplc-bootloader # start at boot (factory setting)

sudo docker stop openplc-bootloader openplc-runtime
sudo docker start openplc-bootloader                           # starts both

sudo docker ps                                                 # what is running

Next#