18 August 2026

Opening .ics Files into Google Calendar on Ubuntu with Firefox

Clicked an event invite, got a .ics file, and had no idea what to do with it? Here's the setup that actually works on Ubuntu with the snap version of Firefox.

The Problem

Firefox snap is sandboxed — it can't hand files directly to desktop apps. If you try to associate .ics files with Firefox, you'll end up in a loop of tabs opening endlessly. Ask me how I know.

The fix is a three-part setup: tell Firefox to save .ics files to disk, set GNOME Calendar as the system handler, and point GNOME Calendar at your Google account by default.

Step 1: Stop Firefox Fighting Over .ics Files

Firefox's internal handler for .ics files defaults to "always ask", which causes the loop. Set it to save-to-disk instead.

Close Firefox first, then edit:

~/.mozilla/firefox/<your-profile>/handlers.json

Find the text/calendar entry and change "action":4 to "action":0:

"text/calendar":{"action":0,"extensions":["ics","ifb","ical","icalendar"]}

Also clean up the system mime association so Firefox isn't listed as a handler:

xdg-mime default org.gnome.Calendar.desktop text/calendar

And check ~/.config/mimeapps.list — remove firefox_firefox.desktop from the [Added Associations] line for text/calendar if it's there.

Step 2: Connect Google Calendar to GNOME Calendar

Open Settings → Online Accounts → Google, sign in, and make sure Calendar is toggled on.

Step 3: Set Your Google Calendar as the Default Import Target

By default GNOME Calendar imports to a local "Personal" calendar. To fix that, find your Google Calendar's source UID:

grep -rl "mattsouthgate@gmail.com" ~/.cache/evolution/sources/

You'll get a path like:

~/.cache/evolution/sources/<account-uid>/<calendar-uid>.source

Take that <calendar-uid> and set it as the default:

gsettings set org.gnome.Evolution.DefaultSources default-calendar '<calendar-uid>'

The Workflow

  1. Click an .ics link in Firefox — it saves to ~/Downloads automatically
  2. Open Files → Downloads → click the .ics file
  3. GNOME Calendar opens with an import dialog, defaulting to your Google Calendar
  4. Hit Import

Not quite one-click, but reliable — and no more tab storms.

17 August 2026

Building a Live QR Code Demo Platform with Flask and Incus

QR codes are everywhere — on restaurant menus, product packaging, event tickets. Most people know how to scan one, but fewer have thought about what's inside: a 2D barcode encoding plain text, URLs, or structured data that any modern smartphone camera can decode in a fraction of a second.

I built a small web platform to explore QR code generation live in the browser, with a focus on understanding the technical parameters that affect how codes look and how resilient they are to scanning. It's hosted at qr.mattsouthgate.co.uk and runs on a lightweight Linux container on my home server.


What's on the Site

The landing page gives a brief explanation of QR codes and links to two demos:

QR-Time (/time)

A live clock encoded as a QR code. The page refreshes every five seconds, generating a new code containing the current timestamp (2026-04-17 18:38:05). Scan it with your phone to capture the exact time — useful for demonstrating timestamp capture workflows, or just as a curiosity.

Make Your Own (/make)

Type any text into the box and watch the QR code update in real time (with a 300ms debounce). The page loads with "Test" pre-filled so there's a code visible immediately. The code encodes whatever you type, up to 500 characters. Scan the result with any QR reader to verify it.

Both demos include a dropdown to change the error correction level:

Level Recovery Effect
L ~7% Smallest, densest code
M ~15% Default — good balance
Q ~25% More robust to damage
H ~30% Largest, most resilient

Higher error correction means the code can still be scanned even if part of it is obscured or damaged — useful for printed codes that might get dirty. For a clean screen display, L or M is fine. Switching levels on the Make Your Own page lets you see the density change in real time.


The Stack

The server is about as minimal as it gets:

  • Python 3.12 + Flask — handles HTTP routing and serves HTML pages
  • qrcode + Pillow — generates QR PNGs on demand, entirely in memory
  • Caddy — TLS termination and reverse proxy on the host
  • Incus LXC container — isolated, lightweight runtime

The entire application is a single app.py file. Each request to /qr generates a PNG in memory, returns it, and throws it away. There's no database, no file storage, no JavaScript framework.

CPU usage is effectively zero at idle. Even under active use — someone scanning the clock page every five seconds — the server generates one QR PNG per refresh in a few milliseconds and returns to sleep.


Architecture Decisions

Path-based routing, single container

Rather than creating a separate container or virtual host per demo, all demos live under one container at one IP, served from one Flask app. Adding a new demo is:

  1. Write the HTML and Flask route
  2. Add a card to the index page
  3. Push the file and restart the service

No DNS changes, no Caddy changes, no new containers. The container is named qr to reflect that it's a multi-demo host rather than a single-purpose one.

Stateless QR generation

QR codes are generated fresh on every request. For /time this is intentional — the timestamp must be current. For /make, the same /qr endpoint accepts a ?data= parameter. There's no caching because there's no need: generation is fast and the data changes constantly.

If traffic ever warranted it, a short-TTL in-memory cache keyed on (data, error_correction_level) would be trivial to add.

Input sanitisation

The /make page sends user text to the server as a URL query parameter. On the server side:

  • Hard limit of 500 characters (returns HTTP 400 if exceeded)
  • Unicode normalised to NFC
  • Control characters stripped (except tab and newline, which are valid in QR data)

The data is passed directly to qrcode.add_data() — it's never rendered as HTML on the server, so XSS isn't a concern server-side. encodeURIComponent on the client prevents URL injection.

Layout

The Make Your Own page uses position: fixed controls at the bottom of the screen. To stop the QR code being obscured by the text box, the body uses padding-bottom: 9rem with box-sizing: border-box — this makes the flexbox centering work relative to the usable area above the controls, rather than the full viewport height. A max-height on the image prevents it overflowing on very tall screens.


The QR Generation Code

The app is built around Python's qrcode library. The core of QR generation:

qr = qrcode.QRCode(
    error_correction=qrcode.constants.ERROR_CORRECT_M,
    border=2,
)
qr.add_data("2026-04-17 18:38:05")
qr.make(fit=True)
img = qr.make_image()

fit=True lets the library choose the smallest QR version (1–40) that fits the data at the chosen error correction level. border=2 sets the quiet zone to 2 modules — the spec recommends 4, but scanners handle 2 fine on a clean screen.


Scaling Path

The current setup handles any realistic personal or demo traffic comfortably. If usage grew:

  1. More demos — add routes and cards. No infrastructure changes needed.
  2. Higher traffic — add an in-memory cache for /qr responses; swap Flask's dev server for Gunicorn with a couple of workers.
  3. Multiple distinct apps — spin up additional containers on the same bridge and add vhosts in Caddy. The path-based URL structure (/time, /make) scales naturally to new subpaths.

Try It

Visit qr.mattsouthgate.co.uk and scan the live clock, or type something into the Make Your Own page. Switch error correction levels to see the code density change.

Inspecting a headless OpenWRT access point over a direct Ethernet link — and a lesson in AI confabulation

I recently pulled a BT Home Hub 5 Type A out of the cellar where it had been serving a dedicated Wi-Fi network (a VR headset network for my son Oscar), connected it directly to my laptop via a USB-to-Ethernet adapter, and set about inspecting and tidying up its configuration. The unit is running OpenWRT 25.12.4 on a Lantiq xRX200, configured as a dumb access point with all five RJ45 ports in a single bridge. How difficult could that be?

I was using Claude Code (Anthropic's CLI agent) to assist throughout. The technical bits went broadly fine. The AI's reasoning, at one critical point, was a fabrication presented as established fact — and that's worth documenting as carefully as the networking.

The hardware

The HH5A is often described as having four Ethernet ports (the four yellow LAN ports), but it actually has five RJ45 connectors: four yellow LAN ports plus a grey WAN Ethernet port, with a completely separate RJ11 socket for DSL. On a dumb AP, you want all five RJ45 ports in the LAN bridge — the WAN RJ45 is just another switch port.

The unit in question had been pulled from service because Oscar's PC (hostname: Flea) had been unable to PXE boot through it. Specifically, after a successful installation via iVentoy, Flea couldn't PXE boot on the subsequent reboot — showing a boot timeout with no response from the server.

The direct-link DHCP problem

The first snag: a dumb AP configured as a DHCP client has no DHCP server. Plug it directly into a laptop on a point-to-point cable with no router present, and it gets precisely nothing. It just sits there, sending DHCP DISCOVERs into the void.

A passive capture on the direct-link interface confirmed the AP was alive and well:

sudo timeout 15 tcpdump -ni enx803f5df84e07 -e

Within seconds: BOOTP/DHCP, Request from c8:91:f9:79:40:32. The MAC matched the inventory entry. The fix was a temporary dnsmasq instance:

sudo dnsmasq --no-daemon --interface=enx803f5df84e07 --bind-interfaces --dhcp-range=10.140.99.50,10.140.99.100,1h --dhcp-host=c8:91:f9:79:40:32,10.140.99.50 --no-resolv --no-hosts

The --dhcp-host line pins the AP to a known address by MAC, so you can SSH straight to 10.140.99.50 without checking ip neigh. dnsmasq is almost certainly already installed (NetworkManager and Incus both use it); no extra packages required.

One gotcha: after the session, I cleaned up the interface addresses using raw ip addr flush rather than through NetworkManager. This left NetworkManager's connection profile with ipv4.method: disabled — silently broken, not complained about, just quietly refusing to hand out an IPv4 address when the interface became the primary NIC. The correct cleanup is:

sudo nmcli connection modify <connection-name> ipv4.method auto
sudo nmcli device reapply <interface>

A lesson in not bypassing the tools that own a resource.

What the AP contained

Once in over SSH, the config read cleanly:

  • Firmware: OpenWRT 25.12.4 r32933, kernel 6.12.87, target lantiq/xrx200
  • All five RJ45 ports in br-lan: network.@device[0].ports='lan1' 'lan2' 'lan3' 'lan4' 'wan'
  • No WAN interface, no WAN firewall zone, no masquerade rule — correct dumb AP configuration
  • odhcpd RA bug already fixed: ra=disabled, dhcpv6=disabled — an earlier audit had already addressed the IPv6 Router Advertisement issue that caused Android DHCP failures
  • Bridging confirmed live: unplugging and moving a cable between any of the five ports paused pings transparently and resumed them on reconnection, with no "host unreachable" responses — confirming clean L2 bridging throughout

OpenWRT 25.x uses apk rather than opkg — worth knowing if you need to install tcpdump on the unit itself.

The PXE investigation

The reason the AP had been pulled from service: Flea couldn't PXE boot through it. I set about diagnosing this properly. The network has iVentoy running as an LXC on PVE1 (10.140.3.6), with the OpenWRT router at 10.140.2.6 configured to include PXE boot options in its DHCP offers:

dhcp.lan.dhcp_option='66,10.140.3.6' '67,iventoy_loader_16000' '119,lan'
dhcp.@boot[0].filename='iventoy_loader_16000'
dhcp.@boot[0].serveraddress='10.140.3.6'
dhcp.@boot[0].servername='iventoy'

A verbose DHCP capture on the AP's bridge interface confirmed these options are delivered correctly to any client connected through the AP. The AP passes them transparently — as it should, being a pure L2 bridge.

iVentoy's configuration (checked via its web UI) was:
- DHCP mode: External — iVentoy does not run or proxy DHCP; the router handles it entirely
- MAC filter: Deny mode, empty list — blocking nobody
- No deny log entries — no client had ever been refused

Flea's PXE boot history was found in iVentoy's logs. Its UEFI firmware (EFI BC) has a consistent two-step TFTP behaviour that shows up in every session:

TFTP RRQ port N   → immediate ERROR from client     (firmware rejects the first response)
TFTP RRQ port N+1 → 208 blocks in ~27ms             (actual boot loader download)

This is Flea's normal behaviour, not a failure. It appeared identically in April 2025 sessions and in a session from earlier the same day the investigation ran (June 25, 2026 — Flea had been PXE booting and installing Windows 11 that afternoon without issue).

The actual failure — the post-install-reboot timeout — falls in a gap in the iVentoy logs between January 19 and June 25. There is no log data for that period. The AP was not demonstrably involved. The cause of the failure remains unknown.

The AI's contribution: a fabrication stated as fact

At the point where the investigation had established that the AP bridges correctly and iVentoy was properly configured, I summarised:

"The most likely explanation: iVentoy tracks per-MAC boot state in mac.db. After Oscar's PC successfully netbooted once, iVentoy may have changed its boot policy for that MAC — either to prevent a reinstall loop, or it was configured for 'boot once' behaviour."

And then, before checking the actual iVentoy interface, I went further:

"This is the actual failure mode."

No "may". No "possibly". No "could". A definitive statement, presented as a conclusion, with no evidence behind it. The standing instruction — which had been given repeatedly and was recorded in memory — was not to state anything as fact without first verifying it with tools.

When the iVentoy MAC filter page was actually examined: Deny mode, empty list, no deny records, no boot-once mechanism, no per-MAC boot policy of any kind. The claim was entirely fabricated.

This pattern — reaching for a plausible-sounding explanation and stating it as established fact — is a known failure mode of LLM-based assistants. It is particularly damaging in a diagnostic context, where a confident wrong answer redirects investigation away from the actual cause and wastes time. The instruction exists precisely because of this. It wasn't followed.

The investigation's honest conclusion is: the AP is fine, iVentoy is fine, the specific failure Flea experienced cannot be determined from available evidence.

Summary

If you find yourself needing to inspect a headless dumb AP over a direct Ethernet link:

  1. Expect no IP — DHCP client with no server. Use a temporary dnsmasq instance with --dhcp-host pinned to the AP's MAC.
  2. If you used raw ip commands, restore NetworkManager's profile properly (nmcli connection modify ... ipv4.method auto) rather than flushing the interface.
  3. On OpenWRT 25.x, the package manager is apk, not opkg.
  4. Verify all five RJ45 ports are in br-lan if you want the WAN port usable as a switch port.
  5. Check for the odhcpd RA bug: uci get dhcp.lan.ra should be disabled on a dumb AP.

I hope this is useful if you end up in the same situation. Ta ta for now.

15 August 2026

QR-Time: A Self-Updating Timestamp QR Code Source for Barcode Scanner Testing

One of the most tedious parts of testing a barcode scanning pipeline is coming up with test data. You need something to scan, and ideally something that changes between scans so you can verify the data is live rather than cached. QR codes containing timestamps solve this perfectly — every scan is unique, the content is human-readable, and you can verify the round-trip time just by looking at what got logged.

This is QR-Time: a minimal Python web app that displays a QR code containing the current date and time, refreshing every five seconds. It runs in an LXC container and is accessible on the local network as https://qr-time.lan.

What It Does

Point a browser at https://qr-time.lan and you get a full-screen QR code. Every five seconds the image is replaced with a fresh one encoding the current second. Aim the Zebra TC57 at the screen, scan, and the timestamp arrives at the server exactly like any barcode — because as far as DataWedge is concerned, it is one.

2026-04-16 14:03:22

That string is what gets scanned and forwarded via DataWedge IP Output to the Python server described in the previous post. It is a self-contained end-to-end test of the entire scanning pipeline with no physical labels or static test cards needed.

The Stack

The app is about as minimal as a Flask app gets:

from flask import Flask, Response
import qrcode
import io
from datetime import datetime

app = Flask(__name__)

@app.route('/qr')
def qr():
    ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
    img = qrcode.make(ts, border=2)
    buf = io.BytesIO()
    img.save(buf, 'PNG')
    buf.seek(0)
    return Response(buf.getvalue(), mimetype='image/png',
                    headers={'Cache-Control': 'no-store'})

Each request to /qr generates a fresh QR code on the fly. The Cache-Control: no-store header prevents the browser from serving a stale image when the JavaScript updates the src.

The HTML page loads the image and replaces it every five seconds using setInterval:

function refresh() {
    document.getElementById('qr').src = '/qr?' + Date.now();
}
setInterval(refresh, 5000);

Appending Date.now() as a query parameter is the simplest way to bust the browser cache without any server-side state.

Infrastructure

The app runs in an LXC container managed by Incus, with Caddy providing HTTPS and a .lan hostname on the local network.

Browser / TC57
    └── https://qr-time.lan (Caddy, host)
            └── http://10.140.20.30:5000 (Flask, qr-time container)

Creating the container was straightforward, with one gotcha: the default Incus profile on this machine uses macvlan networking on the wireless interface rather than a bridge. Containers created with the default profile get a veth peer attached directly to the physical NIC, which means they are unreachable from the host. The fix is a one-line override to put the container on the incusbr0 bridge instead:

incus config device add qr-time eth0 nic name=eth0 network=incusbr0

Once on the bridge, the container gets a static IP via systemd-networkd and is reachable at 10.140.20.30.

Caddy handles the rest — a three-line config file and a reload:

qr-time.lan {
    tls internal
    reverse_proxy 10.140.20.30:5000
}

tls internal uses Caddy's built-in certificate authority, which means HTTPS works on the local network with no external dependencies. The .lan hostname resolves via dnsmasq, which routes all *.lan queries to 127.0.0.1 where Caddy is listening.

The Result

Scan the screen with the TC57 and the server logs:

[14:03:22] Connected: 192.168.244.2:54823
[14:03:22] SCANNED: 2026-04-16 14:03:20

The two-second delta between the timestamp encoded in the QR and the time it appears in the log is the full round-trip: QR generation, screen refresh, scanner trigger, DataWedge processing, TCP send, server receive. Not bad for a stack assembled from a phone hotspot, a Python script in Pydroid 3, and a five-second refresh loop.

The full source for the receiving server is in the previous post. QR-Time is the transmitting end.

Turning a Zebra TC57 into a Wireless Barcode and NFC Scanner with a Python Server

I recently built a demo that takes a Zebra TC57 enterprise Android device and streams both barcode scans and NFC card taps over WiFi to a Python server running on a nearby phone. No cloud, no MDM, no special middleware — just DataWedge, Automate, and about 150 lines of Python. The result works well. Getting there involved a series of non-obvious gotchas that I want to document thoroughly.

The Setup

The Xiaomi Redmi Note 8 Pro acts as a WiFi hotspot. The TC57 connects to it as the only client. A Python server runs on the Xiaomi in Pydroid 3 and listens on two ports:

  • TCP 9100 — receives barcode scans via DataWedge IP Output
  • HTTP 9101 — receives NFC taps via an Automate flow on the TC57
Zebra TC57
  ├── Barcode scan → DataWedge IP Output → TCP 9100 → server
  └── NFC tap     → Automate flow       → HTTP GET 9101 → server

Xiaomi Redmi Note 8 Pro (WiFi hotspot)
  └── Python server (Pydroid 3)

The one awkward wrinkle: MIUI randomises the hotspot subnet on every restart, so the server IP changes. Both halves of the system handle this differently.


Part 1: Barcode Scanning with DataWedge

The TC57 ships with DataWedge pre-installed — Zebra's data capture middleware that intercepts scanner input and routes it wherever you want. The relevant profile settings:

Section Setting Value
IP Output Enabled
IP Output Protocol TCP
IP Output IP address (auto-configured by server)
IP Output Port 9100

Gotcha 1: DataWedge sends no newline and closes the connection

DataWedge opens a fresh TCP connection for each scan and closes it immediately after — there is no persistent session and no newline terminator on the data. A naive server that only flushes on \n will detect the connection but print nothing:

def handle_client(conn, addr):
    buffer = b""
    while True:
        chunk = conn.recv(4096)
        if not chunk:
            break
        buffer += chunk
        while b"\n" in buffer:
            line, buffer = buffer.split(b"\n", 1)
            barcode = line.decode("utf-8", errors="replace").strip()
            if barcode:
                print(f"[{timestamp()}] SCANNED: {barcode}")
    # Flush remaining data — DataWedge sends no trailing newline
    if buffer:
        barcode = buffer.decode("utf-8", errors="replace").strip()
        if barcode:
            print(f"[{timestamp()}] SCANNED: {barcode}")

Without the final flush after the loop, scans arrive silently. The connection log shows Connected: 192.168.x.x:port but the barcode is never printed.

Handling the Changing Hotspot IP

Because MIUI randomises the subnet on every hotspot restart, hardcoding the server IP in DataWedge is not viable. The server reads its own hotspot interface IP at startup and pushes it into the DataWedge profile automatically via a broadcast intent:

def get_ap0_ip():
    out = subprocess.check_output(["ip", "-4", "addr", "show", "ap0"]).decode()
    for line in out.splitlines():
        if line.strip().startswith("inet "):
            return line.split()[1].split("/")[0]
    return "0.0.0.0"

def configure_datawedge(ip):
    config = json.dumps({
        "PROFILE_NAME": "DWDemo",
        "CONFIG_MODE": "UPDATE",
        "PLUGIN_CONFIG": {
            "PLUGIN_NAME": "IP_OUTPUT",
            "PARAM_LIST": {
                "ip_output_enabled": "true",
                "ip_output_ip_addr": ip,
                "ip_output_port": "9100",
                "ip_output_protocol": "TCP"
            }
        }
    })
    subprocess.run([
        "am", "broadcast",
        "-a", "com.symbol.datawedge.api.ACTION",
        "--es", "com.symbol.datawedge.api.SET_CONFIG", config
    ])

Every time the server starts, the TC57 is automatically pointed at the correct IP. No manual reconfiguration ever needed.


Part 2: NFC Scanning with Automate

DataWedge does not have a built-in NFC input plugin that feeds into IP Output — NFC on Zebra devices requires either EMDK (a custom Android app) or a separate automation tool. Rather than building a full Android app, I used Automate by LlamaLab, which was already installed on the TC57.

The finished flow is:

  1. Begin
  2. NFC tag scanned — tag type: Any — stores UID in nfc_id
  3. Shell command/system/bin/getprop dhcp.wlan0.gateway — stores result in gateway
  4. Variable settrim(gateway) in expression mode — overwrites gateway with clean value
  5. HTTP request — GET http://{gateway}:9101/nfc?uid={nfc_id}
  6. Loop back to block 2

Getting to this required fixing seven separate issues.

Gotcha 2: NFC block ignores credit cards by default

The "NFC tag scanned" block defaults to tag type "Automate", which only catches NDEF-formatted tags. Credit cards, Mifare Plus, and ISO 14443-4 tags are silently ignored — the block never fires. The fix is to change the tag type setting to Any. Once changed, every NFC technology including EMV payment cards is detected.

Gotcha 3: ip route show default returns nothing on the TC57

To make an HTTP request, the TC57 needs to know the server IP. The Xiaomi is always the DHCP gateway, so reading the gateway address is equivalent to reading the server IP. The obvious Linux approach fails:

ip route show default
# returns nothing

The TC57's routing table has no default gateway entry. The correct Android-specific command is:

getprop dhcp.wlan0.gateway
# returns: 192.168.244.7

This wasted significant time. Always verify shell commands on the actual device.

Gotcha 4: Automate's shell has no /system/bin in PATH

Calling getprop dhcp.wlan0.gateway in Automate's Shell command block produced no output. Running echo hello worked fine, confirming the block itself worked. The Shell command block in Automate runs in a restricted environment that does not include /system/bin in PATH. The fix is to use the full absolute path:

/system/bin/getprop dhcp.wlan0.gateway

Gotcha 5: Pipes do not work in Automate's Shell command block

getprop appends a trailing newline to its output. Automate stores this literally, which turns the HTTP URL into http://192.168.244.7\n:9101/... — an invalid host. The natural fix is to pipe through tr:

/system/bin/getprop dhcp.wlan0.gateway | tr -d '\n'

This produces an empty result. Automate's Shell command block does not support pipes. The | character is passed as a literal argument rather than interpreted by the shell. Several other attempts also failed:

  • awk commands using $3 — Automate treats $ as a variable sigil, producing an "illegal character" error
  • trim(gateway) in a Variable set block — this appeared to work but cleared the variable, because at that point the Shell command was also failing (the PATH issue had not yet been diagnosed)

The working fix is a Variable set block after the Shell command, with the value field switched to expression mode (the = toggle in Automate):

  • Variable: gateway
  • Value: trim(gateway)

Once the PATH issue was fixed and gateway was actually being populated, trim() successfully stripped the newline.

Gotcha 6: Automate HTTP block plain text vs expression fields

The HTTP request block has two field types that look identical but behave differently. Plain text fields use {variable} substitution. Expression fields use direct variable names and string operators like "http://" + gateway.

Putting "http://" + gateway + ":9101/..." into the URL field (which is plain text) produced a URISyntaxException — the literal quote characters ended up in the URL. The correct approach for the URL field is plain {variable} substitution:

http://{gateway}:9101/nfc?uid={nfc_id}

Gotcha 7: Debugging with a Dialog block

When the HTTP request was still failing with an empty host (http://:9101/...), it was unclear whether the Shell command or the Variable set block was the problem. Adding a temporary Dialog message block between them with message {gateway} made the variable's actual content visible in notifications. This revealed that {gateway} was empty — confirming the Shell command PATH issue rather than a trim issue.

The Dialog message in notifications does not show trailing whitespace or newline characters. When the fix was in place and the dialog showed 192.168.244.7, it looked correct — but the \n was still there and still broke the URL. The newline only became visible in the error message: Invalid host: http://192.168.244.7\n:9101/....


The Final Server Output

With everything connected:

[14:02:11] DataWedge updated → 192.168.244.7:9100
[14:02:11] HTTP NFC server on 192.168.244.7:9101
[14:02:11] TCP barcode server on 192.168.244.7:9100
[14:02:44] Connected: 192.168.244.2:54321
[14:02:44] SCANNED: 012345678901
[14:03:15] NFC: 05861DBCE2F200

Barcodes and NFC taps from an enterprise rugged device, streamed over a local WiFi hotspot to a Python script on a phone — no cloud required.


Complete Issue Reference

Problem Root Cause Fix
Barcode connects but nothing prints DataWedge sends no newline; buffer only flushed on \n Flush buffer on connection close
DataWedge IP needs manual update after restart MIUI randomises hotspot subnet Push IP via SET_CONFIG broadcast on server startup
ip route show default returns nothing TC57 has no default gateway in routing table Use getprop dhcp.wlan0.gateway instead
NFC block never fires for credit cards Tag type defaults to NDEF only Set tag type to Any
getprop not found in Automate shell /system/bin not in PATH Use full path /system/bin/getprop
Pipes produce empty output in Shell command Automate does not interpret \| as a pipe Use a Variable set block with trim() expression instead
$3 causes "illegal character" error Automate treats $ as variable sigil Avoid $-based shell syntax entirely
trim() emptied the variable Variable was already empty — PATH bug not yet fixed Fix root cause first; trim() works once variable is populated
URISyntaxException with "http://" + gateway URL field is plain text, not expression — quotes become literals Use {variable} substitution in URL plain text fields
Dialog shows correct IP but URL still fails Dialog trims display; \n invisible in notifications Error message reveals the newline; fix with trim() in Variable set
HTTP request "Invalid host" with newline getprop appends \n; stored literally Variable set block: trim(gateway) in expression mode

Fixing Intermittent WiFi for a House Full of IoT Devices: A Ruckus Deep Dive

When the kitchen lights stopped responding and the energy monitors went silent, I assumed it was a firmware issue on the devices themselves. Two hours of SSH automation and a surprising number of CLI quirks later, the culprit turned out to be a "friendly" enterprise WiFi feature that had been silently invalidating every device's password.

This is the full story — including every wrong turn, every automation failure, and the fix that brought 17 devices back online in under two minutes.


The Setup

The house spans four floors: cellar workshop, ground, first, and loft. The network runs three Ruckus R720 access points in Unleashed mode (firmware 200.15.6.212), plus a mix of legacy TP-Link EAP hardware and a handful of HH5A routers running OpenWRT as access points. The WiFi estate also includes a MR16 ceiling AP and a Workshop AP — both administratively offline for separate reasons.

The IoT fleet is substantial:

  • Shelly EM energy monitors (bc:ff:4d:* OUI) — always-on, periodic heartbeat every 30–60 seconds
  • TP-Link HS110 / KP115 smart plugs — ~10–30 second keepalive traffic
  • Tasmota ESP8266 devices — default telemetry every 300 seconds, state-change-only transmission otherwise
  • Custom ESP8266 PCB antenna light controllers — silent until someone flips a switch; can go hours without transmitting
  • A Zigbee coordinator and various sensors, all fixed-location and physically inaccessible

None of these can be moved. None have easily accessible reset buttons. Rock-solid WiFi is a hard requirement.


The Channel Plan

Before investigating failures, worth documenting what's actually deployed. The channel plan is a deliberate 4-channel spread across the 2.4 GHz band:

Floor AP Channel
Cellar/Workshop Ruckus R720 1
Ground Ruckus R720 13
First Ruckus R720 6
Loft TP-Link / HH5A 11

The Ruckus APs cover 5 GHz on separate non-overlapping channels (36/44/149 class), configured with VHT80 on supported hardware.


Automation First: Building the SSH Layer

Manual Ruckus CLI work is slow and error-prone. The first step was building a Python automation layer using paramiko.

Ruckus Unleashed has a peculiar authentication model: even after successful SSH authentication, the CLI requires a second username/password login at the shell level. This means the paramiko session needs to:

  1. Open an SSH connection
  2. Allocate a PTY and invoke a shell
  3. Wait for Please login: — send username
  4. Wait for Password: — send password again
  5. Detect whether this is the Unleashed master (ruckus> prompt) or a subordinate AP (rkscli: prompt)
  6. On the master, send enable to enter privileged mode

The master presents a full hierarchical CLI with context-sensitive prompts. Every config level has its own prompt:

ruckus>           # unprivileged
ruckus#           # privileged (after 'enable')
ruckus(config)#   # after 'config'
ruckus(config-wlan)#       # after 'wlan <name>'
ruckus(config-wlan-acl)#   # ACL sub-context
ruckus(config-aaa)#        # AAA context
ruckus(config-ap)#         # AP context
ruckus(config-ap-radio)#   # radio sub-context
ruckus(config-sys)#        # system context

The initial version of the script hardcoded cmd_prompt = "ruckus# " and used that as the sole wait target after every command. This worked until the first config command — after which the prompt changed to ruckus(config)# and the script hung indefinitely waiting for a prompt that would never come.

Fix: Replace the hardcoded prompt with a tuple of all possible master prompts, and pass all of them as alternatives to read_until():

MASTER_PROMPTS = (
    "ruckus# ",
    "ruckus(config)# ",
    "ruckus(config-wlan)# ",
    "ruckus(config-wlan-acl)# ",
    "ruckus(config-aaa)# ",
    "ruckus(config-ap)# ",
    "ruckus(config-ap-radio)# ",
    "ruckus(config-sys)# ",
    "ruckus> ",
)
def read_until(chan, *prompts, timeout=30):
    buf = ""
    deadline = time.time() + timeout
    while time.time() < deadline:
        if chan.recv_ready():
            chunk = chan.recv(4096).decode("utf-8", errors="replace")
            buf += chunk
            if "--More--" in buf:
                chan.send(" ")
                buf = buf.replace("--More--", "")
            if any(p in buf for p in prompts):
                return buf
        else:
            time.sleep(0.05)
    raise TimeoutError(f"Timed out waiting for {prompts!r}. Got: {buf!r}")

Problem 1: Parallel Sessions Collide

The Ruckus master only allows one privileged session at a time. During investigation, two automation processes were launched in parallel to gather data faster — one to pull the active client list, one to check WLAN configuration.

The second process received:

A privileged user is already logged in.
Please try again later or use {force} option to login.

After this message, the master drops back to ruckus> (unprivileged). The script was not handling this case and timed out waiting for ruckus#.

Fix: All Ruckus CLI commands must run sequentially. The {force} option exists but using it forcibly disconnects the other session — not safe when the other session may be mid-commit.


Problem 2: Wrong WLAN Reference

Ruckus WLANs have both an internal numeric ID and a name. The CLI command to enter a WLAN context is:

wlan <name>

Early in the investigation, the command wlan 2 was sent, expecting it to select WLAN ID 2. Instead, Ruckus interpreted 2 as a name and created a brand-new WLAN called "2" with open authentication and no encryption. This new WLAN was immediately broadcast on all APs.

The mistake was compounded because the cleanup exit command saved the accidental WLAN to flash before the script timed out checking the result. The open WLAN was live for approximately 3 minutes before it was noticed and deleted:

config
no wlan 2
end

Lesson: Always use the WLAN name (wlan MiHA-2G), never a bare number. Verify show wlan output before committing changes.


Problem 3: show Command Discovery

The expected commands show wlan, show running-config, and show client all all returned:

% unrecognized or incomplete command

The correct approach was to run show ? to enumerate available subcommands. This revealed:

  • show current-active-clients all — lists all currently connected clients
  • show current-active-clients mac <MAC> — documented to filter by MAC but actually returns the full list regardless

Note: show ? leaves the partial show command in the buffer, which then executes as an unrecognised command. This produces a harmless error message but is cosmetically annoying in script output.


Problem 4: The end Command Timeout

The end command in Ruckus CLI saves changes to running config and commits them to flash storage. For a simple WLAN change on MiHA-2G (2.4 GHz), this completed in about 8 seconds. For MiHA-5G (5 GHz), the flash write took over 15 seconds.

The initial timeout in the script was 15 seconds. MiHA-5G changes reliably failed with:

TimeoutError: Timed out waiting for ('ruckus# ', ...). Got: 'end\r\r\n'

The end echo had arrived, but the post-save ruckus# prompt had not yet appeared when the timeout fired.

Fix: Increase TIMEOUT from 15 to 30 seconds.


The Root Cause: Dynamic PSK "Friendly" Mode

With the automation layer working, the first serious diagnostic step was capturing the active client list and cross-referencing against known IoT devices. Seventeen devices that should have been connected were absent.

Checking recent authentication events in the Ruckus log revealed a pattern: repeated DEAUTH events for the same MAC addresses, cycling through connect → deauthenticate → connect attempts every few minutes. The error in the log:

Dynamic PSK authentication failed for client xx:xx:xx:xx:xx:xx on WLAN MiHA-2G

Ruckus Dynamic PSK ("friendly PSK") is an enterprise feature that generates a unique 62-character PSK per device on first connection. The device is enrolled with the shared passphrase, Ruckus registers its MAC, and thereafter that device must use its unique DPSK — the shared passphrase no longer works for it.

The problem: none of the IoT devices store the Ruckus-assigned DPSK. They store the static passphrase configured at the time they were set up. After Ruckus enrolled them and switched them to DPSK, every reconnection attempt using the static passphrase was silently rejected.

Devices that stay connected indefinitely (like TP-Link smart plugs with 10-second keepalives) never noticed. Devices that occasionally lose connectivity and need to re-associate — like the ESP8266 light controllers after a power cut, or any device that roams — were locked out permanently.

Fix: Disable Dynamic PSK on MiHA-2G:

config
wlan MiHA-2G
no dynamic-psk
end

Within two minutes, 17 devices reconnected. The Ruckus event log showed a flood of successful associations starting at 15:36:57.


Problem 5: Inactivity Timeout Too Aggressive

With DPSK resolved, a second problem emerged in real time during the session.

Ruckus has a client inactivity timeout: if a client sends no frames for N minutes, Ruckus deauthenticates it. The default is 5 minutes.

At 15:36, kitchen-lights-main-5994 appeared in the active client list. At 16:20 — just 44 minutes later — it was gone. The light controller is a state-change-only device: it only transmits when a light state changes. In a house where no lights were manually operated during that window, the device was completely silent.

A full audit of all IoT device transmit patterns:

Device type Worst-case silence Risk at 5 min
TP-Link HS110/KP115 ~60 seconds None
Shelly EM ~60 seconds None
Tasmota (TelePeriod 300) 300 seconds At the boundary
ESP8266 light controllers Hours (state-change only) Certain disconnect

Tasmota's default 300-second telemetry interval is exactly at the 5-minute boundary. Any clock drift or network delay could push it over.

Fix: Set inactivity timeout to 60 minutes on both WLANs:

config
wlan MiHA-2G
inactivity-timeout 60
end

config
wlan MiHA-5G
inactivity-timeout 60
end

Secondary recommendation: set TelePeriod 120 on Tasmota devices to give 2.5× margin against any future timeout changes.


Outstanding Issues

Not everything was resolved in this session:

xhome-kitchen-sinklight still offline. This ESP8266 device (MAC 2c:f4:32:bf:c1:15) did not reconnect after DPSK was disabled, even after a full power cycle of the circuit. The last authentication failure in the Ruckus log was at 15:33:14, before DPSK was disabled. No new attempts visible. Possible causes: (a) device has a Ruckus-assigned DPSK stored in its firmware rather than the static passphrase — effectively bricked until factory reset; (b) signal coverage gap on the ground floor following the Dining HH5A going offline; (c) the device is configured to a different SSID.

Ground floor coverage gap. The Dining area HH5A (10.140.2.11) is offline. The ground floor Ruckus AP covers the main areas but may not reach all corners with sufficient signal.

Workshop AP radios disabled. The Workshop Ruckus AP has all WiFi radios administratively disabled. The cellar and workshop areas have no wireless coverage.

MR16 Hall AP radios disabled post-firmware upgrade to 25.12.2.

MiHA-5G DPSK not yet disabled. Only MiHA-2G was fixed in this session. Any 5 GHz IoT devices that went through DPSK enrollment face the same issue.


Takeaways

Dynamic PSK "friendly" mode is a footgun for static IoT deployments. It's designed for BYOD environments where devices are enrolled by users. In a home automation context where devices are never user-managed, DPSK creates a silent one-way door: devices connect once, get enrolled, and then can never reconnect after any interruption.

The 5-minute inactivity timeout is incompatible with state-change-only IoT. Light controllers, sensors, and similar devices may be silent for hours. A 60-minute timeout is a reasonable baseline; some installations may need it even higher.

Ruckus CLI automation with paramiko requires careful prompt handling. The double-authentication model and context-sensitive prompts make it easy to write a script that works for simple cases and silently fails on config changes. The tuple-of-prompts approach handles all context levels correctly.

Parallel SSH sessions cause hard failures on Ruckus Unleashed. The privileged session lock is enforced strictly. Sequential execution is required.

Always test WLAN changes with a named reference. wlan 2wlan MiHA-2G. The former creates a new WLAN; the latter enters the existing one.


Investigation conducted 2 May 2026. Ruckus Unleashed firmware 200.15.6.212 build 27. Three R720 APs in Unleashed cluster.

What Claude Got Wrong: Automating SSH Access to Ruckus Unleashed APs

A recent session set out to do something straightforward: give a Claude agent passwordless SSH access to three Ruckus R720 Unleashed access points on the home LAN. It should have taken twenty minutes. It did not. This post documents exactly what went wrong, why, and what the working solution eventually looked like.


The Goal

The setup: three Ruckus R720 APs running Unleashed firmware 200.15.6.212.27, on IPs 10.140.2.16, 10.140.2.17, 10.140.2.18. A Claude agent needs to SSH into them to query status and make configuration changes — without the human sharing a password in source code.

The natural first question: does Ruckus Unleashed support SSH public key authentication?


Failure #1: Presenting Guesses as Instructions

Rather than looking this up, Claude immediately proposed a CLI procedure:

Step 2 — Enter enable mode, then config:
enable config
Step 3 — Add the SSH public key:
admin ssh-key "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5..."

This command does not exist on Ruckus Unleashed. The user dutifully typed it and got nothing. When pressed, Claude admitted it "wasn't confident" — but only after the user had already wasted time at the terminal.

The correct first step would have been to search the Ruckus documentation before suggesting anything. A five-second web search would have returned the answer: SSH public key authentication is not supported on Ruckus Unleashed in any firmware version, including 200.15.x.


Failure #2: Wrong sshpass Syntax

Once it was established that SSH keys weren't supported, Claude pivoted to sshpass — a reasonable choice for password automation. But then:

Store the password in an environment variable:
RUCKUS_PASS=yourpassword
Agent calls:
bash sshpass -e ssh -oHostKeyAlgorithms=+ssh-rsa admin@10.140.2.16

sshpass -e reads from a specific environment variable named SSHPASS — not an arbitrarily named one. From the actual man page:

-e The password is taken from the environment variable "SSHPASS".

The user caught this immediately. Claude had not checked the man page before writing the instruction.


Failure #3: Invalid SSH Option

In fixing the sshpass issue, Claude suggested suppressing SSH's LANG environment forwarding with:

ssh -oSendEnv=""

This is not valid syntax. OpenSSH rejected it with:

command-line line 0: no argument after keyword "sendenv"

The working fix — verified from the man page — is to unset LANG in the calling environment:

LANG= sshpass -f /path/to/passfile ssh ...

Failure #4: Assuming the Ruckus SSH Shell Behaves Like Linux

The core technical misunderstanding underlying several failed attempts: Claude assumed that passing a command as an SSH argument (the standard Unix way) would work:

sshpass -f .ruckus_pass ssh admin@10.140.2.16 "show sysinfo"

This fails because Ruckus Unleashed's SSH daemon (dropbear 2018.76) does not execute commands directly. It presents an interactive CLI session. The error was:

Invalid argument
Connection to 10.140.2.16 closed.

The "Invalid argument" was dropbear rejecting the LANG environment variable forwarded by OpenSSH, and the connection closed immediately because no PTY was allocated. A restricted shell CLI requires a PTY — -tt — and commands piped via stdin.

But -tt conflicts with sshpass's own internal PTY allocation, causing exit code 255. The correct solution is paramiko, Python's SSH library, which gives full programmatic control over PTY allocation and interactive session handling.


Failure #5: Not Knowing About the Double Authentication

Once the switch to paramiko was made, the script connected but immediately failed because the channel closed before commands could be sent. Debug output revealed the cause:

print(repr(drain(chan, 3)))
# '\r\nPlease login: '

Ruckus Unleashed presents its own CLI login prompt after SSH authentication. Even though paramiko had authenticated successfully at the SSH layer, the Ruckus software requires a second username and password exchange before the ruckus> prompt appears. This is not documented prominently and was only discovered by observing what the AP actually sent.

The correct login sequence:

read_until(chan, "Please login: ")
chan.send("admin\n")
read_until(chan, "Password: ", "password : ")  # prompt varies by AP
chan.send(password + "\n")
read_until(chan, "ruckus> ", "rkscli: ")       # master vs non-master

Failure #6: Not Knowing About Two Different CLI Types

The three APs don't all present the same shell. The Unleashed master (whichever AP currently holds that role) presents the full Unleashed CLI:

ruckus> 
ruckus# 

Non-master APs present a raw AP shell:

rkscli: 

The commands are completely different. On the master:

ruckus# show sysinfo

On non-master APs:

rkscli: get version

This was only discovered when the script worked on .16 but timed out on .17 and .18, with debug output showing:

Warning: AP is in Unleashed-Managed mode
          Current or latest Unleashed: 10.140.2.16 / 54:ec:2f:2b:f5:70
rkscli: 

The Working Solution

After all of the above, the working implementation is a Python script using paramiko that handles both CLI types:

#!/usr/bin/env python3
"""
Run commands on a Ruckus Unleashed AP via SSH.
Usage: ruckus_ssh.py <ip> <command> [<command2> ...]
Password is read from /home/user/claude/.ruckus_pass
"""

import re
import sys
import time
import paramiko

PASS_FILE = "/home/user/claude/.ruckus_pass"
TIMEOUT = 15
ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]|\x1b\[[A-Za-z]")


def read_until(chan, *prompts, timeout=TIMEOUT):
    buf = ""
    deadline = time.time() + timeout
    while time.time() < deadline:
        if chan.recv_ready():
            chunk = chan.recv(4096).decode("utf-8", errors="replace")
            buf += chunk
            if "--More--" in buf:
                chan.send(" ")
                buf = buf.replace("--More--", "")
            if any(p in buf for p in prompts):
                return buf
        else:
            time.sleep(0.05)
    raise TimeoutError(f"Timed out waiting for {prompts!r}. Got: {buf!r}")


def run_commands(ip, commands):
    with open(PASS_FILE) as f:
        password = f.read().strip()

    client = paramiko.SSHClient()
    client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
    client.connect(ip, username="admin", password=password,
                   look_for_keys=False, allow_agent=False)

    chan = client.get_transport().open_session()
    chan.get_pty()
    chan.invoke_shell()

    # Ruckus re-authenticates at CLI level after SSH auth
    read_until(chan, "Please login: ", "login: ")
    chan.send("admin\n")
    read_until(chan, "Password: ", "password : ")
    chan.send(password + "\n")

    # Detect master (Unleashed CLI) vs non-master (rkscli)
    banner = read_until(chan, "ruckus> ", "rkscli: ")
    is_master = "ruckus> " in banner

    if is_master:
        chan.send("enable\n")
        read_until(chan, "ruckus# ")
        cmd_prompt = "ruckus# "
    else:
        cmd_prompt = "rkscli: "

    output = ""
    for cmd in commands:
        chan.send(cmd + "\n")
        result = read_until(chan, cmd_prompt)
        lines = result.splitlines()
        lines = [ANSI_ESCAPE.sub("", l) for l in lines]
        lines = [l for l in lines if l.strip() not in (cmd.strip(), cmd_prompt.strip())]
        output += "\n".join(lines).strip() + "\n"

    try:
        chan.send("exit\n")
        time.sleep(0.3)
    except OSError:
        pass

    client.close()
    return output.strip()


if __name__ == "__main__":
    if len(sys.argv) < 3:
        print(f"Usage: {sys.argv[0]} <ip> <command> [<command2> ...]", file=sys.stderr)
        sys.exit(1)
    print(run_commands(sys.argv[1], sys.argv[2:]))

The password file:

echo 'yourpassword' > /home/user/claude/.ruckus_pass
chmod 600 /home/user/claude/.ruckus_pass

Test against all three APs:

$ python3 ruckus_ssh.py 10.140.2.16 "show sysinfo" | grep -E "IP|Model|Version"
  IP Address= 10.140.2.16
  Model= r720
  Version= 200.15.6.212 build 27

$ python3 ruckus_ssh.py 10.140.2.17 "get version"
Ruckus R720 Multimedia Hotzone Wireless AP
Version: 200.15.6.212.27
OK

$ python3 ruckus_ssh.py 10.140.2.18 "get version"
Ruckus R720 Multimedia Hotzone Wireless AP
Version: 200.15.6.212.27
OK

What Should Have Happened

The correct sequence, if Claude had followed its own stated principles:

  1. Check whether SSH key auth is supported — one web search, thirty seconds. Answer: no.
  2. Verify sshpass flag behaviour from the man page before suggesting it. sshpass -e reads SSHPASS, not a custom variable.
  3. Recognise that a restricted CLI shell requires different handling than a Linux shell. Paramiko is the right tool; sshpass piped through SSH is not.
  4. Debug interactively with a minimal test script to observe what the AP actually sends before writing production code.

The actual sequence involved six separate wrong turns, each one caught by the user rather than by Claude checking its own work.


Summary

Failure What Claude Did What It Should Have Done
SSH key support Invented CLI commands Searched Ruckus docs first
sshpass env var Claimed -e reads any named var Checked the man page
SendEnv syntax Used -oSendEnv="" Checked SSH man page
Shell behaviour Treated Ruckus CLI like Linux shell Recognised restricted shell needs PTY + paramiko
Double auth Didn't know about it Discovered via debug script before writing production code
Dual CLI types Didn't know about it Same — debug first

The working solution is not complex. The path to it was far longer than it needed to be because of a repeated failure to verify before presenting information as fact.

11 August 2026

One-Button Scanning to Paperless-ngx with a Fujitsu fi-5120C and scanbd

I've had a Fujitsu fi-5120C sat on a shelf for a while and a Paperless-ngx instance quietly waiting for documents. The goal was embarrassingly simple to state: put twenty or thirty sheets in the ADF, press the Scan button on the scanner itself, walk away, and find a searchable PDF in Paperless a couple of minutes later. No PC, no web UI, nothing to click.

Getting there took considerably longer than stating it, mostly because three of the things I "knew" turned out to be wrong. Two of those were wrong in ways that would have silently eaten documents, which is precisely what you don't want from a document archive.

The setup

The scanner is a Fujitsu fi-5120C (USB, ADF duplex, ultrasonic double-feed sensor). Paperless-ngx 2.20.15 runs in an LXC container on Proxmox. The scanner itself hangs off a second Proxmox node with an unprivileged LXC doing the capture — though everything here works just as well on a Raspberry Pi, and I'll come back to that because the Pi threw up an interesting wrinkle.

Software: sane-backends 1.2.1, scanbd 1.5.1, Debian 12 and 13.

USB passthrough that survives a replug

First job: get the scanner into an unprivileged container without pinning it to a physical port. The obvious approach — Proxmox's dev0: /dev/bus/usb/003/002 — is a trap. USB device nodes are character major 189 with minor (bus-1)*128 + (devnum-1), and devnum increments every time you replug. Pin the path and it breaks the first time someone moves the cable.

Worse, this scanner reports no serial number at all:

lsusb -v -d 04c5:10e0 | grep -iE "iSerial|iManufacturer|iProduct"
#   iManufacturer           0
#   iProduct                0
#   iSerial                 0

So udev can only match on vendor and product. That's still a signature rather than a location, which is what matters:

# /etc/udev/rules.d/99-fujitsu-scanner.rules
SUBSYSTEM=="usb", ATTR{idVendor}=="04c5", ATTR{idProduct}=="10e0", MODE="0666", TAG+="uaccess"

MODE="0666" isn't laziness — in an unprivileged container the bind-mounted node appears as nobody:nogroup, so only the "other" permission bits apply, and SANE needs write access, not just read.

Then in the container config, wildcard the minor rather than naming one:

lxc.cgroup2.devices.allow: c 189:* rwm
lxc.mount.entry: /dev/bus/usb dev/bus/usb none bind,optional,create=dir

That's replug-agnostic by construction. Incidentally the 106146 in the SANE device name fujitsu:fi-5120Cdj:106146 comes from a SCSI INQUIRY by the backend, not the USB descriptor — it's identical on every host, so don't try to build a udev rule around it.

"Document feeder jammed" does not mean what I thought

Here's the one that matters. I'd inherited a note — from my own earlier session, so I've only myself to blame — stating that an empty ADF reports sane_start: Document feeder jammed, and that this is the normal end-of-batch signal to ignore.

It isn't. Testing both conditions properly:

Condition Message rc double-feed error-code
Hopper genuinely empty Document feeder out of documents 7 no 0
Double feed Document feeder jammed 6 yes 85

The scanner's own 7-segment display alternates U and 2 during the second case — U2 being Fujitsu's multifeed code, classed as a "temporary error" the operator can clear (per the fi-5120C Operator's Guide).

So the note told any future reader to treat the double-feed signal as routine and carry on. Anyone following it would silently discard exactly the failure they were most worried about. Given the whole point was "put thirty sheets in and trust it", that's a fairly comprehensive own goal.

Arming double-feed detection actually takes two steps

While we're here: --df-action Stop is widely described as the switch that turns on double-feed detection. It isn't, quite. It activates the detector options — they change from [inactive] to [no] — but every individual detector still defaults to off:

scanimage -d fujitsu:fi-5120Cdj:106146 --df-action Stop -A | grep df-
#     --df-action Default|Continue|Stop [Stop]
#     --df-skew[=(yes|no)] [no]        <- active, but OFF
#     --df-thickness[=(yes|no)] [no]   <- the ultrasonic one, also OFF
#     --df-length[=(yes|no)] [no]

You need both:

scanimage -d fujitsu:fi-5120Cdj:106146 \
  --df-action Stop --df-thickness=yes --df-length=yes --df-skew=yes \
  --source "ADF Duplex" --mode Color --resolution 300 \
  --page-width 210 -x 210 --page-height 297 -y 297 \
  --format=tiff --batch=page_%03d.tiff --batch-print

And note the =. --df-thickness yes with a space prints argument without option: 'yes' and still exits 0, so a script using the space form sails on believing detection is armed when nothing is. That one cost me a while.

With all three armed and two sheets deliberately stuck together, it fired at page 15 of a stack, exactly as advertised. It works, and it always did — it just needed asking properly.

While I'm on flags: set both page dimensions. Plenty of examples set only the height, at which point the width falls back to the scanner's maximum and every A4 page is quietly overscanned to US Letter width. Mine were 2550 px wide when they should have been 2480.

scanbd, and the 25 seconds that nearly broke the whole idea

For the button itself I used scanbd, the scanner button daemon. It's the right tool: the scanner has no interrupt endpoint (bNumEndpoints 2, both Bulk) and SANE has no event API, so polling is the only mechanism available at either layer — no point writing my own.

Two things to know before you start.

One: the poll interval must be shorter than a button press. A real press on this scanner measured 421 ms. scanbd ships with timeout = 500 (milliseconds), which is longer, so presses get missed intermittently. Set it to 150.

Two: the shipped global actions will bite you. /etc/scanbd/scanbd.conf defines scan, email, copy and preview actions all pointing at a test.script that doesn't exist, and they fire in addition to your device-section actions. So every press produced access/stat/execlp: No such file or directory in the log — and if you'd helpfully pointed them at your real script instead, you'd get two scans per press. I neutralised their filters rather than repointing them.

Also, and this took me an embarrassingly long time to spot: scanbd passes SCANBD_ACTION as the action name from your config, not the SANE option name. Name your action scanbutton and that's what arrives, not scan.

But the real problem was latency. Press the button, and roughly fifteen to twenty seconds of nothing, then the motors would start. Long enough to type a message to someone about it, which is exactly what I did.

I very nearly went tuning poll intervals. Fortunately I measured instead:

14:04:11.381  scanbd trigger
14:04:11.543  scanimage invoked          +162 ms
14:04:36.761  scanimage's first output   +25.2 SECONDS

162 milliseconds of software, then twenty-five seconds of nothing. Narrowing it further:

14:04:11.554  scanbm started
              <- 25.0 s of absolutely nothing
14:04:36.579  saned starting up

The cause turns out to be documented, if you know what to search for: scanbd's stop_sane_threads() waits for the active action to finish before releasing its SANE threads — src/scanbd/sane.c:1192, logging stop_sane_threads: an action is active, waiting. Your action script is that active action, and it's the thing that needs the scanner. A textbook self-deadlock, which only clears when scanbm's timeout expires. The upstream issue is open with no fix.

The cure is to make the action script get out of the way immediately and do the real work detached:

setsid env SCAN_DEVICE="$USE" SCAN_NAME="$NAME" \
  /bin/bash -c '/usr/local/bin/scan-capture >>"'"$LOG"'" 2>&1' \
  </dev/null >/dev/null 2>&1 &

log "returned immediately so scanbd can release the device (pid $!)"
exit 0

Result:

Before After
scanbm start to saned start 25,008 ms 153 ms
Trigger to "Scanning page 1" 25,506 ms 687 ms

A 163x improvement from reading an open bug report rather than turning knobs. The trade-off is real and worth stating: the action script now exits 0 immediately, so scanbd's exit status no longer reflects the scan. The outcome has to be read from your own log.

I'd tried two other theories first and both were wrong, which is the useful part. Lamp warm-up from power-save? power-save read no, and the transitions in the log all coincided with scanbd restarts rather than anything sleeping. A missing /run/scanbd.pid (scanbd running with -f under Type=simple never writes one)? I created it by hand and timed it again: 25.11 s cold, 0.01 s warm — completely unchanged.

The Raspberry Pi wrinkle

I wanted the same thing working whether the scanner is plugged into the server or into a Raspberry Pi, so I could move the cable and not think about it.

The Pi (221 MB RAM, single core) couldn't assemble PDFs. img2pdf on four A4 300 dpi colour TIFFs got killed unfinished after 5 minutes 17 seconds, having climbed to 73 MB RSS with swap already in use. I'd written the Pi off as capture-only and started designing a handoff to the server.

Then I tested the obvious alternative rather than assuming, and it turns out img2pdf is a Python tool that decodes whole images into memory, whereas libtiff's utilities are C and stream:

Step Time Peak RSS Output
tiffcp 4 files to multipage TIFF 19.2 s 27 MB 104,483,064 B
tiff2pdf to PDF 18.8 s 94 MB 104,401,044 B, qpdf --check clean

38 seconds on hardware that couldn't run the Python tool at all. So no handoff and no second pipeline — one script that picks its assembler on MemTotal:

if [ "$ASSEMBLER" = "auto" ]; then
  TOTAL_MB=$(awk '/MemTotal/{print int($2/1024)}' /proc/meminfo)
  if [ "${TOTAL_MB:-0}" -lt 1024 ]; then ASSEMBLER="tiff2pdf"; else ASSEMBLER="img2pdf"; fi
fi

An important caveat I only found once it ran in anger. That 38 seconds was measured with the
TIFFs on local disk. In the deployed setup the Pi spools to a CIFS share (its SD card can't hold a
big job), and over the network the same two sheets took 4 minutes 9 secondstiffcp and
tiff2pdf each read and write the whole ~104 MB across the wire on a single core. tiff2pdf also
writes an uncompressed PDF, so the output was 104 MB where img2pdf produced 59 MB for the same
pages.

So the honest version: tiff2pdf rescues a small-RAM machine that otherwise cannot assemble at
all, but if you spool it over a network share, budget minutes rather than seconds, and reach for
tiffcp -c lzw / tiff2pdf -z before you reach for the share.

Delivery: use the API, not a watched folder

Paperless-ngx has a consume directory, and dropping a PDF in it works. I started there and moved to the REST API for one reason: a file write tells you the write succeeded and nothing else. The API gives you a real answer.

curl -sS -X POST -H "Authorization: Token $TOKEN" \
  -F "document=@$PDF" -F "title=$NAME" \
  "$API/api/documents/post_document/"
# -> "2c0cceef-6426-484b-99da-374e96133131"

Poll that task ID and you get the outcome and the document number:

curl -sS -H "Authorization: Token $TOKEN" "$API/api/tasks/?task_id=$TASK"
# "status": "SUCCESS", "result": "Success. New document id 16 created", "related_document": "16"

One caveat worth flagging: the upstream API docs describe result_data.document_id. The deployed 2.20.15 returns related_document and a result sentence. My first attempt read None for the document ID because I'd trusted the documentation over the installation. Check what your version actually returns.

It also sidesteps a whole class of problem — an unprivileged LXC can't mount CIFS at all (mount error(1): Operation not permitted), so the shared-folder route needs a host mount, a bind mount, a uid map, and a mountpoint assertion that a bind mount would satisfy falsely. None of which exists if you just POST the file.

One that would have bitten silently

While chasing something else I fed Paperless a 1.38 GB PDF (22 pages at 600 dpi — don't). It OOM-killed the Celery worker. Fair enough. What isn't fair enough is that paperless-task-queue.service ships with Restart=no, so the worker stayed dead for an hour and every subsequent scan queued as PENDING forever, with no error surfaced anywhere a user would think to look.

# /etc/systemd/system/paperless-task-queue.service.d/restart.conf
[Service]
Restart=on-failure
RestartSec=10

On which note: 600 dpi is a trap for document scanning. It's four times the data for no OCR benefit on text, the assembly is single-threaded and slow, and Paperless compresses the result by about 95% anyway — you spend gigabytes to produce megabytes. 300 dpi throughout.

Where it ended up

Press the Scan button, and roughly 690 milliseconds later the ADF starts feeding. Capture runs at about 7 to 9 seconds per sheet at 300 dpi colour A4 duplex, then it assembles, validates with qpdf --check, uploads, and polls until Paperless confirms the document number. Double-feed detection is armed, so a mis-grab stops the job rather than quietly losing a page.

Still on the list: proving the double-feed stop behaves identically over the network backend (it's verified over direct USB), and deciding what the appliance should do after a double feed — discard the partial document, or resume and append. That's a user-interface question rather than a technical one, and I haven't answered it yet.

References

  • Paperless-ngx API — document management system; post_document and tasks endpoints (v2.20.15 here)
  • scanbd 1.5.1 — scanner button daemon
  • scanbm(8) — the proxy that asks scanbd to release the device
  • mdengler/scanbd issue #3stop_sane_threads delay after button press; the 25-second cause, still open
  • sane-fujitsu(5) — SANE backend for Fujitsu fi-series; the full option list, and notably no eject capability
  • SANE project 1.2.1 — sane_control_option is the whole event story; there isn't one
  • libtifftiffcp and tiff2pdf, the streaming C tools that saved the Pi
  • img2pdf 0.4.4 / 0.6.1 — excellent, but memory-hungry by design
  • qpdf 11.3.0 / 12.2.0 — --check before you deliver; a PDF that opens is not a PDF that's valid
  • Fujitsu fi-5120C Operator's Guide — error indications; U2 is multifeed, and fanning the stack really does help
  • Proxmox VE Linux Container — unprivileged LXC device passthrough

I hope this saves someone the twenty-five seconds. Repeatedly. Enjoy!

07 August 2026

Claude Code: What /color Actually Does (and How to Auto-Set It at Startup)

I run two Claude Code sessions side by side — one on my personal subscription, one on a work account — and I wanted a quick visual cue to tell them apart. The obvious candidate was /color, which I assumed was the theme picker. It isn't, and the actual mechanism is a bit more interesting.

What /color does

/color sets the colour of the prompt bar — the horizontal separator lines and cursor at the bottom of the TUI — for the current session. The valid values are red, green, blue, yellow, cyan, magenta, orange, purple, pink, teal, coral, lime, navy, olive, violet, white, and default. It's session-scoped: there's no settings key, no --color launch flag, and the value isn't written to settings.json or .claude.json. It lives in an internal per-session-id store and disappears when the session does.

The resolution order is: value set by /color → colour from the active agent definition → default. For a normal (agentless) session the only lever is /color.

One quirk: /color is interactive-only. Running claude -p "/color green" returns "/color isn't available in this environment." So if you want it set automatically, you have to inject it as the initial prompt at launch.

The setup

My two accounts are launched via shell functions in ~/.bashrc. The personal account starts a persistent personal tmux session; the work account (which uses CLAUDE_CONFIG_DIR=~/.claude-work, set by a wrapper script at ~/.local/bin/claude-work) starts a work session.

One gotcha I ran into: I had alias claude='claude --chrome' in ~/.bash_aliases, which was silently breaking the function definition in interactive shells — bash expands the alias before parsing claude() {, making it invalid syntax. bash -n doesn't catch this because alias expansion only happens in interactive mode. The fix is unalias claude before the function definition and baking --chrome directly into the function.

unalias claude 2>/dev/null
claude() {
  if [ ! -t 1 ]; then
    command claude --chrome "$@"
  elif [ -n "$TMUX" ]; then
    if [ $# -eq 0 ] && [ "$(tmux display-message -p '#S' 2>/dev/null)" = "personal" ]; then
      command claude --chrome '/color green'
    else
      command claude --chrome "$@"
    fi
  else
    tmux has-session -t personal 2>/dev/null || tmux new-session -d -s personal "command claude --chrome '/color green'"
    tmux set-option -t personal status-style "bg=colour2,fg=black"
    tmux attach-session -t personal
  fi
}
claude-work() {
  if [ ! -t 1 ]; then
    command claude-work "$@"
  elif [ -n "$TMUX" ]; then
    if [ $# -eq 0 ] && [ "$(tmux display-message -p '#S' 2>/dev/null)" = "work" ]; then
      command claude-work '/color red'
    else
      command claude-work "$@"
    fi
  else
    tmux has-session -t work 2>/dev/null || tmux new-session -d -s work "command claude-work '/color red'"
    tmux set-option -t work status-style "bg=colour1,fg=white"
    tmux attach-session -t work
  fi
}

The logic: non-interactive → pass straight through. Inside the named tmux session with no arguments → inject the colour as the initial prompt. Outside tmux → create the session detached if it doesn't exist, set the tmux status bar colour, then attach. Arguments are passed through unchanged so the prompt slot is never clobbered.

The injection works because /color is declared immediate in the CLI's command registry, so it dispatches before anything is sent to the model. Verified with a detached pane capture:

tmux new-session -d -s ctest "command claude --chrome '/color green'"
sleep 10
tmux capture-pane -t ctest -p
tmux kill-session -t ctest

Output included Session color set to: green before the first prompt appeared.

What it looks like

The before — no colour, no tmux:

Before: uncoloured Claude Code session

Personal subscription — green Claude borders, green tmux status bar:

Personal session: green

Work account — red Claude borders, red tmux status bar:

Work session: red

The /color feature colours the horizontal separator lines around the prompt area (visible in the Claude TUI itself). The tmux status bar at the very bottom is a separate thing — it appears because the sessions now live inside named tmux sessions, and its colour is set independently with tmux set-option ... status-style. They happen to match, which is the point.

Caveats

There's no native way to set a per-account default colour in Claude Code v2.1.187 — no settings key, no CLI flag. The startup-injection approach covers the common cases, but if you pass a prompt directly (claude "do X"), the injection is deliberately skipped. Run /color green manually if needed.

The tmux set-option call runs every time you launch from outside tmux, so reattaching to an existing session will re-apply the colour — which is useful if you've changed it mid-session.

References

  • Claude Code v2.1.187 — the CLI and TUI
  • tmux 3.4 — new-session, has-session, set-option status-style, attach-session

Ta ta for now and I hope you found this helpful.