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.