# Resilent Plant Floor Dashboards

![Plant Floor dashboards on an OnLogic FR201](../../../../assets/resilient-plant-floor-dashboards-with-the-onlogic-fr201.png)

A plant floor dashboard only earns trust if it's on the screen every time someone walks by. If the display shows a desktop, a login prompt, or a browser error after a power blip, people stop looking at it. This guide walks through configuring an OnLogic FR201 to boot straight into a full-screen Chromium dashboard and recover on its own, with no keyboard or mouse attached.

## Why the FR201

The [OnLogic FR201](https://www.onlogic.com/store/fr201/) is an industrial computer built around the Raspberry Pi Compute Module 4. It's fanless, DIN-rail mountable, accepts industrial power input, has dual ethernet ports (_one of which is `PoE`_), a serial interface, M.2 SATA SSD, and handles the temperature swings and dust you'd expect near a production line. Not to mention they are really fun to work with. In other words, it's a Raspberry Pi you can actually leave on a plant floor for years. Everything in this guide also works on a standard Raspberry Pi 4 if you're prototyping at your desk.

This setup assumes the device is running **Raspberry Pi OS Lite**. That's intentional. The Lite image has no desktop environment, so we install only the minimal X11 stack we need. There's less to update, less to break, and nothing extra running behind the dashboard.

## What You'll Need

Before you start, have the following ready:

- An FR201 (or Raspberry Pi 4+) flashed with Raspberry Pi OS Lite (64-bit)
- A network connection that can reach your dashboard URL
- An admin account with sudo access (this guide uses `pi`, but use whatever account you created when imaging the device)
- The URL of the dashboard you want to display

## How It Fits Together

The boot sequence is simple: `systemd` starts an X session as a dedicated `kiosk` user, and that session runs a script that launches Chromium in kiosk mode pointed at your dashboard.

```mermaid
graph LR
    A["systemd<br/>kiosk.service"] --> B["startx"]
    B --> C[".xinitrc"]
    C --> D["kiosk.sh"]
    D --> E["Chromium<br/>(kiosk mode)"]
    E --> F["Dashboard URL"]
```

If Chromium crashes or the script exits, systemd restarts the whole session automatically. That's the piece that makes this reliable enough for a `24/7` operation.

## First things first

If you have a new FR201 from OnLogic, you will need to do a few things before you can start configuring it as a kiosk. The device will be configured for Great Britain, which means your timezone, locale, and keyboard layout will be set accordingly. Additionally, SSH isn't enabled by default. Here is a script that will do the following:

- Set the hostname to `fr201-kiosk`
- Set the locale to `en_US.UTF-8`
- Set the timezone to `America/Chicago`
- Set the keyboard layout to `us`
- Enable SSH

1. Run the following scripts on the device.
   ```bash title="Initial configuration script"
    #!/bin/bash

    # Set hostname
    sudo raspi-config nonint do_hostname fr201-kiosk

    # Set timezone
    # If you're in Pacific time, change the last argument to America/Los_Angeles
    # If you're in Eastern time, change the last argument to America/New_York
    # If you're in Mountain time, change the last argument to America/Denver
    sudo raspi-config nonint do_change_timezone America/Chicago

    # Set keyboard layout
    sudo raspi-config nonint do_configure_keyboard us

    # Set locale
    sudo raspi-config nonint do_change_locale en_US.UTF-8

    # Enable SSH
    sudo raspi-config nonint do_ssh 0
    ```
## Set Up SSH Key Access

The goal is to run these devices headless on the plant floor, so SSH is how you'll manage them after deployment. Set up key-based authentication now, before the device leaves your desk. It's more secure than passwords, and it makes scripted maintenance across multiple devices painless.

2. First, generate a key pair on your machine if you don't already have one:

   ```bash title="Generate an SSH key pair"
    ssh-keygen -t ed25519 -C "fr201-kiosks"
    ```

    :::note
    If you're on Windows, it will create the key in `%USERPROFILE%\.ssh\id_ed25519` by default. On macOS and Linux, the default is `~/.ssh/id_ed25519`. This will create a public key (`id_ed25519.pub`) and a private key (`id_ed25519`). The public key is what you copy to the device; the private key stays on your machine and should **never be shared**.
    :::

3. Accept the default file location and set a passphrase. Don't forget the passphrase you just set. Then copy the public key to the device:

   ```bash title="Copy your public key to the device"
    ssh-copy-id pi@<device-ip>
    ```

    Windows doesn't ship `ssh-copy-id`, but PowerShell gets you the same result:

    ```powershell title="Copy your public key to the device"
    type $env:USERPROFILE\.ssh\id_ed25519.pub | ssh pi@<device-ip> "mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 700 ~/.ssh && chmod 600 ~/.ssh/authorized_keys"
    ```

    :::note
    By default, the device will run on DHCP and get an IP from your network. If you don't know the IP, check your router's DHCP table or plug in a monitor and keyboard to run `hostname -I` on the device.
    :::

4. Verify the key works by opening a new session. You should land at a shell without a password prompt (you may be asked for your key passphrase instead):

   ```bash title="Test key-based login"
    ssh pi@<device-ip>
    ```

5. Once key login works, disable password authentication so the device only accepts ssh keys:

   ```bash title="Disable password authentication"
    sudo nano /etc/ssh/sshd_config
    ```

6. Scroll down until you see `PasswordAuthentication` and set the following:

   ```console title="/etc/ssh/sshd_config"
    PasswordAuthentication no
    ```

7. Then restart the SSH service:

   ```bash title="Restart sshd"
    sudo systemctl restart ssh
    ```

    :::caution
    Confirm key-based login works from a **second** terminal before you disable password authentication. You don't want to lock yourself out of the device!
    :::
## Install the Required Packages

:::tip[Optional upgrade]
You may want to also do an upgrade to the latest packages. If you do, run `sudo apt update && sudo apt upgrade -y` before installing the packages below. The FR201 ships with a recent Raspberry Pi OS Lite image, so an upgrade is usually not necessary.
:::

8. Update the system and install a minimal X11 stack, a lightweight window manager, and Chromium:

   :::note
    For the purposes of this guide, you don't need to install `xdotool` or `cec-utils` if you don't plan to use them. None of the scripts in this guide use them. They are optional utilities for scripting keystrokes and controlling HDMI-CEC devices, respectively.
    :::

    ```bash title="Install packages"
    sudo apt update
    sudo apt install -y --no-install-recommends \
        xserver-xorg \
        xinit \
        x11-xserver-utils \
        xserver-xorg-legacy \
        matchbox-window-manager \
        chromium-browser \
        unclutter \
        xdotool \
        cec-utils
    ```

    :::note[Bookworm vs Bullseye]
    Raspberry Pi OS ships `chromium-browser` on both Bullseye and Bookworm, so the command above works on either. On plain Debian Bookworm the package is just `chromium`. If `apt` reports that `chromium-browser` has no installation candidate, substitute `chromium` here and in the `kiosk.sh` launch command further down.
    :::

    A quick rundown of the less obvious choices: `matchbox-window-manager` is a tiny window manager that does nothing but keep the browser full screen, `unclutter` hides the mouse cursor after a moment of inactivity, `xdotool` lets you script keystrokes if you ever need to refresh the page remotely, and `cec-utils` gives you HDMI-CEC control so you can turn the attached TV on and off from the command line.
## Create the Kiosk User

9. Run the kiosk under a dedicated account rather than your admin user. If the browser session is ever compromised, the blast radius is a locked-down account that can't sudo.

   ```bash title="Create the kiosk user"
    sudo useradd -m -s /bin/bash kiosk
    sudo passwd kiosk
    ```

10. Create the `.xinitrc` file that `X` will run when the session starts:

   ```bash title="Create .xinitrc"
    sudo nano /home/kiosk/.xinitrc
    ```

11. Add:

   ```bash title="/home/kiosk/.xinitrc"
    #!/bin/sh
    exec /home/kiosk/kiosk.sh
    ```

12. Set permissions and ownership:

   ```bash title="Set permissions"
    sudo chmod +x /home/kiosk/.xinitrc
    sudo chown -R kiosk:kiosk /home/kiosk
    ```

    The `chmod +x` is important. If `.xinitrc` isn't executable, the X session will fail to start and you'll be left at a blank screen. The `chown` ensures the kiosk user owns its home directory and all the files in it.
## Create the Kiosk Configuration File

13. Keep configuration separate from logic. The script that launches the browser never changes; the config file holds everything that varies per device or per site. When you deploy to more than one location, this file is the only thing you touch.

   ```bash title="Create the config file"
    sudo -u kiosk nano /home/kiosk/kiosk-config.sh
    ```

14. Add the following content:

   ```bash title="/home/kiosk/kiosk-config.sh"
    #!/bin/bash
    # Kiosk Dashboard Configuration
    #
    # Everything that varies per device or per site lives here.
    # The kiosk.sh script sources this file at startup.

    # The dashboard URL to display
    DASHBOARD_URL="https://dashboard.example.com/display/$(hostname)"

    # Logging configuration
    LOG_FILE="/home/kiosk/kiosk-startup.log"
    LOG_ENABLED=true
    LOG_PERMISSIONS=664  # Owner read/write, group read/write, others read

    # Network connectivity test settings
    CONNECTIVITY_TEST_TIMEOUT=10
    CONNECTIVITY_TEST_RETRIES=3

    # Browser profile cleanup settings
    CLEANUP_BROWSER_PROFILE=true
    BROWSER_PROFILE_DIR="/tmp/chromium-profile-$(hostname)"
    ```

    :::tip[Per-device URLs]
    Notice the `$(hostname)` in the URL. If your dashboard application can serve a device-specific view based on the hostname, every kiosk gets its own content from the same image and the same script. If your dashboard is a single shared page, just hardcode the full URL instead.
    :::

15. Make it executable and set ownership:

   ```bash title="Set config permissions"
    sudo chmod 755 /home/kiosk/kiosk-config.sh
    sudo chown kiosk:kiosk /home/kiosk/kiosk-config.sh
    ```
## Create the Kiosk Script

16. And now for the meat and potatoes. This is the script that runs inside the X session. It configures the display, cleans up any stale browser state, checks that the dashboard is reachable, and launches Chromium in kiosk mode. Everything it does is logged so you can troubleshoot over SSH later.

   ```bash title="Create the kiosk script"
    sudo -u kiosk nano /home/kiosk/kiosk.sh
    ```

17. Add the following content:

   ```bash title="/home/kiosk/kiosk.sh"
    #!/bin/bash

    # Source kiosk configuration
    SCRIPT_DIR="$(dirname "${BASH_SOURCE[0]}")"
    if [ -f "$SCRIPT_DIR/kiosk-config.sh" ]; then
        source "$SCRIPT_DIR/kiosk-config.sh"
    else
        echo "ERROR: kiosk-config.sh not found in $SCRIPT_DIR"
        exit 1
    fi

    # Function to log messages
    log_message() {
        if [ "$LOG_ENABLED" = true ]; then
            echo "$(date '+%Y-%m-%d %H:%M:%S'): $1" >> "$LOG_FILE"
            chmod "${LOG_PERMISSIONS:-644}" "$LOG_FILE" 2>/dev/null
        fi
        echo "$1"
    }

    # Function to test network connectivity
    test_connectivity() {
        local url="$1"
        local retries="$CONNECTIVITY_TEST_RETRIES"

        for i in $(seq 1 $retries); do
            if curl -s --connect-timeout "$CONNECTIVITY_TEST_TIMEOUT" --head "$url" > /dev/null 2>&1; then
                return 0
            fi
            log_message "Connectivity test attempt $i failed for $url"
            sleep 2
        done
        return 1
    }

    # Start logging
    log_message "=== Kiosk startup initiated ==="

    # Display setup: disable power management, screen saver, and blanking
    xset -dpms
    xset s off
    xset s noblank
    matchbox-window-manager -use_titlebar no &
    unclutter &

    # Set the display resolution to match your screen
    xrandr --output HDMI-1 --mode 1920x1080 --rate 60

    # Clean up browser profile if enabled
    if [ "$CLEANUP_BROWSER_PROFILE" = true ]; then
        log_message "Cleaning up browser profile data"
        rm -rf /home/kiosk/.config/chromium/Singleton*
        rm -rf /home/kiosk/.config/chromium/Default/Singleton*
        rm -rf "$BROWSER_PROFILE_DIR"
    fi

    log_message "Dashboard URL: $DASHBOARD_URL"

    # Test connectivity to dashboard
    log_message "Testing connectivity to dashboard..."
    if test_connectivity "$DASHBOARD_URL"; then
        log_message "Dashboard connectivity test passed"
    else
        log_message "WARNING: Dashboard connectivity test failed - proceeding anyway"
    fi

    # Launch browser
    log_message "Launching Chromium browser in kiosk mode"
    chromium-browser \
        --user-data-dir="$BROWSER_PROFILE_DIR" \
        --noerrdialogs \
        --disable-infobars \
        --kiosk \
        --window-position=0,0 \
        "$DASHBOARD_URL"

    log_message "=== Kiosk session ended ==="
    ```

18. Just like the `.xinitrc` and `/home/kiosk` folder, make it executable and set the ownership:

   ```bash title="Set script permissions"
    sudo chmod +x /home/kiosk/kiosk.sh
    sudo chown kiosk:kiosk /home/kiosk/kiosk.sh
    ```

    A few notes on the details:

    - The `xrandr` line assumes a 1080p display on the first HDMI port. Run `xrandr` with no arguments from inside a session to list the modes your display actually supports, and adjust to match.
    - The `Singleton*` cleanup removes Chromium's lock files. If the device loses power mid-session, Chromium leaves these behind and then refuses to start cleanly. Deleting them at startup makes power cuts a non-event.
    - The connectivity check is a warning, not a gate. If the network is slow coming up, the browser still launches and Chromium retries on its own. You get a log entry either way.

    :::note[Internal certificates]
    If your dashboard uses a certificate from an internal CA, install that CA certificate on the device rather than adding `--ignore-certificate-errors` to the Chromium flags. Trusting the CA properly keeps TLS validation intact for everything else the device talks to.
    :::
## Configure User Groups and Permissions

19. Give the `kiosk` user access to the hardware it needs, and add your admin user to the `kiosk` group so you can read the startup log without sudo:

   ```bash title="Configure groups"
    sudo usermod -aG tty,video,input,dialout,audio kiosk
    sudo usermod -a -G kiosk pi
    sudo chmod 750 /home/kiosk
    ```

    You'll need to log out and back in (or run `newgrp kiosk`) before the group change takes effect for your admin user.
## Allow Xorg to Start from a Service
20. By default, Xorg only starts from a console login. Since systemd will be starting it as the `kiosk` user, relax the wrapper configuration:

   ```bash title="Edit the Xwrapper config"
    sudo nano /etc/X11/Xwrapper.config
    ```

    Set the following:

    ```text title="/etc/X11/Xwrapper.config"
    allowed_users=anybody
    needs_root_rights=yes
    ```
## Create the systemd Service

21. The service ties everything together. It waits for the network, starts X as the `kiosk` user, and restarts the session automatically if it ever exits:

   ```bash title="Create the service file"
    sudo nano /etc/systemd/system/kiosk.service
    ```

    Add this configuration:

    ```ini title="/etc/systemd/system/kiosk.service"
    [Unit]
    Description=Kiosk Dashboard
    After=systemd-user-sessions.service network-online.target getty@tty1.service
    Wants=network-online.target

    [Service]
    User=kiosk
    Environment=DISPLAY=:0
    ExecStart=/usr/bin/startx -- vt1 -keeptty -nocursor
    StandardOutput=inherit
    StandardError=inherit
    Restart=always
    RestartSec=10

    [Install]
    WantedBy=multi-user.target
    ```

    `Restart=always` with a ten second delay is the heart of the reliability story. Browser crash, script error, X server failure: whatever goes wrong, the dashboard is back within seconds without anyone touching the device.
## Enable and Start the Service

22. Reload systemd, enable the service so it starts at boot, and start it now:

   ```bash title="Enable and start"
    sudo systemctl daemon-reload
    sudo systemctl enable kiosk.service
    sudo systemctl start kiosk.service
    ```

    Then reboot to confirm the full boot-to-dashboard path works end to end:

    ```bash title="Reboot"
    sudo reboot
    ```

    The device should come up, sit briefly at the console, and then switch to the full-screen dashboard with no interaction.
## Optional: Auto-Login on the Console

23. If you also want the console on tty1 to log in as the kiosk user automatically, add a getty override:

   ```bash title="Create the override directory"
    sudo mkdir -p /etc/systemd/system/getty@tty1.service.d
    sudo nano /etc/systemd/system/getty@tty1.service.d/override.conf
    ```

    Add the following content:

    ```ini title="/etc/systemd/system/getty@tty1.service.d/override.conf"
    [Service]
    ExecStart=
    ExecStart=-/sbin/agetty --autologin kiosk --noclear %I $TERM
    Type=idle
    ```

    Reload systemd to pick up the change:

    ```bash title="Reload systemd"
    sudo systemctl daemon-reload
    ```
## Optional: Quick Stop and Restart Shortcuts

24. Here's a small quality-of-life trick for when you're standing at the device with a keyboard plugged in and you need to get to the terminal and make some changes. Pressing `Alt+F4` closes Chromium and drops you to the terminal, but the service relaunches the browser ten seconds later, which is rarely enough time to type a full `systemctl` command. These two single-letter scripts solve that problem by allowing you to stop and restart the service with a single keystroke.:

   ```bash title="Create the s and r shortcuts"

    # Create a shortcut to stop the kiosk service when you press 's' in the terminal and hit Enter.
    sudo bash -c 'cat > /usr/local/bin/s << EOF
    #!/bin/bash
    sudo systemctl stop kiosk.service
    echo "Kiosk service stopped"
    EOF'

    # Create a shortcut to restart the kiosk service when you press 'r' in the terminal and hit Enter.
    sudo bash -c 'cat > /usr/local/bin/r << EOF
    #!/bin/bash
    sudo systemctl restart kiosk.service
    echo "Kiosk service restarted"
    EOF'

    # Make them executable or they won't work.
    sudo chmod +x /usr/local/bin/s
    sudo chmod +x /usr/local/bin/r
    ```

    Now when when you hit `Alt+F4`, the browser closes and the terminal appears, type `s` and press `Enter` before the service restarts the browser. The service stops and the terminal is yours. Type `r` when you're done and the dashboard comes right back.

    :::note[Why these need sudo]
    Both scripts call `sudo`, and the `kiosk` user deliberately can't `sudo`. That's the whole point of running the browser under an unprivileged account: if the session is ever compromised, the blast radius is a user that can't control the service or anything else.

    So the full sequence at the device is `Alt+F4` to close the browser, `su - pi` and your admin password, then `s`. If you're quick, you'll beat the ten second restart. Type `r` when you're done.

    Resist the urge to add a sudoers rule granting the kiosk user `systemctl` access. It hands back exactly the privilege the separate account was created to withhold.
    :::
## Troubleshooting

Everything you need to diagnose issues that may arise. And you can do it entirely over SSH.

Check the service status:

```bash title="Service status"
sudo systemctl status kiosk.service
```

View the service logs from systemd's journal:

```bash title="Service logs"
journalctl -u kiosk.service -e
```

View the kiosk startup log written by the script:

```bash title="Startup log"
# As your admin user (if in the kiosk group):
tail -20 /home/kiosk/kiosk-startup.log

# Or with sudo:
sudo tail -20 /home/kiosk/kiosk-startup.log
```

Check the Xorg log if the display never comes up:

```bash title="Xorg log"
cat /home/kiosk/.local/share/xorg/Xorg.0.log
```

Verify file permissions if the session starts but the script fails:

```bash title="Check permissions"
ls -la /home/kiosk/
```

Confirm the configuration file loads correctly:

```bash title="Test config loading"
cd /home/kiosk
sudo -u kiosk bash -c 'source ./kiosk-config.sh && echo "URL: $DASHBOARD_URL" && echo "Logging: $LOG_ENABLED"'
```

## Wrap-Up

You now have an FR201 that boots straight into a full-screen dashboard, restarts itself after crashes and power outages, and can be managed entirely over SSH with key authentication. The pieces worth remembering:

- A dedicated, unprivileged `kiosk` user runs the browser session
- Configuration lives in `kiosk-config.sh`, so per-device changes never touch the logic
- systemd's `Restart=always` handles recovery without human intervention
- SSH keys mean you never type a password to a plant floor device again

If you're rolling this out across multiple sites, image one device, verify it, and clone the image. The only file that changes per device is the config file, and if your dashboard serves per-device views by hostname, even that stays identical.

## Get the Code

Everything above is in a [companion repo](https://github.com/jakeashcraft/plant-floor-dashboard). If you're new to linux and the terminal, going step by step is a great way to learn. If you're comfortable with the command line, cloning the repo and running the installers is faster than copying snippets.:

```bash title="Clone and install"
git clone https://github.com/jakeashcraft/plant-floor-dashboard.git
cd plant-floor-dashboard

# at the device, keyboard attached
sudo ./first-boot.sh

# then over SSH
sudo ./install.sh --url 'https://dashboard.example.com/display/$(hostname)'
```

Every flag defaults to the value used in this guide, so a bare `sudo ./install.sh` reproduces exactly what you just read. Both scripts take `--dry-run` if you'd rather see what they touch before running on your device. The installer also detects the Chromium package name for you, which is the one difference between a Bullseye and a Bookworm image that will otherwise stop you cold.

## See Also

[Companion repo on GitHub](https://github.com/jakeashcraft/plant-floor-dashboard)
[Static IP with systemd-networkd](/guides/onlogic-fr201/systemd-networkd-configuration/)
[OnLogic FR201 Overview](/guides/onlogic-fr201/overview/)
External references:

- [OnLogic FR201 product page](https://www.onlogic.com/store/fr201/)
- [Raspberry Pi OS downloads](https://www.raspberrypi.com/software/operating-systems/)
- [Chromium command line switches](https://peter.sh/experiments/chromium-command-line-switches/)