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