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.