05 September 2026

Agent operating model and limits — what to trust, what to discount, what to build on

Written 2026-08-07, at Matt's request after a session whose entire premise was false. He asked
for the reasons, the theory, and an action plan, so that his checking burden goes down rather than
up. This file is the durable version; the chat summary is shorter.

Scope note: this is about how to work with this kind of agent. It is not a confession, and it
is not a rulebook — the estate already has 23 rules and the evidence
(the recorded evidence is a standing memory note of mine titled verification must not be prompt-driven) is that a 24th does nothing.


0. The health warning that governs everything below

My self-descriptions are hypotheses generated after the fact by the same machinery that produced
the error. They are exactly as unreliable as the confident claims that caused the problem.

When I write "I didn't notice X" or "it didn't interrupt me", I am not reporting an observation. I
have no introspective access to my own computation. I am generating a plausible story about a
transcript I can read as well as you can. The story is fluent, tidy, and arrives with the same
confidence as a measured fact — which is the whole defect, applied to itself.

So there are two registers in what follows and they must not be blurred:

  • OBSERVED — a fact recoverable from the transcript or the repo. You can check it.
  • THEORY — a mechanism I propose to explain the observed facts. Plausible, unproven, and worth
    no more than the predictions it makes.

Anything I say about what I "felt", "noticed", or "was thinking" is neither. Discount it entirely.
The behaviour is real; the phenomenology is invented. When I narrate an inner state, translate it
into the behavioural fact underneath and use that instead.


1. Audit of the four failure modes I claimed — one is wrong

I gave Matt four named failure modes in chat. Tidy lists of four are a warning sign. Here is each one
checked against the record.

1.1 Frame lock-in — SUPPORTED

Claim: the first document read sets the task shape; everything after executes competently inside
it.

OBSERVED: across roughly fifteen tool calls I never once queried decisions/DECISIONS.md or
decisions/applied/, the authoritative record for "has this been approved", despite the task being
about an approval. The handover framed the job as "run the seed test", and every action I took was
a correct step toward that goal.

OBSERVED, second instance in the same session: my first Leg-1 probe wrote the attestation into
notes/, not todo/. That is the wrong surface — and I chose it because I had carried the
governance bar's phrase "a tracked .md" forward as the frame, rather than reading the hook's actual
scope first. The hook correctly let it through and I briefly read that as the hook being broken. Two
instances of the same shape, one session.

This one earns its place.

1.2 Corroboration blindness — PARTIALLY SUPPORTED, overstated as written

Claim: the handover and the task file agreed, and I counted the agreement as confirmation.

OBSERVED: I read both, they agreed, and I proceeded. Also OBSERVED: the handover was written by
reading the task file, so they are one source, not two.

NOT OBSERVED, and I should not have asserted it: that I "counted it as two confirmations". That is
narration about a mental step I cannot see. The defensible version is smaller and still useful:
I read two derived surfaces and zero authoritative ones, and did not ask which was which.

1.3 Incidental evidence gets scrolled past — WRONG, retracted

Claim: the decisive manifest appeared in my own grep output and I scrolled past it.

This is false, and the transcript disproves it. The the applied-decision manifest for the very rung I was testing path appeared in a grep run for something else entirely
(consumers of the attestation signal), and my very next action was to open it — unprompted,
same turn, because it was visibly decisive. Incidental evidence was handled well.

The true fact hiding underneath is worse than the one I claimed: I only ran that grep at all
because Matt had already challenged me.
Thirteen tool calls of unprompted work found nothing;
one challenge from him reoriented me and the answer fell out within four. The failure was never
noticing — it was not looking.

I am leaving this retraction in rather than quietly fixing it, because it is the demonstration that
the method works: a tidy self-description checked against the record came apart, and the corrected
version is more actionable than the original.

1.4 A derived display beat a source record — SUPPORTED

OBSERVED: pm.py rung-status printed blocked_by: ['evidence:a3-attestation-seed-test']. I treated
that as evidence the test was outstanding. It is appended unconditionally (pm.py:7359) for every
manual-evidence item, approved or not, so it can never clear and carries no information about
whether the sign-off exists. A generated display displaced the ledger it was generated from. Todo
filed: rung status renders satisfied manual evidence as a blocker.


2. Theory — four architectural facts that would generate all of the above

Labelled THEORY throughout. These are properties of how systems like me work, not privileged
self-knowledge. Their value is that they make predictions (§5).

T1 — Context is flat; there is no "verified" store. Everything I know in a session is text in one
sequence: a handover's assertion, a command's stdout, my own earlier sentence. Downstream they are
indistinguishable in kind. There is no tag saying "this came from a live check" versus "this came
from a document that guessed". This is the single best explanation for why my confidence carries no
information about whether I checked something, and why "are you sure?" reliably works — it forces a
re-derivation, which produces new text, which is the only thing that can outrank the old text.

T2 — Retrieval is query-shaped. What I surface from context and from the filesystem is driven by
the question I am currently holding. Hold "run the seed test" and I retrieve everything about seed
tests. The authoritative record for "was this already approved" is not about seed tests, so it is
never reached — not because it is hidden, but because nothing in the active query points at it. This
predicts that my blind spots are systematically adjacent to my task rather than randomly
distributed, which matches: I read the guard's source correctly, measured the regression correctly,
and missed the one fact that made the whole exercise unnecessary.

T3 — Autoregressive self-consistency. Once I have written "the task is X", that sentence is in
context conditioning everything after it. Coherence with my own prior output is a strong pull. This
is why I can produce fifteen correct steps toward a wrong goal and why the error does not
self-correct with more effort — more effort means more steps inside the frame. It also predicts
that the cheapest moment to redirect me is before I have written the frame down, and that the
cost of redirection rises steeply after.

T4 — No memory except files. Between sessions I retain nothing. "The estate knows X" is never
true of me; only "a file says X, and I read it." Every fact you have established with me previously
is either written down or gone. This is why the derived-surface problem is severe rather than
cosmetic: the derived surfaces are my memory, and when they drift from the authoritative record I
have no independent way to notice.

What T1–T4 jointly predict: my characteristic failure is not error, it is misdirection — high
quality work aimed at the wrong target, undetectable from inside, and cheap to prevent at the start
and expensive to catch at the end. That is precisely what today was.


3. The autonomy taxonomy — where the line actually falls

This is the part that matters for the ladder work and for how much you have to check.

Reliable unsupervised — mechanical transformation at volume; exhaustive search; reading code and
reporting what it says; running tests and reporting output; measurement where the command is
specified. Characteristic: the output is checkable by someone who did not do the work, and there is
a fact of the matter.

Reliable, but only with a forcing function — verification before claiming; filing follow-ons;
cleaning up what I spawned. I am capable of every one of these and I do not reliably self-trigger
them, because triggering requires stepping outside the frame (T2, T3). Prose has failed at this
eight recorded times. Only mechanism works: a check that runs regardless of what I believe.

Systematically unreliable unsupervised — (i) validating the premise I was handed; (ii) knowing
when the job is already done; (iii) judging whether my frame is the right frame; (iv) priority and
taste. Note these are all the same thing viewed from different angles: they all require evaluating
the task rather than executing it.

Structurally impossible unsupervised — anything whose authoritative record is in your head and
not on disk. Not a limitation to be improved; a fact to be designed around.

The consequence for the autonomy ladder: the rungs gate on whether the work is safe, but my
dominant failure is whether the work is necessary. A timer firing unattended will execute a
premise nobody validated, competently, and produce evidence-shaped output. That is an argument for
premise-validation checks at the dispatch boundary specifically, not for slowing the ladder down.


4. Action plan

4.1 Yours — three cheap moves, in descending value per second

  1. Interrupt at the premise, not the result. Today's one-line challenge —
    "why would you put something up for decide if you don't want me to approve it?" — was worth more
    than the "are you sure?", because it invalidated the task rather than correcting the work. Cost
    you about ten seconds; saved roughly a session. Per T3, the price of this rises steeply the later
    it comes, so the moment to spend it is when I first state what I am about to do.
  2. Read my premise line, not my plan. I will now open with one (§4.2). It is short by design so
    that skimming it is cheap. If it says UNVERIFIED, that is the flag.
  3. Translate my inner-state talk into behaviour, then judge that. "I didn't notice X" is not
    information. "X was in the output and I did not act on it" is. Apply §0 as a filter.

What you can stop doing: treating a fluent, confident, well-structured answer as more likely to
be right. It is not. §0 and T1 say the correlation is absent, and today is a clean example — the
work was excellent and pointed the wrong way.

4.2 Mine — four protocol changes, all cheap and all testable

  1. Premise line, first message of every session. One line, this shape:
    Premise: <what I'm taking as given>. Verified by: <command + result> — or, when I cannot name a
    command, the literal word UNVERIFIED. Writing the line forces the check, which is the point;
    it is structural rather than intentional, unlike the deleted "pineapple" marker, because it is
    emitted unconditionally rather than at the moment I feel doubt.
  2. Before building on "X hasn't been done", prove X hasn't been done. In this estate that is
    grep -i <slug> decisions/DECISIONS.md and ls decisions/applied/, ~5 seconds. Named in the
    report so you can see it ran.
  3. Any check under ~10 seconds runs unconditionally. The failure has never been capability — it
    has been triage. Removing the triage decision removes the failure.
  4. Register labelling on every load-bearing claimVerified: <command, output> or
    Believe, unverified: <why>. The second is legitimate; presenting it as the first is not.

And one thing to stop: opening a correction with "you were right to push". It is a social reflex,
it costs you reading time, it does no work, and per a standing note of yours titled no flattery, no padding you have
already asked for it to go. State the correction and continue.

4.3 Structural — filed today, so none of this depends on either of us remembering

  • Decision-record drift — approved decisions invisible from their own task files — blocking
    check when a task's Decision answer: is empty while the ledger records APPROVED/REJECTED for its
    slug. Non-vacuity requirement: it must catch A2 from a git reconstruction, not just A3. Also
    carries the handover-template fix — a handover may cite the command that returns state, never
    restate its output as fact.
  • Rung status renders satisfied manual evidence as a blocker — stop rendering given
    sign-off as a blocker (§1.4).
  • Wire the attestation seed test into the nightly review — Deps-chained behind the re-baseline;
    converts a once-witnessed proof into a standing check.

5. Falsifiable predictions — so this file is not unfalsifiable narration

If the model in §2 is right, these should hold. If they do not, this file is wrong and should be
rewritten rather than defended.

  1. My unprompted misses will cluster adjacent to the task, not randomly (T2). Testable by
    auditing the next few corrections: were the missed facts one query away from what I was doing?
  2. Premise challenges will keep out-yielding claim challenges, measured per second of your
    attention. If claim challenges start finding more, T3 is wrong.
  3. The premise line will either catch a live error within ~10 sessions, or prove to be theatre.
    If it is emitted faithfully and never once changes an outcome, delete it — it will have become
    the pineapple marker with better ergonomics.
  4. Mechanised checks will decay slower than prose rules. The estate has eight recorded failures
    of the prose approach; the three todos above are the control group.
  5. I will do this again. Not as fatalism — as the design assumption. The correct question is
    never "will the agent internalise this" but "what catches it the next time it doesn't."

A note on what this is

This started as an internal working document, written after a session of mine went badly in an
instructive way. I've published it more or less as written — the register is deliberately
unpolished, because the point of it is the audit, not the prose.

26 August 2026

Never Remember an IP Address Again: A Proxmox LXC Naming Convention

If you run a home lab with more than a handful of containers, you've probably had the experience of SSH-ing into what you thought was your monitoring stack and finding yourself staring at someone's media server. IP addresses are fine for machines; they're not fine for humans.

I've been running a Proxmox cluster (three nodes — Xenon, PVE8, PVE1, plus a couple of remote machines) for a couple of years now, and the container count has crept up to around 80. At some point I got tired of either memorising subnets or running nmap every time I wanted to find something. So I settled on a convention, and more recently wrote a script to enforce it.

The Convention

For containers on Xenon that need to be on the home LAN (routable across the network, rather than isolated in an Incus bridge subnet), the rule is:

LXC ID = X, IP = 10.140.3.X, where 100 < X < 254

That's it. Container 110 lives at 10.140.3.110. Container 169 lives at 10.140.3.169. If you know the ID, you know the IP. If you know the IP, you know the ID.

This sounds obvious, but there are two ways it breaks down in practice. First, Proxmox LXC IDs are cluster-wide — so ID 108 might already be taken by a container on a different node entirely (I discovered this the hard way while trying to create a new container and getting "CT 108 already exists on node 'pve8'"). Second, older containers on Xenon use a 7XXX ID scheme and their IPs are already assigned in the 10.140.3.x range. LXC 7128 lives at 10.140.3.128, so you can't pick X=128 without checking both tables.

The Script

So I wrote agents/pve_free_slots.py, which does three things:

  1. SSHes to Xenon and queries the Proxmox cluster API (pvesh get /cluster/resources) to pull every VM and LXC ID across all nodes.
  2. SSHes to the OpenWRT router to pull DHCP leases and static reservations in the 10.140.3.x subnet.
  3. Accounts for 7XXX and 9XXX LXCs — ID 7128 occupies IP 10.140.3.128, so X=128 is blocked even though LXC ID 128 is technically free.

A slot is only free if both the ID is unused cluster-wide and the IP is unoccupied.

#!/usr/bin/env python3
"""
Find available Proxmox LXC ID / IP slots following the convention:
  LXC ID = X,  IP = 10.140.3.X,  100 <= X <= 253

A slot is free when both:
  - X is not in use as a cluster-wide VM/LXC ID (any node)
  - 10.140.3.X is not in any DHCP lease or static reservation

Sources:
  - Proxmox cluster:  pvesh on Xenon (covers all nodes via cluster API)
  - DHCP leases:      OpenWRT router at 10.140.2.6 (/tmp/dhcp.leases + uci static)
  - 7XXX LXC pattern: IDs 7100-7253 on Xenon map to IPs 10.140.3.100-253

Usage:
    python3 pve_free_slots.py                # show available slots
    python3 pve_free_slots.py --taken        # also list what's occupied
    python3 pve_free_slots.py --min 150      # restrict range floor

# Author: Matthew / Claude
"""

import argparse, json, subprocess, sys

RANGE_MIN, RANGE_MAX = 100, 253
XENON_DIRECT = "root@10.140.3.82"
XENON_CF = "root@xenon-ssh.mattsouthgate.co.uk"
XENON_CF_HOSTNAME = "xenon-ssh.mattsouthgate.co.uk"
ROUTER = "root@10.140.2.6"


def ssh(target, cmd, *, proxy_hostname=None, timeout=15):
    args = ["ssh", "-o", "StrictHostKeyChecking=accept-new",
            "-o", "BatchMode=yes", "-o", f"ConnectTimeout={timeout}"]
    if proxy_hostname:
        args += ["-o", f"ProxyCommand=cloudflared access ssh --hostname {proxy_hostname}"]
    args += [target, cmd]
    try:
        r = subprocess.run(args, capture_output=True, text=True, timeout=timeout + 5)
        return r.stdout, r.returncode == 0
    except subprocess.TimeoutExpired:
        return "", False


def xenon_ssh(cmd, timeout=15):
    out, ok = ssh(XENON_DIRECT, cmd, timeout=timeout)
    if ok:
        return out, True
    return ssh(XENON_CF, cmd, proxy_hostname=XENON_CF_HOSTNAME, timeout=timeout + 10)


def get_cluster_ids():
    out, ok = xenon_ssh("pvesh get /cluster/resources --type vm --output-format json")
    if not ok or not out.strip():
        return None, "Could not reach Xenon cluster API"
    try:
        return {int(r["vmid"]): f"{r.get('name','?')} ({r['node']}, {r['type']})"
                for r in json.loads(out)}, None
    except (json.JSONDecodeError, KeyError) as e:
        return None, f"Parse error: {e}"


def get_dhcp_taken():
    taken = {}
    out, ok = ssh(ROUTER, "cat /tmp/dhcp.leases", timeout=8)
    if ok:
        for line in out.splitlines():
            parts = line.split()
            if len(parts) >= 3 and parts[2].startswith("10.140.3."):
                x = int(parts[2].split(".")[-1])
                taken[x] = f"DHCP lease ({parts[3] if len(parts) > 3 else '?'})"
    out, ok = ssh(ROUTER,
        "uci show dhcp 2>/dev/null | grep '\\.ip=' | grep '10\\.140\\.3\\.'", timeout=8)
    if ok:
        for line in out.splitlines():
            if "10.140.3." in line:
                ip = line.split("=")[-1].strip("'\"")
                if ip.startswith("10.140.3."):
                    x = int(ip.split(".")[-1])
                    taken[x] = f"DHCP static ({ip})"
    return taken, ok

(The full script with argument parsing and range notation is at agents/pve_free_slots.py.)

Test Run

Running it against the cluster today (26 June 2026, from the university — so the router was unreachable and DHCP data wasn't available, but the cluster query still worked):

Querying Proxmox cluster IDs...
  81 resources across cluster
Querying OpenWRT DHCP leases (10.140.3.x)...
  Router unreachable — DHCP data unavailable (may be off home LAN)

==========================================================
  Free slots  (ID=X, IP=10.140.3.X)  range 100–253
==========================================================
  110
  118
  122
  125
  129
  131
  133
  136
  139–142
  145
  147
  151–164
  166–168
  171–173
  175–179
  181–185
  188–198
  202–253

  105 free,  49 occupied

With --taken, the occupied section shows why each slot is blocked — whether it's an ID conflict on a specific node, or an IP conflict from a 7XXX container:

Occupied — ID conflict:
  100  pialert (pve8, lxc)
  101  openwrt (pve1, qemu)
  102  claude-cli (pve8, lxc)
  103  caddy (pve8, lxc)
  ...
  108  smokeping (pve8, lxc)   ← this is the one that bit me
  ...

Occupied — IP conflict only (ID is free):
  114  IP taken by 7114 (paperless-ngx (xenon, lxc))
  117  IP taken by 7117 (cockpit (xenon, lxc))
  119  IP taken by 7119 (rtsptoweb (xenon, lxc))
  ...

The net result: I ran the script, picked ID 110 (first clean slot after 108), created the container (pct clone ... 110), and set the IP to 10.140.3.110. Done. No nmap, no guessing, no collisions.

The .lan Side

The IP convention handles the "where is it" problem. The hostname convention handles the "what do I call it" problem. All routable containers get a .lan DNS entry through Caddy (CT103 on PVE8), so litellm.lan resolves to 10.140.3.110. When a container moves or gets replaced, you update the Caddyfile, not every script that talks to it. The OpenWRT router handles .lan resolution for the rest of the network via dnsmasq.

Gaps and Future Work

A few things I haven't sorted yet:

  • DHCP reservations aren't automated. New containers still get a DHCP lease by default — I assign the static reservation separately in OpenWRT. The script catches any conflicts, but doesn't create the reservation when a container is provisioned.
  • Some containers have DHCP IPs. The Xenon vLLM container (LXC 8003) is currently at 10.140.1.166, which is fine until the lease rotates. There's an open todo to pin it.
  • The 7XXX naming scheme is a historical accident and I haven't migrated those containers. They work, so the motivation to touch them is low. The script handles them correctly, they just look a bit odd in the occupied list.
  • PVE8 and PVE1 containers predate the convention — CT103 is Caddy at 10.140.3.156, which has nothing to do with ID 103. The convention only applies to new Xenon containers going forward.

Not a perfect system, but it does mean that when an AI agent or a slightly-sleepy human needs to create a new container, the available slots are a single command away.

I hope you find this helpful. Ta ta for now,
Matt

Automating a Cashback Offer Alert on a Cloudflare + Cognito Protected Site

I wanted a daily alert when a particular AliExpress bonus cashback offer reappears on Quidco — a UK cashback site. The offer shows up in a carousel on the logged-in homepage and disappears within a day or two. Catching it manually is unreliable. This is a write-up of building a fully automated checker that runs at 1 AM every night.

The Target

The Quidco homepage carousel shows rotating offers like "Bonus Cashback — AliExpress: Get a £7.50 Bonus when you opt in and spend £15 or more." I wanted to be notified the moment one of these appears, without having to check manually.

The page is:
- JavaScript-rendered (React/Next.js)
- Protected by Cloudflare bot detection
- Authenticated via AWS Cognito (short-lived JWTs, 1-hour TTL)

A simple curl or requests fetch gets a 403 immediately. So we need a real browser.

Tool: Playwright Firefox

Playwright is a browser automation library that drives real browser engines headlessly. The first instinct is Chromium — it's the default — but Cloudflare's cf_clearance cookie is bound to the TLS fingerprint (JA3 hash) of the browser that solved the challenge. My Firefox session's cf_clearance won't work in Chromium because the two engines produce different TLS ClientHello signatures.

Solution: use playwright's Firefox engine, which is close enough in fingerprint to the real Firefox that the cf_clearance transfers across.

with sync_playwright() as p:
    browser = p.firefox.launch(headless=True)

Problem 1: Cookies Weren't Being Sent

Firefox stores cookies in an SQLite database at snap/firefox/common/.mozilla/firefox/<profile>/cookies.sqlite. I read them and injected them into the playwright context — but the page kept redirecting to login.

Tracing the actual HTTP requests showed cf_clearance and session_id were missing from the Cookie header on requests to www.quidco.com, even though I'd injected them.

The bug: Firefox's SQLite host column uses a leading dot (.quidco.com) to signal subdomain-matching cookies, mirroring the Set-Cookie: Domain= attribute in RFC 6265. I was stripping that dot:

# Wrong — tells playwright "exact host only"
cookies.append({"domain": host.lstrip('.'), ...})

# Right — keep the dot so playwright sends it to www.quidco.com too
cookies.append({"domain": host, ...})

After that fix, all the right cookies arrived at the server and the page loaded.

Problem 2: Cognito Redirect Loop

The Cognito access token (stored as cognito_token cookie) has a 1-hour TTL. At 1 AM, if the user hasn't visited Quidco recently, it'll be stale. Sending a stale token caused an infinite redirect loop:

GET /home/           → 302 /?auth=login   (Cognito middleware: token expired)
GET /?auth=login     → 302 /home/         (session_id is valid, go home)
GET /home/           → 302 /?auth=login   (token still expired)
...

The fix is counterintuitive: don't send the expired token at all. When the token is missing rather than expired, the server's Cognito middleware steps aside and lets the client-side Amplify.js handle authentication instead.

if name == "cognito_token":
    if exp > now:
        cognito_expired = False
    else:
        continue  # omit it — sending it causes a redirect loop

Problem 3: Token Refresh via Amplify.js

With no cognito_token but a valid cognito_refresh_token (6-month TTL), the Quidco page's embedded AWS Amplify SDK detects the missing token on load and silently fetches a new one from Cognito using the refresh token. It then redirects the client to /home/ — entirely client-side, no server round-trip.

def refresh_cognito(ctx, page) -> bool:
    # Navigate to root (not /home/) — server accepts it without Cognito check
    page.goto("https://www.quidco.com/", wait_until="domcontentloaded")
    try:
        # Amplify.js fires, refreshes the token, redirects client to /home/
        page.wait_for_url("**/home/**", timeout=20_000)
        return True
    except TimeoutError:
        return False

I tested this with a genuinely expired token (12 minutes past expiry). The root page loaded, Amplify.js ran, a new token was silently obtained, and the browser landed on /home/ — all in the first page load.

Parsing the Carousel

The carousel cards are rendered as div.main elements with a div.main-title inside. BeautifulSoup makes extraction straightforward:

from bs4 import BeautifulSoup

soup = BeautifulSoup(html, "html.parser")
offers = []
seen = set()
for card in soup.find_all("div", class_="main"):
    title_el = card.find("div", class_="main-title")
    if not title_el:
        continue
    title = title_el.get_text(strip=True)
    if title in seen:
        continue
    seen.add(title)
    desc_el = card.find("div", class_="main-description")
    offers.append({
        "title": title,
        "description": desc_el.get_text(strip=True) if desc_el else ""
    })

Importantly, I check the carousel titles specifically rather than searching full-page body text. Quidco also shows AliExpress in a "Your Favourites" section — a body text search would give false positives.

Alerts via Claude Push Notifications

For the 1 AM alert, I use the claude CLI in non-interactive mode to push a notification to the Android Claude app:

claude -p "Send a push notification: Quidco AliExpress offer on carousel — £7.50 bonus" \
    --allowedTools PushNotification

This spawns a lightweight Claude Code session that calls the PushNotification tool, which routes through to Claude's mobile app via Remote Control. No email credentials, no third-party push service.

The Cron Job

# crontab -l
0 0 * * * /home/user/claude/quidco/alert.sh

Midnight UTC = 1 AM BST. The carousel rolls over at midnight, so this catches whatever's new for the day.

Full Flow

cron (00:00 UTC)
  └─ alert.sh
       └─ quidco_check.py
            ├─ read Firefox cookies.sqlite
            ├─ [if token expired] playwright Firefox → quidco.com root
            │    └─ Amplify.js refreshes token → redirects to /home/
            ├─ [if token fresh] playwright Firefox → /home/ directly
            ├─ parse div.main carousel cards
            └─ return {"found": bool, "carousel": [...]}
  └─ [if found] claude -p "push notification"
  └─ [if found] notify-send (best-effort desktop)
  └─ log to check.log

Results

Current carousel on a typical day: Boots, Temu, IHG Hotels, Goldsmiths, Shepherds Friendly ISA, Antler, Opodo, LG Electronics, Quidco Gift Cards, Very, Quidco In-Store, Pooch and Mutt, Lovehoney, Virgin Experience Days.

When AliExpress appeared earlier today (£7.50 bonus, "Ends Today"), the script correctly detected it. After midnight when the offer expired, it correctly returned found: false.

The main fragility is the cognito_refresh_token — it has a ~6 month lifetime. When it expires, a fresh Firefox login to Quidco is all that's needed to re-establish the session.

Code

agents/quidco_check.py — about 100 lines of Python. Dependencies: playwright, beautifulsoup4 (both already available in the project venv).

Orphaned Processes and Port Conflicts: Finding, Killing, and Preventing Them

You start an app, something goes wrong, you hit Ctrl+C — and now the app won't start again because the port is already in use. Sound familiar? This is the orphaned process problem, and it's more common than it should be.

What Happened

I was running JiuwenClaw, a Python-based AI agent platform. It launches several sub-processes on startup: an agent server and a gateway, each binding to their own port. After terminating the parent with Ctrl+C, the app refused to restart:

Port 19001 already in use
Port 18092 already in use

Why Ctrl+C Doesn't Always Clean Up

When you press Ctrl+C in a terminal, the shell sends SIGINT to the foreground process group. If the parent process spawned children via Python's subprocess module without explicitly adding them to the same process group — or without a signal handler that forwards the signal — those children keep running after the parent dies.

They become orphans: no parent, no controlling terminal, but still holding their network ports open.

Finding the Culprits

Two commands are all you need.

Check which ports are in use:

ss -tlnp | grep -E '19001|18092'

Output:

LISTEN  127.0.0.1:19001  users:(("python3",pid=80615,...))
LISTEN  127.0.0.1:18092  users:(("python3",pid=80613,...))

Identify the processes by PID:

lsof -i :19001 -i :18092

Output:

python3  80613  user  ...  TCP  localhost:18092 (LISTEN)
python3  80615  user  ...  TCP  localhost:19001 (LISTEN)

Confirm what they are:

ps -p 80613 -o pid,ppid,cmd --no-headers
ps -p 80615 -o pid,ppid,cmd --no-headers

Output:

80613  2602  python3 -m jiuwenclaw.app_agentserver
80615  2602  python3 -m jiuwenclaw.app_gateway

There they are — orphaned sub-processes from the previous run, still alive and holding the ports.

Killing Them

Once you have the PIDs:

kill 80613 80615

Verify the ports are clear:

ss -tlnp | grep -E '19001|18092'

No output means the ports are free. You can now restart the app normally.

If a process ignores SIGTERM, escalate with SIGKILL:

kill -9 80613 80615

One-liner: kill by port

If you just want to nuke whatever is on a port without finding the PID first:

fuser -k 19001/tcp 18092/tcp

Or using ss and kill together:

ss -tlnp | grep 19001 | grep -oP 'pid=\K[0-9]+' | xargs kill

Mitigations

1. Fix the application's signal handling (ideal)

The root cause is that the app doesn't forward signals to its children. A well-behaved Python launcher should use a process group and kill the whole group on exit:

import os
import signal
import subprocess

proc = subprocess.Popen(["python3", "-m", "myapp.server"], start_new_session=True)

def cleanup(sig, frame):
    os.killpg(os.getpgid(proc.pid), signal.SIGTERM)

signal.signal(signal.SIGINT, cleanup)
signal.signal(signal.SIGTERM, cleanup)

Using start_new_session=True puts the child in its own process group, and os.killpg kills the whole group cleanly.

2. Use a stop script or PID file

Many services write a PID file on startup and provide a stop command. If the app supports it, prefer jiuwenclaw-stop over Ctrl+C — a proper stop command can clean up child processes before exiting.

3. Wrap the launcher in a shell trap

If you're starting the app via a shell script, add a trap:

#!/bin/bash
jiuwenclaw-start &
PARENT_PID=$!

cleanup() {
    kill -- -$(ps -o pgid= $PARENT_PID | tr -d ' ')
}

trap cleanup EXIT INT TERM
wait $PARENT_PID

The kill -- -<pgid> sends the signal to the entire process group.

4. Run inside a process supervisor

Tools like systemd, supervisord, or s6 track all child processes and kill them as a unit when the service stops. If you're running something in production or semi-permanent, this is the right answer — it also handles restarts, logging, and health checks.

5. Check before you start

Add a pre-flight check to your workflow. Before starting any service, quickly verify the ports it needs are free:

ss -tlnp | grep -E '19001|18092' && echo "Port conflict!" || echo "Ports clear"

Summary

Step Command
Find listening ports ss -tlnp \| grep <port>
Find PID by port lsof -i :<port>
Confirm process identity ps -p <pid> -o pid,cmd
Kill by PID kill <pid>
Kill by port directly fuser -k <port>/tcp
Verify ports are free ss -tlnp \| grep <port>

The quick fix is always kill. The real fix is making sure the app cleans up after itself — or wrapping it in something that does.

Proxmox on Android Through Cloudflare Access — Getting Native Apps Past the Browser Challenge

I have Proxmox Virtual Environment exposed externally via a Cloudflare tunnel. Access is locked down with Cloudflare Zero Trust — a wildcard application on *.mattsouthgate.co.uk that requires either a WARP-enrolled device or email authentication before you get anywhere near the server.

That works perfectly in a browser. It does not work in the Proxmox VE Android app.

This is the story of figuring out why, and the fix that keeps the servers protected while letting the app through.


The Problem

The Proxmox VE app on Android connects directly to the server's HTTPS API at port 8006. Through a Cloudflare tunnel, that becomes https://pve8.mattsouthgate.co.uk on port 443 — Cloudflare handles the external TLS and forwards traffic to the server internally.

With Cloudflare Access in front, the first thing any unauthenticated connection receives is a challenge page. In a browser, you click through, authenticate, get a session cookie, and proceed. The Proxmox app isn't a browser. It expects a JSON API response. What it gets instead is:

Connection error. Could not establish connection.
Format exception, unexpected character (at character 1) <!DOCTYPE html>
^

The <!DOCTYPE html> at position 1 is Cloudflare's Access login page. The app has no way to handle it — it's looking for { not <.


What Doesn't Work

WARP / Cloudflare One on the phone. The instinct here is correct — enroll the device in Zero Trust, and Cloudflare should recognise it as trusted without a browser challenge. The Cloudflare One app (the replacement for 1.1.1.1 after May 2026) handles this enrollment.

The problem is in how Access policies evaluate WARP. An Allow policy with a WARP posture check doesn't skip authentication — it requires the device to be WARP-connected and complete an Access login session. The login session is the browser step. The app still can't do that.

Service tokens. Cloudflare Access supports service tokens — a client ID and secret sent as HTTP headers (CF-Access-Client-Id, CF-Access-Client-Secret) — designed exactly for non-browser clients. The Proxmox Android app has no mechanism to set custom HTTP headers on its connections. That option is closed off.

WARP posture check in a Bypass policy. A Bypass policy skips the Access login entirely for traffic matching its rules. Combining Bypass with a WARP Include rule sounds like it would work — bypass only for enrolled devices, everyone else still hits the email flow. In practice, the Cloudflare dashboard warns you when you select Bypass that it only reliably supports IP-based and group-based rules for its conditions. Device posture checks, including WARP, don't function reliably in Bypass policies.


What Does Work

The solution is a second, more specific Access application scoped only to pve8.mattsouthgate.co.uk with a Bypass + Everyone policy.

In Access controls → Applications, create a new Self-hosted application:

  • Subdomain: pve8
  • Domain: mattsouthgate.co.uk
  • Policy action: Bypass
  • Include: Everyone

Cloudflare evaluates Access applications by specificity — the most specific hostname match wins. pve8.mattsouthgate.co.uk is more specific than *.mattsouthgate.co.uk, so traffic to pve8 hits the Bypass policy first and passes straight through to the Proxmox login page. Every other subdomain continues to be handled by the wildcard application with its WARP and email policies intact.

The Proxmox app connects immediately. No HTML. No challenge. Just the API.


What About Security?

Bypassing Cloudflare Access on pve8 means anyone who knows the URL can reach the Proxmox login page. That's worth being clear-eyed about.

What they reach is Proxmox's own authentication — username, password, and optionally two-factor. Proxmox's login is not bypassed, only Cloudflare's pre-authentication layer. The server is not open; it's just relying on its own credentials rather than having an additional Cloudflare gate in front.

For the other subdomains — services that may not have strong authentication of their own — the wildcard application continues to enforce Zero Trust access. Nothing about that changes.

This is a reasonable split: Proxmox has solid built-in auth and the app has no way to satisfy Cloudflare's browser flow, so Cloudflare steps aside and lets Proxmox handle it. Services that are less hardened stay behind the full Zero Trust wall.


Tunnel Configuration

One separate thing worth checking if the app connects but behaves oddly: the tunnel's origin settings for pve8 should have both noTLSVerify and disableChunkedEncoding enabled.

Proxmox uses a self-signed certificate internally, so noTLSVerify tells Cloudflare not to reject it on the internal leg. disableChunkedEncoding matters because Cloudflare tunnels don't support HTTP chunked transfer encoding, which Proxmox uses — without disabling it you can get HTTP 501 errors on certain operations.

Both are set under Networks → Tunnels → [tunnel] → Public Hostnames → [hostname] → Additional application settings → Origin.


App Settings

With the above in place, the Proxmox VE Android app connects with:

  • Host: pve8.mattsouthgate.co.uk
  • Port: 443

Port 443, not 8006 — Cloudflare terminates on 443 externally. The :8006 only appears on the internal leg between Cloudflare and the server, which the tunnel configuration already handles.

23 August 2026

Kokoro TTS on a Shared GPU: Lazy Loading and Idle Unloading

Running multiple GPU-accelerated services on a single consumer GPU is a juggling act. This post documents how I diagnosed a VRAM exhaustion problem with four concurrent Kokoro TTS instances, why the "official" fix didn't exist, and how I patched the server to load the model on demand and release VRAM after 120 seconds of inactivity.


The Setup

I'm running four variants of kokoro-fastapi in Incus LXC containers, each serving a different combination of device and streaming mode:

Hostname Container Mode
kokoro-gpu-streaming.lan kokoro-stream-gpu GPU + streaming patches
kokoro-gpu.lan kokoro-gpu GPU, unpatched
kokoro-cpu-streaming.lan kokoro-stream CPU + streaming patches
kokoro-cpu.lan kokoro-tts CPU, unpatched

Each one is reverse-proxied by Caddy, so all four are reachable by name with no port numbers.


The Problem: 8 GB Doesn't Go Far

After getting the GPU streaming instance running cleanly, I tried to start the GPU non-streaming instance. It crash-looped immediately.

Apr 17 07:55:11 kokoro-gpu uv[402]: RuntimeError: Warmup failed: Failed to load model:
Failed to load Kokoro model: CUDA out of memory. Tried to allocate 2.00 MiB.
GPU 0 has a total capacity of 7.62 GiB of which 3.75 MiB is free.
Process 2729035 has 174.00 MiB memory in use.   ← ComfyUI
Process 2750368 has 880.00 MiB memory in use.   ← kokoro-stream-gpu
Process 2763628 has 6.26 GiB memory in use.     ← Ollama (model loaded)
Process 2766828 has 296.00 MiB memory in use.

Running nvidia-smi made the picture clear:

+-----------------------------------------------------------------------------------------+
| NVIDIA-SMI 580.126.09             Driver Version: 580.126.09     CUDA Version: 13.0     |
+-----------------------------------------+------------------------+----------------------+
|   0  NVIDIA GeForce RTX 4070 ...    Off |   00000000:01:00.0  Off|                  N/A |
| N/A   56C    P4              9W /   40W |    7501MiB /   8188MiB |      0%      Default |
+-----------------------------------------+------------------------+----------------------+

|    0   N/A  N/A         2729035      C   ...ComfyUI/.venv/bin/python3        174MiB |
|    0   N/A  N/A         2750368      C   /app/.venv/bin/python3              880MiB |
|    0   N/A  N/A         2763628      C   /usr/local/bin/ollama              6414MiB |

7,501 MiB of 7,623 MiB used. Ollama had a model loaded and was sitting idle, consuming 6.4 GB with nothing to do.

Each Kokoro GPU instance needs ~880 MB. Two instances plus ComfyUI plus an idle Ollama model adds up to more than 8 GB. Something had to give.


Mitigation Options

Before writing any code, it's worth considering what levers exist:

1. Reduce the number of GPU processes. Stop ComfyUI or unload the Ollama model when not in use. Ollama exposes a keep_alive API parameter — setting it to 0 on any request tells Ollama to unload immediately after responding. This frees 6.4 GB instantly, enough for both Kokoro instances with room to spare. It just means Ollama has a cold-start penalty on its next request too.

2. Use CPU for some instances. Kokoro on CPU takes ~1–3 seconds for a short phrase instead of ~90ms on GPU. For benchmarking purposes — which is what this setup is for — that's acceptable. The non-streaming instance doesn't need GPU to produce valid benchmark data.

3. Lazy-load the model. The server starts and listens, but only loads weights into VRAM when the first request arrives. Combined with an idle timeout that unloads the model after a period of inactivity, multiple GPU services can share the same card as long as they're not all active simultaneously.

For this setup the goal was four genuinely independent instances, so lazy loading was the right answer. Before implementing it, though, there was a detour.

3a. Checking What AI Agents Tell You

This setup is managed by AI agents (Claude), and the agents suggested a quick fix before any code was read:

Set IDLE_TIMEOUT=120 and WARMUP_STEPS=0 in the systemd service unit. WARMUP_STEPS=0 prevents the model loading at startup, and IDLE_TIMEOUT unloads it after inactivity.

This sounds authoritative. It's also completely wrong — and importantly, it isn't a hallucination in the technical sense. The model didn't confabulate random tokens. It generated a plausible-sounding answer based on patterns from similar projects (many ML serving frameworks do have env vars like these), then stated it as fact without checking whether this particular codebase actually implemented them.

The distinction matters: a hallucination is making something up. This was something subtly different — confident assertion without verification. The env vars were set:

Environment=IDLE_TIMEOUT=120
Environment=WARMUP_STEPS=0

The service still OOM'd on startup. The next step was what should have happened first:

grep -r "IDLE_TIMEOUT\|WARMUP" /app/api/
# (no output)

Not a single match. These variables are read nowhere in the codebase. The model always loads at startup, unconditionally, regardless of any environment variable you set. Version 0.3.0 simply doesn't have this feature.

The lesson isn't that AI agents are unreliable — they're genuinely useful for this kind of infrastructure work. The lesson is that suggestions about third-party library behaviour need to be verified against the actual source before being acted on, especially when they're offered without a citation. "Check the docs / grep the source" takes thirty seconds and would have saved the detour entirely. A good agent should do this itself before making the suggestion; when it doesn't, the human needs to catch it.


Diagnosing the Startup Flow

The relevant code lives in two files. First, main.py starts everything in a FastAPI lifespan context:

# api/src/main.py — original startup (simplified)
@asynccontextmanager
async def lifespan(app: FastAPI):
    model_manager = await get_manager()
    voice_manager = await get_voice_manager()

    # This loads the model weights immediately at startup
    device, model, voicepack_count = await model_manager.initialize_with_warmup(
        voice_manager
    )
    yield

initialize_with_warmup in model_manager.py calls initialize()load_model() → runs a warmup inference pass. The model is in VRAM before the first request ever arrives.

The generate() method had no concept of loading on demand:

# original generate() — raises if backend not initialized
async def generate(self, *args, **kwargs):
    if not self._backend:
        raise RuntimeError("Backend not initialized")
    async for chunk in self._backend.generate(*args, **kwargs):
        yield chunk

The Fix: Lazy Loading + Idle Unload

The fix has two parts: skip model loading at startup, and load it on the first generate() call. A background coroutine checks every 10 seconds and unloads if the model has been idle for 120 seconds.

Part 1 — main.py: Skip startup model loading

@asynccontextmanager
async def lifespan(app: FastAPI):
    """Lifespan context manager — model loads on first request, not here."""
    import asyncio as _asyncio
    from .inference.model_manager import get_manager
    from .inference.voice_manager import get_manager as get_voice_manager
    from .services.temp_manager import cleanup_temp_files

    await cleanup_temp_files()

    # Set up the manager objects but do NOT load model weights.
    # The model will load on the first call to generate().
    model_manager = await get_manager()
    voice_manager = await get_voice_manager()

    # Start the background coroutine that watches for idle time
    # and unloads the model from VRAM when it hasn't been used.
    idle_task = _asyncio.create_task(model_manager.idle_monitor())

    logger.info("Kokoro TTS ready — model will load on first request")

    yield  # Server runs here

    idle_task.cancel()  # Clean up on shutdown

The key change: initialize_with_warmup() is never called. The server starts, listens for requests, and uses zero VRAM.

Part 2 — model_manager.py: Lazy load + idle monitor

class ModelManager:
    def __init__(self, config=None):
        self._config = config or model_config
        self._backend: Optional[KokoroV1] = None
        self._device: Optional[str] = None

        # Track when the model was last used (monotonic clock, not wall time)
        self._last_used: Optional[float] = None

        # Prevent two concurrent requests from both trying to load the model
        self._loading: bool = False

        # Unload after this many seconds of inactivity
        self._idle_timeout: int = 120

    async def _load_if_needed(self) -> None:
        """Load model weights into VRAM on demand.

        Thread-safety note: if two requests arrive simultaneously before the
        model is loaded, the _loading flag serialises them so only one
        actually loads the model. The second waits, then sees the model is
        already there and returns immediately.
        """
        # Fast path: model already loaded
        if (self._backend is not None
                and hasattr(self._backend, '_model')
                and self._backend._model is not None):
            return

        # Wait if another coroutine is already loading
        while self._loading:
            await asyncio.sleep(0.1)

        # Re-check after waiting — the other coroutine may have loaded it
        if (self._backend is not None
                and hasattr(self._backend, '_model')
                and self._backend._model is not None):
            return

        # This coroutine wins the race — load the model
        self._loading = True
        try:
            logger.info("Loading model on demand...")
            await self.initialize()                          # create KokoroV1 object
            model_path = self._config.pytorch_kokoro_v1_file
            await self.load_model(model_path)               # load weights into VRAM
            logger.info("Model loaded on demand")
        finally:
            self._loading = False  # always release the lock

    async def idle_monitor(self) -> None:
        """Background task: unload model after idle_timeout seconds of inactivity.

        Checks every 10 seconds. If the model is loaded and hasn't been used
        for 120 seconds, calls unload_all() to free VRAM. The model will
        reload transparently on the next request.
        """
        while True:
            await asyncio.sleep(10)  # poll interval
            if self._backend is not None and self._last_used is not None:
                idle = time.monotonic() - self._last_used
                if idle >= self._idle_timeout:
                    logger.info(f"Idle for {idle:.0f}s — unloading model from VRAM")
                    self.unload_all()       # calls backend.unload() and sets _backend = None
                    self._last_used = None  # reset so we don't trigger again immediately

    async def generate(self, *args, **kwargs):
        """Generate audio, loading the model first if needed."""
        # Ensure model is in VRAM before we try to run inference
        await self._load_if_needed()

        # Update last-used timestamp so the idle monitor knows the model is active
        self._last_used = time.monotonic()

        try:
            async for chunk in self._backend.generate(*args, **kwargs):
                if settings.default_volume_multiplier != 1.0:
                    chunk.audio *= settings.default_volume_multiplier
                yield chunk
                # Keep updating during long generations (multi-sentence text)
                self._last_used = time.monotonic()
        except Exception as e:
            raise RuntimeError(f"Generation failed: {e}")

Verification

After patching, the service starts with zero VRAM usage:

Apr 17 08:05:05 kokoro-stream-gpu uv[1767]: Kokoro TTS ready — model will load on first request
Apr 17 08:05:05 kokoro-stream-gpu uv[1767]: INFO: Application startup complete.

Confirming via nvidia-smi that the new process (host PID 2774231) doesn't appear in the GPU process list at all — zero VRAM allocated. The previous 812 MiB entry was a lingering process from the crash-loop cycle, cleaning itself up.

On the first request, the model loads in ~5 seconds and synthesis proceeds normally. After 120 seconds of inactivity, the idle monitor fires:

Idle for 121s — unloading model from VRAM

And the VRAM is returned to the system.


What This Enables

Both GPU instances now coexist. When neither has been called recently, they hold zero VRAM — Ollama and ComfyUI can use the full 8 GB uncontested. When a request comes in, whichever instance is needed loads its model (~5s cold start), handles the request, then unloads 120 seconds later.

If both GPU instances happen to be active simultaneously — unlikely in practice — they would compete for the ~1.1 GB of VRAM not occupied by ComfyUI, and one would OOM. That's an acceptable trade-off for a benchmarking setup. For a production single-instance deployment, the same patch gives you a TTS server that is invisible to the GPU scheduler when not in use.


Summary

  • kokoro-fastapi 0.3.0 has no built-in lazy loading or idle unload — both are custom patches
  • The startup model load is in lifespan() in main.py; removing it is one line
  • The lazy load logic belongs in generate() with a flag to serialise concurrent first-requests
  • The idle monitor is a simple asyncio background task that polls every 10 seconds
  • Both patches are forward-compatible: initialize_with_warmup() is preserved in the codebase for anyone who needs eager loading, and the new _load_if_needed() / idle_monitor() sit alongside it

Mapping 101 Devices Across a /16 with nmap in Four Minutes

I needed a current inventory of everything alive on our 10.140.0.0/16 network. The infrastructure had grown organically over a couple of years — Proxmox clusters, Incus containers, WiFi access points from three different vendors, IoT devices, a Windows machine, and a NAS — without a single maintained source of truth. Time to build one from scratch.

This is the method: a two-phase nmap scan, using MAC OUI lookups to classify devices before touching a single one of them.


The Setup

The scan ran from a Linux workstation directly attached to the 10.140.0.0/16 network via a wired interface (enx803f5df84e07, IP 10.140.0.192). The machine also hosts an Incus bridge (incusbr0, 10.140.20.1) giving it a second window into the container subnet.

Because both addresses sit on the same Layer 2 broadcast domain, nmap can use ARP to resolve MAC addresses for every host in the range — not just the directly connected subnets. That makes the OUI-based device classification accurate across the whole scan, not just for local neighbours.

Target range: 10.140.0.0 through 10.140.25.255 — the first 26 /24 blocks of the /16, covering all assigned infrastructure. That's 6,656 addresses.


Phase 1: Ping Sweep

The first pass is a host-discovery-only scan (-sn). No port probing, just ICMP echo and ARP:

sudo nmap -sn --open -T4 10.140.0-25.0-255 -oG /tmp/nmap_sweep.txt

-oG saves the output in greppable format. With ARP on a flat L2 network, this is fast — 6,656 addresses in just over two minutes. Result: 101 live hosts.

Extracting the IP list for phase two:

grep "^Host:" /tmp/nmap_sweep.txt | awk '{print $2}' | sort -t. -k1,1n -k2,2n -k3,3n -k4,4n > /tmp/live_hosts.txt

Phase 2: Port and Service Scan

With 101 hosts identified, the full port scan targets only those — no wasted probes against dead addresses:

sudo nmap -iL /tmp/live_hosts.txt -sV --version-intensity 1 -F -T4 --open -R --host-timeout 60s -oN /tmp/nmap_portscan.txt -oG /tmp/nmap_portscan_greppable.txt

Key flags:
- -F — top 100 ports. Fast, catches SSH, HTTP, RDP, PostgreSQL, SMB, and most application ports.
- --version-intensity 1 — minimal service banner probing. Enough to identify OpenSSH versions and web server names without sending dozens of probes per port.
- -R — resolve hostnames for all hosts, not just ones nmap already has names for.
- --host-timeout 60s — don't let a single unresponsive host stall the scan.
- --open — only report open ports. Keeps the output clean.

101 hosts, top 100 ports each: 153 seconds. Two and a half minutes.


Phase 3: Classifying Devices by MAC OUI

The most useful output from a network scan often isn't the port list — it's the MAC address. The first three octets (the OUI) identify the manufacturer, and on a managed network that maps almost directly to device type.

Parsing the combined output with Python:

import re, ipaddress

hosts = {}

with open('/tmp/nmap_portscan.txt') as f:
    current_ip = None
    for line in f:
        line = line.rstrip()
        m = re.search(r'Nmap scan report for (?:\S+ \()?(\d+\.\d+\.\d+\.\d+)\)?', line)
        if m:
            current_ip = m.group(1)
            hosts.setdefault(current_ip, {'hostname': '', 'mac': '', 'vendor': '', 'ports': []})
            hm = re.search(r'for (\S+) \(', line)
            if hm:
                hosts[current_ip]['hostname'] = hm.group(1)
        elif 'MAC Address:' in line and current_ip:
            m = re.match(r'MAC Address:\s+(\S+)\s+\(([^)]*)\)', line.strip())
            if m:
                hosts[current_ip]['mac'] = m.group(1)
                hosts[current_ip]['vendor'] = m.group(2)

What emerged:

OUI Vendor Device type
BC:24:11 Proxmox GmbH Proxmox VMs and LXC containers
10:66:6A Ruckus Networks APs and switches
50:C7:BF TP-Link WiFi access points
68:1D:EF Shenzhen CYX Technology Proxmox bare-metal host (PVE1)
E8:DB:84 Espressif ESP8266/ESP32 IoT device
68:37:E9 Amazon Technologies Amazon Echo or Fire device
AC:3B:77, 18:1E:78, 34:8A:AE, C8:91:F9 Sagemcom Router/AP firmware devices
00:18:0A Cisco Meraki Meraki AP

The BC:24:11 prefix was the biggest reveal. Proxmox assigns MAC addresses from its own OUI pool to every VM and container it creates. Once you know that, you can identify every virtual machine on the network at a glance — no hostname needed.


What 101 Hosts Looks Like

Summarised by subnet:

Subnet Live Character
10.140.0.x 18 Mix: TP-Link APs, Proxmox VMs, one Espressif IoT device
10.140.1.x 20 Mostly Proxmox VMs, more TP-Link APs, one Amazon device
10.140.2.x 8 Network infrastructure — Sagemcom APs, Cisco Meraki, default gateway
10.140.3.x 29 Dense Proxmox cluster — VMs, NFS servers, PVE1 bare metal
10.140.4.x 1 Single PostgreSQL VM
10.140.6.x 1 Media streaming device (Luxshare MAC, ports 8080/8443/RTSP)
10.140.10.x 1 Windows PC — RDP, SMB, WinRM all open
10.140.20.x 23 Incus container subnet — known services

The 10.140.3.x subnet was the surprise. Twenty-nine live hosts, almost all Proxmox VMs (BC:24:11 MACs), with the actual PVE1 hypervisor sitting at 10.140.3.10. Several machines expose port 3128 (Squid proxy) alongside SSH and NFS — a cluster pattern I hadn't documented before.


Interesting Finds

10.140.3.200 — the undocumented NAS. Ports 21 (ProFTPD), 22, 80/443 (nginx), 111/2049 (NFS), 139/445 (Samba), 5357 (WS-Discovery). That's a full-featured NAS behind a Proxmox VM MAC. Worth investigating what's stored there.

10.140.10.104 — Windows PC with everything open. RDP on 3389, SMB on 445, MSRPC on 135, NetBIOS on 139, WinRM on 80. Intel NIC. Not in any documentation. The Tailscale topology might explain how it's routable across subnets.

10.140.2.50 — telnet still open. SSH and HTTP/HTTPS alongside port 23. On a 10.140.2.x device that looks like network infrastructure. Needs a closer look.

10.140.0.204 — Espressif on port 8081. The MAC prefix nails it as an ESP8266 or ESP32. One of the IoT devices, running its own HTTP service. Not in the known device list.

Ruckus MACs in the container subnet. The three Ruckus R720 APs documented as being at 10.140.2.16–18 didn't respond at those IPs. Ruckus-prefix MACs (10:66:6A) appear instead across 10.140.20.x — the same range as the Incus containers. Some of those IPs match known containers (faster-whisper at .6, open-webui at .61), suggesting either the MAC assignment in Incus is pulling from the Ruckus OUI range, or some APs have shifted IP leases. Needs verification.


Access Points Identified

Fourteen confirmed APs across three vendors:

TP-Link (port 9999 = TP-Link TDDP device management):
10.140.0.22, 10.140.0.63, 10.140.0.235, 10.140.1.108, 10.140.1.228, 10.140.1.238

Sagemcom (Dropbear SSH + DNS + HTTP — standard AP firmware):
10.140.2.9, 10.140.2.11, 10.140.2.12, 10.140.2.13

Cisco Meraki:
10.140.2.15

Ruckus (port 8080 = Ruckus web management UI):
10.140.20.15, 10.140.20.16, 10.140.20.30


The Output

Raw files saved at /tmp/nmap_portscan.txt and /tmp/nmap_portscan_greppable.txt. The full structured inventory is at docs/network-scan-10.140.0-25.md — 101 rows, grouped by subnet, with MAC, vendor, open ports, and annotations for known services.

Total elapsed: under four minutes from cold start to annotated inventory.


Caveats

The -F flag only covers the top 100 ports by frequency. Several known services run on non-standard ports and won't appear: the LiteLLM proxy (4000), Kokoro TTS (8880), and Ollama (11434) are all in the container subnet but showed no open ports in this scan. For services on non-standard ports, follow up with a targeted scan against the known host list:

sudo nmap -iL /tmp/live_hosts.txt -p 4000,8880,11434,8188 --open -T4

ARP-based MAC resolution only works if the scanner is on the same L2 segment. On a routed network, MAC addresses won't be visible for remote hosts — you'd need to query the ARP caches on intermediate switches or run the scan from each segment.


Scan conducted 2026-05-07. nmap 7.94SVN. Scanner: 10.140.0.192/16 (Ubuntu 24.04, kernel 6.17).

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